Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e10c710b | ||
|
|
551a5c29a1 | ||
|
|
d4ddfe3b80 | ||
|
|
0e93aa25bd | ||
|
|
143a30a23a | ||
|
|
81784f6f02 | ||
|
|
317e5b39bd | ||
|
|
ae6013d9d6 | ||
|
|
d25369067a | ||
|
|
c24a959e1d |
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
link TEXT,
|
||||
actions_json TEXT,
|
||||
mandatory INTEGER NOT NULL DEFAULT 0 CHECK(mandatory IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
read_at TEXT,
|
||||
@@ -67,6 +68,59 @@ CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, category)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oversight_notification_preferences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
oversight_category TEXT NOT NULL CHECK(oversight_category IN ('data_changes', 'avatars')),
|
||||
frequency TEXT NOT NULL DEFAULT 'immediate' CHECK(frequency IN ('immediate', '15_minutes', '1_hour', 'daily')),
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, oversight_category)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oversight_notification_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
oversight_category TEXT NOT NULL CHECK(oversight_category IN ('data_changes', 'avatars')),
|
||||
review_item_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
link TEXT NOT NULL,
|
||||
frequency TEXT NOT NULL CHECK(frequency IN ('15_minutes', '1_hour', 'daily')),
|
||||
created_at TEXT NOT NULL,
|
||||
deliver_after TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS automated_system_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
event_key TEXT NOT NULL CHECK(event_key IN ('account.created')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
|
||||
send_delay_minutes INTEGER NOT NULL DEFAULT 0 CHECK(send_delay_minutes BETWEEN 0 AND 10080),
|
||||
source TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
link TEXT,
|
||||
actions_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS automated_system_message_deliveries (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES automated_system_messages(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_key TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
scheduled_for TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
notification_id TEXT REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(message_id,user_id,event_key,event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS automated_system_message_deliveries_due_idx ON automated_system_message_deliveries(delivered_at,scheduled_for);
|
||||
INSERT OR IGNORE INTO automated_system_messages(id,name,description,event_key,enabled,send_delay_minutes,source,title,body,link,actions_json,created_at,updated_at)
|
||||
VALUES('welcome-agent','Welcome Agent','Introduces newly registered agents to SHD.build community channels and contribution workflows','account.created',1,0,'ISAC COMMUNITY NETWORK','Welcome to SHD.build','Welcome, Agent. SHD.build is your community intelligence hub for The Division Resurgence. Explore field news, search the game database, discover community builds and guides, or configure your own loadout in the Builder. ISAC also lets every registered agent inspect game data and enable Contributor Mode to suggest additions and corrections for Oversight review. Join the SHD Discord to meet other agents, share intelligence, and help the network grow.','/account','[{"label":"EXPLORE NEWS","href":"/news"},{"label":"QUERY DATABASE","href":"/database"},{"label":"VIEW COMMUNITY BUILDS","href":"/builds"},{"label":"READ FIELD GUIDES","href":"/guides"},{"label":"OPEN BUILDER","href":"/builder"},{"label":"CONTRIBUTE THROUGH ISAC","href":"/isac/contributor-guidelines"},{"label":"JOIN SHD DISCORD","href":"https://discord.gg/dhpd33VwQK"}]','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
|
||||
CREATE INDEX IF NOT EXISTS oversight_notification_queue_due_idx ON oversight_notification_queue(delivered_at, deliver_after);
|
||||
CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at);
|
||||
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
@@ -185,6 +239,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
|
||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
|
||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
|
||||
@@ -684,6 +739,42 @@ CREATE TABLE IF NOT EXISTS weapon_mod_effect_quality_values (
|
||||
value REAL NOT NULL,
|
||||
PRIMARY KEY(effect_id,quality)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mods (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS gear_mods_name_nocase_idx ON gear_mods(name COLLATE NOCASE);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
|
||||
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
primary_level_1_min REAL NOT NULL,
|
||||
primary_level_1_max REAL NOT NULL,
|
||||
primary_level_20_min REAL NOT NULL,
|
||||
primary_level_20_max REAL NOT NULL,
|
||||
PRIMARY KEY(gear_mod_id,quality),
|
||||
CHECK(primary_level_1_max>=primary_level_1_min),
|
||||
CHECK(primary_level_20_max>=primary_level_20_min),
|
||||
CHECK(primary_level_20_min>=primary_level_1_min),
|
||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
||||
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
|
||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
||||
PRIMARY KEY(gear_mod_id,stat_attribute_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
||||
gear_mod_id TEXT NOT NULL,
|
||||
stat_attribute_id TEXT NOT NULL,
|
||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||
min_value REAL NOT NULL,
|
||||
max_value REAL NOT NULL,
|
||||
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
|
||||
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
||||
CHECK(max_value>=min_value)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
|
||||
entity_id TEXT NOT NULL,
|
||||
|
||||
+267
-3
@@ -1,6 +1,10 @@
|
||||
# SHD.build Design System
|
||||
|
||||
This document defines the visual language for SHD.build. New pages should feel like part of the same tactical data network as the admin dashboard, while public pages may use more space and larger editorial typography.
|
||||
This document defines the visual language for SHD.build, the independent
|
||||
Division Resurgence community for builds, guides, game data, news, and
|
||||
discussion. New pages should feel like part of the same tactical intelligence
|
||||
network as the admin dashboard, while public pages may use more space and larger
|
||||
editorial typography.
|
||||
|
||||
## Design principles
|
||||
|
||||
@@ -64,8 +68,81 @@ widths and becomes a two-column touch-friendly grid on narrow mobile screens.
|
||||
|
||||
The footer repeats the complete SHD brand lockup used by the global header:
|
||||
double-ring SHD mark, white `SHD`, orange `.build`, and the restrained
|
||||
`LOADOUT NETWORK` descriptor. Do not substitute a plain text wordmark or color
|
||||
the complete product name orange.
|
||||
`RESURGENCE COMMUNITY` descriptor. Do not substitute a plain text wordmark or
|
||||
color the complete product name orange.
|
||||
|
||||
### Community platform positioning
|
||||
|
||||
SHD.build is a full Division Resurgence community, not solely a loadout tool.
|
||||
Primary product language and global navigation must represent five durable
|
||||
channels: News, Database, Builds, Guides, and Builder. Loadout-specific terms
|
||||
remain appropriate inside build routes, but the global brand descriptor,
|
||||
homepage metadata, hero, sign-in introduction, and footer describe the broader
|
||||
community platform.
|
||||
|
||||
The homepage uses a community-channel grid to establish this information
|
||||
architecture. Each channel has an operational code, plain-language purpose,
|
||||
and explicit destination. New channels should extend this reusable pattern
|
||||
rather than turning the navigation into an undifferentiated list. Honest
|
||||
launch-state pages may describe planned capability, but must distinguish queued
|
||||
features from live data. Database landing metrics use approved active records,
|
||||
not invented activity counts.
|
||||
|
||||
On desktop the five public destinations remain visible in the global
|
||||
navigation. Mobile navigation numbers destinations dynamically so adding a
|
||||
channel cannot collide with Inbox, Profile, or ISAC codes. Community channel
|
||||
cards collapse from four columns to two and then one; hover color alone never
|
||||
replaces their textual destination labels, focus-visible treatment, or pressed
|
||||
feedback.
|
||||
|
||||
Authenticated desktop account destinations are consolidated into one
|
||||
agent-status menu at the right edge of the global header. Its trigger combines
|
||||
the agent avatar, display name, online state, unread count, and an explicit
|
||||
expanded marker. The menu contains My Profile, Notifications, and Sign Out;
|
||||
these actions are not duplicated as standalone desktop header links. The menu
|
||||
uses the same square, compact control-surface treatment as the rest of the
|
||||
header, with orange reserved for the open/current state and unread count and
|
||||
danger color reserved for the Sign Out hover state.
|
||||
|
||||
The account trigger exposes its expanded and controlled-menu state. Opening the
|
||||
menu moves keyboard focus to its first destination; Escape closes it and
|
||||
returns focus to the trigger, while an outside click dismisses it. Mobile keeps
|
||||
Profile, Notifications, and Sign Out as explicit navigation actions and shows
|
||||
the avatar in the signed-in identity block. Avatar imagery supplements rather
|
||||
than replaces the textual identity, state, and unread indicators. User avatars
|
||||
are always rendered as circular portraits with the restrained orange SHD ring
|
||||
used by the Agent Profile; the source image remains square and uses
|
||||
`object-fit: cover` so authenticated surfaces crop it consistently.
|
||||
|
||||
When unread notifications exist on mobile, repeat the compact orange count on
|
||||
both the menu trigger and the Notifications destination. The trigger badge sits
|
||||
outside the upper-right control edge without changing the hamburger geometry.
|
||||
Its accessible name includes the full unread count; the visible badge is hidden
|
||||
from assistive technology to avoid announcing the count twice.
|
||||
|
||||
Oversight reviewers receive a permission-aware cadence console inside
|
||||
Notification Preferences. Each available review queue is shown independently
|
||||
and uses the shared segmented radio pattern for Immediate, 15-minute, one-hour,
|
||||
or daily delivery. Do not expose categories the agent cannot review.
|
||||
Consolidated delivery groups queue items rather than issuing repeated messages;
|
||||
daily delivery targets 10:00 AM in `America/New_York` so the Eastern wall-clock
|
||||
time remains stable across daylight-saving transitions. On mobile, cadence
|
||||
segments wrap to two columns and each review category becomes its own row.
|
||||
|
||||
ISAC System Configuration is a distinct navigation domain from System
|
||||
Oversight. It contains operational configuration such as Automated Messages and
|
||||
read-only observability such as System Metrics. Automated-message editors use
|
||||
the standard horizontal edit lifecycle: successful edits close the editor,
|
||||
validation preserves it, and event triggers remain explicit. Content, enabled
|
||||
state, post-event delay, primary destination, and up to eight action callouts
|
||||
are displayed as structured fields rather than raw JSON.
|
||||
|
||||
Automated ISAC messages may expose multiple square action callouts in the
|
||||
notification inbox and detail view. Callouts wrap on desktop and become
|
||||
full-width touch targets on mobile. Each retains a specific verb and
|
||||
destination; do not replace the set with a generic “open” action. External
|
||||
community actions such as Discord use secure HTTPS destinations while internal
|
||||
actions use routed paths.
|
||||
|
||||
Contact notifications use the mandatory ISAC visual identity because they are
|
||||
operational inbound messages. The notification carries a concise preview and a
|
||||
@@ -1155,6 +1232,89 @@ back to a short orange text mark rather than removing the relationship.
|
||||
Use this pattern only for small bounded sets that remain scannable, such as the
|
||||
two-to-four compatible Brand Sets on Gear. Long or unbounded relationships
|
||||
continue to use counts, searchable inspection, or expanded detail views.
|
||||
Public Gear cards use the same rail for compatible Brand Sets and available
|
||||
Talents so players can identify actual options instead of reading a count.
|
||||
Each icon retains its accessible name and hover title, and missing artwork uses
|
||||
the shared placeholder without suppressing the relationship. Relationship
|
||||
icons are buttons: activating one opens a modal quick view using the same card
|
||||
facts as that record's public database category. The overlay closes through
|
||||
its explicit close action, backdrop, or Escape key. Do not render an empty
|
||||
Talent rail on item types that cannot equip Talents.
|
||||
|
||||
### Public game-database registries
|
||||
|
||||
The public Database is a player-facing reference surface, separate from the
|
||||
ISAC editing workflow. Its index exposes eight durable categories: Gear,
|
||||
Weapons, Weapon Mods, Gear Mods, OS Protocols, Talents, Brands, and Skill
|
||||
Chips. Attributes are implementation vocabulary resolved into the facts and
|
||||
effects shown on those records; do not promote Attributes to a public
|
||||
top-level category.
|
||||
|
||||
Each index category is a fully interactive square registry card with a live
|
||||
approved-record count, concise scope statement, visible hover/focus state, and
|
||||
pressed feedback. A category page uses the shared compact category rail,
|
||||
live name search, pagination, and a responsive card grid. Record cards use the
|
||||
same hierarchy:
|
||||
|
||||
- a fixed icon cell and compact identity header;
|
||||
- small textual badges for quality, tier, or exceptional state;
|
||||
- bordered fact cells for primary values;
|
||||
- relationship lines and tier descriptions only when applicable.
|
||||
|
||||
When a record represents an ordered progression rather than independent facts,
|
||||
use the stacked fact treatment. Brand Set bonuses are shown vertically in
|
||||
two-, three-, then four-piece order; do not add a redundant “3/3 bonuses”
|
||||
badge when all three rows are already visible.
|
||||
|
||||
Weapon cards also use stacked facts. Their level-50 Damage range is the primary
|
||||
fact: a taller orange-railed row with enlarged values and the Damage Type in
|
||||
the label. Public damage is calculated from the stored level-1 endpoints using
|
||||
the same `weaponBaseRange` curve as the builder; never label the configured
|
||||
base endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use
|
||||
separate clickable icon rails and the standard Talent quick view.
|
||||
|
||||
Record cards may end with contextual actions that connect related public
|
||||
registries. Actions use the compact orange tactical-button treatment and
|
||||
encode their destination filter in the URL. Brand cards provide a “find Gear”
|
||||
action that opens the Gear registry with that Brand selected; the same action
|
||||
must remain available in Brand quick views.
|
||||
|
||||
Cards display canonical active data only. Attribute references must be
|
||||
resolved to readable names, values, and units instead of exposing IDs or raw
|
||||
JSON. Missing uploaded art uses the standard missing-item placeholder.
|
||||
Desktop layouts use three record columns, intermediate layouts use two, and
|
||||
narrow layouts use one. The eight-category rail reduces from eight to four to
|
||||
two columns. Preserve keyboard focus, pressed feedback, reduced-motion
|
||||
behavior, and readable textual state in every layout.
|
||||
|
||||
Category-specific discovery uses the shared tactical filter matrix. Filters
|
||||
apply immediately, remain encoded in the URL, reset pagination, and compose
|
||||
with name search. Multiple selections inside one facet are inclusive (`OR`);
|
||||
active facet groups combine restrictively (`AND`). For example, selecting two
|
||||
Brands and four Talents returns Gear matching any selected Brand and any
|
||||
selected Talent. Never implement multi-select facets as an implicit match-all
|
||||
within the same group.
|
||||
|
||||
Use segmented icon choices for bounded visual vocabularies of six or fewer,
|
||||
such as Gear slots. Use the matching searchable multi-select list for growing
|
||||
sets such as Brands and Talents. Always expose selected counts, a single clear
|
||||
action, `aria-pressed` state, keyboard focus, and explicit text explaining the
|
||||
combination rule. At intermediate width, the bounded facet spans the row above
|
||||
two searchable facets; on narrow screens all facet groups stack vertically.
|
||||
Weapon discovery applies this pattern to weapon-family icons, damage-type
|
||||
icons, and the searchable pool of compatible Talents. Talent facet rows show
|
||||
the rendered Tier I description beneath the name. Talent searches match both
|
||||
fields case-insensitively so mechanical phrases can discover talents even when
|
||||
the talent name is unknown. Brand facets similarly index the names of all
|
||||
set-bonus Attributes, allowing players to search by the desired mechanic
|
||||
without exposing that metadata as extra visual clutter.
|
||||
|
||||
Public database command headers are content-sized and must explicitly override
|
||||
the global 76-pixel site-header rule. Their eyebrow, title, description, count,
|
||||
and index action remain inside the bordered command surface at every scale.
|
||||
Children use `min-width: 0`, long titles may wrap, and category navigation
|
||||
tracks use zero-minimum grid columns so translated or enlarged text cannot
|
||||
force horizontal page overflow.
|
||||
|
||||
### Build persistence and sharing
|
||||
|
||||
@@ -1208,3 +1368,107 @@ its own unique quality: reveal only the required exotic-weapon assignment,
|
||||
remove weapon-category and regular-quality controls, and replace random ranges
|
||||
with fixed level-1 and level-20 attribute values. Keep an explicit Exotic
|
||||
Quality state panel visible so the change in data semantics is unmistakable.
|
||||
|
||||
### Gear Mod registry
|
||||
|
||||
Gear Mods reuse the compact quality matrix, level preview, and pooled
|
||||
secondary-attribute rows established by Weapon Mods. Do not render exotic,
|
||||
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
|
||||
Present the five primary options as an always-visible informational band
|
||||
because the actual primary is selected on the build instance, not persisted on
|
||||
the canonical definition.
|
||||
|
||||
Use explicit textual tier capacity wherever Gear tier is summarized: `1 MOD
|
||||
SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity band
|
||||
and preview controls stack, while repeated quality data may scroll rather than
|
||||
compressing numeric controls below usable sizes.
|
||||
|
||||
### Deferred game-data icons
|
||||
|
||||
Game-data icons are optional enrichment, never a blocker for creating or
|
||||
proposing a canonical item. Upload fields must state that an icon may be
|
||||
contributed later and show the shared `/missing-item-icon.svg` artwork when no
|
||||
upload exists. Registries, relationship rails, and builder selection controls
|
||||
use the same placeholder instead of initials, empty boxes, or ad hoc glyphs.
|
||||
|
||||
Treat the placeholder as an explicit incomplete-data state: keep its subdued
|
||||
SHD styling and useful alternative text where the surrounding item name is not
|
||||
already available. Do not persist placeholder metadata or copy the placeholder
|
||||
into per-item storage. A later contributor replaces it through the normal icon
|
||||
upload proposal, preserving the canonical record and audit history. The upload
|
||||
itself retains all transparent-format, size, SVG-safety, focus, drag/drop, and
|
||||
paste validation requirements.
|
||||
|
||||
### Public database performance facts
|
||||
|
||||
Use the shared `DatabaseFacts` renderer for registry-card performance data and
|
||||
quick views. Weapon identity headers keep the native tier beside the category
|
||||
identity as a compact textual `T0`, `T1`, or `T2` marker. Place tier and the
|
||||
smaller damage-type icon in a dedicated top-right status cluster; do not
|
||||
interrupt the readable `category // damage type` metadata with icons. Reserve
|
||||
the emphasized first fact for the weapon's damage range, pair it with the
|
||||
canonical damage-type icon, and render the calculated level as a subdued
|
||||
annotation rather than part of the primary label. Public level-50 damage must
|
||||
be calculated from the stored level-1 endpoints with `weaponBaseRange`; never
|
||||
relabel raw source values.
|
||||
|
||||
Represent bounded handling values such as Accuracy and Stability with compact
|
||||
horizontal meters. Always retain the numeric value and an accessible
|
||||
`value out of 100` label so the graphic does not carry meaning alone. Use the
|
||||
stable sequence `label → meter → exact value`, with the exact number aligned in
|
||||
a dedicated column to the right of the graphic. Clamp display values to
|
||||
`0–100`, use orange only for the filled measure, and keep the unfilled track
|
||||
neutral. Stack meter labels and tracks on narrow cards rather than shrinking
|
||||
them below a reliably readable width.
|
||||
|
||||
Talent registry cards treat the two tier descriptions as their primary
|
||||
content; never replace them with configured/missing summary facts. Render Tier
|
||||
I and Tier II as distinct structured data blocks, then place optional cooldown
|
||||
metadata below both descriptions. Do not use the serif, quote-like prose
|
||||
treatment for tier effects. Use compact fixed tier labels, sans-serif effect
|
||||
text, and engineered borders. Tier I is the neutral baseline; Tier II uses the
|
||||
orange rail, label, and subtly elevated surface to communicate that it is the
|
||||
improved version. Duration and stack limits may remain compact badges. The
|
||||
top-right header status uses the applicable Backpack or Body Armor icon for
|
||||
gear talents and the SMG icon as the generic Weapon Talent symbol. Weapon
|
||||
Talent headers pair that icon with an explicit `1 · TOP` or `2 · BOTTOM`
|
||||
status badge; never rely on the slot number alone.
|
||||
|
||||
When a public registry needs only one small bounded filter vocabulary, use the
|
||||
compact facet-panel form: one full-width group with all options visible in a
|
||||
single row on wide screens. Talent slot filtering uses Backpack, Body Armor,
|
||||
Weapon 1 · Top, and Weapon 2 · Bottom. Always include the Top/Bottom context
|
||||
when presenting numbered Weapon Talent slots to users. The filter remains
|
||||
additive within the group and wraps to two columns on narrow screens; do not
|
||||
introduce search or dropdown controls for this four-option set.
|
||||
|
||||
Notification delivery preferences are a temporary profile control surface.
|
||||
After a successful save, close the interface and return focus to the inbox
|
||||
context. Consume a unique server result token only once so later actions or
|
||||
reopening the panel do not close it again. Validation and server errors keep
|
||||
the interface open with the submitted controls available for correction.
|
||||
|
||||
### Global toast feedback
|
||||
|
||||
Use the root-mounted `ToastViewport` and the shared API in `src/lib/toast.ts`
|
||||
for transient action feedback. Enhanced server mutations should normally use
|
||||
`PendingForm`, which derives success and failure feedback from the action
|
||||
result. Client-only operations call `toast.success`, `toast.error`,
|
||||
`toast.warning`, or `toast.info` directly. Do not create page-specific toast
|
||||
implementations.
|
||||
|
||||
Toasts occupy the top-right safe area below the global header and show no more
|
||||
than four messages. Each uses a textual state label, tone-colored top and left
|
||||
rails, a concise title, optional supporting text, and an explicit dismiss
|
||||
control. Success is green, errors use danger red, warnings use SHD orange, and
|
||||
informational feedback uses restrained blue-gray. Color never carries state
|
||||
alone.
|
||||
|
||||
Use success to confirm completed mutations, error for failed requests, warning
|
||||
when attention is required, and info for neutral client or system state. Keep
|
||||
inline validation beside affected fields even when an error toast is also
|
||||
emitted. Avoid toasts for passive navigation, filtering, hover, unpersisted
|
||||
selection changes, and background refreshes. Toasts automatically dismiss
|
||||
according to severity, remain keyboard dismissible, use `status` or `alert`
|
||||
semantics, and disable entrance motion with reduced motion. On mobile they span
|
||||
the safe viewport width below the header without obscuring navigation.
|
||||
|
||||
@@ -261,6 +261,26 @@ availability is not tied to a numbered slot in canonical data. Mod Effects are
|
||||
triggerless, hold one or two attribute references, and store fixed per-quality
|
||||
values in `weapon_mod_effect_quality_values`.
|
||||
|
||||
### Gear Mods and tier slot capacity
|
||||
|
||||
Gear Mod definitions are standard-quality records only; there is no exotic
|
||||
Gear Mod branch. Every definition supplies primary roll ranges for Standard,
|
||||
Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
|
||||
those endpoints scale linearly. A build instance selects one primary attribute
|
||||
from Firepower, Toughness, Engineering, Health, or Armor.
|
||||
|
||||
The two secondary slots draw distinct attributes from one shared option pool.
|
||||
Each available secondary attribute stores its own minimum and maximum for all
|
||||
four qualities. Gear tier determines capacity without per-item configuration:
|
||||
T0 gear has one Gear Mod slot, while T1 and T2 gear have two.
|
||||
|
||||
Canonical data is stored in `gear_mods`, `gear_mod_quality_values`,
|
||||
`gear_mod_secondary_options`, and `gear_mod_secondary_quality_values`.
|
||||
Successful mutations use `gear_mod.created`, `gear_mod.updated`,
|
||||
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
|
||||
submissions use the `gearmods` proposal module and remain non-canonical until
|
||||
approved by Oversight.
|
||||
|
||||
# Skill Chip quality scaling
|
||||
|
||||
Skill Chip sets support Superior and High-end chip instances. Each set owns one
|
||||
@@ -293,3 +313,58 @@ served by the validated `/icons/game-data/[type]/[id]` endpoint with immutable
|
||||
cache headers and MIME sniffing disabled. ISAC save actions process files
|
||||
before mutation, apply upload or removal after record validation, and include
|
||||
only safe icon metadata—not image bytes—in their audit snapshots.
|
||||
|
||||
Icon metadata is optional. Canonical records and contributor proposals may be
|
||||
created without an upload, allowing values and artwork to be contributed in
|
||||
separate dependency-safe changes. Missing artwork is represented at render
|
||||
time by the static `/missing-item-icon.svg` asset through the shared
|
||||
`ItemIcon` component; the placeholder is never inserted into
|
||||
`game_data_icons`. A later edit or proposal can attach the real icon without
|
||||
recreating or replacing the canonical item.
|
||||
# Oversight notification dispatch
|
||||
|
||||
New Data Change and Avatar Oversight review items are routed only to agents who
|
||||
currently hold `data.approve` or `avatars.review` respectively. Each reviewer
|
||||
chooses Immediate, 15-minute, one-hour, or daily delivery independently for
|
||||
each authorized queue. Immediate alerts are created during submission;
|
||||
consolidated alerts are persisted in `oversight_notification_queue`.
|
||||
|
||||
Production must run the idempotent dispatcher once per minute from exactly one
|
||||
scheduler instance:
|
||||
|
||||
```text
|
||||
* * * * * cd /path/to/shd.build && npm run notifications:oversight:production
|
||||
```
|
||||
|
||||
The dispatcher groups due rows by reviewer, queue, cadence, and delivery
|
||||
window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in
|
||||
`America/New_York`, including daylight-saving transitions. Successfully
|
||||
dispatched rows retain `delivered_at` evidence and are never selected again.
|
||||
The existing ISAC notification delivery preferences still determine optional
|
||||
browser and email fan-out after the consolidated inbox message is created.
|
||||
|
||||
## Automated ISAC system messages
|
||||
|
||||
Automated message definitions live in `automated_system_messages` and are
|
||||
managed through ISAC → System Configuration → Automated Messages by agents with
|
||||
`system.notifications.manage`. Edits are recorded under the
|
||||
`system_configuration` audit category. Definitions identify a lifecycle event,
|
||||
enabled state, delay in minutes, message content, primary destination, and
|
||||
structured action callouts.
|
||||
|
||||
`account.created` is emitted by password, magic-link, and Discord registration.
|
||||
The delivery table has a unique message/user/event/event-ID constraint, so the
|
||||
same account creation cannot deliver the Welcome Agent message more than once.
|
||||
The default welcome introduces News, Database, Builds, Guides, Builder, ISAC
|
||||
Contributor Mode, and the SHD Discord community.
|
||||
|
||||
Immediate definitions are delivered in the registration request. Definitions
|
||||
with a delay are persisted in `automated_system_message_deliveries`. Production
|
||||
must run the delayed dispatcher once per minute:
|
||||
|
||||
```text
|
||||
* * * * * cd /path/to/shd.build && npm run notifications:system:production
|
||||
```
|
||||
|
||||
Only one dispatcher instance should run for the embedded database. Delivered
|
||||
rows retain both their delivery timestamp and generated notification ID.
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
|
||||
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
||||
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
||||
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
|
||||
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
|
||||
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
|
||||
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
|
||||
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
||||
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
||||
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
||||
|
||||
@@ -93,6 +93,12 @@ for (const [name, definition] of [
|
||||
await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL");
|
||||
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
||||
|
||||
const notificationColumns = await (await database.prepare("PRAGMA table_info('notifications')")).all();
|
||||
if (!notificationColumns.some((column) => column.name === 'actions_json')) {
|
||||
console.log('Adding structured action callouts to notifications.');
|
||||
await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT');
|
||||
}
|
||||
|
||||
const proposalDefinition = await (await database.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
||||
)).get();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
||||
const now = new Date().toISOString();
|
||||
const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue
|
||||
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now);
|
||||
const groups = new Map();
|
||||
for (const item of due) {
|
||||
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(item);
|
||||
}
|
||||
|
||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT || 587),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||
disableFileAccess: true,
|
||||
disableUrlAccess: true
|
||||
}) : null;
|
||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
||||
let delivered = 0;
|
||||
|
||||
for (const items of groups.values()) {
|
||||
const first = items[0];
|
||||
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
||||
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
||||
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n');
|
||||
const remainder = items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
||||
const body = `${details}${remainder}`;
|
||||
const notificationId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO notifications
|
||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
|
||||
notificationId, first.user_id, title, body, first.link, now
|
||||
);
|
||||
for (const item of items) {
|
||||
await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id);
|
||||
}
|
||||
const preference = await (await database.prepare(
|
||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
||||
)).get(first.user_id);
|
||||
if (transport && preference?.email_frequency === 'immediate') {
|
||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
|
||||
if (typeof identity?.email === 'string') {
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: identity.email,
|
||||
subject: `SHD.build · ${title}`,
|
||||
text: `${body}\n\n${origin}${first.link}`,
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`
|
||||
});
|
||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
||||
} catch (error) {
|
||||
console.error(`Unable to email Oversight digest ${notificationId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
delivered += items.length;
|
||||
}
|
||||
|
||||
console.log(`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
||||
const now = new Date().toISOString();
|
||||
const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
||||
FROM automated_system_message_deliveries delivery
|
||||
JOIN automated_system_messages message ON message.id=delivery.message_id
|
||||
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
||||
ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
|
||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT || 587),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||
disableFileAccess: true,
|
||||
disableUrlAccess: true
|
||||
}) : null;
|
||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[character]);
|
||||
let delivered = 0;
|
||||
|
||||
for (const message of due) {
|
||||
const notificationId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO notifications
|
||||
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
||||
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
|
||||
notificationId, message.user_id, message.source, message.title, message.body,
|
||||
message.link ?? null, message.actions_json ?? null, now
|
||||
);
|
||||
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
||||
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
||||
.run(now, notificationId, message.delivery_id);
|
||||
const preference = await (await database.prepare(
|
||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
||||
)).get(message.user_id);
|
||||
if (transport && preference?.email_frequency === 'immediate') {
|
||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
|
||||
if (typeof identity?.email === 'string') {
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: identity.email,
|
||||
subject: `SHD.build · ${message.title}`,
|
||||
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
|
||||
});
|
||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
||||
} catch (error) {
|
||||
console.error(`Unable to email automated notification ${notificationId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
delivered += 1;
|
||||
}
|
||||
console.log(`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`);
|
||||
+32
File diff suppressed because one or more lines are too long
@@ -5,21 +5,26 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { user, isacAccess, notificationSummary, children }: {
|
||||
user: { username: string; displayName: string; role: string } | null;
|
||||
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
|
||||
isacAccess: boolean;
|
||||
notificationSummary: { unread: number };
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
const links = [
|
||||
{ href: '/builds', label: 'Discover' },
|
||||
{ href: '/builder', label: 'Builder' },
|
||||
{ href: '/guides', label: 'Guides' }
|
||||
{ href: '/news', label: 'News' },
|
||||
{ href: '/database', label: 'Database' },
|
||||
{ href: '/builds', label: 'Builds' },
|
||||
{ href: '/guides', label: 'Guides' },
|
||||
{ href: '/builder', label: 'Builder' }
|
||||
];
|
||||
|
||||
let showCookieNotice = $state(false);
|
||||
let mobileOpen = $state(false);
|
||||
let mobilePanel = $state<HTMLElement | null>(null);
|
||||
let accountOpen = $state(false);
|
||||
let accountButton = $state<HTMLButtonElement | null>(null);
|
||||
let accountMenu = $state<HTMLElement | null>(null);
|
||||
let unreadNotifications = $state(0);
|
||||
$effect(() => { unreadNotifications = notificationSummary.unread; });
|
||||
|
||||
@@ -68,61 +73,109 @@
|
||||
event.preventDefault();
|
||||
closeMobileNavigation();
|
||||
}
|
||||
if (event.key === 'Escape' && accountOpen) {
|
||||
event.preventDefault();
|
||||
accountOpen = false;
|
||||
accountButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAccountMenu() {
|
||||
accountOpen = !accountOpen;
|
||||
if (accountOpen) {
|
||||
await tick();
|
||||
accountMenu?.querySelector<HTMLElement>('a')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeAccountFromOutside(event: MouseEvent) {
|
||||
if (accountOpen && !(event.target as Element).closest('.desktop-agent-menu')) accountOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
mobileOpen = false;
|
||||
accountOpen = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleNavigationKey}/>
|
||||
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="SHD.build home">
|
||||
<span class="brand-mark"><i></i><b>SHD</b></span><span class="brand-name">SHD<span>.build</span><small>LOADOUT NETWORK</small></span>
|
||||
<span class="brand-mark"><i></i><b>SHD</b></span><span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
|
||||
</a>
|
||||
<nav aria-label="Main navigation">
|
||||
{#each links as link}
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
|
||||
{/if}
|
||||
{#if isacAccess}
|
||||
<a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{#if user}
|
||||
<div class="account">
|
||||
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
|
||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
|
||||
<div class="desktop-agent-menu">
|
||||
<button
|
||||
class="desktop-agent-trigger"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountOpen}
|
||||
aria-controls="desktop-agent-menu"
|
||||
bind:this={accountButton}
|
||||
onclick={toggleAccountMenu}
|
||||
>
|
||||
<img src={user.avatarUrl} alt=""/>
|
||||
<span><small><i></i> AGENT ONLINE</small><b>{user.displayName}</b></span>
|
||||
{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{/if}
|
||||
<strong aria-hidden="true">{accountOpen ? '−' : '+'}</strong>
|
||||
</button>
|
||||
{#if accountOpen}
|
||||
<div id="desktop-agent-menu" class="desktop-agent-panel" role="menu" bind:this={accountMenu}>
|
||||
<header><img src={user.avatarUrl} alt=""/><div><small>ACTIVE AGENT</small><b>{user.displayName}</b><span>{user.role.toUpperCase()} ACCESS</span></div></header>
|
||||
<a role="menuitem" class:active={page.url.pathname === '/account'} href="/account"><span>MY PROFILE</span><b>AGENT DOSSIER →</b></a>
|
||||
<a role="menuitem" class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><span>NOTIFICATIONS</span><b>{unreadNotifications ? `${unreadNotifications > 99 ? '99+' : unreadNotifications} UNREAD` : 'INBOX CLEAR'} →</b></a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button role="menuitem" class="desktop-agent-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a class="sign-in" href="/sign-in">SIGN IN →</a>
|
||||
{/if}
|
||||
<button class="mobile-nav-toggle" type="button" aria-label={mobileOpen ? 'Close navigation' : 'Open navigation'} aria-expanded={mobileOpen} aria-controls="mobile-navigation" onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}><span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b></button>
|
||||
<button
|
||||
class="mobile-nav-toggle"
|
||||
type="button"
|
||||
aria-label={`${mobileOpen ? 'Close' : 'Open'} navigation${unreadNotifications ? `, ${unreadNotifications} unread notification${unreadNotifications === 1 ? '' : 's'}` : ''}`}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-navigation"
|
||||
onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}
|
||||
>
|
||||
<span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b>
|
||||
{#if unreadNotifications}
|
||||
<em class="mobile-nav-unread" aria-hidden="true">{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if mobileOpen}
|
||||
<div class="mobile-nav-layer">
|
||||
<button class="mobile-nav-backdrop" type="button" aria-label="Close navigation" onclick={closeMobileNavigation}></button>
|
||||
<nav id="mobile-navigation" class="mobile-navigation" aria-label="Mobile navigation" bind:this={mobilePanel}>
|
||||
<div class="mobile-nav-heading"><div><span>SHD NETWORK</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
|
||||
<div class="mobile-nav-heading"><div><span>SHD COMMUNITY</span><strong>NAVIGATION</strong></div><b>ONLINE</b></div>
|
||||
<a class:active={page.url.pathname === '/'} href="/"><small>00</small><span>Home</span><b>›</b></a>
|
||||
{#each links as link, index}
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>04</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account"><small>05</small><span>My Profile</span><b>›</b></a>
|
||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Notifications</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account"><small>{String(links.length + 2).padStart(2, '0')}</small><span>My Profile</span><b>›</b></a>
|
||||
{:else}
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Sign In</span><b>›</b></a>
|
||||
{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>06</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>{String(links.length + 3).padStart(2, '0')}</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if user}
|
||||
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||
<div class="mobile-agent-state"><img src={user.avatarUrl} alt=""/><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -135,9 +188,9 @@
|
||||
<div>
|
||||
<a class="brand footer-brand" href="/" aria-label="SHD.build home">
|
||||
<span class="brand-mark"><i></i><b>SHD</b></span>
|
||||
<span class="brand-name">SHD<span>.build</span><small>LOADOUT NETWORK</small></span>
|
||||
<span class="brand-name">SHD<span>.build</span><small>RESURGENCE COMMUNITY</small></span>
|
||||
</a>
|
||||
<p>Independent loadout intelligence for the agent community.</p>
|
||||
<p>Independent builds, guides, game data, news, and community intelligence for The Division Resurgence.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-discord" href="https://discord.gg/dhpd33VwQK" target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
value = $bindable(''),
|
||||
@@ -24,7 +26,7 @@
|
||||
<div class="builder-picker">
|
||||
<span>{label}</span>
|
||||
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
|
||||
{#if selected?.iconUrl}<img src={selected.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
|
||||
<ItemIcon src={selected?.iconUrl} alt=""/>
|
||||
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
|
||||
</button>
|
||||
{#if open}
|
||||
@@ -34,7 +36,7 @@
|
||||
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
|
||||
{#each results as item}
|
||||
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
|
||||
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
|
||||
</button>
|
||||
{:else}<p>NO MATCHING ITEMS</p>{/each}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
|
||||
let {
|
||||
label,
|
||||
name,
|
||||
@@ -42,7 +44,7 @@
|
||||
<label title={item.name}>
|
||||
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
|
||||
<span>
|
||||
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>{item.name.slice(0,2).toUpperCase()}</i>{/if}
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
<b>{item.name}</b>
|
||||
{#if item.category}<small>{item.category}</small>{/if}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { databaseCategories } from '$lib/database/categories';
|
||||
let { active = '' }: { active?: string } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="database-category-nav" aria-label="Game database categories">
|
||||
{#each databaseCategories as category}
|
||||
<a class:active={active === category.slug} aria-current={active === category.slug ? 'page' : undefined} href={`/database/${category.slug}`}>
|
||||
<small>{category.code}</small><span>{category.label}</span><b>›</b>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import GameIcon from './GameIcon.svelte';
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
|
||||
type Option = { value: string; label: string; description?: string; searchText?: string; iconUrl?: string; gameIcon?: GameIconName };
|
||||
type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
|
||||
let { groups }: { groups: Group[] } = $props();
|
||||
let queries = $state<Record<string, string>>({});
|
||||
|
||||
const change = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
const selected = params.getAll(key);
|
||||
params.delete(key);
|
||||
for (const entry of selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value]) params.append(key, entry);
|
||||
params.delete('page');
|
||||
void goto(`${page.url.pathname}?${params}`, { keepFocus: true, noScroll: true });
|
||||
};
|
||||
const clear = () => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
for (const group of groups) params.delete(group.key);
|
||||
params.delete('page');
|
||||
void goto(`${page.url.pathname}?${params}`, { noScroll: true });
|
||||
};
|
||||
const visible = (group: Group) => {
|
||||
const query = (queries[group.key] ?? '').normalize('NFKC').trim().toLocaleLowerCase();
|
||||
return group.options.filter((option) => !query
|
||||
|| option.label.toLocaleLowerCase().includes(query)
|
||||
|| option.description?.toLocaleLowerCase().includes(query)
|
||||
|| option.searchText?.toLocaleLowerCase().includes(query));
|
||||
};
|
||||
const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
|
||||
</script>
|
||||
|
||||
<section class="database-facet-panel" aria-label="Database filters">
|
||||
<header><div><small>TACTICAL FILTER MATRIX</small><strong>MATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP</strong></div>{#if selectedCount}<button type="button" onclick={clear}>CLEAR {selectedCount} FILTER{selectedCount === 1 ? '' : 'S'}</button>{/if}</header>
|
||||
<div class:compact={groups.length === 1} class="database-facet-groups">
|
||||
{#each groups as group}
|
||||
<fieldset class:searchable={group.kind === 'searchable'} class="database-facet-group">
|
||||
<legend>{group.label}<span>{group.selected.length ? `${group.selected.length} ACTIVE` : 'ANY'}</span></legend>
|
||||
{#if group.kind === 'searchable'}
|
||||
<label class="database-facet-search"><span class="sr-only">Search {group.label}</span><input type="search" value={queries[group.key] ?? ''} oninput={(event) => queries[group.key] = event.currentTarget.value} placeholder={`FILTER ${group.label}`}/></label>
|
||||
{/if}
|
||||
<div>
|
||||
{#each visible(group) as option}
|
||||
<button type="button" class:selected={group.selected.includes(option.value)} aria-pressed={group.selected.includes(option.value)} onclick={() => change(group.key, option.value)}>
|
||||
{#if option.gameIcon}<GameIcon name={option.gameIcon} decorative/>{:else if option.iconUrl !== undefined}<ItemIcon src={option.iconUrl} alt=""/>{:else}<i aria-hidden="true"></i>{/if}
|
||||
<span>{option.label}{#if option.description}<small><b>T1</b>{option.description}</small>{/if}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<small>NO MATCHING OPTIONS</small>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import GameIcon from './GameIcon.svelte';
|
||||
import type { DatabaseFact } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
facts,
|
||||
layout = 'grid'
|
||||
}: {
|
||||
facts: DatabaseFact[];
|
||||
layout?: 'grid' | 'stack';
|
||||
} = $props();
|
||||
|
||||
const meterValue = (value: number) => Math.max(0, Math.min(100, value));
|
||||
</script>
|
||||
|
||||
<div class:stacked={layout === 'stack'} class="database-item-facts">
|
||||
{#each facts as fact}
|
||||
<div class:primary={fact.emphasis === 'primary'}>
|
||||
<span>{fact.label}</span>
|
||||
<div class="database-fact-value">
|
||||
{#if fact.gameIcon}<GameIcon name={fact.gameIcon}/>{/if}
|
||||
<b>{fact.value}</b>
|
||||
{#if fact.note}<small>{fact.note}</small>{/if}
|
||||
</div>
|
||||
{#if fact.meters?.length}
|
||||
<div class="database-stat-meters">
|
||||
{#each fact.meters as meter}
|
||||
<div>
|
||||
<i>{meter.label}</i>
|
||||
<progress max="100" value={meterValue(meter.value)} aria-label={`${meter.label}: ${meterValue(meter.value)} out of 100`}></progress>
|
||||
<output aria-label={`${meter.label} exact value`}>{meterValue(meter.value)}</output>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import GameIcon from './GameIcon.svelte';
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import ItemIconRail from './ItemIconRail.svelte';
|
||||
import DatabaseFacts from './DatabaseFacts.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
item
|
||||
}: {
|
||||
item: DatabaseItem & { gameIcon?: GameIconName }
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="database-item-card">
|
||||
<header>
|
||||
<div class="database-item-icon">
|
||||
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
|
||||
</div>
|
||||
<div class="database-item-identity">
|
||||
<small>{item.kicker}</small>
|
||||
<h2>{item.name}</h2>
|
||||
</div>
|
||||
{#if item.headerBadge || item.headerMetaIcon}
|
||||
<div class="database-header-status">
|
||||
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
|
||||
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
|
||||
{#if item.lines?.length}
|
||||
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
{#if item.iconGroups?.length}
|
||||
<div class="database-item-icon-groups">
|
||||
{#each item.iconGroups as group}
|
||||
<div><span>{group.label}</span><ItemIconRail label={group.label} items={group.items} emptyLabel={group.emptyLabel}/></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||
{#if item.afterLines?.length}
|
||||
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
{#if item.actions?.length}
|
||||
<footer class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<fieldset class="icon-upload-field">
|
||||
<legend>{label}</legend>
|
||||
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} {resetToken} emptyLabel="NO ICON" browseLabel={currentUrl ? 'REPLACE ICON' : 'CHOOSE ICON'}/>
|
||||
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
|
||||
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
|
||||
<small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
|
||||
<small>OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
|
||||
</fieldset>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
accept,
|
||||
required = false,
|
||||
currentUrl = '',
|
||||
placeholderUrl = '',
|
||||
emptyLabel = 'NO IMAGE',
|
||||
browseLabel = 'CHOOSE FILE',
|
||||
resetToken = 0
|
||||
@@ -14,6 +15,7 @@
|
||||
accept: string;
|
||||
required?: boolean;
|
||||
currentUrl?: string | null;
|
||||
placeholderUrl?: string | null;
|
||||
emptyLabel?: string;
|
||||
browseLabel?: string;
|
||||
resetToken?: string | number;
|
||||
@@ -120,8 +122,8 @@
|
||||
>
|
||||
<input bind:this={input} class="image-file-input" {name} type="file" {accept} {required} onchange={choose}/>
|
||||
<div class="image-upload-preview">
|
||||
{#if previewUrl || currentUrl}
|
||||
<img src={previewUrl || currentUrl || ''} alt="Selected upload preview"/>
|
||||
{#if previewUrl || currentUrl || placeholderUrl}
|
||||
<img class:missing-item-icon={!previewUrl && !currentUrl && Boolean(placeholderUrl)} src={previewUrl || currentUrl || placeholderUrl || ''} alt={previewUrl || currentUrl ? 'Selected upload preview' : emptyLabel}/>
|
||||
{:else}
|
||||
<span>{emptyLabel}</span>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
src = '',
|
||||
alt = '',
|
||||
className = ''
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<img src={src || '/missing-item-icon.svg'} {alt} class:missing-item-icon={!src} class={className}/>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import DatabaseFacts from './DatabaseFacts.svelte';
|
||||
import type { DatabaseRelatedItem } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
label,
|
||||
items,
|
||||
emptyLabel = 'NONE ASSIGNED'
|
||||
}: {
|
||||
label: string;
|
||||
items: DatabaseRelatedItem[];
|
||||
emptyLabel?: string;
|
||||
} = $props();
|
||||
let selected = $state<DatabaseRelatedItem | null>(null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') selected = null; }}/>
|
||||
|
||||
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
|
||||
{#if items.length}
|
||||
{#each items as item}
|
||||
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} onclick={() => selected = item}>
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<small>{emptyLabel}</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selected?.preview}
|
||||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
|
||||
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
|
||||
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" aria-label="Close details" onclick={() => selected = null}>×</button></header>
|
||||
<article class="database-item-card">
|
||||
<header>
|
||||
<div class="database-item-icon"><ItemIcon src={selected.preview.iconUrl} alt=""/></div>
|
||||
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
|
||||
</header>
|
||||
{#if selected.preview.badges?.length}<div class="database-item-badges">{#each selected.preview.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<DatabaseFacts facts={selected.preview.facts} layout={selected.preview.factLayout}/>
|
||||
{#if selected.preview.lines?.length}<div class="database-item-lines">{#each selected.preview.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
|
||||
{#if selected.preview.description}<p class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.description}</p>{/if}
|
||||
{#if selected.preview.secondaryDescription}<p class="secondary-description" class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.secondaryDescription}</p>{/if}
|
||||
{#if selected.preview.afterLines?.length}<div class="database-item-lines after-description">{#each selected.preview.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
|
||||
{#if selected.preview.actions?.length}<footer class="database-item-actions">{#each selected.preview.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>{/if}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,10 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { actionFeedback } from '$lib/toast';
|
||||
import type { Snippet } from 'svelte';
|
||||
let { action, method = 'POST', enctype, class: className = '', children }: { action: string; method?: 'POST'; enctype?: 'multipart/form-data'; class?: string; children: Snippet<[boolean]> } = $props();
|
||||
let {
|
||||
action,
|
||||
method = 'POST',
|
||||
enctype,
|
||||
class: className = '',
|
||||
toast = true,
|
||||
successMessage,
|
||||
children
|
||||
}: {
|
||||
action: string;
|
||||
method?: 'POST';
|
||||
enctype?: 'multipart/form-data';
|
||||
class?: string;
|
||||
toast?: boolean;
|
||||
successMessage?: string;
|
||||
children: Snippet<[boolean]>;
|
||||
} = $props();
|
||||
let pending = $state(false);
|
||||
</script>
|
||||
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
|
||||
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ result, update }) => { try { if (toast) actionFeedback(result, action, successMessage); await update(); } finally { pending = false; } }; }}>
|
||||
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { dismissToast, toasts, type Toast } from '$lib/toast';
|
||||
|
||||
const toneLabel = (toast: Toast) => ({
|
||||
success: 'OPERATION COMPLETE',
|
||||
error: 'ACTION FAILED',
|
||||
warning: 'ATTENTION REQUIRED',
|
||||
info: 'SYSTEM UPDATE'
|
||||
})[toast.tone];
|
||||
|
||||
function startTimer(_node: HTMLElement, toast: Toast) {
|
||||
const timer = window.setTimeout(() => dismissToast(toast.id), toast.duration);
|
||||
return {
|
||||
destroy() {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="toast-viewport" aria-label="Application feedback">
|
||||
{#each $toasts as item (item.id)}
|
||||
<article
|
||||
class={`toast-message ${item.tone}`}
|
||||
role={item.tone === 'error' || item.tone === 'warning' ? 'alert' : 'status'}
|
||||
aria-atomic="true"
|
||||
use:startTimer={item}
|
||||
>
|
||||
<i aria-hidden="true"></i>
|
||||
<div>
|
||||
<span>{toneLabel(item)}</span>
|
||||
<strong>{item.title}</strong>
|
||||
{#if item.message}<p>{item.message}</p>{/if}
|
||||
</div>
|
||||
<button type="button" aria-label={`Dismiss ${item.title}`} onclick={() => dismissToast(item.id)}>×</button>
|
||||
</article>
|
||||
{/each}
|
||||
</aside>
|
||||
@@ -0,0 +1,14 @@
|
||||
export const databaseCategories = [
|
||||
{ slug: 'gear', label: 'Gear', code: 'GEAR', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
|
||||
{ slug: 'weapons', label: 'Weapons', code: 'WPN', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
|
||||
{ slug: 'weapon-mods', label: 'Weapon Mods', code: 'WMOD', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
|
||||
{ slug: 'gear-mods', label: 'Gear Mods', code: 'GMOD', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
|
||||
{ slug: 'os-protocols', label: 'OS Protocols', code: 'OSPR', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
|
||||
{ slug: 'talents', label: 'Talents', code: 'TLNT', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
|
||||
{ slug: 'brands', label: 'Brands', code: 'BRND', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
|
||||
{ slug: 'skill-chips', label: 'Skill Chips', code: 'CHIP', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' }
|
||||
] as const;
|
||||
|
||||
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
|
||||
|
||||
export const databaseCategory = (slug: string) => databaseCategories.find((category) => category.slug === slug);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
export type DatabaseMeter = { label: string; value: number };
|
||||
export type DatabaseFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
emphasis?: 'primary';
|
||||
gameIcon?: GameIconName;
|
||||
note?: string;
|
||||
meters?: DatabaseMeter[];
|
||||
};
|
||||
|
||||
export type DatabaseItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kicker: string;
|
||||
iconUrl?: string;
|
||||
gameIcon?: string;
|
||||
headerBadge?: string;
|
||||
headerMetaIcon?: GameIconName;
|
||||
badges?: DatabaseBadge[];
|
||||
facts: DatabaseFact[];
|
||||
factLayout?: 'grid' | 'stack';
|
||||
actions?: Array<{ label: string; href: string }>;
|
||||
lines?: DatabaseFact[];
|
||||
afterLines?: DatabaseFact[];
|
||||
iconGroups?: DatabaseIconGroup[];
|
||||
description?: string;
|
||||
secondaryDescription?: string;
|
||||
descriptionStyle?: 'prose' | 'data';
|
||||
};
|
||||
|
||||
export type DatabaseRelatedItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl?: string;
|
||||
preview?: DatabaseItem;
|
||||
};
|
||||
|
||||
export type DatabaseIconGroup = {
|
||||
label: string;
|
||||
items: DatabaseRelatedItem[];
|
||||
emptyLabel?: string;
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export async function recordIsacAudit(input: {
|
||||
}
|
||||
|
||||
export async function auditRecord(table: string, id: string): Promise<Record<string, unknown> | null> {
|
||||
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
|
||||
if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'weapon_mods', 'gear_mods', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) {
|
||||
throw new Error('Unsupported audit table');
|
||||
}
|
||||
const database = await getDatabase();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
export const AUTOMATED_MESSAGE_EVENTS = [
|
||||
{ value: 'account.created', label: 'ACCOUNT CREATED', description: 'After a new SHD.build account is created' }
|
||||
] as const;
|
||||
export type AutomatedMessageEvent = (typeof AUTOMATED_MESSAGE_EVENTS)[number]['value'];
|
||||
|
||||
type MessageRow = Record<string, unknown>;
|
||||
|
||||
function parseActions(value: unknown): Array<{ label: string; href: string }> {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item) => item && typeof item.label === 'string' && typeof item.href === 'string')
|
||||
.map((item) => ({ label: item.label.slice(0, 60), href: item.href.slice(0, 500) }))
|
||||
.slice(0, 8);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function deliver(message: MessageRow, deliveryId: string, userId: string) {
|
||||
const notificationId = await createNotification({
|
||||
userId,
|
||||
category: 'isac',
|
||||
source: String(message.source),
|
||||
title: String(message.title),
|
||||
body: String(message.body),
|
||||
link: typeof message.link === 'string' ? message.link : null,
|
||||
actions: parseActions(message.actions_json),
|
||||
mandatory: true
|
||||
});
|
||||
if (!notificationId) return;
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
||||
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
||||
.run(new Date().toISOString(), notificationId, deliveryId);
|
||||
}
|
||||
|
||||
export async function triggerAutomatedSystemMessages(input: {
|
||||
eventKey: AutomatedMessageEvent;
|
||||
userId: string;
|
||||
eventId: string;
|
||||
}) {
|
||||
const database = await getDatabase();
|
||||
const messages = await (await database.prepare(
|
||||
'SELECT * FROM automated_system_messages WHERE event_key=? AND enabled=1 ORDER BY name'
|
||||
)).all(input.eventKey);
|
||||
const now = new Date();
|
||||
for (const message of messages) {
|
||||
const existing = await (await database.prepare(`SELECT id FROM automated_system_message_deliveries
|
||||
WHERE message_id=? AND user_id=? AND event_key=? AND event_id=?`))
|
||||
.get(message.id, input.userId, input.eventKey, input.eventId);
|
||||
if (existing) continue;
|
||||
const deliveryId = randomUUID();
|
||||
const scheduledFor = new Date(now.getTime() + Number(message.send_delay_minutes ?? 0) * 60_000);
|
||||
await (await database.prepare(`INSERT OR IGNORE INTO automated_system_message_deliveries
|
||||
(id,message_id,user_id,event_key,event_id,scheduled_for,created_at)
|
||||
VALUES(?,?,?,?,?,?,?)`)).run(
|
||||
deliveryId, message.id, input.userId, input.eventKey, input.eventId,
|
||||
scheduledFor.toISOString(), now.toISOString()
|
||||
);
|
||||
if (Number(message.send_delay_minutes ?? 0) === 0) {
|
||||
await deliver(message, deliveryId, input.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
|
||||
export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_AVATAR_PIXELS = 20_000_000;
|
||||
@@ -60,9 +61,17 @@ export async function saveUploadedAvatar(database: Database, userId: string, out
|
||||
.run(userId, 'upload', key, output.length, now);
|
||||
await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'"))
|
||||
.run(now, userId);
|
||||
const reviewId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO avatar_reviews
|
||||
(id,user_id,avatar_updated_at,storage_key,byte_size,status,submitted_at)
|
||||
VALUES(?,?,?,?,?,'pending',?)`)).run(randomUUID(), userId, now, key, output.length, now);
|
||||
VALUES(?,?,?,?,?,'pending',?)`)).run(reviewId, userId, now, key, output.length, now);
|
||||
await notifyOversightReview({
|
||||
category: 'avatars',
|
||||
itemId: reviewId,
|
||||
title: 'Avatar awaiting review',
|
||||
summary: 'A new custom agent avatar was uploaded and is ready for Oversight review.',
|
||||
link: '/isac/avatar-oversight?status=pending'
|
||||
}).catch((error) => console.error('Unable to notify Avatar Oversight reviewers', error));
|
||||
return now;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import type { AuthUser } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
import { notifyOversightReview } from '$lib/server/oversight-notifications';
|
||||
|
||||
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'] as const;
|
||||
export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
|
||||
export type DataModule = typeof DATA_MODULES[number];
|
||||
|
||||
const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: string; mode?: string }>>> = {
|
||||
@@ -18,6 +19,7 @@ const proposalResultKeys: Partial<Record<DataModule, Record<string, { saved: str
|
||||
},
|
||||
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
|
||||
weaponmods: { save: { saved: 'weaponModSaved', mode: 'weaponModMode' } },
|
||||
gearmods: { save: { saved: 'gearModSaved', mode: 'gearModMode' } },
|
||||
talents: {
|
||||
saveTrigger: { saved: 'triggerSaved', mode: 'triggerMode' },
|
||||
saveTalent: { saved: 'talentSaved', mode: 'talentMode' }
|
||||
@@ -156,6 +158,13 @@ export async function queueDataProposal(user: AuthUser, module: DataModule, acti
|
||||
before: null,
|
||||
after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary }
|
||||
});
|
||||
await notifyOversightReview({
|
||||
category: 'data_changes',
|
||||
itemId: id,
|
||||
title: 'Data change awaiting review',
|
||||
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
|
||||
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`
|
||||
}).catch((error) => console.error('Unable to notify Data Oversight reviewers', error));
|
||||
return { id, entityId, draftEntityId };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function createNotification(input: {
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string | null;
|
||||
actions?: Array<{ label: string; href: string }>;
|
||||
mandatory?: boolean;
|
||||
}) {
|
||||
const database = await getDatabase();
|
||||
@@ -26,10 +27,12 @@ export async function createNotification(input: {
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO notifications
|
||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`)).run(
|
||||
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
id, input.userId, input.category, input.source, input.title.slice(0, 140),
|
||||
input.body.slice(0, 2000), input.link ?? null, mandatory ? 1 : 0, now
|
||||
input.body.slice(0, 2000), input.link ?? null,
|
||||
input.actions?.length ? JSON.stringify(input.actions.slice(0, 8)) : null,
|
||||
mandatory ? 1 : 0, now
|
||||
);
|
||||
if (emailFrequency === 'immediate') {
|
||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(input.userId);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
export const OVERSIGHT_NOTIFICATION_CATEGORIES = [
|
||||
{ id: 'data_changes', label: 'DATA CHANGES', permission: 'data.approve', link: '/isac/data-moderation' },
|
||||
{ id: 'avatars', label: 'AVATAR REVIEW', permission: 'avatars.review', link: '/isac/avatar-oversight' }
|
||||
] as const;
|
||||
|
||||
export const OVERSIGHT_NOTIFICATION_FREQUENCIES = ['immediate', '15_minutes', '1_hour', 'daily'] as const;
|
||||
export type OversightNotificationCategory = (typeof OVERSIGHT_NOTIFICATION_CATEGORIES)[number]['id'];
|
||||
export type OversightNotificationFrequency = (typeof OVERSIGHT_NOTIFICATION_FREQUENCIES)[number];
|
||||
|
||||
function easternParts(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(date);
|
||||
return Object.fromEntries(parts.map((part) => [part.type, Number(part.value)])) as Record<string, number>;
|
||||
}
|
||||
|
||||
function easternInstant(year: number, month: number, day: number, hour: number) {
|
||||
for (const offset of [4, 5]) {
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day, hour + offset));
|
||||
const parts = easternParts(candidate);
|
||||
if (parts.year === year && parts.month === month && parts.day === day && parts.hour === hour) return candidate;
|
||||
}
|
||||
throw new Error('Unable to resolve Eastern delivery time.');
|
||||
}
|
||||
|
||||
function deliveryTime(frequency: Exclude<OversightNotificationFrequency, 'immediate'>, now: Date) {
|
||||
if (frequency !== 'daily') {
|
||||
const window = frequency === '15_minutes' ? 15 * 60_000 : 60 * 60_000;
|
||||
return new Date(Math.floor(now.getTime() / window) * window + window);
|
||||
}
|
||||
const parts = easternParts(now);
|
||||
const localDate = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
|
||||
if (parts.hour >= 10) localDate.setUTCDate(localDate.getUTCDate() + 1);
|
||||
return easternInstant(localDate.getUTCFullYear(), localDate.getUTCMonth() + 1, localDate.getUTCDate(), 10);
|
||||
}
|
||||
|
||||
async function eligibleReviewers(permission: string) {
|
||||
const database = await getDatabase();
|
||||
return (await database.prepare(`
|
||||
SELECT DISTINCT users.id
|
||||
FROM users
|
||||
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||||
LEFT JOIN user_status status ON status.user_id=users.id
|
||||
LEFT JOIN user_roles ON user_roles.user_id=users.id
|
||||
LEFT JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
|
||||
LEFT JOIN permissions role_permission ON role_permission.id=role_permissions.permission_id
|
||||
LEFT JOIN user_permissions ON user_permissions.user_id=users.id
|
||||
LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id
|
||||
WHERE deleted.user_id IS NULL AND status.blocked_at IS NULL
|
||||
AND (status.banned_until IS NULL OR status.banned_until<=?)
|
||||
AND (users.role='admin' OR role_permission.name IN (?, 'system.admin') OR direct_permission.name IN (?, 'system.admin'))
|
||||
`)).all(new Date().toISOString(), permission, permission);
|
||||
}
|
||||
|
||||
export async function notifyOversightReview(input: {
|
||||
category: OversightNotificationCategory;
|
||||
itemId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
link?: string;
|
||||
}) {
|
||||
const definition = OVERSIGHT_NOTIFICATION_CATEGORIES.find((item) => item.id === input.category);
|
||||
if (!definition) throw new Error('Unknown Oversight notification category.');
|
||||
const database = await getDatabase();
|
||||
const now = new Date();
|
||||
for (const reviewer of await eligibleReviewers(definition.permission)) {
|
||||
const userId = String(reviewer.id);
|
||||
const preference = await (await database.prepare(
|
||||
'SELECT frequency FROM oversight_notification_preferences WHERE user_id=? AND oversight_category=?'
|
||||
)).get(userId, input.category);
|
||||
const frequency = String(preference?.frequency ?? 'immediate') as OversightNotificationFrequency;
|
||||
if (frequency === 'immediate') {
|
||||
await createNotification({
|
||||
userId,
|
||||
category: 'isac',
|
||||
source: 'ISAC OVERSIGHT NETWORK',
|
||||
title: input.title,
|
||||
body: input.summary,
|
||||
link: input.link ?? definition.link,
|
||||
mandatory: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await (await database.prepare(`INSERT INTO oversight_notification_queue
|
||||
(id,user_id,oversight_category,review_item_id,title,summary,link,frequency,created_at,deliver_after)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(), userId, input.category, input.itemId, input.title.slice(0, 140),
|
||||
input.summary.slice(0, 2000), input.link ?? definition.link, frequency,
|
||||
now.toISOString(), deliveryTime(frequency, now).toISOString()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastTone = 'success' | 'error' | 'warning' | 'info';
|
||||
export type Toast = {
|
||||
id: string;
|
||||
tone: ToastTone;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const store = writable<Toast[]>([]);
|
||||
export const toasts = { subscribe: store.subscribe };
|
||||
|
||||
const defaultDuration: Record<ToastTone, number> = {
|
||||
success: 5000,
|
||||
info: 6000,
|
||||
warning: 8000,
|
||||
error: 10000
|
||||
};
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
store.update((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
export function pushToast(input: {
|
||||
tone?: ToastTone;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}) {
|
||||
const tone = input.tone ?? 'info';
|
||||
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||
const toast: Toast = {
|
||||
id,
|
||||
tone,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
duration: input.duration ?? defaultDuration[tone]
|
||||
};
|
||||
store.update((items) => [...items.slice(-3), toast]);
|
||||
return id;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) => pushToast({ tone: 'success', title, message }),
|
||||
error: (title: string, message?: string) => pushToast({ tone: 'error', title, message }),
|
||||
warning: (title: string, message?: string) => pushToast({ tone: 'warning', title, message }),
|
||||
info: (title: string, message?: string) => pushToast({ tone: 'info', title, message })
|
||||
};
|
||||
|
||||
const readableKey = (key: string) =>
|
||||
key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
|
||||
const successfulKey = /(saved|created|updated|changed|removed|deleted|unlinked|linked|added|sent|cleared|approved|rejected|reverted|archived|unarchived|queued|enabled|disabled|restored)$/i;
|
||||
|
||||
export function actionFeedback(
|
||||
result: { type: string; data?: unknown },
|
||||
action: string,
|
||||
successMessage?: string
|
||||
) {
|
||||
const data = result.data && typeof result.data === 'object' ? result.data as Record<string, unknown> : {};
|
||||
if (result.type === 'failure' || result.type === 'error') {
|
||||
const message = Object.entries(data).find(([key, value]) =>
|
||||
/error|message/i.test(key) && typeof value === 'string'
|
||||
)?.[1];
|
||||
toast.error('REQUEST NOT COMPLETED', typeof message === 'string' ? message : 'Review the form and try again.');
|
||||
return;
|
||||
}
|
||||
if (result.type !== 'success' && result.type !== 'redirect') return;
|
||||
const explicit = typeof data.toastMessage === 'string'
|
||||
? data.toastMessage
|
||||
: typeof data.message === 'string'
|
||||
? data.message
|
||||
: successMessage;
|
||||
if (explicit) {
|
||||
toast.success(explicit);
|
||||
return;
|
||||
}
|
||||
const resultKey = Object.entries(data).find(([key, value]) => value === true && successfulKey.test(key))?.[0];
|
||||
if (resultKey) {
|
||||
toast.success(readableKey(resultKey));
|
||||
return;
|
||||
}
|
||||
const actionName = action.split('?/')[1]?.split(/[?#]/)[0] ?? '';
|
||||
const actionMessages: Record<string, string> = {
|
||||
save: 'Changes saved',
|
||||
saveTalent: 'Talent saved',
|
||||
saveTrigger: 'Trigger saved',
|
||||
savePreferences: 'Preferences saved',
|
||||
saveCoreRanges: 'Configuration saved',
|
||||
saveProfile: 'Profile saved',
|
||||
approve: 'Approved successfully',
|
||||
reject: 'Rejected successfully',
|
||||
correct: 'Corrections saved',
|
||||
revert: 'Revert completed',
|
||||
remove: 'Removed successfully',
|
||||
unlinkDiscord: 'Discord connection unlinked',
|
||||
removePasskey: 'Passkey removed',
|
||||
removeEmail: 'Email sign-in removed',
|
||||
removeCustomAvatar: 'Custom avatar removed',
|
||||
changePassword: 'Password updated',
|
||||
changeDisplayName: 'Display name updated',
|
||||
changeLoginUsername: 'Login username updated',
|
||||
requestEmailVerification: 'Verification email sent',
|
||||
markAllRead: 'All messages marked as read',
|
||||
markRead: 'Message marked as read',
|
||||
send: 'Message sent'
|
||||
};
|
||||
if (actionMessages[actionName]) toast.success(actionMessages[actionName]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import AppShell from '$lib/components/AppShell.svelte';
|
||||
import ToastViewport from '$lib/components/ToastViewport.svelte';
|
||||
let { data, children } = $props();
|
||||
</script>
|
||||
|
||||
@@ -9,3 +10,4 @@
|
||||
</svelte:head>
|
||||
|
||||
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
|
||||
<ToastViewport/>
|
||||
|
||||
+12
-3
@@ -5,11 +5,20 @@
|
||||
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7, votes: 76, tags: ['Skill', 'Status', 'Support'], color: '#d36b5c' }
|
||||
];
|
||||
</script>
|
||||
<svelte:head><title>SHD.build — Division Resurgence Loadouts</title><meta name="description" content="Create, optimize, and share loadouts for The Division Resurgence." /></svelte:head>
|
||||
<svelte:head><title>SHD.build — The Division Resurgence Community</title><meta name="description" content="The Division Resurgence community for builds, guides, game data, news, and agent discussion." /></svelte:head>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="hero-copy"><p class="eyebrow"><span></span> SHD LOADOUT NETWORK</p><h1>BUILD SMARTER.<br /><em>HIT HARDER.</em></h1><p class="lede">Plan every stat. Test every synergy. Share the loadout that gets your agent through the toughest fights in Manhattan.</p><div class="hero-actions"><a class="primary" href="/builder">START A BUILD <span>+</span></a><a class="secondary" href="/builds">EXPLORE BUILDS ↓</a></div><div class="metrics"><div><strong>2,847</strong><span>PUBLIC BUILDS</span></div><div><strong>14.2K</strong><span>AGENTS</span></div><div><strong>8,931</strong><span>RATINGS</span></div></div></div>
|
||||
<div class="loadout-preview"><div class="scan-lines"></div><div class="preview-top"><span>FEATURED LOADOUT</span><b>01 / 03</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['MASK','VEST','PACK','GLOVES'] as label,i}<div class="gear-node n-{i}"><small>{label}</small><b>{['42','38','39','6.5%'][i]}</b></div>{/each}</div><div class="preview-name"><small>META // PVE</small><strong>STRIKER CRITICAL RUSH</strong><span>BY AGENTNOVA</span></div></div>
|
||||
<div class="hero-copy"><p class="eyebrow"><span></span> THE DIVISION RESURGENCE COMMUNITY</p><h1>CONNECT.<br /><em>ADAPT. RISE.</em></h1><p class="lede">Build stronger agents, learn from field-tested guides, explore the game database, follow Resurgence news, and share intelligence with the community.</p><div class="hero-actions"><a class="primary" href="/builder">CREATE A BUILD <span>+</span></a><a class="secondary" href="/news">ENTER THE NETWORK ↓</a></div><div class="metrics community-metrics"><div><strong>BUILD</strong><span>LOADOUT LAB</span></div><div><strong>LEARN</strong><span>FIELD GUIDES</span></div><div><strong>TRACK</strong><span>DATA & NEWS</span></div></div></div>
|
||||
<div class="loadout-preview community-preview"><div class="scan-lines"></div><div class="preview-top"><span>COMMUNITY INTELLIGENCE</span><b>SHD // LIVE</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['BUILDS','GUIDES','DATA','NEWS'] as label,i}<a href={['/builds','/guides','/database','/news'][i]} class="gear-node n-{i}"><small>{label}</small><b>{['OPTIMIZE','LEARN','REFERENCE','UPDATES'][i]}</b></a>{/each}</div><div class="preview-name"><small>COMMUNITY // ONLINE</small><strong>RESURGENCE INTELLIGENCE HUB</strong><span>BUILT FOR AGENTS</span></div></div>
|
||||
</section>
|
||||
<section class="community-channels">
|
||||
<div class="section-heading"><div><p class="eyebrow"><span></span> COMMUNITY CHANNELS</p><h2>ONE NETWORK. EVERY MISSION.</h2></div><p>SHD.build grows with the community and the game.</p></div>
|
||||
<div class="community-channel-grid">
|
||||
<a href="/news"><small>01 // FIELD REPORTS</small><strong>NEWS & UPDATES</strong><span>Track game announcements, updates, and community reporting.</span><b>OPEN CHANNEL →</b></a>
|
||||
<a href="/database"><small>02 // ISAC ARCHIVE</small><strong>GAME DATABASE</strong><span>Reference attributes, talents, brands, gear, weapons, mods, and protocols.</span><b>QUERY DATABASE →</b></a>
|
||||
<a href="/builds"><small>03 // AGENT LOADOUTS</small><strong>COMMUNITY BUILDS</strong><span>Discover, rate, copy, and discuss configurations from other agents.</span><b>EXPLORE BUILDS →</b></a>
|
||||
<a href="/guides"><small>04 // FIELD MANUAL</small><strong>GUIDES</strong><span>Read detailed play styles, rotations, strategies, and build walkthroughs.</span><b>READ GUIDES →</b></a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="featured"><div class="section-heading"><div><p class="eyebrow"><span></span> FIELD-TESTED</p><h2>FEATURED BUILDS</h2></div><a href="/builds">VIEW ALL BUILDS →</a></div><div class="build-grid">{#each builds as build}<article style={`--build-color:${build.color}`}><div class="card-top"><span>{build.mode}</span><span>★ {build.rating}</span></div><div class="weapon-art"><div></div><span>SHD LOADOUT</span></div><div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="tags">{#each build.tags as tag}<span>{tag}</span>{/each}</div><div class="author"><span class="avatar">{build.author.slice(0,1)}</span><span>BY <strong>{build.author}</strong></span><span>{build.votes} RATINGS</span></div></div></article>{/each}</div></section>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
|
||||
import { onMount } from 'svelte';
|
||||
let { data, form } = $props();
|
||||
@@ -64,9 +65,11 @@
|
||||
const result = await verification.json();
|
||||
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey registration failed.');
|
||||
passkeyMessage = 'Passkey registered. Reloading account methods…';
|
||||
toast.success('Passkey registered', 'Your new sign-in method is ready.');
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
|
||||
toast.error('Passkey not registered', passkeyClientError);
|
||||
passkeyPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
@@ -34,6 +36,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
${where} ORDER BY n.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
|
||||
const permissions = await getPermissions(user);
|
||||
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
||||
(category) => permissions.includes('*') || permissions.includes(category.permission)
|
||||
);
|
||||
const oversightRows = oversightCategories.length
|
||||
? await (await database.prepare('SELECT * FROM oversight_notification_preferences WHERE user_id=?')).all(user.id)
|
||||
: [];
|
||||
const oversightPreferences = oversightCategories.map((category) => ({
|
||||
...category,
|
||||
frequency: String(oversightRows.find((row) => row.oversight_category === category.id)?.frequency ?? 'immediate')
|
||||
}));
|
||||
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
|
||||
const stored = preferenceRows.find((row) => row.category === name);
|
||||
return {
|
||||
@@ -46,7 +59,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
|
||||
return { notifications, preferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
|
||||
return { notifications, preferences, oversightPreferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -81,6 +94,34 @@ export const actions: Actions = {
|
||||
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
|
||||
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
|
||||
}
|
||||
return { notificationPreferencesSaved: true };
|
||||
return {
|
||||
notificationPreferencesSaved: true,
|
||||
notificationPreferenceSaveToken: crypto.randomUUID()
|
||||
};
|
||||
},
|
||||
saveOversightPreferences: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const available = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
||||
(category) => permissions.includes('*') || permissions.includes(category.permission)
|
||||
);
|
||||
if (!available.length) return fail(403, { oversightPreferenceError: 'Oversight permission is required.' });
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
for (const category of available) {
|
||||
const frequency = String(form.get(`oversight:${category.id}`) ?? 'immediate');
|
||||
if (!OVERSIGHT_NOTIFICATION_FREQUENCIES.includes(frequency as any)) {
|
||||
return fail(400, { oversightPreferenceError: 'Choose a valid Oversight notification frequency.' });
|
||||
}
|
||||
await (await database.prepare(`INSERT INTO oversight_notification_preferences(user_id,oversight_category,frequency,updated_at)
|
||||
VALUES(?,?,?,?) ON CONFLICT(user_id,oversight_category) DO UPDATE SET
|
||||
frequency=excluded.frequency,updated_at=excluded.updated_at`)).run(user.id, category.id, frequency, now);
|
||||
}
|
||||
return {
|
||||
oversightPreferencesSaved: true,
|
||||
notificationPreferenceSaveToken: crypto.randomUUID()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
let { data, form } = $props();
|
||||
let settingsOpen = $state(false);
|
||||
let consumedPreferenceSaveToken = $state<string | null>(null);
|
||||
let selected = $state<any>(null);
|
||||
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
|
||||
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
|
||||
@@ -35,9 +36,18 @@
|
||||
if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link };
|
||||
if (notification.category === 'builds') return { label: 'OPEN BUILD', href: notification.link };
|
||||
if (notification.source === 'SHD CONTACT RELAY') return { label: notification.title.startsWith('DMCA NOTICE') ? 'REVIEW COPYRIGHT REQUEST' : 'OPEN CONTACT MESSAGE', href: notification.link };
|
||||
if (notification.source === 'ISAC OVERSIGHT NETWORK') return { label: 'OPEN OVERSIGHT QUEUE', href: notification.link };
|
||||
if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link };
|
||||
return { label: 'OPEN RELATED PAGE', href: notification.link };
|
||||
};
|
||||
const storedActions = (notification: any): Array<{ label: string; href: string }> => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(notification.actions_json ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => item?.label && item?.href).slice(0, 8) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
async function enableBrowser() {
|
||||
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
|
||||
browserPermission = await Notification.requestPermission();
|
||||
@@ -45,6 +55,13 @@
|
||||
function applyFilters() {
|
||||
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
|
||||
}
|
||||
$effect(() => {
|
||||
const token = form?.notificationPreferenceSaveToken;
|
||||
if (token && token !== consumedPreferenceSaveToken) {
|
||||
consumedPreferenceSaveToken = token;
|
||||
settingsOpen = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
|
||||
@@ -75,6 +92,34 @@
|
||||
{#if form?.notificationPreferencesSaved}<p class="success">Notification preferences synchronized.</p>{/if}
|
||||
<div class="form-actions"><button class="primary">SAVE PREFERENCES →</button></div>
|
||||
</PendingForm>
|
||||
{#if data.oversightPreferences.length}
|
||||
<div class="oversight-preference-heading">
|
||||
<div><p class="eyebrow">REVIEW QUEUE ALERTS</p><h3>OVERSIGHT DELIVERY CADENCE</h3></div>
|
||||
<span>10:00 AM EASTERN DAILY DISPATCH</span>
|
||||
</div>
|
||||
<p class="notification-rule"><b>PERMISSION-AWARE DELIVERY.</b> Only queues you are authorized to review appear below. Consolidated alerts combine new review items by queue.</p>
|
||||
<PendingForm action="?/saveOversightPreferences" class="oversight-preference-form">
|
||||
{#each data.oversightPreferences as preference}
|
||||
<div class="oversight-preference-row">
|
||||
<div><b>{preference.label}</b><small>{preference.id === 'data_changes' ? 'CONTRIBUTED DATA AWAITING APPROVAL' : 'CUSTOM AGENT IMAGES AWAITING REVIEW'}</small></div>
|
||||
<SegmentedChoice
|
||||
legend={`${preference.label} DELIVERY`}
|
||||
name={`oversight:${preference.id}`}
|
||||
options={[
|
||||
{ value: 'immediate', label: 'IMMEDIATE' },
|
||||
{ value: '15_minutes', label: '15 MIN' },
|
||||
{ value: '1_hour', label: '1 HOUR' },
|
||||
{ value: 'daily', label: 'DAILY · 10 AM ET' }
|
||||
]}
|
||||
value={preference.frequency}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if form?.oversightPreferenceError}<p class="error">{form.oversightPreferenceError}</p>{/if}
|
||||
{#if form?.oversightPreferencesSaved}<p class="success">Oversight delivery cadence synchronized.</p>{/if}
|
||||
<div class="form-actions"><button class="primary">SAVE OVERSIGHT CADENCE →</button></div>
|
||||
</PendingForm>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<div class="notification-toolbar">
|
||||
@@ -90,7 +135,7 @@
|
||||
{#each data.notifications as notification}
|
||||
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
|
||||
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
|
||||
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
|
||||
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if storedActions(notification).length}{#each storedActions(notification) as callout}<a class="notification-response-action" href={callout.href}>{callout.label} →</a>{/each}{:else if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
|
||||
</article>
|
||||
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
|
||||
</section>
|
||||
@@ -103,7 +148,7 @@
|
||||
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
|
||||
<h2 id="notification-detail-title">{selected.title}</h2>
|
||||
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
|
||||
<div class="form-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
|
||||
<div class="form-actions notification-detail-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if storedActions(selected).length}{#each storedActions(selected) as callout}<a class="primary button" href={callout.href}>{callout.label} →</a>{/each}{:else if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||
|
||||
const STATE_COOKIE = 'shd_discord_oauth_state';
|
||||
|
||||
@@ -120,6 +121,8 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
|
||||
await insertUser.run(id, username, discordUser.id, 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, discordUser.global_name || discordUser.username, now);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
user = { id };
|
||||
created = true;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import {
|
||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
|
||||
type LoadoutDraft, type Roll
|
||||
@@ -326,8 +327,8 @@
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
|
||||
saved = true; message = 'Draft saved on this device.';
|
||||
setTimeout(() => (message = ''), 2200);
|
||||
saved = true;
|
||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const [gear, weapons, chips, protocols, brands] = await Promise.all([
|
||||
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
||||
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
|
||||
(await database.prepare('SELECT id,name,quality FROM skill_chip_sets')).all(),
|
||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all()
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { databaseCategories } from '$lib/database/categories';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const tables = {
|
||||
gear: 'gear',
|
||||
weapons: 'weapons',
|
||||
'weapon-mods': 'weapon_mods',
|
||||
'gear-mods': 'gear_mods',
|
||||
'os-protocols': 'os_protocols',
|
||||
talents: 'talents',
|
||||
brands: 'brand_sets',
|
||||
'skill-chips': 'skill_chip_sets'
|
||||
} as const;
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const database = await getDatabase();
|
||||
const counts = await Promise.all(databaseCategories.map(async (category) => {
|
||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
|
||||
return { ...category, count: Number(row?.count ?? 0) };
|
||||
}));
|
||||
return {
|
||||
registries: counts,
|
||||
total: counts.reduce((total, registry) => total + registry.count, 0)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Game database — SHD.build</title>
|
||||
<meta name="description" content="Community-maintained The Division Resurgence attributes, talents, brands, gear, weapons, mods, protocols, and Skill Chip data."/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="page community-index-page">
|
||||
<header class="catalogue-hero community-index-hero">
|
||||
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE</p><h1>GAME<br/><em>DATABASE.</em></h1><p>Community-maintained Resurgence data powering SHD.build tools, loadouts, and reference material.</p></div>
|
||||
<div class="catalogue-count"><strong>{data.total}</strong><span>ACTIVE RECORDS</span><a class="primary" href="/isac">CONTRIBUTE DATA →</a></div>
|
||||
</header>
|
||||
<section class="community-database-intro">
|
||||
<div><span>DATABASE STATUS</span><strong>PUBLIC INDEX ONLINE</strong><small>Select a registry to inspect active, approved records and their resolved gameplay attributes.</small></div>
|
||||
<div><span>DATA STEWARDSHIP</span><strong>COMMUNITY + OVERSIGHT</strong><small>Registered agents can inspect source data and propose verified corrections through ISAC.</small></div>
|
||||
</section>
|
||||
<section class="database-registry-grid">
|
||||
{#each data.registries as registry, index}
|
||||
<a href={`/database/${registry.slug}`}>
|
||||
<header><small>{String(index + 1).padStart(2, '0')} // {registry.code}</small><b>{registry.count}</b></header>
|
||||
<h2>{registry.label}</h2>
|
||||
<p>{registry.description}</p>
|
||||
<span>{registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,540 @@
|
||||
import { databaseCategory } from '$lib/database/categories';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import { weaponBaseRange } from '$lib/builder/loadout';
|
||||
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const number = (value: unknown) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? (Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '')) : '—';
|
||||
};
|
||||
const valueWithUnit = (value: unknown, unit: unknown) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
|
||||
const range = (min: unknown, max: unknown, unit: unknown = '') => `${valueWithUnit(min, unit)}–${valueWithUnit(max, unit)}`;
|
||||
const list = (value: unknown, fallback = 'NONE') => typeof value === 'string' && value ? value : fallback;
|
||||
const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName;
|
||||
const weaponIcon = (category: unknown) => String(category).toLowerCase() as GameIconName;
|
||||
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
|
||||
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const category = databaseCategory(params.category);
|
||||
if (!category) error(404, 'Database category not found');
|
||||
const database = await getDatabase();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const searchArgs = search ? [pattern] : [];
|
||||
const searchClause = search ? " AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
|
||||
let queryArgs: unknown[] = searchArgs;
|
||||
let filters: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'segmented' | 'searchable';
|
||||
selected: string[];
|
||||
options: Array<{ value: string; label: string; description?: string; searchText?: string; iconUrl?: string; gameIcon?: GameIconName }>;
|
||||
}> = [];
|
||||
let countSql = '';
|
||||
let rowsSql = '';
|
||||
|
||||
switch (category.slug) {
|
||||
case 'gear': {
|
||||
const [brandRows, talentRows] = await Promise.all([
|
||||
(await database.prepare(`SELECT brands.id,brands.name,
|
||||
(SELECT GROUP_CONCAT(DISTINCT attributes.name)
|
||||
FROM brand_set_effects effects
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE effects.brand_set_id=brands.id) AS bonus_attributes,
|
||||
icons.updated_at FROM brand_sets brands
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=brands.id
|
||||
WHERE brands.active=1 ORDER BY brands.name COLLATE NOCASE`)).all(),
|
||||
(await database.prepare(`SELECT talents.id,talents.name,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
||||
icons.updated_at FROM talents
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
|
||||
ORDER BY talents.name COLLATE NOCASE`)).all()
|
||||
]);
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const validBrands = new Set(brandRows.map((row) => String(row.id)));
|
||||
const validTalents = new Set(talentRows.map((row) => String(row.id)));
|
||||
const selectedSlots = url.searchParams.getAll('slot').filter((value) => slots.includes(value));
|
||||
const selectedBrands = url.searchParams.getAll('brand').filter((value) => validBrands.has(value));
|
||||
const selectedTalents = url.searchParams.getAll('talent').filter((value) => validTalents.has(value));
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
}
|
||||
if (selectedSlots.length) {
|
||||
conditions.push(`item.slot IN (${selectedSlots.map(() => '?').join(',')})`);
|
||||
queryArgs.push(...selectedSlots);
|
||||
}
|
||||
if (selectedBrands.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM gear_brand_sets filter_brands
|
||||
WHERE filter_brands.gear_id=item.id AND filter_brands.brand_set_id IN (${selectedBrands.map(() => '?').join(',')}))`);
|
||||
queryArgs.push(...selectedBrands);
|
||||
}
|
||||
if (selectedTalents.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM gear_talents filter_talents
|
||||
WHERE filter_talents.gear_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
||||
queryArgs.push(...selectedTalents);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.* FROM gear item WHERE ${where}
|
||||
ORDER BY item.slot,item.tier,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
filters = [
|
||||
{
|
||||
key: 'slot', label: 'GEAR SLOT', kind: 'segmented', selected: selectedSlots,
|
||||
options: slots.map((slot) => ({ value: slot, label: slot, gameIcon: gearIcon(slot) }))
|
||||
},
|
||||
{
|
||||
key: 'brand', label: 'BRAND SETS', kind: 'searchable', selected: selectedBrands,
|
||||
options: brandRows.map((row) => ({
|
||||
value: String(row.id), label: String(row.name),
|
||||
searchText: String(row.bonus_attributes ?? ''),
|
||||
iconUrl: iconUrl('brand_set', row.id, row.updated_at)
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: 'talent', label: 'TALENTS', kind: 'searchable', selected: selectedTalents,
|
||||
options: talentRows.map((row) => ({
|
||||
value: String(row.id), label: String(row.name),
|
||||
description: String(row.tier_i_description ?? ''),
|
||||
iconUrl: iconUrl('talent', row.id, row.updated_at)
|
||||
}))
|
||||
}
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'weapons': {
|
||||
const talentRows = await (await database.prepare(`SELECT DISTINCT talents.id,talents.name,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
||||
icons.updated_at
|
||||
FROM weapon_talent_options options
|
||||
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id AND source_weapons.active=1
|
||||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
ORDER BY talents.name COLLATE NOCASE`)).all();
|
||||
const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||
const validTalents = new Set(talentRows.map((row) => String(row.id)));
|
||||
const selectedTypes = url.searchParams.getAll('weaponType').filter((value) => weaponTypes.includes(value));
|
||||
const selectedDamageTypes = url.searchParams.getAll('damageType').filter((value) => damageTypes.includes(value));
|
||||
const selectedTalents = url.searchParams.getAll('weaponTalent').filter((value) => validTalents.has(value));
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
}
|
||||
if (selectedTypes.length) {
|
||||
conditions.push(`item.category IN (${selectedTypes.map(() => '?').join(',')})`);
|
||||
queryArgs.push(...selectedTypes);
|
||||
}
|
||||
if (selectedDamageTypes.length) {
|
||||
conditions.push(`item.damage_type IN (${selectedDamageTypes.map(() => '?').join(',')})`);
|
||||
queryArgs.push(...selectedDamageTypes);
|
||||
}
|
||||
if (selectedTalents.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents
|
||||
WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
||||
queryArgs.push(...selectedTalents);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
native.damage_min,native.damage_max,native.reference_level,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
|
||||
(SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
|
||||
FROM weapons item
|
||||
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
|
||||
WHERE ${where}
|
||||
ORDER BY item.is_exotic DESC,item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
filters = [
|
||||
{
|
||||
key: 'weaponType', label: 'WEAPON TYPE', kind: 'segmented', selected: selectedTypes,
|
||||
options: weaponTypes.map((type) => ({ value: type, label: type, gameIcon: weaponIcon(type) }))
|
||||
},
|
||||
{
|
||||
key: 'damageType', label: 'DAMAGE TYPE', kind: 'segmented', selected: selectedDamageTypes,
|
||||
options: damageTypes.map((type) => ({ value: type, label: type, gameIcon: weaponIcon(type) }))
|
||||
},
|
||||
{
|
||||
key: 'weaponTalent', label: 'TALENT POOL', kind: 'searchable', selected: selectedTalents,
|
||||
options: talentRows.map((row) => ({
|
||||
value: String(row.id), label: String(row.name),
|
||||
description: String(row.tier_i_description ?? ''),
|
||||
iconUrl: iconUrl('talent', row.id, row.updated_at)
|
||||
}))
|
||||
}
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'weapon-mods':
|
||||
countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
|
||||
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
|
||||
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
|
||||
FROM weapon_mods item
|
||||
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
|
||||
LEFT JOIN weapon_mod_compatible_categories categories ON categories.weapon_mod_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
GROUP BY item.id ORDER BY item.slot_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'gear-mods':
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
|
||||
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
|
||||
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
|
||||
FROM gear_mods item WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'os-protocols':
|
||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
|
||||
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at
|
||||
FROM os_protocols item
|
||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=primary_effect.stat_attribute_id
|
||||
LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'talents': {
|
||||
const talentSlots = [
|
||||
{ value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const },
|
||||
{ value: 'Body Armor', label: 'Body Armor', gameIcon: 'body_armor' as const },
|
||||
{ value: 'Weapon 1', label: 'Weapon 1 · Top', gameIcon: 'smg' as const },
|
||||
{ value: 'Weapon 2', label: 'Weapon 2 · Bottom', gameIcon: 'smg' as const }
|
||||
];
|
||||
const selectedTalentSlots = url.searchParams.getAll('talentSlot')
|
||||
.filter((value) => talentSlots.some((slot) => slot.value === value));
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
}
|
||||
if (selectedTalentSlots.length) {
|
||||
const slotConditions = selectedTalentSlots.map((slot) => {
|
||||
if (slot === 'Backpack') return "item.category='Backpack'";
|
||||
if (slot === 'Body Armor') return "item.category IN ('Body Armor','Vest')";
|
||||
queryArgs.push(slot === 'Weapon 1' ? 1 : 2);
|
||||
return `item.category='Weapon' AND EXISTS (
|
||||
SELECT 1 FROM weapon_talent_slots filter_slots
|
||||
WHERE filter_slots.talent_id=item.id AND filter_slots.slot=?
|
||||
)`;
|
||||
});
|
||||
conditions.push(`(${slotConditions.map((condition) => `(${condition})`).join(' OR ')})`);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii,
|
||||
(SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot,
|
||||
(SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope,
|
||||
icons.updated_at AS icon_updated_at
|
||||
FROM talents item
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=item.id
|
||||
WHERE ${where}
|
||||
ORDER BY item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
filters = [{
|
||||
key: 'talentSlot',
|
||||
label: 'TALENT SLOT',
|
||||
kind: 'segmented',
|
||||
selected: selectedTalentSlots,
|
||||
options: talentSlots
|
||||
}];
|
||||
break;
|
||||
}
|
||||
case 'brands':
|
||||
countSql = `SELECT COUNT(*) AS total FROM brand_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,icons.updated_at AS icon_updated_at
|
||||
FROM brand_sets item
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'skill-chips':
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||
talents.name AS talent_name,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
|
||||
icons.updated_at AS icon_updated_at
|
||||
FROM skill_chip_sets item
|
||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
}
|
||||
|
||||
const total = Number((await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const rows = await (await database.prepare(rowsSql)).all(...queryArgs, pagination.perPage, pagination.offset);
|
||||
const weaponRelations = category.slug === 'weapons' && rows.length
|
||||
? await (await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii,
|
||||
icons.updated_at AS icon_updated_at
|
||||
FROM weapon_talent_options options
|
||||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
WHERE options.weapon_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const gearRelations = category.slug === 'gear' && rows.length
|
||||
? await (await database.prepare(`SELECT relation.*,icons.updated_at
|
||||
FROM (
|
||||
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
|
||||
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
|
||||
NULL AS tier_i,NULL AS tier_ii
|
||||
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
|
||||
WHERE brands.active=1
|
||||
UNION ALL
|
||||
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
|
||||
talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii
|
||||
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
|
||||
WHERE talents.active=1
|
||||
) relation
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_id=relation.id
|
||||
AND icons.entity_type=CASE relation.kind WHEN 'brand' THEN 'brand_set' ELSE 'talent' END
|
||||
WHERE relation.gear_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY relation.kind,relation.name COLLATE NOCASE`)).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const gearBrandEffects = category.slug === 'gear' && gearRelations.some((relation) => relation.kind === 'brand')
|
||||
? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
|
||||
attributes.name,attributes.unit FROM brand_set_effects effects
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE effects.brand_set_id IN (${gearRelations.filter((relation) => relation.kind === 'brand').map(() => '?').join(',')})
|
||||
ORDER BY effects.pieces_required`)).all(...gearRelations.filter((relation) => relation.kind === 'brand').map((relation) => relation.id))
|
||||
: [];
|
||||
const brandEffects = category.slug === 'brands' && rows.length
|
||||
? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
|
||||
attributes.name,attributes.unit FROM brand_set_effects effects
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
|
||||
const items = rows.map((row) => {
|
||||
switch (category.slug) {
|
||||
case 'gear':
|
||||
{
|
||||
const relations = gearRelations.filter((relation) => relation.gear_id === row.id);
|
||||
const relatedItems = (kind: 'brand' | 'talent', type: GameDataIconType) => relations
|
||||
.filter((relation) => relation.kind === kind)
|
||||
.map((relation) => {
|
||||
const relationIcon = iconUrl(type, relation.id, relation.updated_at);
|
||||
const effects = kind === 'brand' ? gearBrandEffects.filter((effect) => effect.brand_set_id === relation.id) : [];
|
||||
return {
|
||||
id: String(relation.id),
|
||||
name: String(relation.name),
|
||||
iconUrl: relationIcon,
|
||||
preview: kind === 'brand' ? {
|
||||
id: String(relation.id), name: String(relation.name), kicker: 'BRAND SET // EQUIPMENT',
|
||||
iconUrl: relationIcon,
|
||||
factLayout: 'stack' as const,
|
||||
actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(relation.id))}` }],
|
||||
facts: effects.map((effect) => ({
|
||||
label: `${effect.pieces_required} PIECES`,
|
||||
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
||||
}))
|
||||
} : {
|
||||
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
|
||||
iconUrl: relationIcon,
|
||||
badges: [
|
||||
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
|
||||
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
|
||||
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [
|
||||
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
|
||||
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
|
||||
],
|
||||
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
|
||||
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
|
||||
}
|
||||
};
|
||||
});
|
||||
const iconGroups = [
|
||||
{ label: 'BRAND OPTIONS', items: relatedItems('brand', 'brand_set'), emptyLabel: 'NO BRANDS ASSIGNED' },
|
||||
...(['Backpack', 'Body Armor'].includes(String(row.slot))
|
||||
? [{ label: 'AVAILABLE TALENTS', items: relatedItems('talent', 'talent'), emptyLabel: 'NO TALENTS ASSIGNED' }]
|
||||
: [])
|
||||
];
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.slot} // T${row.tier}`,
|
||||
gameIcon: gearIcon(row.slot),
|
||||
badges: [{ label: `T${row.tier}` }, { label: `${Number(row.tier) === 0 ? 1 : 2} MOD SLOT${Number(row.tier) === 0 ? '' : 'S'}` }],
|
||||
facts: [
|
||||
{ label: 'BASE HEALTH', value: range(row.base_health_min,row.base_health_max) },
|
||||
...(row.base_armor_min === null ? [] : [{ label: 'BASE ARMOR', value: range(row.base_armor_min,row.base_armor_max) }])
|
||||
],
|
||||
iconGroups
|
||||
};
|
||||
}
|
||||
case 'weapons':
|
||||
{
|
||||
const relatedTalents = weaponRelations.filter((relation) => relation.weapon_id === row.id);
|
||||
const talentItems = (slot: number) => relatedTalents
|
||||
.filter((relation) => Number(relation.talent_slot) === slot)
|
||||
.map((relation) => {
|
||||
const talentIcon = iconUrl('talent', relation.id, relation.icon_updated_at);
|
||||
return {
|
||||
id: String(relation.id),
|
||||
name: String(relation.name),
|
||||
iconUrl: talentIcon,
|
||||
preview: {
|
||||
id: String(relation.id), name: String(relation.name), kicker: `WEAPON TALENT // SLOT ${slot}`,
|
||||
iconUrl: talentIcon,
|
||||
badges: [
|
||||
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
|
||||
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
|
||||
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [
|
||||
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
|
||||
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
|
||||
],
|
||||
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
|
||||
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
|
||||
}
|
||||
};
|
||||
});
|
||||
const level50Damage = row.damage_min === null ? null : weaponBaseRange(row.damage_min, row.damage_max, 50);
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.category} // ${row.damage_type}`,
|
||||
gameIcon: weaponIcon(row.category),
|
||||
headerBadge: `T${row.native_tier}`,
|
||||
headerMetaIcon: weaponIcon(row.damage_type),
|
||||
badges: Number(row.is_exotic) ? [{ label: 'EXOTIC', tone: 'exotic' }] : [],
|
||||
factLayout: 'stack' as const,
|
||||
facts: [
|
||||
{
|
||||
label: 'DAMAGE',
|
||||
value: level50Damage ? `${level50Damage.min.toLocaleString('en-US')}–${level50Damage.max.toLocaleString('en-US')}` : 'NOT CONFIGURED',
|
||||
emphasis: 'primary' as const,
|
||||
gameIcon: weaponIcon(row.damage_type),
|
||||
note: 'AT LEVEL 50'
|
||||
},
|
||||
{ label: 'RATE / MAG', value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG` },
|
||||
{ label: 'RELOAD', value: valueWithUnit(row.reload_speed,'s') },
|
||||
{
|
||||
label: 'HANDLING',
|
||||
value: '',
|
||||
meters: [
|
||||
{ label: 'ACCURACY', value: Number(row.accuracy) || 0 },
|
||||
{ label: 'STABILITY', value: Number(row.stability) || 0 }
|
||||
]
|
||||
}
|
||||
],
|
||||
lines: [
|
||||
{ label: 'BASE BONUSES', value: String(row.bonus_count) }
|
||||
],
|
||||
iconGroups: [
|
||||
{ label: 'TALENT 1 POOL', items: talentItems(1), emptyLabel: 'NO TALENTS ASSIGNED' },
|
||||
{ label: 'TALENT 2 POOL', items: talentItems(2), emptyLabel: 'NO TALENTS ASSIGNED' }
|
||||
]
|
||||
};
|
||||
}
|
||||
case 'weapon-mods':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.slot_type} // WEAPON MOD`,
|
||||
badges: row.exotic_weapon_id ? [{ label: 'EXOTIC', tone: 'exotic' }] : [{ label: 'STANDARD → HIGH-END' }],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–20' },
|
||||
{ label: 'SECONDARY POOL', value: row.exotic_weapon_id ? 'FIXED ATTRIBUTES' : `${row.secondary_count} OPTIONS` },
|
||||
{ label: 'MOD EFFECTS', value: row.exotic_weapon_id ? 'FIXED' : String(row.effect_count) }
|
||||
],
|
||||
lines: [{ label: row.exotic_weapon_id ? 'ASSIGNED WEAPON' : 'WEAPON CATEGORIES', value: row.exotic_weapon_id ? list(row.exotic_weapon) : list(row.categories).toUpperCase() }]
|
||||
};
|
||||
case 'gear-mods':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: 'GEAR MOD // STANDARD QUALITIES',
|
||||
badges: [{ label: 'STANDARD → HIGH-END' }],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–20' },
|
||||
{ label: 'PRIMARY OPTIONS', value: '5' },
|
||||
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
|
||||
],
|
||||
lines: [{ label: 'OBSERVED PRIMARY ENVELOPE', value: `${number(row.level_1_floor)} → ${number(row.level_20_ceiling)}` }]
|
||||
};
|
||||
case 'os-protocols':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.primary_attribute_type} // OS PROTOCOL`,
|
||||
iconUrl: iconUrl('os_protocol',row.id,row.icon_updated_at),
|
||||
badges: [{ label: String(row.quality), tone: String(row.quality).toLowerCase().replace('-','') }],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–50' },
|
||||
{ label: 'PRIMARY EFFECT', value: row.primary_stat ? `${row.primary_stat} · ${valueWithUnit(row.base_value,row.primary_unit)}` : 'NOT CONFIGURED' },
|
||||
{ label: 'TALENT UPGRADES', value: '0–5' }
|
||||
],
|
||||
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
|
||||
};
|
||||
case 'talents': {
|
||||
const talentCategory = String(row.category);
|
||||
const isWeaponTalent = talentCategory === 'Weapon';
|
||||
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
|
||||
const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase();
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`,
|
||||
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
|
||||
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
|
||||
headerBadge: isWeaponTalent && row.weapon_slot
|
||||
? `${row.weapon_slot} · ${Number(row.weapon_slot) === 1 ? 'TOP' : 'BOTTOM'}`
|
||||
: undefined,
|
||||
badges: [
|
||||
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []),
|
||||
...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []),
|
||||
...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [],
|
||||
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
|
||||
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
|
||||
descriptionStyle: 'data' as const,
|
||||
afterLines: row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : []
|
||||
};
|
||||
}
|
||||
case 'brands': {
|
||||
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: 'BRAND SET // EQUIPMENT',
|
||||
iconUrl: iconUrl('brand_set',row.id,row.icon_updated_at),
|
||||
factLayout: 'stack' as const,
|
||||
actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(row.id))}` }],
|
||||
facts: effects.map((effect) => ({
|
||||
label: `${effect.pieces_required} PIECES`,
|
||||
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
||||
}))
|
||||
};
|
||||
}
|
||||
case 'skill-chips':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: 'SKILL CHIP SET // 2–3 PIECES',
|
||||
iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at),
|
||||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||||
facts: [
|
||||
{ label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' },
|
||||
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
|
||||
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
|
||||
],
|
||||
lines: [{ label: '3-PIECE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return { category, items, pagination, search, filters };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import DatabaseCategoryNav from '$lib/components/DatabaseCategoryNav.svelte';
|
||||
import DatabaseItemCard from '$lib/components/DatabaseItemCard.svelte';
|
||||
import DatabaseFacetFilters from '$lib/components/DatabaseFacetFilters.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.category.label} database — SHD.build</title>
|
||||
<meta name="description" content={`${data.category.description} Community-maintained data for The Division Resurgence.`}/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="page database-category-page">
|
||||
<header class="database-category-command">
|
||||
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE / {data.category.code}</p><h1>{data.category.label}</h1><p>{data.category.description}</p></div>
|
||||
<div><strong>{data.pagination.total}</strong><span>ACTIVE RECORDS</span><a href="/database">DATABASE INDEX →</a></div>
|
||||
</header>
|
||||
<DatabaseCategoryNav active={data.category.slug}/>
|
||||
{#if data.filters?.length}<DatabaseFacetFilters groups={data.filters}/>{/if}
|
||||
<RegistryPagination pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
|
||||
<section class="database-item-grid">
|
||||
{#each data.items as item}
|
||||
<DatabaseItemCard item={item as any}/>
|
||||
{:else}
|
||||
<div class="database-empty">
|
||||
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
|
||||
<p>{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `This registry is awaiting approved ${data.category.label.toLocaleLowerCase()} data.`}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</main>
|
||||
@@ -17,6 +17,7 @@
|
||||
{ href: '/isac/gear', label: 'Gear', code: 'GEAR', module: 'gear' },
|
||||
{ href: '/isac/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' },
|
||||
{ href: '/isac/weaponmods', label: 'Weapon Mods', code: 'WMOD', module: 'weaponmods' },
|
||||
{ href: '/isac/gearmods', label: 'Gear Mods', code: 'GMOD', module: 'gearmods' },
|
||||
{ href: '/isac/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' },
|
||||
{ href: '/isac/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' }
|
||||
];
|
||||
@@ -28,10 +29,14 @@
|
||||
const oversightSections: Array<{ href: string; label: string; code: string; permission: string; reviewKey?: 'data' | 'avatars' }> = [
|
||||
{ href: '/isac/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve', reviewKey: 'data' },
|
||||
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review', reviewKey: 'avatars' },
|
||||
{ href: '/isac/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
|
||||
{ href: '/isac/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
|
||||
{ href: '/isac/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' }
|
||||
];
|
||||
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
|
||||
const systemSections = [
|
||||
{ href: '/isac/system-messages', label: 'Automated messages', code: 'MSG', permission: 'system.notifications.manage' },
|
||||
{ href: '/isac/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
|
||||
];
|
||||
const visibleSystemSections = $derived(systemSections.filter((section) => can(section.permission)));
|
||||
const selectNumericValue = (event: FocusEvent) => {
|
||||
const input = event.target;
|
||||
if (input instanceof HTMLInputElement && input.type === 'number' && !input.disabled) input.select();
|
||||
@@ -62,6 +67,12 @@
|
||||
<a class:active={page.url.pathname.startsWith(section.href)} class:has-pending={reviewCount > 0} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if reviewCount}<em aria-label={`${reviewCount} item${reviewCount === 1 ? '' : 's'} awaiting review`} title="Awaiting review">{reviewCount}</em>{/if}<b>›</b></a>
|
||||
{/each}</nav>
|
||||
{/if}
|
||||
{#if visibleSystemSections.length}
|
||||
<div class="sidebar-label section-label"><span>SYSTEM CONFIGURATION</span><b>{String(visibleSystemSections.length).padStart(2, '0')} MODULE{visibleSystemSections.length === 1 ? '' : 'S'}</b></div>
|
||||
<nav aria-label="System configuration">{#each visibleSystemSections as section}
|
||||
<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>
|
||||
{/each}</nav>
|
||||
{/if}
|
||||
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
|
||||
</aside>
|
||||
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
||||
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||
redirect(303, '/account');
|
||||
};
|
||||
|
||||
@@ -72,7 +72,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
|
||||
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
|
||||
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access
|
||||
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
|
||||
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration
|
||||
FROM audit_log`)).get()
|
||||
]);
|
||||
const pagination = getPagination(url, Number(total?.total ?? 0));
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
|
||||
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
|
||||
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
|
||||
,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'}
|
||||
]}/>
|
||||
<form class="audit-filter">
|
||||
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
let { data, form } = $props();
|
||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||
@@ -58,7 +59,7 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
|
||||
{#each data.brands as brand}
|
||||
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending}><div class="registry-icon-identity">{#if brand.iconUrl}<img src={brand.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button onclick={() => edit(brand)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending}><div class="registry-icon-identity"><ItemIcon src={brand.iconUrl} alt=""/><div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button onclick={() => edit(brand)}>EDIT</button>{/if}</div>
|
||||
{:else}
|
||||
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
||||
{/each}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</section>
|
||||
<section>
|
||||
<span>03</span>
|
||||
<div><h3>APPROPRIATE IMAGES</h3><p>Upload only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, or SVG assets that remain legible at builder-icon sizes.</p></div>
|
||||
<div><h3>APPROPRIATE IMAGES</h3><p>Icons are optional when contributing item data. If you do not have appropriate artwork, save the item without one; SHD.build will use a missing-icon placeholder and another contributor can add the icon later. When uploading, use only relevant game-data icons or assets you are permitted to share. Images must be safe for a general audience and must not contain harassment, explicit material, personal information, watermarks, advertisements, or misleading edits. Prefer clean transparent PNG, WebP, AVIF, or SVG assets that remain legible at builder-icon sizes.</p></div>
|
||||
</section>
|
||||
<section>
|
||||
<span>04</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'protocols', 'skillchips'];
|
||||
const modules = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
|
||||
);
|
||||
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||||
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value.toUpperCase();
|
||||
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value === 'gearmods' ? 'GEAR MODS' : value.toUpperCase();
|
||||
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
|
||||
const openReview = (proposal: any) => {
|
||||
reviewing = proposal;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
let { data, form } = $props();
|
||||
type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
|
||||
@@ -135,6 +136,6 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
||||
{#each data.gear as item}
|
||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}><ItemIcon src={brand.iconUrl} alt={brand.name}/></span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
||||
type QualityValue = {
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
primaryLevel1Max: number;
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type SecondaryRange = { statId: string; values: Record<string, { min: number; max: number }> };
|
||||
|
||||
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
||||
try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; }
|
||||
}
|
||||
|
||||
async function snapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
return {
|
||||
record: await auditRecord('gear_mods', id),
|
||||
qualityValues: await (await database.prepare('SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality')).all(id),
|
||||
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id')).all(id),
|
||||
secondaryQualityValues: await (await database.prepare('SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality')).all(id)
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)).get(...args),
|
||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'gearmods', action: 'save', kind: 'gearmod', fields: ['qualityValues', 'secondaryRanges'] }])
|
||||
]);
|
||||
const pendingRows = filter === 'archived' || filter === 'active' ? [] : pendingMods.filter((item) =>
|
||||
!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length);
|
||||
const pendingOnPage = pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), [])
|
||||
}));
|
||||
return {
|
||||
mods: [...parsedPending, ...mods.map((mod) => ({
|
||||
...mod,
|
||||
qualityValues: qualityRows.filter((row) => row.gear_mod_id === mod.id).map((row) => ({
|
||||
quality: row.quality,
|
||||
primaryLevel1Min: Number(row.primary_level_1_min),
|
||||
primaryLevel1Max: Number(row.primary_level_1_max),
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
primaryLevel20Max: Number(row.primary_level_20_max)
|
||||
})),
|
||||
secondaryRanges: secondaryOptions.filter((option) => option.gear_mod_id === mod.id).map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(secondaryValues
|
||||
.filter((row) => row.gear_mod_id === mod.id && row.stat_attribute_id === option.stat_attribute_id)
|
||||
.map((row) => [String(row.quality), { min: Number(row.min_value), max: Number(row.max_value) }]))
|
||||
}))
|
||||
}))],
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingMods.length,
|
||||
pending: pendingMods.length
|
||||
},
|
||||
filter,
|
||||
pagination,
|
||||
search
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const qualityValues = parseJson<QualityValue[]>(form.get('qualityValues'), []);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(form.get('secondaryRanges'), []);
|
||||
if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.' });
|
||||
if (qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length ||
|
||||
qualityValues.some((row) => !qualities.includes(row.quality as typeof qualities[number]) ||
|
||||
[row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max].some((value) => !Number.isFinite(Number(value))) ||
|
||||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
|
||||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max))) {
|
||||
return fail(400, { gearModError: 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.' });
|
||||
}
|
||||
if (secondaryRanges.length < 2 || secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !== secondaryRanges.length ||
|
||||
secondaryRanges.some((range) => !range.statId || qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return !values || !Number.isFinite(Number(values.min)) || !Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min);
|
||||
}))) {
|
||||
return fail(400, { gearModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { gearModError: 'That Gear Mod name is already registered.' });
|
||||
}
|
||||
const statIds = secondaryRanges.map((range) => range.statId);
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length) return fail(400, { gearModError: 'One or more selected attributes are unavailable.' });
|
||||
const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
if (id && !before?.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id) await (await database.prepare('UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(modId, name, now, now);
|
||||
await (await database.prepare('DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?')).run(modId);
|
||||
for (const row of qualityValues) await (await database.prepare(`INSERT INTO gear_mod_quality_values
|
||||
(gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max)
|
||||
VALUES(?,?,?,?,?,?)`)).run(modId,row.quality,row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max);
|
||||
await (await database.prepare('DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?')).run(modId);
|
||||
for (const range of secondaryRanges) {
|
||||
await (await database.prepare('INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId, range.statId);
|
||||
for (const quality of qualities) await (await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`))
|
||||
.run(modId, range.statId, quality, range.values[quality].min, range.values[quality].max);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear_mod.updated' : 'gear_mod.created',
|
||||
entityId: modId,
|
||||
before,
|
||||
after: await snapshot(modId)
|
||||
});
|
||||
return { gearModSaved: true, gearModMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.archived', entityId: id, before, after: await snapshot(id) });
|
||||
return { gearModArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.unarchived', entityId: id, before, after: await snapshot(id) });
|
||||
return { gearModUnarchived: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const qualityRow = (quality: string) => ({
|
||||
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
|
||||
});
|
||||
const blankSecondary = () => ({
|
||||
statId: '',
|
||||
values: Object.fromEntries(data.qualities.map((quality: string) => [quality, { min: 0, max: 0 }]))
|
||||
});
|
||||
const blank = () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
active: 1,
|
||||
qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(), blankSecondary()]
|
||||
});
|
||||
let editing = $state<any | null>(null);
|
||||
let levelPreview = $state(1);
|
||||
let qualityPreview = $state('High-end');
|
||||
let handledResultToken = $state('');
|
||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||
const display = (value: unknown) => Number.isFinite(Number(value))
|
||||
? (Number.isInteger(Number(value)) ? String(Number(value)) : Number(value).toFixed(2).replace(/\.?0+$/,''))
|
||||
: '—';
|
||||
const edit = (item: any) => {
|
||||
editing = {
|
||||
...item,
|
||||
qualityValues: data.qualities.map((quality: string) => ({
|
||||
...qualityRow(quality),
|
||||
...(item.qualityValues.find((row: any) => row.quality === quality) ?? {})
|
||||
})),
|
||||
secondaryRanges: item.secondaryRanges.length
|
||||
? item.secondaryRanges.map((range: any) => ({ statId: range.statId, values: structuredClone(range.values) }))
|
||||
: [blankSecondary(), blankSecondary()]
|
||||
};
|
||||
levelPreview = 1;
|
||||
qualityPreview = 'High-end';
|
||||
};
|
||||
const addSecondary = () => {
|
||||
if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges, blankSecondary()];
|
||||
};
|
||||
const removeSecondary = (index: number) => {
|
||||
if (editing.secondaryRanges.length > 2) {
|
||||
editing.secondaryRanges = editing.secondaryRanges.filter((_: any, itemIndex: number) => itemIndex !== index);
|
||||
}
|
||||
};
|
||||
$effect(() => {
|
||||
if (form?.gearModSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = form.gearModMode === 'add' ? blank() : null;
|
||||
levelPreview = 1;
|
||||
qualityPreview = 'High-end';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Gear Mods — ISAC</title></svelte:head>
|
||||
|
||||
<div class="module-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">EQUIPMENT MODIFICATION REGISTRY</p>
|
||||
<h2>GEAR MODS</h2>
|
||||
<p>Configure quality-scaled primary rolls and shared secondary attribute pools.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={() => editing = blank()}>ADD GEAR MOD <span>+</span></button>
|
||||
</div>
|
||||
|
||||
<KpiFilters active={data.filter} items={[
|
||||
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all Gear Mod definitions'},
|
||||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to the builder'},
|
||||
{value:'pending',label:'PENDING',count:data.summary.pending,description:'Awaiting Oversight review'},
|
||||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||||
]}/>
|
||||
|
||||
{#if editing}
|
||||
<div class="dashboard-editor weapon-mod-editor gear-mod-editor">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}</p><h3>{editing.id ? editing.name : 'GEAR MOD DEFINITION'}</h3></div>
|
||||
<button aria-label="Close editor" onclick={() => editing = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/save">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||||
<div class="gear-mod-identity">
|
||||
<label class="field">GEAR MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
|
||||
<div class="weapon-mod-primary-types gear-mod-primary-types">
|
||||
<span>PRIMARY ATTRIBUTE OPTIONS</span>
|
||||
<div>
|
||||
<b><GameIcon name="firepower" decorative/>FIREPOWER</b>
|
||||
<b><GameIcon name="toughness" decorative/>TOUGHNESS</b>
|
||||
<b><GameIcon name="engineering" decorative/>ENGINEERING</b>
|
||||
<b>HP HEALTH</b>
|
||||
<b>AR ARMOR</b>
|
||||
</div>
|
||||
<small>One primary attribute is selected while configuring the mod in a build.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="weapon-mod-quality-section">
|
||||
<div class="selection-head">
|
||||
<div><span>QUALITY & LEVEL SCALING</span><small>PRIMARY ROLLS SCALE LINEARLY FROM LEVEL 1 TO 20</small></div>
|
||||
<b>4 QUALITY PROFILES</b>
|
||||
</div>
|
||||
<div class="weapon-mod-quality-table">
|
||||
<div class="weapon-mod-quality-row heading"><span>QUALITY</span><span>PRIMARY · LEVEL 1</span><span>PRIMARY · LEVEL 20</span></div>
|
||||
{#each editing.qualityValues as row}
|
||||
<div class="weapon-mod-quality-row">
|
||||
<strong class={qualityClass(row.quality)}>{row.quality.toUpperCase()}</strong>
|
||||
<div><input type="number" step="any" min="0" bind:value={row.primaryLevel1Min} aria-label={`${row.quality} primary Level 1 minimum`} required/><i>–</i><input type="number" step="any" min="0" bind:value={row.primaryLevel1Max} aria-label={`${row.quality} primary Level 1 maximum`} required/></div>
|
||||
<div><input type="number" step="any" min="0" bind:value={row.primaryLevel20Min} aria-label={`${row.quality} primary Level 20 minimum`} required/><i>–</i><input type="number" step="any" min="0" bind:value={row.primaryLevel20Max} aria-label={`${row.quality} primary Level 20 maximum`} required/></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="weapon-mod-preview">
|
||||
<div>
|
||||
<label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label>
|
||||
<SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/>
|
||||
</div>
|
||||
{#if previewRow}
|
||||
<div class="weapon-mod-preview-values">
|
||||
<span><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span>
|
||||
<span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="weapon-mod-secondary-section">
|
||||
<div class="selection-head">
|
||||
<div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div>
|
||||
<button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length >= 50}>ADD ATTRIBUTE +</button>
|
||||
</div>
|
||||
<div class="weapon-mod-secondary-options">
|
||||
{#each editing.secondaryRanges as range,index}
|
||||
<div>
|
||||
<strong>{String(index + 1).padStart(2,'0')}</strong>
|
||||
<SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={range.statId}/>
|
||||
{#each data.qualities as quality}
|
||||
<label>
|
||||
<span class={qualityClass(quality)}>{quality.toUpperCase()}</span>
|
||||
<div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div>
|
||||
</label>
|
||||
{/each}
|
||||
<button type="button" aria-label={`Remove secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={editing.secondaryRanges.length <= 2}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.gearModError}<p class="error">{form.gearModError}</p>{/if}
|
||||
<div class="form-actions split-actions">
|
||||
<div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE GEAR MOD' : 'UNARCHIVE GEAR MOD'}</button>{/if}</div>
|
||||
<div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE GEAR MOD →</button></div>
|
||||
</div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row gear-mod-row table-heading"><span>GEAR MOD</span><span>PRIMARY</span><span>SECONDARIES</span><span>QUALITY</span><span>LEVELS</span><span>STATE</span><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}>
|
||||
<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div>
|
||||
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
|
||||
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
|
||||
<span>STANDARD → HIGH-END</span>
|
||||
<span>01 → 20</span>
|
||||
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
|
||||
{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-dashboard"><strong>NO GEAR MODS REGISTERED</strong><span>Add the first quality-scaled Gear Mod definition.</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -166,6 +167,6 @@
|
||||
<div class="dashboard-table protocol-dashboard-table">
|
||||
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
||||
{#each data.protocols as protocol}
|
||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending}><div class="registry-icon-identity">{#if protocol.iconUrl}<img src={protocol.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
const filters = ['isac', 'data', 'users', 'audit'];
|
||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
@@ -16,7 +16,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const database = await getDatabase();
|
||||
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : '';
|
||||
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
|
||||
const conditions = [
|
||||
...(permissionPrefix ? [`EXISTS (
|
||||
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||
@@ -32,6 +32,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'data.%') THEN 1 ELSE 0 END) AS data,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
|
||||
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'system.%') THEN 1 ELSE 0 END) AS system
|
||||
FROM roles`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
|
||||
(await database.prepare(`SELECT id,name,description FROM permissions
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
|
||||
{value:'data',label:'DATA OPERATIONS',count:data.summary.data,description:'Builder-data capabilities'},
|
||||
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'},
|
||||
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'}
|
||||
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'},
|
||||
{value:'system',label:'SYSTEM CONFIG',count:data.summary.system,description:'Automated system capabilities'}
|
||||
]}/>
|
||||
|
||||
{#if editing}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
@@ -106,6 +107,6 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
|
||||
{#each data.sets as item}
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { AUTOMATED_MESSAGE_EVENTS } from '$lib/server/automated-messages';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const parseActions = (value: unknown) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
|
||||
const database = await getDatabase();
|
||||
const messages = await (await database.prepare(`SELECT message.*,
|
||||
(SELECT COUNT(*) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS delivery_count,
|
||||
(SELECT MAX(delivered_at) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS last_delivered_at
|
||||
FROM automated_system_messages message ORDER BY name`)).all();
|
||||
return {
|
||||
events: AUTOMATED_MESSAGE_EVENTS,
|
||||
messages: messages.map((message) => ({ ...message, actions: parseActions(message.actions_json) }))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '').normalize('NFKC').trim();
|
||||
const title = String(form.get('title') ?? '').normalize('NFKC').trim();
|
||||
const body = String(form.get('body') ?? '').normalize('NFKC').trim();
|
||||
const link = String(form.get('link') ?? '').trim();
|
||||
const delay = Number(form.get('sendDelayMinutes') ?? 0);
|
||||
const enabled = form.get('enabled') === 'on';
|
||||
if (!id) return fail(400, { messageError: 'Choose an automated message to edit.' });
|
||||
if (source.length < 2 || source.length > 80) return fail(400, { messageError: 'Source must be 2–80 characters.' });
|
||||
if (title.length < 2 || title.length > 140) return fail(400, { messageError: 'Title must be 2–140 characters.' });
|
||||
if (body.length < 20 || body.length > 2000) return fail(400, { messageError: 'Message content must be 20–2,000 characters.' });
|
||||
if (!Number.isInteger(delay) || delay < 0 || delay > 10080) return fail(400, { messageError: 'Delay must be between 0 and 10,080 minutes.' });
|
||||
const validHref = (href: string) => href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
|
||||
if (link && !validHref(link)) return fail(400, { messageError: 'Primary destinations must use an internal path or secure HTTPS URL.' });
|
||||
const actions = form.getAll('actionLabel').map((label, index) => ({
|
||||
label: String(label).normalize('NFKC').trim(),
|
||||
href: String(form.getAll('actionHref')[index] ?? '').trim()
|
||||
})).filter((action) => action.label || action.href);
|
||||
if (actions.length > 8) return fail(400, { messageError: 'Use no more than eight action callouts.' });
|
||||
if (actions.some((action) => action.label.length < 2 || action.label.length > 60 || !validHref(action.href))) {
|
||||
return fail(400, { messageError: 'Each action needs a 2–60 character label and a valid internal or HTTPS destination.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM automated_system_messages WHERE id=?')).get(id);
|
||||
if (!before) return fail(404, { messageError: 'Automated message not found.' });
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
...before, source, title, body, link: link || null, actions_json: JSON.stringify(actions),
|
||||
enabled: enabled ? 1 : 0, send_delay_minutes: delay, updated_at: now, updated_by: operator.id
|
||||
};
|
||||
await (await database.prepare(`UPDATE automated_system_messages SET
|
||||
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`))
|
||||
.run(enabled ? 1 : 0, delay, source, title, body, link || null, JSON.stringify(actions), now, operator.id, id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'automated_message.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { messageSaved: true, editorMode: 'edit', resultToken: crypto.randomUUID() };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
let { data, form } = $props();
|
||||
let editing = $state<any>(null);
|
||||
let consumedToken = $state<string | null>(null);
|
||||
const date = (value: unknown) => value ? new Date(String(value)).toLocaleString() : 'NEVER';
|
||||
const edit = (message: any) => editing = { ...message, actions: [...message.actions, ...Array(Math.max(0, 8 - message.actions.length)).fill(null).map(() => ({ label: '', href: '' }))] };
|
||||
$effect(() => {
|
||||
if (form?.messageSaved && form?.resultToken && form.resultToken !== consumedToken) {
|
||||
consumedToken = form.resultToken;
|
||||
editing = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Automated system messages — ISAC</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>AUTOMATED MESSAGES</h2><p>Control event-driven ISAC notifications, delivery timing, content, and action callouts.</p></div></div>
|
||||
<div class="module-kpis"><div><span>DEFINITIONS</span><strong>{data.messages.length}</strong><small>Automated messages</small></div><div><span>ACTIVE</span><strong>{data.messages.filter((item: any) => item.enabled).length}</strong><small>Enabled triggers</small></div><div><span>EVENT TYPES</span><strong>{data.events.length}</strong><small>System lifecycle events</small></div></div>
|
||||
|
||||
{#if editing}
|
||||
<section class="horizontal-editor system-message-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">EDIT AUTOMATED MESSAGE</p><h3>{editing.name}</h3></div><button type="button" aria-label="Cancel editing automated message" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<div class="system-message-editor-grid">
|
||||
<label class="field wide">MESSAGE SOURCE<input name="source" value={editing.source} maxlength="80" required/></label>
|
||||
<label class="field wide">TITLE<input name="title" value={editing.title} maxlength="140" required/></label>
|
||||
<label class="field event-field">SEND EVENT<input value={data.events.find((event: any) => event.value === editing.event_key)?.label ?? editing.event_key} disabled/><small>{data.events.find((event: any) => event.value === editing.event_key)?.description}</small></label>
|
||||
<label class="field">DELAY AFTER EVENT · MINUTES<input name="sendDelayMinutes" type="number" min="0" max="10080" step="1" value={editing.send_delay_minutes}/><small>0 sends immediately · maximum 7 days</small></label>
|
||||
<label class="field system-message-enabled"><span>DELIVERY STATE</span><input name="enabled" type="checkbox" bind:checked={editing.enabled}/><b>{editing.enabled ? 'ENABLED' : 'DISABLED'}</b></label>
|
||||
<label class="field full">MESSAGE CONTENT<textarea name="body" rows="7" maxlength="2000" required>{editing.body}</textarea></label>
|
||||
<label class="field full">PRIMARY DESTINATION<input name="link" value={editing.link ?? ''} placeholder="/account"/></label>
|
||||
</div>
|
||||
<div class="system-message-actions"><div><span>ACTION CALLOUTS</span><small>Optional internal paths or secure external links · up to 8</small></div>
|
||||
{#each editing.actions as action, index}
|
||||
<div><b>{String(index + 1).padStart(2,'0')}</b><input name="actionLabel" value={action.label} placeholder="ACTION LABEL"/><input name="actionHref" value={action.href} placeholder="/destination or https://…"/></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.messageError}<p class="error">{form.messageError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE MESSAGE →</button></div>
|
||||
</PendingForm>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="dashboard-table system-message-table">
|
||||
<div class="dashboard-row heading"><span>MESSAGE</span><span>TRIGGER</span><span>DELIVERY</span><span>ACTIVITY</span><span></span></div>
|
||||
{#each data.messages as message}
|
||||
<div class="dashboard-row"><div><strong>{message.name}</strong><small>{message.description}</small></div><div><strong>{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}</strong><small>{message.enabled ? 'ENABLED' : 'DISABLED'}</small></div><div><strong>{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}</strong><small>{message.title}</small></div><div><strong>{message.delivery_count} SENT</strong><small>LAST · {date(message.last_delivered_at)}</small></div><button class="table-action" onclick={() => edit(message)}>EDIT →</button></div>
|
||||
{/each}
|
||||
</section>
|
||||
@@ -3,6 +3,7 @@
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
let { data, form } = $props();
|
||||
@@ -98,4 +99,4 @@
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svelte:head>
|
||||
<title>News & updates — SHD.build</title>
|
||||
<meta name="description" content="The Division Resurgence game news, update coverage, community reports, and SHD.build development notices."/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="page community-index-page news-page">
|
||||
<header class="catalogue-hero community-index-hero">
|
||||
<div><p class="eyebrow"><span></span> SHD FIELD REPORTS</p><h1>NEWS &<br/><em>UPDATES.</em></h1><p>Game announcements, update intelligence, community reporting, and SHD.build development transmissions.</p></div>
|
||||
<div class="catalogue-count"><strong>FEED</strong><span>EDITORIAL CHANNEL</span><a class="primary" href="/contact?team=admins">SUBMIT A LEAD →</a></div>
|
||||
</header>
|
||||
<section class="news-channel-grid">
|
||||
<article class="news-primary">
|
||||
<div><span>CHANNEL STATUS</span><b>EDITORIAL SYSTEM IN DEVELOPMENT</b></div>
|
||||
<p class="eyebrow"><span></span> TRANSMISSION 00</p>
|
||||
<h2>THE COMMUNITY NEWSROOM IS COMING ONLINE.</h2>
|
||||
<p>SHD.build is expanding beyond loadouts into a complete Resurgence community resource. This channel will cover official announcements, game updates, database changes, community projects, and field reports without presenting speculation as confirmed information.</p>
|
||||
<footer><span>SOURCE POLICY <b>VERIFIABLE</b></span><span>COVERAGE <b>RESURGENCE</b></span><span>STATUS <b>QUEUED</b></span></footer>
|
||||
</article>
|
||||
<aside>
|
||||
<p class="eyebrow">PLANNED FEEDS</p>
|
||||
{#each ['OFFICIAL GAME NEWS','PATCH & DATA UPDATES','COMMUNITY REPORTS','SHD.BUILD DEVELOPMENT'] as channel,index}
|
||||
<div><small>{String(index + 1).padStart(2, '0')}</small><span>{channel}</span><b>QUEUED</b></div>
|
||||
{/each}
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
@@ -7,6 +7,7 @@ import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/aut
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => ({
|
||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||
@@ -42,6 +43,8 @@ export const actions: Actions = {
|
||||
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, 'New Agent', now);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
identity = { user_id: id, created_at: null };
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
@@ -83,6 +86,8 @@ export const actions: Actions = {
|
||||
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
|
||||
.run(id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
const sessionId = await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, '/account');
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
</script>
|
||||
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
|
||||
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD IDENTITY NETWORK</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds, join field discussions, and keep your loadouts synchronized across devices.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
|
||||
<main class="auth-page"><div class="auth-frame"><section class="auth-intel"><p class="eyebrow"><span></span> SHD COMMUNITY IDENTITY</p><h1>AGENT<br/><em>ACCESS.</em></h1><p>Authenticate to publish builds and guides, contribute game data, join discussions, and keep your SHD intelligence synchronized.</p><div class="auth-status"><span><i></i> NETWORK ONLINE</span><span>NODE NYC-01</span></div></section><section class="auth-console"><div class="console-head"><span>SECURE SIGN-IN</span><b>SHD // AUTH</b></div>
|
||||
<p class="auth-mode-note">Sign in to an existing account or create one with email, Discord, or local credentials.</p>
|
||||
<button class="primary full passkey-signin" disabled={!passkeySupported || passkeyPending} aria-busy={passkeyPending} onclick={signInWithPasskey}>{passkeyPending ? 'VERIFYING PASSKEY…' : 'SIGN IN WITH A PASSKEY →'}</button>
|
||||
{#if !passkeySupported}<p class="auth-note">This browser does not support passkeys.</p>{/if}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="#disclaimers">09 · DISCLAIMERS</a>
|
||||
</nav>
|
||||
<article class="legal-document">
|
||||
<section id="service"><span>01</span><div><h2>THE SERVICE</h2><p>SHD.build is an independent community loadout, build-guide, and discussion service. By accessing or using it, you agree to these terms. If you do not agree, do not use the service. Features may change, be suspended, or be discontinued as development continues.</p></div></section>
|
||||
<section id="service"><span>01</span><div><h2>THE SERVICE</h2><p>SHD.build is an independent community service for The Division Resurgence, including loadouts, guides, community-maintained game data, news, and discussion. By accessing or using it, you agree to these terms. If you do not agree, do not use the service. Features may change, be suspended, or be discontinued as development continues.</p></div></section>
|
||||
<section id="accounts"><span>02</span><div><h2>ACCOUNTS & SECURITY</h2><p>You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.</p><p>Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.</p></div></section>
|
||||
<section id="content"><span>03</span><div><h2>USER CONTENT</h2><p>You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.</p><p>Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.</p></div></section>
|
||||
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title description">
|
||||
<title id="title">Missing item icon</title>
|
||||
<desc id="description">SHD placeholder used while an item icon awaits contribution.</desc>
|
||||
<g fill="none" stroke="#596164">
|
||||
<path stroke-width="5" stroke-dasharray="13 10" d="M22 22h212v212H22z"/>
|
||||
<path stroke-width="4" d="m128 50 78 78-78 78-78-78z"/>
|
||||
</g>
|
||||
<g fill="none" stroke="#f6a821" stroke-linecap="square">
|
||||
<path stroke-width="10" d="M88 88h80v80H88z"/>
|
||||
<path stroke-width="12" d="m78 178 100-100"/>
|
||||
</g>
|
||||
<path fill="#f6a821" d="M120 115h16v16h-16zm0 28h16v16h-16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
Reference in New Issue
Block a user