From 7b3aff30d83e5bb2fbb58122f80fbfcd6b7a9392 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Jul 2026 00:31:16 -0400 Subject: [PATCH] checkpoint ISAC registries and system monitoring --- .env.example | 3 + database/schema.sql | 33 ++- docs/DEPLOYMENT.md | 8 + docs/DESIGN_SYSTEM.md | 71 ++++-- docs/architecture.md | 9 + package.json | 1 + scripts/data/resurgence-attributes.json | 40 +++ scripts/db-migrate.mjs | 25 ++ scripts/import-resurgence-attributes.mjs | 124 +++++++++ src/app.css | 76 +++++- .../components/RegistryInlineEditor.svelte | 17 ++ src/lib/components/RegistryPagination.svelte | 2 +- .../components/SearchableAssignment.svelte | 10 +- .../components/SortableColumnHeader.svelte | 48 ++++ src/lib/server/pagination.test.ts | 42 ++++ src/lib/server/pagination.ts | 33 +++ src/lib/server/remove-background.ts | 7 + src/lib/server/remove-bg-monitor.test.ts | 80 ++++++ src/lib/server/remove-bg-monitor.ts | 236 ++++++++++++++++++ src/lib/server/scheduled-notifications.ts | 6 +- src/routes/isac/attributes/+page.server.ts | 37 ++- src/routes/isac/attributes/+page.svelte | 11 +- src/routes/isac/audit/+page.server.ts | 22 +- src/routes/isac/audit/+page.svelte | 3 +- src/routes/isac/brands/+page.server.ts | 33 ++- src/routes/isac/brands/+page.svelte | 55 ++-- src/routes/isac/calculations/+page.server.ts | 25 +- src/routes/isac/calculations/+page.svelte | 17 +- .../isac/data-moderation/+page.server.ts | 18 +- src/routes/isac/data-moderation/+page.svelte | 3 +- src/routes/isac/gear/+page.server.ts | 30 ++- src/routes/isac/gear/+page.svelte | 65 ++--- src/routes/isac/gearmods/+page.server.ts | 10 +- src/routes/isac/gearmods/+page.svelte | 19 +- src/routes/isac/metrics/+page.server.ts | 9 +- src/routes/isac/metrics/+page.svelte | 3 +- src/routes/isac/protocols/+page.server.ts | 17 +- src/routes/isac/protocols/+page.svelte | 10 +- src/routes/isac/roles/+page.server.ts | 18 +- src/routes/isac/roles/+page.svelte | 37 +-- src/routes/isac/skillchips/+page.server.ts | 14 +- src/routes/isac/skillchips/+page.svelte | 10 +- .../isac/specializations/+page.server.ts | 14 +- src/routes/isac/specializations/+page.svelte | 3 +- src/routes/isac/system-messages/+page.svelte | 18 +- src/routes/isac/talents/+page.server.ts | 14 +- src/routes/isac/talents/+page.svelte | 10 +- src/routes/isac/users/+page.server.ts | 45 +++- src/routes/isac/users/+page.svelte | 3 +- src/routes/isac/weaponmods/+page.server.ts | 15 +- src/routes/isac/weaponmods/+page.svelte | 17 +- src/routes/isac/weapons/+page.server.ts | 37 ++- src/routes/isac/weapons/+page.svelte | 17 +- 53 files changed, 1351 insertions(+), 179 deletions(-) create mode 100644 scripts/data/resurgence-attributes.json create mode 100644 scripts/import-resurgence-attributes.mjs create mode 100644 src/lib/components/RegistryInlineEditor.svelte create mode 100644 src/lib/components/SortableColumnHeader.svelte create mode 100644 src/lib/server/pagination.test.ts create mode 100644 src/lib/server/remove-bg-monitor.test.ts create mode 100644 src/lib/server/remove-bg-monitor.ts diff --git a/.env.example b/.env.example index 6f0c3f4..45d06ee 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,6 @@ SMTP_FROM=SHD.build # Long random bearer token used only by the internal container scheduler. SCHEDULER_TOKEN= REMOVE_BG_API_KEY= +REMOVE_BG_LOW_CREDIT_THRESHOLD=5 +REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10 +REMOVE_BG_CREDIT_CHECK_HOURS=6 diff --git a/database/schema.sql b/database/schema.sql index d4b30cb..75122e3 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -345,7 +345,7 @@ CREATE TABLE IF NOT EXISTS stat_attributes ( CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)), CHECK(cap_value IS NULL OR cap_value >= 0) ); -CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_nocase_idx ON stat_attributes(name COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit); CREATE TABLE IF NOT EXISTS talents ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -1092,3 +1092,34 @@ CREATE INDEX IF NOT EXISTS managed_images_category_idx ON managed_images(categor CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx ON managed_images(source_entity_type,source_entity_id) WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL; +CREATE TABLE IF NOT EXISTS external_service_status ( + provider TEXT PRIMARY KEY, + credits_total REAL, + credits_subscription REAL, + credits_payg REAL, + credits_enterprise REAL, + free_calls INTEGER, + checked_at TEXT, + next_check_at TEXT, + last_charge REAL, + last_charge_at TEXT, + error_message TEXT, + severity TEXT NOT NULL DEFAULT 'unknown' CHECK(severity IN ('unknown','ok','warning','critical')), + episode_id TEXT, + episode_started_at TEXT, + recovered_at TEXT, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS external_service_alert_deliveries ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL REFERENCES external_service_status(provider), + episode_id TEXT NOT NULL, + severity TEXT NOT NULL CHECK(severity IN ('warning','critical')), + user_id TEXT NOT NULL REFERENCES users(id), + notification_id TEXT REFERENCES notifications(id), + created_at TEXT NOT NULL, + delivered_at TEXT, + UNIQUE(provider,episode_id,severity,user_id) +); +CREATE INDEX IF NOT EXISTS external_service_alert_pending_idx + ON external_service_alert_deliveries(delivered_at,created_at); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8617635..91288b3 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -288,6 +288,14 @@ Transparent icon uploads skip remove.bg. Opaque sources use its preview output, then pass through local trimming, white normalization, WebP conversion, and storage. +The application stores a remove.bg capacity snapshot and refreshes it from the +internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup +interval and defaults to six hours. Low-capacity alerts require both remaining +pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to +five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free +calls. Alerts are mandatory ISAC notifications for active Administrators and +are deduplicated for each warning or critical episode. + ```sh npm run images:export ``` diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 1f22efa..782166c 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -414,6 +414,12 @@ squeezed beside long module headings on mobile. - The administrative system is named ISAC. Separate navigation into explicit operational domains such as User Operations and Data Management; visibility must reflect server-provided capabilities. +- In the ISAC rail, operational-domain headers are compact disclosure bars, not + module destinations. Give them a stronger surface, bold uppercase text, a + count, and a short leading status rail that turns orange while expanded. + Module destinations sit on the darker recessed surface below, use sentence + case and an additional left inset, and reserve the full orange rail for the + current module. Preserve this hierarchy when the rail becomes a mobile grid. - ISAC module navigation uses complete, game-facing names such as `Weapons`, `Talents`, and `OS Protocols`. Do not expose internal abbreviations such as `WPN`, `TLNT`, or `OSPR`. Canonical game icons may supplement visible labels @@ -445,7 +451,13 @@ squeezed beside long module headings on mobile. Each provides context-appropriate filters and namespaced pagination controls so changing one history page does not reset the others. - Each module starts with `module-toolbar`, followed by `module-kpis`. -- Editors use `dashboard-editor` and appear before the affected table. +- Editors use `dashboard-editor`. New-record editors appear before the affected + table. Editing an existing record expands the same editor directly beneath + its selected row, which gains an orange selection rail and an explicit + expanded relationship for assistive technology. Inline editors stay aligned + to the visible registry viewport when the table scrolls horizontally. A + short clipped reveal confirms the expansion; reduced-motion preferences + remove that animation. - Optional record descriptions use a full-width narrative field beneath the identity controls. Preserve the same full-width placement in Oversight before/after previews so reviewers can compare meaning without reading raw @@ -488,7 +500,11 @@ squeezed beside long module headings on mobile. restrained ten-second interval. Metrics must not expose environment values, credentials, full filesystem paths, or other deployment secrets. The `metrics.view` permission is independent of mutation access and is included - in the read-only Auditor role. + in the read-only Auditor role. External-service capacity reuses the same + metric panel: show distinct provider pools, the last authoritative check, + configured alert thresholds, and explicit `OK`, `WARNING`, `CRITICAL`, or + unavailable text. Green, orange, and red reinforce these states but never + replace their labels. - Account deletion uses a collapsed danger zone, explicit identity confirmation, and plain-language retention guidance. Soft-deleted accounts retain authored content under a deleted-agent identity while releasing credentials, usernames, @@ -613,10 +629,13 @@ back to its weapon rather than opening the shared-talent editor. Large relationship sets use the searchable assignment control instead of checkbox walls. It keeps assigned records visible as removable chips, filters -available records by name, renders at most thirty results at once, and exposes -counts for both assignments and matches. Mutually exclusive pools pass their -assigned IDs to one another as exclusions so invalid combinations cannot be -selected. Keyboard focus, pressed state, empty results, and overflow remain +available records by identity, renders at most thirty results at once, and +exposes counts for both assignments and matches. When records can share a name, +show the identity-defining qualifier below it in both selected chips and search +results. Attribute assignments use `UNIT · %`, `UNIT · s`, or `UNIT · FLAT`; +search and accessible names include that unit. Mutually exclusive pools pass +their assigned IDs to one another as exclusions so invalid combinations cannot +be selected. Keyboard focus, pressed state, empty results, and overflow remain explicit. Where a relationship has a global slot invariant, filter each assignment control at the data boundary instead of presenting cross-slot choices. Weapon @@ -749,9 +768,9 @@ never silently describe only the visible page. On narrow screens, retain previous, next, current page, and page-size controls before optional page numbers. -When only one page exists, omit page navigation and row-count presets; retain -only the useful result range and any registry search. Do not render disabled -pagination controls to preserve the rail's shape. +The row-count presets remain available even when the current result fits on one +page. Omit only the page navigation when there is no second page. Do not render +disabled pagination controls merely to preserve the rail's shape. Registries with a meaningful name, title, or agent-identity column place the shared server-side search inside this pagination rail. Results update after a @@ -770,6 +789,29 @@ and navigation controls. At mobile widths all groups stack, the input retains a usable width, and search, clear, previous, and next remain explicit keyboard and touch targets. +### Sortable registry columns + +Every meaningful data column in an ISAC table uses the shared sortable-column +header. The first activation sorts a new column in its most useful default +direction; later activations reverse the direction. Place a neutral +bidirectional indicator immediately before each inactive column label and an +orange directional indicator immediately before the active column label. +Action columns do not sort. + +Sorting applies on the server before pagination. Store the selected column and +direction in the URL, preserve active search, filters, and row-count settings, +and return to page one when the sort changes. Server loaders must map public +sort keys to fixed, allowlisted SQL expressions or in-memory comparators. Never +interpolate a query-string value into SQL. Composite columns use a documented, +stable primary value and deterministic tie-breaker. Pending contribution rows +remain ahead of published records unless the registry explicitly defines a +different moderation order. + +Sortable headers must expose column-header semantics and `aria-sort`, remain +keyboard operable, and show visible hover, focus, active, and selected states. +At narrow widths, retain the table's existing horizontal overflow behavior so +labels and sort state do not collapse into ambiguous controls. + ### Registry metric filters The KPI row above an ISAC registry is an operational filter surface, not passive @@ -962,11 +1004,12 @@ responsive editor layouts. Large many-to-many selections use the shared searchable assignment control, including weapon talent pools, gear talent compatibility, and compatible brand sets. The control keeps assigned records visible as removable chips, filters -available records by name, limits the rendered result set, and submits canonical -hidden inputs. Configure its subject labels and empty-state copy for the entity -being assigned rather than embedding talent-specific language in the shared -component. Pending contributor records retain their orange treatment and named -proposer in both search results and dependency workflows. +available records by their displayed identity, limits the rendered result set, +and submits canonical hidden inputs. Configure its subject labels, qualifiers, +and empty-state copy for the entity being assigned rather than embedding +talent-specific language in the shared component. Pending contributor records +retain their orange treatment and named proposer in both search results and +dependency workflows. Audit History may use a five-column KPI filter rail when all durable operational domains need first-class visibility. It retains the standard compact KPI height diff --git a/docs/architecture.md b/docs/architecture.md index 5eff4ff..8a2cbd8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -182,6 +182,15 @@ and historical analysis should eventually use a dedicated external telemetry system. This surface deliberately omits secrets, environment-variable values, and full database paths. +The same surface reports remove.bg paid credits and free API calls as separate +capacity pools. The scheduler reconciles the account balance no more often than +the configured interval, while successful image processing applies the +provider's `X-Credits-Charged` value to the persisted estimate. Warning and +critical transitions create one alert episode with a unique delivery for each +active Administrator. A later critical transition may alert again; repeated +scheduler runs within the same episode do not. Provider errors preserve the +last known balance and never expose the API key or raw response body. + # Weapon-owned exotic talents Standard weapon talent pools remain globally reusable and classified by Talent diff --git a/package.json b/package.json index d885548..53e641b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", "isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs", "isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs", + "isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs", "isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs", "images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs", "images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs", diff --git a/scripts/data/resurgence-attributes.json b/scripts/data/resurgence-attributes.json new file mode 100644 index 0000000..e5a383f --- /dev/null +++ b/scripts/data/resurgence-attributes.json @@ -0,0 +1,40 @@ +[ + {"name":"Weapon Critical Hit Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2.4,6]}, + {"name":"Weapon Critical Hit Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]}, + {"name":"Weapon Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, + {"name":"Weapon Multi-Shot Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, + {"name":"Skill Critical Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2.4,6]}, + {"name":"Skill Critical Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]}, + {"name":"Skill Intensity","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, + {"name":"Skill Multi-Shot Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]}, + {"name":"Headshot Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]}, + {"name":"Max Health","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]}, + {"name":"Armor","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.66,11.66],"weaponRange":[9.33,23.33]}, + {"name":"Damage Reduction","unit":"%","type":"Defensive","category":"Character","calculationType":"Multiplicative","gearRange":[2,5],"weaponRange":[4,10]}, + {"name":"Received Healing","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]}, + {"name":"Damage to Elites","unit":"%","type":"Offensive","category":"Character","calculationType":"Additive","gearRange":[2.4,6],"weaponRange":[4.8,12]}, + {"name":"Signature Ability Charge Efficiency","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]}, + {"name":"Skill Duration","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[3.2,8]}, + {"name":"Skill Radius","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]}, + {"name":"Skill Cooldown Recovery","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[-5,-2]}, + {"name":"Weapon Blast Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]}, + {"name":"Weapon Piercing Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]}, + {"name":"Weapon Shredding Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]}, + {"name":"Skill Blast Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]}, + {"name":"Skill Piercing Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]}, + {"name":"Skill Shredding Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]}, + {"name":"Rate of Fire","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Reload Speed","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Health","unit":"","type":"Defensive","category":"Character","calculationType":"Additive"}, + {"name":"Armor","unit":"","type":"Defensive","category":"Character","calculationType":"Additive"}, + {"name":"Movement Speed","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive"}, + {"name":"Piercing Damage","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"RPM","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Optimal Range","unit":"m","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"ADS Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Reload Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Magazine Size","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Handling Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Accuracy","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"}, + {"name":"Vertical Stability","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"} +] diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index a0f7e93..674d2cb 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -619,6 +619,31 @@ for (const [name, definition] of [ await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`); } } +const nameOnlyAttributeIndex = await (await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='index' AND name='stat_attributes_name_nocase_idx'" +)).get(); +if (nameOnlyAttributeIndex) { + console.log('Allowing Attribute names to repeat when their units differ.'); + await database.exec('DROP INDEX stat_attributes_name_nocase_idx'); +} +for (const [previousName, currentName] of [ + ['Engineering %', 'Engineering'], + ['Firepower %', 'Firepower'], + ['Toughness %', 'Toughness'], + ['Weapon Damage %', 'Weapon Damage'] +]) { + const previous = await (await database.prepare( + 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' + )).get(previousName, '%'); + const current = await (await database.prepare( + 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' + )).get(currentName, '%'); + if (previous && !current) { + await (await database.prepare('UPDATE stat_attributes SET name=?,updated_at=? WHERE id=?')) + .run(currentName, new Date().toISOString(), previous.id); + } +} +await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)'); const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all(); for (const [name, definition] of [ diff --git a/scripts/import-resurgence-attributes.mjs b/scripts/import-resurgence-attributes.mjs new file mode 100644 index 0000000..9f2ca25 --- /dev/null +++ b/scripts/import-resurgence-attributes.mjs @@ -0,0 +1,124 @@ +import { createServer } from 'vite'; +import { readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { resolve } from 'node:path'; + +const sourcePath = resolve(process.argv[2] || './scripts/data/resurgence-attributes.json'); +const records = JSON.parse(await readFile(sourcePath, 'utf8')); +if (!Array.isArray(records) || !records.length) throw new Error('The Attribute import source is empty or invalid.'); + +const identities = new Set(); +for (const record of records) { + const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`; + if (identities.has(identity)) throw new Error(`Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`); + identities.add(identity); + if (!['Offensive', 'Defensive'].includes(record.type) + || !['Weapon', 'Skill', 'Character'].includes(record.category) + || !['Additive', 'Multiplicative'].includes(record.calculationType) + || !['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')) { + throw new Error(`Invalid Attribute classification in import source: ${record.name}`); + } + for (const range of [record.gearRange, record.weaponRange]) { + if (range === undefined) continue; + if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] > range[1]) { + throw new Error(`Invalid source range in import source: ${record.name}`); + } + } +} + +const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' }); +try { + const { getDatabase } = await vite.ssrLoadModule('/src/lib/server/database.ts'); + const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule('/src/lib/server/audit.ts'); + const database = await getDatabase(); + const actor = await (await database.prepare(`SELECT users.id FROM users + LEFT JOIN user_profiles profile ON profile.user_id=users.id + LEFT JOIN user_roles link ON link.user_id=users.id + WHERE users.role='admin' OR link.role_id='role-admin' + ORDER BY CASE WHEN users.username=? COLLATE NOCASE OR profile.display_name=? COLLATE NOCASE THEN 0 ELSE 1 END,users.created_at + LIMIT 1`)).get('Aewyn', 'Aewyn'); + if (!actor?.id) throw new Error('No administrator account is available to own the Attribute import audit events.'); + + let created = 0; + let updated = 0; + let unchanged = 0; + await database.exec('BEGIN'); + try { + for (const record of records) { + const name = String(record.name).normalize('NFKC').trim(); + const unit = String(record.unit ?? ''); + const existing = await (await database.prepare( + 'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' + )).get(name, unit); + const id = String(existing?.id ?? randomUUID()); + const now = new Date().toISOString(); + const next = { + ...(existing ?? {}), + id, + name, + description: existing?.description ?? null, + type: record.type, + category: record.category, + calculation_type: record.calculationType, + unit, + gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null, + gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null, + gear_reference_level: Number(existing?.gear_reference_level ?? 50), + weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null, + weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null, + weapon_reference_level: Number(existing?.weapon_reference_level ?? 50), + skill_chip_min: existing?.skill_chip_min ?? null, + skill_chip_max: existing?.skill_chip_max ?? null, + cap_value: existing?.cap_value ?? null, + active: 1, + created_at: String(existing?.created_at ?? now), + updated_at: now + }; + const comparableExisting = existing ? { ...existing, updated_at: now } : null; + if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) { + unchanged++; + continue; + } + const before = existing ? await auditRecord('stat_attributes', id) : null; + if (existing) { + await (await database.prepare(`UPDATE stat_attributes SET + name=?,description=?,type=?,category=?,calculation_type=?,unit=?, + gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?, + cap_value=?,active=1,updated_at=? WHERE id=?`)).run( + next.name, next.description, next.type, next.category, next.calculation_type, next.unit, + next.gear_min, next.gear_max, next.weapon_min, next.weapon_max, next.skill_chip_min, + next.skill_chip_max, next.cap_value, now, id + ); + updated++; + } else { + await (await database.prepare(`INSERT INTO stat_attributes( + id,name,description,type,category,calculation_type,unit, + gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, + skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)).run( + id, next.name, next.description, next.type, next.category, next.calculation_type, next.unit, + next.gear_min, next.gear_max, next.gear_reference_level, next.weapon_min, next.weapon_max, + next.weapon_reference_level, next.skill_chip_min, next.skill_chip_max, next.cap_value, now, now + ); + created++; + } + await recordIsacAudit({ + actorId: String(actor.id), + category: 'game_data', + action: existing ? 'attribute.updated' : 'attribute.created', + entityId: id, + before, + after: await auditRecord('stat_attributes', id) + }); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + console.log(`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`); + console.log(`Source: ${sourcePath}`); +} finally { + await vite.close(); +} diff --git a/src/app.css b/src/app.css index 8f802b3..42ec775 100644 --- a/src/app.css +++ b/src/app.css @@ -82,6 +82,7 @@ /* SHD admin operations dashboard */ .admin-page{width:100%;min-height:calc(100vh - 76px);padding:18px clamp(14px,2vw,32px) 40px;background:linear-gradient(135deg,#101314aa 0,transparent 38%)}.admin-command{height:auto;min-height:76px;position:relative;top:auto;display:flex;justify-content:space-between;align-items:center;padding:0 0 16px;background:transparent;border-bottom:1px solid #303536}.command-title h1{margin:4px 0 2px;font-size:27px;letter-spacing:1px}.command-title>p:last-child{margin:0;color:#697073;font-size:var(--shd-type-telemetry);letter-spacing:1.1px}.command-status{display:flex;gap:7px}.command-status>div{position:relative;min-width:105px;height:47px;padding:9px 12px;background:#111415;border:1px solid #303536;display:grid;align-content:center}.command-status span{font-size:var(--shd-type-telemetry);letter-spacing:1.3px;color:#6f7678}.command-status strong{font-size:var(--shd-type-telemetry);letter-spacing:1px;margin-top:3px}.command-status i{position:absolute;right:9px;top:9px;width:6px;height:6px;border-radius:50%;background:#68bd82;box-shadow:0 0 8px #68bd82}.admin-dashboard{display:grid;grid-template-columns:210px minmax(0,1fr);gap:12px;margin-top:12px}.admin-sidebar{position:sticky;top:88px;min-height:520px;height:calc(100vh - 118px);display:flex;flex-direction:column;background:#0f1213;border:1px solid #2c3132}.sidebar-label{height:43px;padding:10px 12px;border-bottom:1px solid #2c3132;display:flex;justify-content:space-between;align-items:center}.sidebar-label span,.sidebar-label b{font-size:var(--shd-type-telemetry);letter-spacing:1.3px}.sidebar-label span{color:#8d9395}.sidebar-label b{color:#505759}.admin-sidebar nav{display:flex;flex-direction:column;margin:0;height:auto}.admin-sidebar nav a{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;min-height:49px;padding:0 12px 0 0;color:#858c8e;border:0;border-bottom:1px solid #272c2d;font-size:var(--shd-type-telemetry)}.admin-sidebar nav a small{height:100%;display:grid;place-items:center;background:#141819;color:#535a5c;font-size:var(--shd-type-telemetry);letter-spacing:.6px}.admin-sidebar nav a span{padding-left:12px}.admin-sidebar nav a b{font-size:17px;color:#f6a821}.admin-sidebar nav a em{font-style:normal;font-size:var(--shd-type-telemetry);color:#555c5e;letter-spacing:1px}.admin-sidebar nav a:hover,.admin-sidebar nav a.active{color:#fff;background:#151819}.admin-sidebar nav a.active{box-shadow:inset 2px 0 #f6a821}.admin-sidebar nav a.active small{background:#2b2111;color:#f6a821}.admin-sidebar nav a.pending{opacity:.5;pointer-events:none}.sidebar-foot{margin-top:auto;padding:14px;border-top:1px solid #2c3132;display:grid}.sidebar-foot span{font-size:var(--shd-type-telemetry);color:#5e6668;letter-spacing:1.3px}.sidebar-foot strong{font-size:14px;color:#f6a821;margin:4px 0}.sidebar-foot small{font-size:var(--shd-type-telemetry);color:#596164}.admin-content{min-width:0}.module-toolbar{min-height:63px;display:flex;align-items:center;justify-content:space-between;padding:0 2px 12px;border-bottom:1px solid #303536}.module-toolbar h2{font-size:19px;letter-spacing:.4px;margin:3px 0}.module-toolbar p{margin:0}.module-toolbar>div>p:last-child{font-size:var(--shd-type-telemetry);color:#71787a}.module-toolbar>div:last-child{display:flex;gap:8px}.module-toolbar .primary,.module-toolbar .secondary{height:40px;padding:0 15px}.module-kpis{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;margin:10px 0}.module-kpis>div{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid}.module-kpis>div:first-child{border-top-color:#f6a821}.module-kpis span{font-size:var(--shd-type-telemetry);letter-spacing:1.3px;color:#737a7c}.module-kpis strong{font-size:23px;line-height:1.1;margin:3px 0}.module-kpis small{font-size:var(--shd-type-telemetry);color:#555d5f}.dashboard-editor{margin:10px 0;background:#131617;border:1px solid #4a5051;border-top:2px solid #f6a821;padding:14px 16px}.editor-title{display:flex;justify-content:space-between;align-items:start;padding-bottom:9px;border-bottom:1px solid #303536}.editor-title h3{font-size:16px;margin:3px 0}.editor-title button{border:0;background:transparent;color:#7e8587;font-size:24px;line-height:1}.dashboard-editor .form-grid{grid-template-columns:repeat(5,minmax(0,1fr));gap:9px}.dashboard-editor .field{margin:8px 0}.dashboard-editor .field input,.dashboard-editor .field select,.dashboard-editor .field textarea{padding:8px 9px;margin-top:5px;font-size:var(--shd-type-telemetry)}.range-field>div,.uptime-field>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}.dashboard-table{border:1px solid #2d3233;background:#111415;overflow:auto}.dashboard-row{min-width:820px;display:grid;grid-template-columns:1.5fr 1fr .75fr .85fr .6fr .3fr;align-items:center;gap:10px;min-height:46px;padding:7px 13px;border-top:1px solid #272c2d;font-size:var(--shd-type-telemetry)}.dashboard-row:hover:not(.table-heading){background:#15191a}.dashboard-row>div{display:grid;gap:3px}.dashboard-row>div small{font-size:var(--shd-type-telemetry);color:#60686a}.dashboard-row>span{width:max-content}.dashboard-row>button{justify-self:end;border:0;border-bottom:1px solid transparent;background:transparent;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold;padding:5px;cursor:pointer}.dashboard-row>button:hover{border-color:#f6a821}.table-heading{min-height:32px;border-top:0;background:#0d1011;color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.state-dot{font-size:var(--shd-type-telemetry);letter-spacing:.8px;color:#75c08c}.state-dot:before{content:"";display:inline-block;width:5px;height:5px;margin-right:6px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor}.dashboard-row.archived{opacity:.45}.type-offensive,.type-defensive{padding:4px 6px;border:1px solid}.type-offensive{color:#df8069;border-color:#70463c}.type-defensive{color:#70b9d6;border-color:#365e6e}.tier-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:10px}.tier-card{min-width:0;padding:8px 11px 11px;border:1px solid #343a3b;background:#101314}.tier-card legend{padding:0 7px;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1.3px}.trigger-list{max-height:130px;overflow:auto;border:1px solid #292f30}.trigger-list>div{display:grid;grid-template-columns:1fr auto;align-items:center;min-height:33px;padding:4px 7px;border-top:1px solid #252a2b;background:#121516}.trigger-list label{display:flex;align-items:center;gap:7px;font-size:var(--shd-type-telemetry)}.trigger-list input{width:auto;margin:0;accent-color:#f6a821}.trigger-list button,.effects-head button{border:0;background:transparent;color:#f6a821;font-size:var(--shd-type-telemetry)}.effects-head{display:flex;justify-content:space-between;align-items:center;margin-top:10px}.talent-effect{display:grid;grid-template-columns:1fr 90px 27px;gap:5px;margin-top:5px}.talent-effect select,.talent-effect input{min-width:0;padding:7px;margin:0;font-size:var(--shd-type-telemetry)}.talent-effect button{border:1px solid #563734;background:#1d1212;color:#da746c}.talent-identity{grid-template-columns:2fr 1fr!important}.uptime-field>span{display:flex;align-items:center;gap:7px}.uptime-field>span input{width:auto;margin:0;accent-color:#f6a821}.script-note{align-self:end;margin:8px 0;padding:9px;border:1px dashed #394041;display:grid}.script-note b{font-size:var(--shd-type-telemetry);color:#8e9597;letter-spacing:1.2px}.script-note span{font-size:var(--shd-type-telemetry);color:#5d6567;margin-top:4px}.configured{color:#79bd8d;font-size:var(--shd-type-telemetry)} +.dashboard-table>.registry-inline-editor{position:sticky;left:0;z-index:1;width:100%;background:#101314;animation:registry-editor-reveal .18s cubic-bezier(.16,1,.3,1)}.registry-inline-editor>.dashboard-editor{margin:0;border-right:0;border-left:0;background:#131617;box-sizing:border-box}.dashboard-row.registry-row-editing{background:#19170f;box-shadow:inset 2px 0 var(--shd-orange)}.dashboard-row.registry-row-editing:hover{background:#19170f}.dashboard-row.registry-row-editing>button{color:var(--shd-text);border-color:var(--shd-orange)}@keyframes registry-editor-reveal{from{opacity:.82;clip-path:inset(0 0 100% 0)}to{opacity:1;clip-path:inset(0)}}@media(max-width:700px){.registry-inline-editor>.dashboard-editor{padding:12px}}@media(prefers-reduced-motion:reduce){.dashboard-table>.registry-inline-editor{animation:none}} @media(max-width:900px){.admin-command{align-items:flex-start}.command-status>div:nth-child(n+2){display:none}.admin-dashboard{grid-template-columns:1fr}.admin-sidebar{position:static;height:auto;min-height:0}.admin-sidebar nav{display:grid;grid-template-columns:1fr 1fr}.sidebar-foot{display:none}.module-kpis{grid-template-columns:1fr 1fr}.dashboard-editor .form-grid{grid-template-columns:1fr 1fr}.tier-grid{grid-template-columns:1fr}}@media(max-width:560px){.admin-command,.module-toolbar{flex-direction:column;align-items:flex-start;gap:12px}.module-toolbar>div:last-child{width:100%}.module-toolbar button{flex:1}.module-kpis{grid-template-columns:1fr 1fr}.dashboard-editor .form-grid{grid-template-columns:1fr}.talent-identity{grid-template-columns:1fr!important}} /* Shared SHD routed surfaces */ @@ -182,6 +183,7 @@ .mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}} .avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:var(--shd-type-telemetry);font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:var(--shd-type-telemetry)}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:var(--shd-type-telemetry);text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:var(--shd-type-telemetry)}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:var(--shd-type-telemetry);line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}} .assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.55px} +.assignment-chips>button span .assignment-item-unit,.assignment-results>button>span .assignment-item-unit{color:var(--shd-text-muted);font-weight:700;letter-spacing:.45px} .notification-link{position:relative;width:34px;height:34px;display:grid!important;place-items:center;border:1px solid var(--shd-border);color:var(--shd-orange)!important}.notification-link>span{font-size:var(--shd-type-telemetry)}.notification-link>b{position:absolute;top:-5px;right:-5px;min-width:17px;height:17px;display:grid;place-items:center;padding:0 4px;border:1px solid #d98921;background:var(--shd-orange);color:#111;font-size:var(--shd-type-telemetry)}.mobile-navigation>a>em{min-width:20px;height:20px;display:grid;place-items:center;padding:0 4px;background:var(--shd-orange);color:#111;font-size:var(--shd-type-telemetry);font-style:normal}.notification-page{max-width:1480px;margin:auto;padding:36px clamp(14px,4vw,56px) 80px}.notification-command{display:flex;align-items:end;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--shd-border)}.notification-command h1{margin:5px 0;font-size:48px}.notification-command p{margin:0;color:var(--shd-text-muted)}.notification-command>div:last-child{display:grid;grid-template-columns:auto auto;align-items:center;gap:2px 8px}.notification-command>div:last-child strong{grid-row:1/3;color:var(--shd-orange);font-size:32px}.notification-command>div:last-child span{font-size:var(--shd-type-telemetry);letter-spacing:1px}.notification-command>div:last-child button{grid-column:1/3;margin-top:6px}.notification-settings{margin:10px 0;padding:10px;border:1px solid #625025;border-top:2px solid var(--shd-orange);background:#111415}.notification-settings .module-head{padding-bottom:8px}.notification-settings .module-head>span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.notification-rule{margin:6px 0;padding:8px;border-left:2px solid var(--shd-orange);background:#18150f;color:#8f887a;font-size:var(--shd-type-telemetry);line-height:1.5}.notification-rule b{color:#d9bc82}.browser-permission{margin:4px 0 8px}.notification-preference-form{display:grid}.notification-preference-head,.notification-preference-row{display:grid;grid-template-columns:minmax(190px,1fr) 100px 100px 150px;align-items:center;gap:7px;padding:7px 9px;border-top:1px solid var(--shd-border)}.notification-preference-head{background:#0d1011;color:#697174;font-size:var(--shd-type-telemetry);letter-spacing:1px}.notification-preference-row>div{display:grid;gap:3px}.notification-preference-row>div b{font-size:var(--shd-type-telemetry)}.notification-preference-row>div small{color:#697174;font-size:var(--shd-type-telemetry)}.notification-preference-row label{display:flex;align-items:center;gap:5px;color:#8a9293;font-size:var(--shd-type-telemetry);cursor:pointer}.notification-preference-row input{width:auto;margin:0;accent-color:var(--shd-orange)}.notification-preference-row select{height:31px;margin:0;font-size:var(--shd-type-telemetry)}.notification-toolbar{display:flex;justify-content:space-between;gap:8px;margin-top:12px}.notification-toolbar form{display:flex;gap:6px}.notification-toolbar select{height:36px;margin:0;font-size:var(--shd-type-telemetry)}.notification-list{display:grid;border:1px solid var(--shd-border)}.notification-list>article{display:grid;grid-template-columns:145px minmax(0,1fr);border-top:1px solid var(--shd-border);background:#101314}.notification-list>article:first-child{border-top:0}.notification-list>article.unread{background:#141719;box-shadow:inset 2px 0 var(--shd-orange)}.notification-list>article.mandatory{box-shadow:inset 2px 0 #68a9c4}.notification-source{display:grid;align-content:start;gap:4px;padding:13px;border-right:1px solid var(--shd-border);background:#0d1011}.notification-source b{color:var(--shd-orange);font-size:var(--shd-type-telemetry);line-height:1.4}.notification-source span{color:#667073;font-size:var(--shd-type-telemetry)}.notification-list>article>div:last-child{padding:12px 14px}.notification-list article header{display:flex;justify-content:space-between;gap:12px}.notification-list h2{font-size:12px}.notification-list time{color:#697174;font-size:var(--shd-type-telemetry)}.notification-list article p{margin:7px 0;color:#9aa1a2;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.notification-list article footer{display:flex;align-items:center;justify-content:space-between}.notification-list article footer a,.notification-list article footer button,.notification-list article footer span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.notification-list article footer button{padding:0;border:0;background:none;cursor:pointer}.notification-list article footer button:hover{color:#fff}.notification-list article footer form{margin-left:auto}@media(max-width:700px){.notification-page{padding-top:22px}.notification-command{align-items:start;flex-direction:column;gap:12px}.notification-command>div:last-child{width:100%}.notification-preference-head{display:none}.notification-preference-row{grid-template-columns:1fr 1fr 1fr}.notification-preference-row>div{grid-column:1/4}.notification-preference-row select{width:100%}.notification-toolbar{align-items:stretch;flex-direction:column}.notification-toolbar form{display:grid;grid-template-columns:1fr 1fr}.notification-toolbar form button{grid-column:1/3;min-height:44px}.notification-toolbar>form:last-child button{width:100%}.notification-list>article{grid-template-columns:1fr}.notification-source{display:flex;justify-content:space-between;border-right:0;border-bottom:1px solid var(--shd-border)}.notification-list article header{display:grid}.notification-list article footer{min-height:36px}} .avatar-remove-reason{grid-template-columns:1fr;align-content:center}.avatar-remove-reason>form{display:grid;grid-template-columns:minmax(150px,1fr) auto;gap:6px}.avatar-remove-reason>form>input{height:30px;margin:0;padding:0 7px;font-size:var(--shd-type-telemetry)}.avatar-remove-reason>form>div{height:30px;display:grid;grid-template-columns:58px 96px;gap:5px}.avatar-remove-reason>form button{height:30px;min-height:0}@media(max-width:600px){.avatar-remove-reason>form{grid-template-columns:1fr}.avatar-remove-reason>form>div{grid-template-columns:1fr 1fr}} .page-breadcrumb{display:flex;align-items:center;gap:10px;margin:0}.page-breadcrumb .breadcrumb-line{flex:0 0 28px;width:28px;height:2px;background:var(--shd-orange)}.page-breadcrumb ol{display:flex;align-items:center;gap:0;margin:0;padding:0;list-style:none}.page-breadcrumb li{display:flex;align-items:center}.page-breadcrumb li+li:before{content:"/";margin:0 8px;color:#4b5254}.page-breadcrumb a{color:var(--shd-orange)}.page-breadcrumb [aria-current=page]{color:#9da1a2}@media(max-width:700px){.page-breadcrumb li+li:before{margin:0 6px}} @@ -397,10 +399,10 @@ text-align:center; } .registry-pagination:not(:has(.pagination-pages)){ - grid-template-columns:minmax(190px,260px) 1fr; + grid-template-columns:minmax(190px,260px) 1fr auto; } .registry-pagination:not(:has(.pagination-pages)):not(:has(.pagination-search)){ - grid-template-columns:1fr; + grid-template-columns:1fr auto; } .registry-pagination:not(:has(.pagination-pages)) .pagination-range{justify-content:flex-end} @media(max-width:900px){ @@ -414,6 +416,76 @@ .registry-pagination:not(:has(.pagination-pages)) .pagination-range{justify-content:center} } +/* Registry columns sort the full filtered result set before pagination. */ +.table-heading .sortable-column{ + min-width:0; + display:flex; + align-items:center; + justify-content:flex-start; + gap:6px; + padding:5px 0; + color:inherit; + font:inherit; + letter-spacing:inherit; + text-decoration:none; + cursor:pointer; +} +.table-heading .sortable-column:hover, +.table-heading .sortable-column:focus-visible{color:var(--shd-orange)} +.table-heading .sortable-column:focus-visible{ + outline:2px solid var(--shd-orange); + outline-offset:2px; +} +.table-heading .sortable-column:active{transform:translateY(1px)} +.table-heading .sortable-column.active{color:var(--shd-text)} +.sort-direction{ + flex:0 0 auto; + color:var(--shd-text-dim); + font-size:var(--shd-type-telemetry); +} +.sortable-column.active .sort-direction{color:var(--shd-orange)} +@media(prefers-reduced-motion:reduce){ + .table-heading .sortable-column:active{transform:none} +} + +/* Separate ISAC operational domains from their module destinations. */ +.admin-sidebar button.sidebar-label{ + position:relative; + min-height:39px; + height:39px; + padding:8px 10px; + border-bottom-color:#343a3b; + background:#171b1c; +} +.admin-sidebar button.sidebar-label::before{ + content:""; + flex:0 0 3px; + width:3px; + height:15px; + margin-right:8px; + background:#596164; +} +.admin-sidebar button.sidebar-label[aria-expanded="true"]::before{background:var(--shd-orange)} +.admin-sidebar button.sidebar-label:hover{background:#1c2021} +.admin-sidebar button.sidebar-label span{ + margin-right:auto; + color:#c3c8c6; + font-weight:900; +} +.admin-sidebar button.sidebar-label b{color:#737b7d} +.admin-sidebar nav{background:#0b0e0f} +.admin-sidebar nav a{ + min-height:47px; + background:#0d1011; + color:#7d8587; + font-weight:600; +} +.admin-sidebar nav a span{padding-left:20px} +.admin-sidebar nav a.active{box-shadow:inset 3px 0 var(--shd-orange)} +.metrics-panel header b.metric-ok,.metric-ok{color:var(--shd-success)} +.metrics-panel header b.metric-warning,.metric-warning{color:var(--shd-orange)} +.metrics-panel header b.metric-critical,.metric-critical{color:var(--shd-danger)} + /* State the consequence of an ISAC data mutation before an editor is opened. */ .module-toolbar>div:first-child:last-child{display:block} .mutation-policy-strip{ diff --git a/src/lib/components/RegistryInlineEditor.svelte b/src/lib/components/RegistryInlineEditor.svelte new file mode 100644 index 0000000..5c77025 --- /dev/null +++ b/src/lib/components/RegistryInlineEditor.svelte @@ -0,0 +1,17 @@ + + +
+ {@render children()} +
diff --git a/src/lib/components/RegistryPagination.svelte b/src/lib/components/RegistryPagination.svelte index 2497b0b..edff7a5 100644 --- a/src/lib/components/RegistryPagination.svelte +++ b/src/lib/components/RegistryPagination.svelte @@ -87,6 +87,6 @@ {/each} NEXT → -
ROWS{#each [10, 25, 50, 100] as size}{size}{/each}
{/if} +
ROWS{#each [10, 25, 50, 100] as size}{size}{/each}
diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte index e6aa213..e7192a6 100644 --- a/src/lib/components/SearchableAssignment.svelte +++ b/src/lib/components/SearchableAssignment.svelte @@ -1,5 +1,5 @@ + + + + {label} + diff --git a/src/lib/server/pagination.test.ts b/src/lib/server/pagination.test.ts new file mode 100644 index 0000000..4c75efb --- /dev/null +++ b/src/lib/server/pagination.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { getPagination, getRegistrySort } from './pagination.js'; + +const options = { + name: { asc: 'name ASC', desc: 'name DESC' }, + updated: { asc: 'updated_at ASC', desc: 'updated_at DESC', defaultDirection: 'desc' as const } +}; + +describe('getRegistrySort', () => { + it('returns only allowlisted sort expressions', () => { + const url = new URL('https://shd.build/isac/attributes?sort=name&direction=desc'); + expect(getRegistrySort(url, options, 'name')).toEqual({ + key: 'name', + direction: 'desc', + orderBy: 'name DESC' + }); + }); + + it('falls back for unknown keys and directions', () => { + const url = new URL('https://shd.build/isac/attributes?sort=DROP%20TABLE&direction=sideways'); + expect(getRegistrySort(url, options, 'updated')).toEqual({ + key: 'updated', + direction: 'desc', + orderBy: 'updated_at DESC' + }); + }); + + it('supports namespaced registry parameters', () => { + const url = new URL('https://shd.build/isac/users/1?loginSort=name&loginDirection=asc'); + expect(getRegistrySort(url, options, 'updated', 'desc', 'login')).toMatchObject({ + key: 'name', + direction: 'asc' + }); + }); +}); + +describe('getPagination', () => { + it('keeps supported row counts and clamps the selected page', () => { + const url = new URL('https://shd.build/isac/attributes?perPage=50&page=9'); + expect(getPagination(url, 75)).toMatchObject({ perPage: 50, page: 2, pageCount: 2 }); + }); +}); diff --git a/src/lib/server/pagination.ts b/src/lib/server/pagination.ts index fc74f8d..6dd6a17 100644 --- a/src/lib/server/pagination.ts +++ b/src/lib/server/pagination.ts @@ -2,6 +2,39 @@ import type { URL } from 'node:url'; export const PAGE_SIZE_PRESETS = [10, 25, 50, 100] as const; +export type RegistrySortDirection = 'asc' | 'desc'; +export type RegistrySort = { + key: string; + direction: RegistrySortDirection; + orderBy: string; +}; +export type RegistrySortOptions = Record< + string, + { asc: string; desc: string; defaultDirection?: RegistrySortDirection } +>; + +export function getRegistrySort( + url: URL, + options: RegistrySortOptions, + defaultKey: string, + defaultDirection: RegistrySortDirection = 'asc', + namespace = '' +): RegistrySort { + const sortKey = namespace ? `${namespace}Sort` : 'sort'; + const directionKey = namespace ? `${namespace}Direction` : 'direction'; + const requestedKey = String(url.searchParams.get(sortKey) ?? ''); + const key = Object.hasOwn(options, requestedKey) ? requestedKey : defaultKey; + const option = options[key]; + if (!option) throw new Error(`Unknown default registry sort: ${defaultKey}`); + const requestedDirection = String(url.searchParams.get(directionKey) ?? '').toLowerCase(); + const fallbackDirection = option.defaultDirection ?? defaultDirection; + const direction: RegistrySortDirection = + requestedDirection === 'asc' || requestedDirection === 'desc' + ? requestedDirection + : fallbackDirection; + return { key, direction, orderBy: option[direction] }; +} + export function getRegistrySearch(url: URL, namespace = '') { const key = namespace ? `${namespace}Q` : 'q'; const search = String(url.searchParams.get(key) ?? '').normalize('NFKC').trim().slice(0, 80); diff --git a/src/lib/server/remove-background.ts b/src/lib/server/remove-background.ts index 0716228..70f4eb9 100644 --- a/src/lib/server/remove-background.ts +++ b/src/lib/server/remove-background.ts @@ -1,4 +1,5 @@ import sharp from 'sharp'; +import { recordRemoveBgCharge } from '$lib/server/remove-bg-monitor'; const REMOVE_BG_ENDPOINT = 'https://api.remove.bg/v1.0/removebg'; const REMOVE_BG_PREVIEW_EDGE = 1500; @@ -41,6 +42,12 @@ export async function removeBackgroundPreview(input: Buffer): Promise 0) { + await recordRemoveBgCharge(charged).catch((error) => { + console.error('Unable to update the cached remove.bg credit balance.', error); + }); + } const output = Buffer.from(await response.arrayBuffer()); const metadata = await sharp(output, { failOn: 'warning' }).metadata(); if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.'); diff --git a/src/lib/server/remove-bg-monitor.test.ts b/src/lib/server/remove-bg-monitor.test.ts new file mode 100644 index 0000000..b1863c9 --- /dev/null +++ b/src/lib/server/remove-bg-monitor.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { parseRemoveBgAccount } from './remove-bg-monitor'; + +describe('parseRemoveBgAccount', () => { + it('normalizes the official account response fields', () => { + expect(parseRemoveBgAccount({ + data: { + attributes: { + credits: { + total: 23.5, + subscription: 20, + payg: 3.5, + enterprise: 0 + }, + api: { + free_calls: 17, + sizes: 'preview' + } + } + } + })).toEqual({ + total: 23.5, + subscription: 20, + payg: 3.5, + enterprise: 0, + freeCalls: 17 + }); + }); + + it('floors fractional free-call balances and accepts numeric API strings', () => { + expect(parseRemoveBgAccount({ + data: { + attributes: { + credits: { total: '7.25', subscription: '5', payg: '2.25' }, + api: { free_calls: '4.9' } + } + } + })).toEqual({ + total: 7.25, + subscription: 5, + payg: 2.25, + enterprise: 0, + freeCalls: 4 + }); + }); + + it.each([ + undefined, + {}, + { data: { attributes: {} } }, + { data: { attributes: { credits: { total: 'unknown' }, api: { free_calls: 10 } } } }, + { data: { attributes: { credits: { total: 10 }, api: { free_calls: 'unknown' } } } }, + { data: { attributes: { credits: { total: -1 }, api: { free_calls: 10 } } } }, + { data: { attributes: { credits: { total: 10 }, api: { free_calls: -1 } } } } + ])('rejects a malformed or negative required balance: %j', (payload) => { + expect(() => parseRemoveBgAccount(payload)).toThrow('remove.bg returned an invalid account balance.'); + }); + + it('defaults malformed or negative optional credit buckets to zero', () => { + expect(parseRemoveBgAccount({ + data: { + attributes: { + credits: { + total: 12, + subscription: -1, + payg: 'not-a-number', + enterprise: Number.POSITIVE_INFINITY + }, + api: { free_calls: 8 } + } + } + })).toEqual({ + total: 12, + subscription: 0, + payg: 0, + enterprise: 0, + freeCalls: 8 + }); + }); +}); diff --git a/src/lib/server/remove-bg-monitor.ts b/src/lib/server/remove-bg-monitor.ts new file mode 100644 index 0000000..2be5e91 --- /dev/null +++ b/src/lib/server/remove-bg-monitor.ts @@ -0,0 +1,236 @@ +import { randomUUID } from 'node:crypto'; +import { env } from '$env/dynamic/private'; +import { getDatabase } from '$lib/server/database'; +import { createNotification } from '$lib/server/notifications'; + +const PROVIDER = 'remove_bg'; +const ACCOUNT_ENDPOINT = 'https://api.remove.bg/v1.0/account'; + +export type RemoveBgCreditStatus = { + configured: boolean; + total: number | null; + subscription: number | null; + payg: number | null; + enterprise: number | null; + freeCalls: number | null; + checkedAt: string | null; + nextCheckAt: string | null; + lastCharge: number | null; + lastChargeAt: string | null; + error: string | null; + severity: 'unknown' | 'ok' | 'warning' | 'critical'; + warningCreditThreshold: number; + warningFreeCallThreshold: number; +}; + +const finiteNonNegative = (value: unknown) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +}; + +export function parseRemoveBgAccount(payload: unknown) { + const attributes = (payload as { data?: { attributes?: Record } })?.data?.attributes; + const credits = attributes?.credits as Record | undefined; + const api = attributes?.api as Record | undefined; + const total = finiteNonNegative(credits?.total); + const freeCalls = finiteNonNegative(api?.free_calls); + if (total === null || freeCalls === null) throw new Error('remove.bg returned an invalid account balance.'); + return { + total, + subscription: finiteNonNegative(credits?.subscription) ?? 0, + payg: finiteNonNegative(credits?.payg) ?? 0, + enterprise: finiteNonNegative(credits?.enterprise) ?? 0, + freeCalls: Math.floor(freeCalls) + }; +} + +function settings() { + const warningCreditThreshold = finiteNonNegative(env.REMOVE_BG_LOW_CREDIT_THRESHOLD) ?? 5; + const warningFreeCallThreshold = finiteNonNegative(env.REMOVE_BG_LOW_FREE_CALL_THRESHOLD) ?? 10; + const checkHours = Math.max(1, finiteNonNegative(env.REMOVE_BG_CREDIT_CHECK_HOURS) ?? 6); + return { warningCreditThreshold, warningFreeCallThreshold, checkHours }; +} + +function severity(total: number, freeCalls: number) { + const { warningCreditThreshold, warningFreeCallThreshold } = settings(); + if (total <= 0 && freeCalls <= 0) return 'critical' as const; + if (total <= warningCreditThreshold && freeCalls <= warningFreeCallThreshold) return 'warning' as const; + return 'ok' as const; +} + +async function activeAdministrators() { + 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 user_roles.role_id='role-admin' + OR role_permission.name='system.admin' OR direct_permission.name='system.admin') + `)).all(new Date().toISOString()); +} + +async function queueAlertEpisode(episodeId: string, alertSeverity: 'warning' | 'critical') { + const database = await getDatabase(); + const now = new Date().toISOString(); + for (const administrator of await activeAdministrators()) { + await (await database.prepare(`INSERT OR IGNORE INTO external_service_alert_deliveries + (id,provider,episode_id,severity,user_id,created_at) + VALUES(?,?,?,?,?,?)`)).run( + randomUUID(), PROVIDER, episodeId, alertSeverity, administrator.id, now + ); + } +} + +async function persistBalance(balance: ReturnType) { + const database = await getDatabase(); + const previous = await (await database.prepare( + 'SELECT severity,episode_id FROM external_service_status WHERE provider=?' + )).get(PROVIDER); + const now = new Date(); + const nextCheck = new Date(now.getTime() + settings().checkHours * 60 * 60_000); + const nextSeverity = severity(balance.total, balance.freeCalls); + const previousSeverity = String(previous?.severity ?? 'unknown'); + const recovered = nextSeverity === 'ok' && (previousSeverity === 'warning' || previousSeverity === 'critical'); + const startsEpisode = (nextSeverity === 'warning' || nextSeverity === 'critical') && + (previousSeverity === 'unknown' || previousSeverity === 'ok'); + const episodeId = startsEpisode ? randomUUID() : String(previous?.episode_id ?? '') || null; + await (await database.prepare(`INSERT INTO external_service_status( + provider,credits_total,credits_subscription,credits_payg,credits_enterprise,free_calls, + checked_at,next_check_at,error_message,severity,episode_id,episode_started_at,recovered_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(provider) DO UPDATE SET + credits_total=excluded.credits_total,credits_subscription=excluded.credits_subscription, + credits_payg=excluded.credits_payg,credits_enterprise=excluded.credits_enterprise, + free_calls=excluded.free_calls,checked_at=excluded.checked_at,next_check_at=excluded.next_check_at, + error_message=NULL,severity=excluded.severity,episode_id=excluded.episode_id, + episode_started_at=CASE WHEN excluded.episode_id<>external_service_status.episode_id THEN excluded.episode_started_at ELSE external_service_status.episode_started_at END, + recovered_at=excluded.recovered_at,updated_at=excluded.updated_at`)).run( + PROVIDER, balance.total, balance.subscription, balance.payg, balance.enterprise, balance.freeCalls, + now.toISOString(), nextCheck.toISOString(), null, nextSeverity, + recovered ? null : episodeId, startsEpisode ? now.toISOString() : null, + recovered ? now.toISOString() : null, now.toISOString() + ); + if (episodeId && (startsEpisode || (previousSeverity === 'warning' && nextSeverity === 'critical'))) { + await queueAlertEpisode(episodeId, nextSeverity); + } +} + +export async function refreshRemoveBgCredits(force = false) { + const database = await getDatabase(); + const existing = await (await database.prepare( + 'SELECT next_check_at FROM external_service_status WHERE provider=?' + )).get(PROVIDER); + if (!force && existing?.next_check_at && String(existing.next_check_at) > new Date().toISOString()) { + return getRemoveBgCreditStatus(); + } + const apiKey = env.REMOVE_BG_API_KEY?.trim(); + if (!apiKey) return getRemoveBgCreditStatus(); + try { + const response = await fetch(ACCOUNT_ENDPOINT, { + headers: { 'X-Api-Key': apiKey }, + signal: AbortSignal.timeout(15_000) + }); + if (!response.ok) throw new Error(`remove.bg account check failed with status ${response.status}.`); + await persistBalance(parseRemoveBgAccount(await response.json())); + } catch (error) { + const now = new Date(); + const retryAt = new Date(now.getTime() + 60 * 60_000); + const message = error instanceof Error ? error.message : 'remove.bg account check failed.'; + await (await database.prepare(`INSERT INTO external_service_status(provider,next_check_at,error_message,updated_at) + VALUES(?,?,?,?) ON CONFLICT(provider) DO UPDATE SET + next_check_at=excluded.next_check_at,error_message=excluded.error_message,updated_at=excluded.updated_at`)) + .run(PROVIDER, retryAt.toISOString(), message.slice(0, 240), now.toISOString()); + } + return getRemoveBgCreditStatus(); +} + +export async function recordRemoveBgCharge(charged: number) { + if (!Number.isFinite(charged) || charged <= 0) return; + const database = await getDatabase(); + const row = await (await database.prepare( + 'SELECT credits_total,free_calls,severity,episode_id FROM external_service_status WHERE provider=?' + )).get(PROVIDER); + if (row?.credits_total === null || row?.credits_total === undefined) return; + const now = new Date().toISOString(); + const total = Math.max(0, Number(row.credits_total) - charged); + const nextSeverity = severity(total, Number(row.free_calls ?? 0)); + const previousSeverity = String(row.severity ?? 'unknown'); + const startsEpisode = (nextSeverity === 'warning' || nextSeverity === 'critical') && + (previousSeverity === 'unknown' || previousSeverity === 'ok'); + const episodeId = startsEpisode ? randomUUID() : String(row.episode_id ?? '') || null; + await (await database.prepare(`UPDATE external_service_status SET credits_total=?,last_charge=?,last_charge_at=?, + severity=?,episode_id=?,episode_started_at=CASE WHEN ? THEN ? ELSE episode_started_at END,updated_at=? + WHERE provider=?`)).run( + total, charged, now, nextSeverity, episodeId, startsEpisode ? 1 : 0, now, now, PROVIDER + ); + if (episodeId && (startsEpisode || (previousSeverity === 'warning' && nextSeverity === 'critical'))) { + await queueAlertEpisode(episodeId, nextSeverity); + } +} + +export async function deliverRemoveBgCreditAlerts() { + const database = await getDatabase(); + const pending = await (await database.prepare(`SELECT delivery.*,status.credits_total,status.free_calls + FROM external_service_alert_deliveries delivery + JOIN external_service_status status ON status.provider=delivery.provider + WHERE delivery.delivered_at IS NULL ORDER BY delivery.created_at LIMIT 100`)).all(); + let delivered = 0; + for (const alert of pending) { + const title = alert.severity === 'critical' + ? 'remove.bg credits exhausted' + : 'remove.bg credits running low'; + const body = `remove.bg has ${Number(alert.credits_total ?? 0).toLocaleString()} paid credits and ${Number(alert.free_calls ?? 0).toLocaleString()} free calls remaining. Review image-processing capacity in System Metrics.`; + const notificationId = await createNotification({ + userId: String(alert.user_id), + category: 'isac', + source: 'ISAC SYSTEM OVERSIGHT', + title, + body, + link: '/isac/metrics', + actions: [{ label: 'VIEW SYSTEM METRICS', href: '/isac/metrics' }], + mandatory: true + }); + await (await database.prepare(`UPDATE external_service_alert_deliveries + SET notification_id=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`)) + .run(notificationId, new Date().toISOString(), alert.id); + delivered++; + } + return delivered; +} + +export async function getRemoveBgCreditStatus(): Promise { + const database = await getDatabase(); + const row = await (await database.prepare('SELECT * FROM external_service_status WHERE provider=?')).get(PROVIDER); + const { warningCreditThreshold, warningFreeCallThreshold } = settings(); + return { + configured: Boolean(env.REMOVE_BG_API_KEY?.trim()), + total: finiteNonNegative(row?.credits_total), + subscription: finiteNonNegative(row?.credits_subscription), + payg: finiteNonNegative(row?.credits_payg), + enterprise: finiteNonNegative(row?.credits_enterprise), + freeCalls: finiteNonNegative(row?.free_calls), + checkedAt: row?.checked_at ? String(row.checked_at) : null, + nextCheckAt: row?.next_check_at ? String(row.next_check_at) : null, + lastCharge: finiteNonNegative(row?.last_charge), + lastChargeAt: row?.last_charge_at ? String(row.last_charge_at) : null, + error: row?.error_message ? String(row.error_message) : null, + severity: (['ok', 'warning', 'critical'].includes(String(row?.severity)) + ? String(row?.severity) + : 'unknown') as RemoveBgCreditStatus['severity'], + warningCreditThreshold, + warningFreeCallThreshold + }; +} + +export async function runRemoveBgCreditMonitor() { + await refreshRemoveBgCredits(false); + return deliverRemoveBgCreditAlerts(); +} diff --git a/src/lib/server/scheduled-notifications.ts b/src/lib/server/scheduled-notifications.ts index cd11cf0..a3dda90 100644 --- a/src/lib/server/scheduled-notifications.ts +++ b/src/lib/server/scheduled-notifications.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { getDatabase } from '$lib/server/database'; import nodemailer from 'nodemailer'; +import { runRemoveBgCreditMonitor } from '$lib/server/remove-bg-monitor'; function mailTransport() { if (!env.SMTP_HOST || !env.SMTP_FROM) return null; @@ -159,14 +160,15 @@ async function deliverDailyDigests() { return sent; } -let running: Promise<{ system: number; oversight: number; digests: number }> | null = null; +let running: Promise<{ system: number; oversight: number; digests: number; serviceAlerts: number }> | null = null; export function runScheduledNotificationTasks() { if (running) return running; running = (async () => { const system = await deliverSystemMessages(); const oversight = await deliverOversightMessages(); const digests = await deliverDailyDigests(); - return { system, oversight, digests }; + const serviceAlerts = await runRemoveBgCreditMonitor(); + return { system, oversight, digests, serviceAlerts }; })() .finally(() => { running = null; }); return running; diff --git a/src/routes/isac/attributes/+page.server.ts b/src/routes/isac/attributes/+page.server.ts index 3fdac7c..8526a64 100644 --- a/src/routes/isac/attributes/+page.server.ts +++ b/src/routes/isac/attributes/+page.server.ts @@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { getPendingEntities } from '$lib/server/data-moderation'; @@ -33,6 +33,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get(); const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC' }, + type: { asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' }, + category: { asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' }, + calculation: { asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' }, + state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []), ...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) @@ -42,21 +49,26 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const visiblePending = pending.filter((stat) => (filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true) && (!search || String(stat.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())) - ); + ).sort((left, right) => { + const field = sort.key === 'calculation' ? 'calculation_type' : sort.key; + const result = String((left as any)[field] ?? '').localeCompare(String((right as any)[field] ?? ''), undefined, { sensitivity: 'base', numeric: true }); + return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' }); + }); const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(...args); const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length); const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : []; const databaseOffset = Math.max(0, pagination.offset - visiblePending.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const stats = databaseLimit - ? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) + ? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : []; return { stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))], summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length }, pagination, filter, - search + search, + sort }; }; export const actions: Actions = { @@ -70,12 +82,23 @@ export const actions: Actions = { if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' }); if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' }); if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' }); - const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; }; + const number = (key: string, allowNegative = true) => { + const raw = String(form.get(key) ?? '').trim(); + if (!raw) return null; + const value = Number(raw); + if (!Number.isFinite(value) || (!allowNegative && value < 0)) throw new Error(); + return value; + }; let values: Array; - try { values = ['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax','capValue'].map(number); } catch { return fail(400, { error: 'Ranges must be positive numbers.' }); } + try { + values = [ + ...['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax'].map((key) => number(key)), + number('capValue', false) + ]; + } catch { return fail(400, { error: 'Ranges must be numbers. Optional caps cannot be negative.' }); } for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' }); const database = await getDatabase(); - if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' }); + if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?')).get(name, unit, id || 'new')) return fail(409, { error: 'That attribute and unit combination already exists.' }); const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await auditRecord('stat_attributes', id) : null; if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id); diff --git a/src/routes/isac/attributes/+page.svelte b/src/routes/isac/attributes/+page.svelte index a7e6ae0..438a79a 100644 --- a/src/routes/isac/attributes/+page.svelte +++ b/src/routes/isac/attributes/+page.svelte @@ -1,6 +1,8 @@ +{#snippet attributeEditor()} + {#if editing} +

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
+ {/if} +{/snippet} Attributes — SHD.build admin

ATTRIBUTE REGISTRY

ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

-{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} +{#if editing && !editing.id}{@render attributeEditor()}{/if} -
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each visibleStats as stat}
{stat.name}{attributeMeta(stat)}
{stat.type}{stat.category}{stat.calculation_type}{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}{#if stat.pending}{:else}{/if}
{/each}
+
{#each visibleStats as stat}
{stat.name}{attributeMeta(stat)}
{stat.type}{stat.category}{stat.calculation_type}{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}{#if stat.pending}{:else}{/if}
{#if editing?.id === stat.id}{@render attributeEditor()}{/if}{/each}
diff --git a/src/routes/isac/audit/+page.server.ts b/src/routes/isac/audit/+page.server.ts index cbe9311..f3b9762 100644 --- a/src/routes/isac/audit/+page.server.ts +++ b/src/routes/isac/audit/+page.server.ts @@ -1,7 +1,7 @@ import { getPermissions, requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; -import { getPagination } from '$lib/server/pagination'; +import { getPagination, getRegistrySort } from '$lib/server/pagination'; import { recordIsacAudit } from '$lib/server/audit'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; @@ -47,6 +47,22 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? ''; const category = url.searchParams.get('category')?.trim() ?? ''; const action = url.searchParams.get('action')?.trim() ?? ''; + const sort = getRegistrySort(url, { + event: { + asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC', + desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC' + }, + operator: { + asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC', + desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC' + }, + target: { asc: 'audit_log.entity_id COLLATE NOCASE ASC', desc: 'audit_log.entity_id COLLATE NOCASE DESC' }, + time: { + asc: 'audit_log.created_at ASC', + desc: 'audit_log.created_at DESC', + defaultDirection: 'desc' + } + }, 'time', 'desc'); const conditions: string[] = []; const args: unknown[] = []; if (query) { @@ -82,13 +98,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { COALESCE(user_profiles.display_name,users.username) AS actor_name,users.login_username AS actor_login FROM audit_log JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id - ${where} ORDER BY audit_log.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset); + ${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset); const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : []; const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id))); return { entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })), - categories, actions, summary, pagination, filters: { query, category, action }, canRevert + categories, actions, summary, pagination, filters: { query, category, action }, canRevert, sort }; }; diff --git a/src/routes/isac/audit/+page.svelte b/src/routes/isac/audit/+page.svelte index f802548..c31532a 100644 --- a/src/routes/isac/audit/+page.svelte +++ b/src/routes/isac/audit/+page.svelte @@ -3,6 +3,7 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; let { data, form } = $props(); let confirming = $state(null); let handledResultToken = $state(''); @@ -46,7 +47,7 @@
-
EVENTOPERATORTARGETTIME
+
{#each data.entries as entry}
{String(entry.action).toUpperCase()}{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}
{entry.actor_name}{entry.actor_login ?? entry.actor_id}
{entry.entity_id}EVENT {String(entry.id).slice(0,8).toUpperCase()}
{date(entry.created_at)}DETAILS +
diff --git a/src/routes/isac/brands/+page.server.ts b/src/routes/isac/brands/+page.server.ts index 657ceac..2e23854 100644 --- a/src/routes/isac/brands/+page.server.ts +++ b/src/routes/isac/brands/+page.server.ts @@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { getPendingEntities } from '$lib/server/data-moderation'; @@ -33,6 +33,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => { SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)).get(); const filter = ['active', 'archived', 'complete'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const { search, pattern } = getRegistrySearch(url); + const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE(( + SELECT attributes.name FROM brand_set_effects effect + JOIN stat_attributes attributes ON attributes.id=effect.stat_attribute_id + WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces} + LIMIT 1 + ), '') COLLATE NOCASE ${direction}, COALESCE(( + SELECT effect.value FROM brand_set_effects effect + WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces} + LIMIT 1 + ), 0) ${direction}, name COLLATE NOCASE ASC, id ASC`; + const sort = getRegistrySort(url, { + name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' }, + bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') }, + bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') }, + bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') }, + state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'complete' ? ['(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3'] : []), ...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) @@ -40,13 +57,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const args = search ? [pattern] : []; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(...args); - const visiblePending = mappedPendingBrands.filter((brand) => filter !== 'archived' && (!search || String(brand.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))); + const visiblePending = mappedPendingBrands.filter((brand) => filter !== 'archived' && (!search || String(brand.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))).sort((left, right) => { + const pieces = sort.key.startsWith('bonus') ? Number(sort.key.slice(5)) : 0; + const valueFor = (brand: any) => pieces + ? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}` + : sort.key === 'state' ? String(brand.active ?? 1) : String(brand.name); + const result = valueFor(left).localeCompare(valueFor(right), undefined, { sensitivity: 'base', numeric: true }); + return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' }); + }); const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length); const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : []; const databaseOffset = Math.max(0, pagination.offset - visiblePending.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const [brands, effects, stats, effectSummary, icons] = await Promise.all([ - databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [], + databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [], (await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(), @@ -61,7 +85,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) }, pagination, filter, - search + search, + sort }; }; diff --git a/src/routes/isac/brands/+page.svelte b/src/routes/isac/brands/+page.svelte index eb638d4..d974fda 100644 --- a/src/routes/isac/brands/+page.svelte +++ b/src/routes/isac/brands/+page.svelte @@ -1,6 +1,8 @@ +{#snippet brandEditor()} + {#if editing} +
+

{editing.id ? 'MODIFY SET' : 'NEW SET'}

{editing.id ? editing.name : 'BRAND SET DEFINITION'}

+ + +
+ +
+ +
+ {#each [2, 3, 4] as pieces} + {@const effect = effectFor(editing, pieces)} +
{pieces}{pieces === 4 ? '+ PIECES' : ' PIECES'}
ATTRIBUTE
+ {/each} +
+
+
+ {#if form?.brandError}

{form.brandError}

{/if} +
{#if editing.id}{#if editing.active === 1}{:else}{/if}{/if}
+
+
+ {/if} +{/snippet} + Brand sets — SHD.build admin

EQUIPMENT SYNERGY REGISTRY

BRAND SETS

Map equipment brands to the attributes unlocked at each set threshold.

-{#if editing} -
-

{editing.id ? 'MODIFY SET' : 'NEW SET'}

{editing.id ? editing.name : 'BRAND SET DEFINITION'}

- - -
- -
- -
- {#each [2, 3, 4] as pieces} - {@const effect = effectFor(editing, pieces)} -
{pieces}{pieces === 4 ? '+ PIECES' : ' PIECES'}
ATTRIBUTE
- {/each} -
-
-
- {#if form?.brandError}

{form.brandError}

{/if} -
{#if editing.id}{#if editing.active === 1}{:else}{/if}{/if}
-
-
-{/if} +{#if editing && !editing.id}{@render brandEditor()}{/if}
-
BRAND SET2 PIECES3 PIECES4+ PIECESSTATE
+
{#each data.brands as brand} -
{brand.name}{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if brand.pending}{:else}{/if}
+
{brand.name}{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}
{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}{/each}{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if brand.pending}{:else}{/if}
+ {#if editing?.id === brand.id}{@render brandEditor()}{/if} {:else}
NO BRAND SETS REGISTEREDAdd the first set to begin mapping equipment synergy bonuses.
{/each} diff --git a/src/routes/isac/calculations/+page.server.ts b/src/routes/isac/calculations/+page.server.ts index df6aceb..db76729 100644 --- a/src/routes/isac/calculations/+page.server.ts +++ b/src/routes/isac/calculations/+page.server.ts @@ -5,7 +5,7 @@ import { requireDataView, requirePermission } from '$lib/server/auth/authorizati import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { recordIsacAudit } from '$lib/server/audit'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { parseLoadoutDraft } from '$lib/builder/loadout'; const operators = new Set([ @@ -62,6 +62,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => { await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); const { search, pattern } = getRegistrySearch(url); + const observationCount = `((calibration.body_hit IS NOT NULL) + (calibration.critical_body_hit IS NOT NULL) + + (calibration.headshot IS NOT NULL) + (calibration.critical_headshot IS NOT NULL) + + (calibration.magazine_seconds IS NOT NULL) + (calibration.reload_seconds IS NOT NULL))`; + const sort = getRegistrySort(url, { + name: { asc: 'calibration.name COLLATE NOCASE ASC', desc: 'calibration.name COLLATE NOCASE DESC' }, + weapon: { asc: 'calibration.selected_weapon ASC', desc: 'calibration.selected_weapon DESC' }, + observations: { + asc: `${observationCount} ASC`, + desc: `${observationCount} DESC`, + defaultDirection: 'desc' + }, + prediction: { + asc: '(calibration.predicted_json IS NOT NULL) ASC', + desc: '(calibration.predicted_json IS NOT NULL) DESC', + defaultDirection: 'desc' + }, + state: { asc: 'calibration.status COLLATE NOCASE ASC', desc: 'calibration.status COLLATE NOCASE DESC' } + }, 'name'); const where = search ? "WHERE calibration.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : ''; const args = search ? [pattern] : []; const total = Number((await (await database.prepare( @@ -77,7 +95,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { FROM calculation_calibration_cases calibration LEFT JOIN users user ON user.id=calibration.created_by LEFT JOIN user_profiles profile ON profile.user_id=user.id - ${where} ORDER BY calibration.updated_at DESC LIMIT ? OFFSET ?` + ${where} ORDER BY ${sort.orderBy},calibration.name COLLATE NOCASE,calibration.id LIMIT ? OFFSET ?` )).all(...args, pagination.perPage, pagination.offset), (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN status='ready' THEN 1 ELSE 0 END) AS ready, @@ -95,7 +113,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { calibrationCases, calibrationSummary, pagination, - search + search, + sort }; }; diff --git a/src/routes/isac/calculations/+page.svelte b/src/routes/isac/calculations/+page.svelte index 4126f27..85a2d51 100644 --- a/src/routes/isac/calculations/+page.svelte +++ b/src/routes/isac/calculations/+page.svelte @@ -1,6 +1,8 @@ +{#snippet gearEditor()} + {#if editing} +
+

{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}

{editing.id ? editing.name : 'GEAR DEFINITION'}

+ + +
+ + changeSlot(values[0])} required/> + +
+
+
HPBASE HEALTH
+
ARBASE ARMOR
+
+
COMPATIBLE BRAND SETSSELECT AT LEAST TWO
{editing.brandIds.length} SELECTED
+ + {#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'} +
{editing.slot.toUpperCase()} TALENTSSELECT AT LEAST ONE · REQUIRED
{editing.talentIds.length} SELECTED
+ + {#if gearTalents(editing.slot).length === 0}

Create an active {editing.slot} talent before adding gear for this slot.

{/if} + {/if} + {#if data.brands.length < 2}

Create at least two active brand sets before adding gear.

{/if} + {#if form?.gearError}

{form.gearError}

{/if} +
{#if editing.id}{/if}
+
+
+ {/if} +{/snippet} + Gear — SHD.build admin

EQUIPMENT DEFINITION REGISTRY

GEAR

Configure shared tactical augments, slot progression, survivability, brands, and talents.

@@ -105,37 +137,12 @@
{/if} -{#if editing} -
-

{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}

{editing.id ? editing.name : 'GEAR DEFINITION'}

- - -
- - changeSlot(values[0])} required/> - -
-
-
HPBASE HEALTH
-
ARBASE ARMOR
-
-
COMPATIBLE BRAND SETSSELECT AT LEAST TWO
{editing.brandIds.length} SELECTED
- - {#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'} -
{editing.slot.toUpperCase()} TALENTSSELECT AT LEAST ONE · REQUIRED
{editing.talentIds.length} SELECTED
- - {#if gearTalents(editing.slot).length === 0}

Create an active {editing.slot} talent before adding gear for this slot.

{/if} - {/if} - {#if data.brands.length < 2}

Create at least two active brand sets before adding gear.

{/if} - {#if form?.gearError}

{form.gearError}

{/if} -
{#if editing.id}{/if}
-
-
-{/if} +{#if editing && !editing.id}{@render gearEditor()}{/if}
-
GEARSLOTTIERBASE RANGESBRAND SETSSTATE
+
{#each data.gear as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}
{item.slot}T{item.tier} · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null} · AR {item.base_armor_min}–{item.base_armor_max}{/if}
{#each compatibleBrands(item.brandIds) as brand}{/each}
{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+ {#if editing?.id === item.id}{@render gearEditor()}{/if} {:else}
NO GEAR REGISTEREDAdd the first gear definition after configuring at least two brand sets.
{/each}
diff --git a/src/routes/isac/gearmods/+page.server.ts b/src/routes/isac/gearmods/+page.server.ts index 73018ef..f0eeade 100644 --- a/src/routes/isac/gearmods/+page.server.ts +++ b/src/routes/isac/gearmods/+page.server.ts @@ -3,7 +3,7 @@ import { requireDataView, requirePermission } from '$lib/server/auth/authorizati 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 { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; @@ -38,6 +38,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const requestedFilter = url.searchParams.get('filter') ?? ''; const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC' }, + secondaries: { asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC' }, + state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []), ...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) @@ -61,7 +66,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 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) : [], + ORDER BY ${sort.orderBy} 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(), @@ -98,6 +103,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }, filter, pagination, + sort, search }; }; diff --git a/src/routes/isac/gearmods/+page.svelte b/src/routes/isac/gearmods/+page.svelte index 9ede477..ab84c63 100644 --- a/src/routes/isac/gearmods/+page.svelte +++ b/src/routes/isac/gearmods/+page.svelte @@ -3,6 +3,8 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; @@ -82,7 +84,7 @@ {value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'} ]}/> -{#if editing} +{#snippet gearModEditor()}

{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}

{editing.id ? editing.name : 'GEAR MOD DEFINITION'}

@@ -165,21 +167,30 @@
+{/snippet} + +{#if editing && !editing.id} + {@render gearModEditor()} {/if}
-
GEAR MODPRIMARYSECONDARIESQUALITYLEVELSSTATE
+
PRIMARYQUALITYLEVELS
{#each data.mods as item} -
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}
5 OPTIONS {item.secondaryRanges.length} POSSIBLE · SELECT 2 STANDARD → HIGH-END 01 → 20 {item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'} - {#if item.pending}{:else}{/if} + {#if item.pending}{:else}{/if}
+ {#if editing?.id === item.id} + + {@render gearModEditor()} + + {/if} {:else}
NO GEAR MODS REGISTEREDAdd the first quality-scaled Gear Mod definition.
{/each} diff --git a/src/routes/isac/metrics/+page.server.ts b/src/routes/isac/metrics/+page.server.ts index 08bef7f..2ca927f 100644 --- a/src/routes/isac/metrics/+page.server.ts +++ b/src/routes/isac/metrics/+page.server.ts @@ -7,6 +7,7 @@ import { basename, resolve } from 'node:path'; import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os'; import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; import type { PageServerLoad } from './$types'; +import { getRemoveBgCreditStatus, refreshRemoveBgCredits } from '$lib/server/remove-bg-monitor'; const loopDelay = monitorEventLoopDelay({ resolution: 20 }); loopDelay.enable(); @@ -31,7 +32,7 @@ export const load: PageServerLoad = async ({ cookies }) => { previousElu = performance.eventLoopUtilization(); const memory = process.memoryUsage(); - const [pageCount, pageSize, freePages, tableCount, counts, databaseFile] = await Promise.all([ + const [pageCount, pageSize, freePages, tableCount, counts, databaseFile, removeBackground] = await Promise.all([ (await database.prepare('PRAGMA page_count')).get(), (await database.prepare('PRAGMA page_size')).get(), (await database.prepare('PRAGMA freelist_count')).get(), @@ -44,7 +45,8 @@ export const load: PageServerLoad = async ({ cookies }) => { (SELECT COUNT(*) FROM audit_log) AS audit_events, (SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+ (SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)).get(new Date().toISOString()), - stat(databasePath).catch(() => null) + stat(databasePath).catch(() => null), + refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus()) ]); const pages = numericPragma(pageCount); const bytesPerPage = numericPragma(pageSize); @@ -86,6 +88,7 @@ export const load: PageServerLoad = async ({ cookies }) => { freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0, tableCount: Number(tableCount?.count ?? 0) }, - counts + counts, + services: { removeBackground } }; }; diff --git a/src/routes/isac/metrics/+page.svelte b/src/routes/isac/metrics/+page.svelte index ed1084a..776bcf7 100644 --- a/src/routes/isac/metrics/+page.svelte +++ b/src/routes/isac/metrics/+page.svelte @@ -35,5 +35,6 @@

HOST CAPACITY

OPERATING ENVIRONMENT

{data.host.cores} CORES
PLATFORM{data.host.platform.toUpperCase()} · {data.host.architecture}
RELEASE{data.host.release}
HOST UPTIME{duration(data.host.uptimeSeconds)}
MEMORY USED{data.host.usedMemoryPercent}%
MEMORY AVAILABLE{bytes(data.host.freeMemory)} / {bytes(data.host.totalMemory)}
LOAD AVERAGE{data.host.loadAverage.map((value: number) => value.toFixed(2)).join(' · ')}

EMBEDDED TURSO

DATABASE HEALTH

{data.database.tableCount} TABLES
FILE{data.database.filename}
ALLOCATED PAGES{data.database.pageCount.toLocaleString()}
PAGE SIZE{bytes(data.database.pageSize)}
FREE PAGES{data.database.freePages.toLocaleString()} · {data.database.freePercent}%
LAST FILE WRITE{data.database.modifiedAt ? new Date(data.database.modifiedAt).toLocaleString() : 'UNAVAILABLE'}
METRIC QUERY{data.collectionMs} ms

SERVICE ACTIVITY

RECORD VOLUME

LIVE COUNTS
USERS{data.counts.users.toLocaleString()}
ACTIVE SESSIONS{data.counts.active_sessions.toLocaleString()}
BUILDS / GUIDES{data.counts.builds.toLocaleString()}
COMMENTS{data.counts.comments.toLocaleString()}
GAME-DATA RECORDS{data.counts.game_records.toLocaleString()}
AUDIT EVENTS{data.counts.audit_events.toLocaleString()}
+

EXTERNAL IMAGE PROCESSING

REMOVE.BG CAPACITY

{data.services.removeBackground.configured ? data.services.removeBackground.severity.toUpperCase() : 'NOT CONFIGURED'}
PAID CREDITS{data.services.removeBackground.total?.toLocaleString() ?? 'UNAVAILABLE'}
FREE API CALLS{data.services.removeBackground.freeCalls?.toLocaleString() ?? 'UNAVAILABLE'}
SUBSCRIPTION / PAY-AS-YOU-GO{data.services.removeBackground.subscription?.toLocaleString() ?? '—'} / {data.services.removeBackground.payg?.toLocaleString() ?? '—'}
ENTERPRISE{data.services.removeBackground.enterprise?.toLocaleString() ?? '—'}
LAST ACCOUNT CHECK{data.services.removeBackground.checkedAt ? new Date(data.services.removeBackground.checkedAt).toLocaleString() : 'PENDING'}
ALERT THRESHOLDS≤ {data.services.removeBackground.warningCreditThreshold} CREDITS · ≤ {data.services.removeBackground.warningFreeCallThreshold} FREE
{#if data.services.removeBackground.error}
LAST CHECK ERROR{data.services.removeBackground.error}
{/if}
-

CAPTURED {new Date(data.capturedAt).toLocaleString()} · VALUES ARE PROCESS-LOCAL OBSERVATIONS AND DO NOT REPLACE HOST-LEVEL ALERTING.

+

CAPTURED {new Date(data.capturedAt).toLocaleString()} · RUNTIME VALUES ARE PROCESS-LOCAL OBSERVATIONS AND DO NOT REPLACE HOST-LEVEL ALERTING.

diff --git a/src/routes/isac/protocols/+page.server.ts b/src/routes/isac/protocols/+page.server.ts index 45489e3..4d3b079 100644 --- a/src/routes/isac/protocols/+page.server.ts +++ b/src/routes/isac/protocols/+page.server.ts @@ -2,7 +2,7 @@ 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 { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; @@ -57,6 +57,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const conditions: string[] = []; const args: unknown[] = []; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC' }, + quality: { + asc: "CASE quality WHEN 'Standard' THEN 1 WHEN 'Specialized' THEN 2 WHEN 'Superior' THEN 3 WHEN 'High-end' THEN 4 ELSE 5 END ASC,name COLLATE NOCASE ASC", + desc: "CASE quality WHEN 'Standard' THEN 1 WHEN 'Specialized' THEN 2 WHEN 'Superior' THEN 3 WHEN 'High-end' THEN 4 ELSE 5 END DESC,name COLLATE NOCASE ASC" + }, + type: { asc: 'primary_attribute_type COLLATE NOCASE ASC,name COLLATE NOCASE ASC', desc: 'primary_attribute_type COLLATE NOCASE DESC,name COLLATE NOCASE ASC' }, + primary: { asc: "COALESCE((SELECT attribute.name FROM os_protocol_primary_effects effect JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id WHERE effect.protocol_id=os_protocols.id),'') COLLATE NOCASE ASC,name COLLATE NOCASE ASC", desc: "COALESCE((SELECT attribute.name FROM os_protocol_primary_effects effect JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id WHERE effect.protocol_id=os_protocols.id),'') COLLATE NOCASE DESC,name COLLATE NOCASE ASC" }, + talent: { asc: "COALESCE((SELECT talent.name FROM os_protocol_talents talent WHERE talent.protocol_id=os_protocols.id),'') COLLATE NOCASE ASC,name COLLATE NOCASE ASC", desc: "COALESCE((SELECT talent.name FROM os_protocol_talents talent WHERE talent.protocol_id=os_protocols.id),'') COLLATE NOCASE DESC,name COLLATE NOCASE ASC" }, + state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc' } + }, 'name'); if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`); else if (qualities.some((quality) => quality.toLowerCase() === filter)) { conditions.push('quality=?'); @@ -82,7 +93,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([ - (await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), + (await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(), (await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(), (await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(), @@ -138,7 +149,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })), stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers], - summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination, search + summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination, search, sort }; }; diff --git a/src/routes/isac/protocols/+page.svelte b/src/routes/isac/protocols/+page.svelte index 21deb46..3c2747d 100644 --- a/src/routes/isac/protocols/+page.svelte +++ b/src/routes/isac/protocols/+page.svelte @@ -1,6 +1,8 @@ -Roles — ISAC -

ACCESS POLICY REGISTRY

ROLES

Compose reusable access roles from discrete SHD.build capabilities.

-

REGISTERED AGENT BASELINEEvery active account inherently receives isac.access, data.view, and data.propose eligibility. Submitting proposals additionally requires accepted guidelines and enabled contributor mode. Roles add elevated capabilities and cannot remove this baseline.

- - -{#if editing} +{#snippet roleEditor()}

{editing.id ? 'MODIFY ACCESS ROLE' : 'NEW ACCESS ROLE'}

{editing.id ? editing.name : 'ROLE DEFINITION'}

@@ -44,12 +34,27 @@
-{/if} +{/snippet} + +Roles — ISAC +

ACCESS POLICY REGISTRY

ROLES

Compose reusable access roles from discrete SHD.build capabilities.

+

REGISTERED AGENT BASELINEEvery active account inherently receives isac.access, data.view, and data.propose eligibility. Submitting proposals additionally requires accepted guidelines and enabled contributor mode. Roles add elevated capabilities and cannot remove this baseline.

+ + +{#if editing && !editing.id}{@render roleEditor()}{/if}
-
ROLEASSIGNED USERSDISCRETE PERMISSIONSSTATE
+
{#each data.roles as role} -
{role.name}{role.description ?? 'NO DESCRIPTION'}
{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}
{#each role.permissionIds as permissionId}{permissionName(permissionId)}{:else}NO PERMISSIONS{/each}
{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}{#if role.protected}{:else}{/if}
+
{role.name}{role.description ?? 'NO DESCRIPTION'}
{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}
{#each role.permissionIds as permissionId}{permissionName(permissionId)}{:else}NO PERMISSIONS{/each}
{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}{#if role.protected}{:else}{/if}
+ {#if editing?.id === role.id}{@render roleEditor()}{/if} {:else}
NO ROLES MATCH THIS FILTERSelect another access domain or create a role.
{/each}
diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index 0f4fd0a..6275b7c 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -4,7 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPendingEntities } from '$lib/server/data-moderation'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { refreshAbilityFocuses } from '$lib/server/specialization-readiness'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; @@ -48,6 +48,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const requestedFilter = url.searchParams.get('filter') ?? ''; const filter = ['active', 'archived', 'configured', 'assigned'].includes(requestedFilter) ? requestedFilter : ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC', desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC' }, + ability: { asc: "COALESCE((SELECT ability.name FROM specialization_abilities ability WHERE ability.id=sets.specialization_ability_id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT ability.name FROM specialization_abilities ability WHERE ability.id=sets.specialization_ability_id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC" }, + twoPiece: { asc: "COALESCE((SELECT attribute.name FROM skill_chip_two_piece_bonuses bonus JOIN stat_attributes attribute ON attribute.id=bonus.stat_attribute_id WHERE bonus.skill_chip_set_id=sets.id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT attribute.name FROM skill_chip_two_piece_bonuses bonus JOIN stat_attributes attribute ON attribute.id=bonus.stat_attribute_id WHERE bonus.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC" }, + talent: { asc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC" }, + rollPools: { asc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) ASC,sets.name COLLATE NOCASE ASC', desc: '((SELECT COUNT(*) FROM skill_chip_primary_options primary_option WHERE primary_option.skill_chip_set_id=sets.id)+(SELECT COUNT(*) FROM skill_chip_secondary_options secondary_option WHERE secondary_option.skill_chip_set_id=sets.id)) DESC,sets.name COLLATE NOCASE ASC' }, + state: { asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC', desc: 'sets.active DESC,sets.name COLLATE NOCASE ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0'] : filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL'] @@ -72,7 +80,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ]); const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([ - (await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), + (await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(), (await database.prepare('SELECT * FROM skill_chip_primary_options')).all(), (await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(), @@ -165,7 +173,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length, pending: currentPendingSets.length }, - filter, pagination, search + filter, pagination, search, sort }; }; diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index 273e9f1..b249d7e 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -3,7 +3,9 @@ import ItemIcon from '$lib/components/ItemIcon.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import PendingForm from '$lib/components/PendingForm.svelte'; + import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; @@ -73,6 +75,7 @@ {value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'} ]}/> +{#snippet skillChipEditor()} {#if editing}

{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}

{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}

@@ -109,11 +112,14 @@
{/if} +{/snippet} +{#if editing && !editing.id}{@render skillChipEditor()}{/if}
-
SKILL CHIPABILITYQUALITY2-PIECE BONUS3-PIECE TALENTROLL POOLSSTATE
+
QUALITY
{#each data.sets as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
SUPHE{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'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
SUPHE{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'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+ {#if editing?.id === item.id}{@render skillChipEditor()}{/if} {:else}
NO SKILL CHIP SETS REGISTEREDAdd the first Superior and High-end Skill Chip definition.
{/each}
diff --git a/src/routes/isac/specializations/+page.server.ts b/src/routes/isac/specializations/+page.server.ts index 57475aa..4f67da5 100644 --- a/src/routes/isac/specializations/+page.server.ts +++ b/src/routes/isac/specializations/+page.server.ts @@ -5,7 +5,7 @@ import type { Actions, PageServerLoad } from './$types'; import { requireDataView, requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { deserializeProposal, getPendingEntities } from '$lib/server/data-moderation'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { @@ -179,6 +179,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ? String(url.searchParams.get('filter')) : ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'specializations.name COLLATE NOCASE ASC,specializations.id ASC', desc: 'specializations.name COLLATE NOCASE DESC,specializations.id DESC' }, + focuses: { asc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id) ASC,specializations.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id) DESC,specializations.name COLLATE NOCASE ASC' }, + abilities: { asc: '(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=specializations.id) ASC,specializations.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM specialization_abilities ability WHERE ability.specialization_id=specializations.id) DESC,specializations.name COLLATE NOCASE ASC' }, + readiness: { asc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1) ASC,specializations.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1) DESC,specializations.name COLLATE NOCASE ASC' }, + state: { asc: 'specializations.active ASC,specializations.name COLLATE NOCASE ASC', desc: 'specializations.active DESC,specializations.name COLLATE NOCASE ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'active' ? ['specializations.active=1'] : filter === 'ready' ? ['EXISTS (SELECT 1 FROM specialization_focuses focus WHERE focus.specialization_id=specializations.id AND focus.builder_ready=1)'] @@ -218,7 +225,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const [specializations, focuses, abilities, abilityEffects, focusAbilities, nodes, nodeRanks, nodeEffects, nodeCosts, combatEffects, nodeCombatEffects, nodeCombatModifiers, resources, rankProfiles, progression, attributes, chipCounts, icons, summary] = await Promise.all([ databaseLimit ? (await database.prepare( - `SELECT * FROM specializations ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?` + `SELECT * FROM specializations ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?` )).all(...args, databaseLimit, databaseOffset) : [], (await database.prepare('SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE')).all(), @@ -370,7 +377,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }, pagination, filter, - search + search, + sort }; }; diff --git a/src/routes/isac/specializations/+page.svelte b/src/routes/isac/specializations/+page.svelte index a3482eb..47a5740 100644 --- a/src/routes/isac/specializations/+page.svelte +++ b/src/routes/isac/specializations/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; @@ -592,7 +593,7 @@
-
SPECIALIZATIONFOCUSESABILITIESREADINESSSTATE
+
{#each data.specializations as item}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.description || String(item.id).slice(0,8).toUpperCase()}
diff --git a/src/routes/isac/system-messages/+page.svelte b/src/routes/isac/system-messages/+page.svelte index 23623db..6c6c3ea 100644 --- a/src/routes/isac/system-messages/+page.svelte +++ b/src/routes/isac/system-messages/+page.svelte @@ -1,5 +1,6 @@ -Automated system messages — ISAC -

SYSTEM CONFIGURATION

AUTOMATED MESSAGES

Control event-driven ISAC notifications, delivery timing, content, and action callouts.

-
DEFINITIONS{data.messages.length}Automated messages
ACTIVE{data.messages.filter((item: any) => item.enabled).length}Enabled triggers
EVENT TYPES{data.events.length}System lifecycle events
- -{#if editing} -
+{#snippet messageEditor()} +

EDIT AUTOMATED MESSAGE

{editing.name}

@@ -40,11 +37,16 @@
-{/if} +{/snippet} + +Automated system messages — ISAC +

SYSTEM CONFIGURATION

AUTOMATED MESSAGES

Control event-driven ISAC notifications, delivery timing, content, and action callouts.

+
DEFINITIONS{data.messages.length}Automated messages
ACTIVE{data.messages.filter((item: any) => item.enabled).length}Enabled triggers
EVENT TYPES{data.events.length}System lifecycle events
MESSAGETRIGGERDELIVERYACTIVITY
{#each data.messages as message} -
{message.name}{message.description}
{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}{message.enabled ? 'ENABLED' : 'DISABLED'}
{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}{message.title}
{message.delivery_count} SENTLAST · {date(message.last_delivered_at)}
+
{message.name}{message.description}
{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}{message.enabled ? 'ENABLED' : 'DISABLED'}
{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}{message.title}
{message.delivery_count} SENTLAST · {date(message.last_delivered_at)}
+ {#if editing?.id === message.id}{@render messageEditor()}{/if} {/each}
diff --git a/src/routes/isac/talents/+page.server.ts b/src/routes/isac/talents/+page.server.ts index 7dd6f7f..d6f3f59 100644 --- a/src/routes/isac/talents/+page.server.ts +++ b/src/routes/isac/talents/+page.server.ts @@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { getPendingEntities } from '$lib/server/data-moderation'; @@ -17,6 +17,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get(); const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'talents.name COLLATE NOCASE ASC,talents.id ASC', desc: 'talents.name COLLATE NOCASE DESC,talents.id DESC' }, + category: { asc: 'talents.category COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC', desc: 'talents.category COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC' }, + tier1: { asc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='I'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='I'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC" }, + tier2: { asc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='II'),'') COLLATE NOCASE ASC,talents.name COLLATE NOCASE ASC", desc: "COALESCE((SELECT tier.description FROM talent_tiers tier WHERE tier.talent_id=talents.id AND tier.tier='II'),'') COLLATE NOCASE DESC,talents.name COLLATE NOCASE ASC" }, + state: { asc: 'talents.active ASC,talents.name COLLATE NOCASE ASC', desc: 'talents.active DESC,talents.name COLLATE NOCASE ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'slot1' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)'] : filter === 'slot2' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=2)'] @@ -29,7 +36,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(...args); const pagination = getPagination(url, Number(filteredRow?.total ?? 0)); const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([ - (await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), + (await db.prepare(`SELECT * FROM talents ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(), (await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(), (await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), @@ -115,7 +122,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }, pagination, filter, - search + search, + sort }; }; diff --git a/src/routes/isac/talents/+page.svelte b/src/routes/isac/talents/+page.svelte index c814fc0..9179641 100644 --- a/src/routes/isac/talents/+page.svelte +++ b/src/routes/isac/talents/+page.svelte @@ -4,6 +4,8 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import IconUploadField from '$lib/components/IconUploadField.svelte'; import ItemIcon from '$lib/components/ItemIcon.svelte'; + import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; let { data, form } = $props(); @@ -79,6 +81,7 @@

{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}

{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}

CALCULATION SCRIPTReserved for the future calculation engine

{#if form?.triggerError}

{form.triggerError}

{/if}
{/if} +{#snippet talentEditor()} {#if talent}

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

@@ -97,6 +100,11 @@
{/if} +{/snippet} + +{#if talent && !talent.id} + {@render talentEditor()} +{/if} -
TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

{item.pending ? 'PENDING' : 'ACTIVE'}{#if item.pending}{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{/each}
+
{#each data.talents as item}
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}{#if cooldownLine(item.cooldown_seconds)}{cooldownLine(item.cooldown_seconds)}{/if}
{item.category}

{tierDescription(item.tiers, 'I')}

{tierDescription(item.tiers, 'II')}

{item.pending ? 'PENDING' : 'ACTIVE'}{#if item.pending}{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}EDIT WEAPON{:else}{/if}{:else}{/if}
{#if String(talent?.id ?? '') === String(item.id)}{@render talentEditor()}{/if}{/each}
diff --git a/src/routes/isac/users/+page.server.ts b/src/routes/isac/users/+page.server.ts index 48598cf..128cdd3 100644 --- a/src/routes/isac/users/+page.server.ts +++ b/src/routes/isac/users/+page.server.ts @@ -2,17 +2,31 @@ import { requirePermission } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import type { PageServerLoad } from './$types'; -import { getPagination } from '$lib/server/pagination'; +import { getPagination, getRegistrySort } from '$lib/server/pagination'; export const load: PageServerLoad = async ({ cookies, url }) => { await requirePermission(await getSessionUser(cookies), 'users.view'); const query = url.searchParams.get('q')?.trim() ?? ''; const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; + const sort = getRegistrySort(url, { + user: { asc: 'display_name ASC', desc: 'display_name DESC' }, + access: { asc: 'access_state ASC', desc: 'access_state DESC' }, + roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' }, + lastLogin: { asc: 'last_login_at ASC', desc: 'last_login_at DESC', defaultDirection: 'desc' }, + lastActive: { asc: 'last_active_at ASC', desc: 'last_active_at DESC', defaultDirection: 'desc' }, + content: { asc: 'content_count ASC', desc: 'content_count DESC', defaultDirection: 'desc' } + }, 'lastActive', 'desc'); const database = await getDatabase(); const summaryRows = await (await database.prepare(` - SELECT users.id,user_status.last_active_at,user_status.blocked_at,user_status.banned_until, + SELECT users.id,users.login_username, + COALESCE(user_profiles.display_name,users.username) AS display_name, + (SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles + JOIN roles ON roles.id=user_roles.role_id WHERE user_roles.user_id=users.id) AS assigned_roles, + user_status.last_active_at,user_status.blocked_at,user_status.banned_until, deleted_accounts.deleted_at, - (SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at + (SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at, + (SELECT COUNT(*) FROM builds WHERE owner_id=users.id) AS build_count, + (SELECT COUNT(*) FROM comments WHERE user_id=users.id) AS comment_count FROM users LEFT JOIN user_status ON user_status.user_id=users.id LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id @@ -31,9 +45,28 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const filter = ['active', 'inactive', 'restricted'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive : filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true); + const comparisonValue = (user: Record) => { + if (sort.key === 'user') return String(user.display_name ?? user.login_username ?? '').toLocaleLowerCase(); + if (sort.key === 'access') return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`; + if (sort.key === 'roles') return String(user.assigned_roles ?? user.role ?? 'User').toLocaleLowerCase(); + if (sort.key === 'lastLogin') return user.last_login_at ? new Date(String(user.last_login_at)).getTime() : -1; + if (sort.key === 'lastActive') return user.last_active_at ? new Date(String(user.last_active_at)).getTime() : -1; + return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0); + }; + const direction = sort.direction === 'asc' ? 1 : -1; + filteredStates.sort((left, right) => { + const leftValue = comparisonValue(left); + const rightValue = comparisonValue(right); + const comparison = typeof leftValue === 'number' && typeof rightValue === 'number' + ? leftValue - rightValue + : String(leftValue).localeCompare(String(rightValue)); + return comparison * direction + || String(left.display_name ?? left.login_username ?? '').localeCompare(String(right.display_name ?? right.login_username ?? '')) + || String(left.id).localeCompare(String(right.id)); + }); const pagination = getPagination(url, filteredStates.length); const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id)); - const users = pageIds.length ? await (await database.prepare(` + const userRows = pageIds.length ? await (await database.prepare(` SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name, users.role,users.discord_id,users.created_at, (SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles @@ -48,14 +81,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => { LEFT JOIN user_profiles ON user_profiles.user_id=users.id LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id IN (${pageIds.map(() => '?').join(',')}) - ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC `)).all(...pageIds) : []; + const rowsById = new Map(userRows.map((user) => [String(user.id), user])); + const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean); return { users: users.map((user) => { return { ...user, ...state(user) }; }), query, filter, + sort, summary: { results: summaryRows.length, active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length, diff --git a/src/routes/isac/users/+page.svelte b/src/routes/isac/users/+page.svelte index 67836a8..4f1d378 100644 --- a/src/routes/isac/users/+page.svelte +++ b/src/routes/isac/users/+page.svelte @@ -1,6 +1,7 @@ @@ -9,7 +10,7 @@
-
USERACCESSROLESLAST LOGINLAST ACTIVECONTENT
+
{#each data.users as user}
{user.display_name}LOGIN: {user.username ?? 'NOT CONFIGURED'}
{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}
{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}{role.toUpperCase()}{/each}
{user.last_login_provider?.toUpperCase() ?? 'NONE'}{date(user.last_login_at)}{date(user.last_active_at)}{user.build_count} BUILDS · {user.comment_count} COMMENTSVIEW
{:else}
NO USERS FOUNDAdjust the username search and try again.
{/each}
diff --git a/src/routes/isac/weaponmods/+page.server.ts b/src/routes/isac/weaponmods/+page.server.ts index 67d4854..699d84f 100644 --- a/src/routes/isac/weaponmods/+page.server.ts +++ b/src/routes/isac/weaponmods/+page.server.ts @@ -3,7 +3,7 @@ import { requireDataView, requirePermission } from '$lib/server/auth/authorizati 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 { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; @@ -49,6 +49,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const requestedFilter = url.searchParams.get('filter') ?? ''; const filter = ['active', 'archived', 'regular', 'exotic'].includes(requestedFilter) ? requestedFilter : ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'mods.name COLLATE NOCASE ASC,mods.id ASC', desc: 'mods.name COLLATE NOCASE DESC,mods.id DESC' }, + slot: { asc: 'mods.slot_type COLLATE NOCASE ASC,mods.name COLLATE NOCASE ASC', desc: 'mods.slot_type COLLATE NOCASE DESC,mods.name COLLATE NOCASE DESC' }, + primary: { asc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END ASC,mods.name COLLATE NOCASE ASC', desc: 'CASE WHEN mods.exotic_weapon_id IS NULL THEN 0 ELSE 1 END DESC,mods.name COLLATE NOCASE ASC' }, + secondaries: { asc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) ASC,mods.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM weapon_mod_secondary_options option WHERE option.weapon_mod_id=mods.id) DESC,mods.name COLLATE NOCASE ASC' }, + compatibility: { asc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE ASC,mods.name COLLATE NOCASE ASC", desc: "COALESCE(weapons.name,(SELECT MIN(link.weapon_category) FROM weapon_mod_compatible_categories link WHERE link.weapon_mod_id=mods.id),'') COLLATE NOCASE DESC,mods.name COLLATE NOCASE ASC" }, + effects: { asc: '(SELECT COUNT(*) FROM weapon_mod_effects effect WHERE effect.weapon_mod_id=mods.id) ASC,mods.name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM weapon_mod_effects effect WHERE effect.weapon_mod_id=mods.id) DESC,mods.name COLLATE NOCASE ASC' }, + state: { asc: 'mods.active ASC,mods.name COLLATE NOCASE ASC', desc: 'mods.active DESC,mods.name COLLATE NOCASE ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(filter === 'active' ? ['mods.active=1'] : filter === 'archived' ? ['mods.active=0'] : filter === 'regular' ? ['mods.exotic_weapon_id IS NULL'] : filter === 'exotic' ? ['mods.exotic_weapon_id IS NOT NULL'] : []), @@ -79,7 +88,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const [mods, compatibility, qualityRows, secondaryOptions, secondaryValues, exoticAttributes, effects, effectValues, stats, weapons] = await Promise.all([ (await database.prepare(`SELECT mods.*,weapons.name AS exotic_weapon_name FROM weapon_mods mods LEFT JOIN weapons ON weapons.id=mods.exotic_weapon_id ${where} - ORDER BY mods.active DESC,mods.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), + ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM weapon_mod_compatible_categories')).all(), (await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(), (await database.prepare('SELECT * FROM weapon_mod_secondary_options')).all(), @@ -130,7 +139,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { qualities, weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })), summary: { ...summary, total: Number(summary?.total ?? 0) + currentPendingMods.length, pending: currentPendingMods.length }, - filter, pagination, search + filter, pagination, search, sort }; }; diff --git a/src/routes/isac/weaponmods/+page.svelte b/src/routes/isac/weaponmods/+page.svelte index c8273a4..127102b 100644 --- a/src/routes/isac/weaponmods/+page.svelte +++ b/src/routes/isac/weaponmods/+page.svelte @@ -2,6 +2,8 @@ import KpiFilters from '$lib/components/KpiFilters.svelte'; import PendingForm from '$lib/components/PendingForm.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; @@ -78,7 +80,7 @@ {value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'} ]}/> -{#if editing} +{#snippet weaponModEditor()}

{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}

{editing.id ? editing.name : 'WEAPON MOD DEFINITION'}

@@ -146,12 +148,21 @@
{#if editing.id}{/if}
+{/snippet} + +{#if editing && !editing.id} + {@render weaponModEditor()} {/if}
-
WEAPON MODSLOTPRIMARYSECONDARIESCOMPATIBILITYMOD EFFECTSTATE
+
{#each data.mods as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}
{item.slot_type}{#if item.exotic_weapon_id}FIXED{:else}ANY CORE{/if}{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`} · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}
{item.slot_type}{#if item.exotic_weapon_id}FIXED{:else}ANY CORE{/if}{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`} · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}{#if item.pending}{:else}{/if}
+ {#if editing?.id === item.id} + + {@render weaponModEditor()} + + {/if} {:else}
NO WEAPON MODS REGISTEREDAdd the first slot-compatible Weapon Mod definition.
{/each}
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts index 2155ab2..c27046e 100644 --- a/src/routes/isac/weapons/+page.server.ts +++ b/src/routes/isac/weapons/+page.server.ts @@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; -import { getPagination, getRegistrySearch } from '$lib/server/pagination'; +import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { getPendingEntities } from '$lib/server/data-moderation'; @@ -37,6 +37,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const filter = ['active', 'standard', 'exotic'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; const requestedWeaponId = url.searchParams.get('weapon')?.trim() ?? ''; const { search, pattern } = getRegistrySearch(url); + const sort = getRegistrySort(url, { + name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' }, + class: { asc: 'category COLLATE NOCASE ASC, native_tier ASC, name COLLATE NOCASE ASC, id ASC', desc: 'category COLLATE NOCASE DESC, native_tier DESC, name COLLATE NOCASE ASC, id ASC' }, + fixed: { asc: 'rpm ASC, magazine ASC, reload_speed ASC, name COLLATE NOCASE ASC, id ASC', desc: 'rpm DESC, magazine DESC, reload_speed DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }, + tiers: { + asc: '(SELECT COUNT(*) FROM weapon_tier_ranges tier_range WHERE tier_range.weapon_id=weapons.id) ASC, native_tier ASC, name COLLATE NOCASE ASC, id ASC', + desc: '(SELECT COUNT(*) FROM weapon_tier_ranges tier_range WHERE tier_range.weapon_id=weapons.id) DESC, native_tier DESC, name COLLATE NOCASE ASC, id ASC', + defaultDirection: 'desc' + }, + talents: { + asc: '(SELECT COUNT(*) FROM weapon_talent_options talent_option WHERE talent_option.weapon_id=weapons.id) ASC, name COLLATE NOCASE ASC, id ASC', + desc: '(SELECT COUNT(*) FROM weapon_talent_options talent_option WHERE talent_option.weapon_id=weapons.id) DESC, name COLLATE NOCASE ASC, id ASC', + defaultDirection: 'desc' + }, + augments: { asc: 'is_exotic ASC, name COLLATE NOCASE ASC, id ASC', desc: 'is_exotic DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }, + state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' } + }, 'name'); const conditions = [ ...(requestedWeaponId ? ['id=?'] : filter === 'active' ? ['active=1'] : filter === 'standard' ? ['is_exotic=0'] : filter === 'exotic' ? ['is_exotic=1'] : []), ...(!requestedWeaponId && search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) @@ -46,7 +63,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([ - (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), + (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(), (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), @@ -99,7 +116,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean), augmentEffects: [], exoticTalent: null - })); + })).sort((left, right) => { + const valueFor = (weapon: any) => sort.key === 'class' ? `${weapon.category}:${weapon.native_tier}` + : sort.key === 'fixed' ? Number(weapon.rpm ?? 0) + : sort.key === 'tiers' ? weapon.tiers.length + : sort.key === 'talents' ? weapon.talent1Ids.length + weapon.talent2Ids.length + : sort.key === 'augments' ? Number(weapon.is_exotic) + : sort.key === 'state' ? Number(weapon.active ?? 1) + : weapon.name; + const leftValue = valueFor(left), rightValue = valueFor(right); + const result = typeof leftValue === 'number' && typeof rightValue === 'number' + ? leftValue - rightValue + : String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true }); + return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' }); + }); return { weapons: [...pendingWeapons, ...weapons.map((weapon) => { const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : ''; @@ -145,6 +175,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { pagination, filter, search, + sort, requestedWeaponId ,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name })) }; diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte index fab9bd7..64499cc 100644 --- a/src/routes/isac/weapons/+page.svelte +++ b/src/routes/isac/weapons/+page.svelte @@ -2,6 +2,8 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte'; + import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte'; import GameIcon from '$lib/components/GameIcon.svelte'; import type { GameIconName } from '$lib/game-icons'; @@ -89,7 +91,7 @@

ARMAMENT DEFINITION REGISTRY

WEAPONS

Configure fixed handling, tier progression, talent pools, and tactical augmentations.

-{#if editing} +{#snippet weaponEditor()}

{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}

{editing.id ? editing.name : 'WEAPON DEFINITION'}

@@ -160,12 +162,21 @@
{#if editing.id}{/if}
+{/snippet} + +{#if editing && !editing.id} + {@render weaponEditor()} {/if}
-
WEAPONCLASSFIXED ATTRIBUTESTIER RANGETALENT POOLSAUGMENTSSTATE
+
{#each data.weapons as weapon} -
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if weapon.pending}{:else}{/if}
+
{weapon.name}{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}
{#if Number(weapon.is_exotic)}{/if}{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}{#if weapon.pending}{:else}{/if}
+ {#if editing?.id === weapon.id} + + {@render weaponEditor()} + + {/if} {:else}
NO WEAPONS REGISTEREDAdd active weapon talents and attributes, then create the first armament definition.
{/each}