checkpoint ISAC registries and system monitoring

This commit is contained in:
JB
2026-07-27 00:31:16 -04:00
parent 8b80e55e6c
commit 7b3aff30d8
53 changed files with 1351 additions and 179 deletions
+3
View File
@@ -41,3 +41,6 @@ SMTP_FROM=SHD.build <no-reply@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
+32 -1
View File
@@ -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);
+8
View File
@@ -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
```
+57 -14
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -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",
+40
View File
@@ -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"}
]
+25
View File
@@ -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 [
+124
View File
@@ -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();
}
+74 -2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
id,
label,
children
}: {
id: string;
label: string;
children: Snippet;
} = $props();
</script>
<section class="registry-inline-editor" {id} aria-label={label}>
{@render children()}
</section>
+1 -1
View File
@@ -87,6 +87,6 @@
{/each}
<a class:disabled={pagination.page === pagination.pageCount} aria-disabled={pagination.page === pagination.pageCount} href={href(Math.min(pagination.pageCount, pagination.page + 1))}>NEXT →</a>
</div>
<div class="pagination-sizes"><span>ROWS</span>{#each [10, 25, 50, 100] as size}<a class:active={size === pagination.perPage} href={href(1, size)}>{size}</a>{/each}</div>
{/if}
<div class="pagination-sizes"><span>ROWS</span>{#each [10, 25, 50, 100] as size}<a class:active={size === pagination.perPage} href={href(1, size)}>{size}</a>{/each}</div>
</nav>
@@ -1,5 +1,5 @@
<script lang="ts">
type Item = { id: string; name: string; pending?: boolean; proposerName?: string };
type Item = { id: string; name: string; unit?: string; pending?: boolean; proposerName?: string };
let {
items,
selected = $bindable<string[]>([]),
@@ -33,10 +33,12 @@
const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase());
const available = $derived(items.filter((item) =>
!excludedIds.includes(item.id)
&& (!normalizedQuery || item.name.toLocaleLowerCase().includes(normalizedQuery))
&& (!normalizedQuery || `${item.name} ${item.unit ?? ''}`.toLocaleLowerCase().includes(normalizedQuery))
));
const visible = $derived(available.slice(0, 30));
const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item)));
const unitLabel = (item: Item) => item.unit === undefined ? '' : item.unit || 'FLAT';
const accessibleName = (item: Item) => unitLabel(item) ? `${item.name}, unit ${unitLabel(item)}` : item.name;
const toggle = (id: string) => {
if (mode === 'single') {
value = value === id ? '' : id;
@@ -57,7 +59,7 @@
<div class="assignment-summary"><span>{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}</span><b>{selectedIds.length}</b></div>
<div class="assignment-chips">
{#each selectedItems as item}
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${item.name}`}><span>{item.name}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b></button>
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}><span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b></button>
{:else}
<small>{emptySelectionLabel}</small>
{/each}
@@ -68,7 +70,7 @@
<div class="assignment-results" role="list">
{#each visible as item}
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} aria-pressed={selectedIds.includes(item.id)} onclick={() => toggle(item.id)}>
<span>{item.name}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
</button>
{:else}
<p>{emptyLabel}</p>
@@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
import type { RegistrySort, RegistrySortDirection } from '$lib/server/pagination';
let {
label,
column,
sort,
namespace = '',
defaultDirection = 'asc',
class: className = ''
}: {
label: string;
column: string;
sort: Pick<RegistrySort, 'key' | 'direction'>;
namespace?: string;
defaultDirection?: RegistrySortDirection;
class?: string;
} = $props();
const active = $derived(sort.key === column);
const nextDirection = $derived<RegistrySortDirection>(
active ? (sort.direction === 'asc' ? 'desc' : 'asc') : defaultDirection
);
const sortKey = $derived(namespace ? `${namespace}Sort` : 'sort');
const directionKey = $derived(namespace ? `${namespace}Direction` : 'direction');
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
const href = $derived.by(() => {
const params = new URLSearchParams(page.url.searchParams);
params.set(sortKey, column);
params.set(directionKey, nextDirection);
params.delete(pageKey);
const query = params.toString();
return `${page.url.pathname}${query ? `?${query}` : ''}`;
});
</script>
<a
class="sortable-column {className}"
class:active
{href}
role="columnheader"
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}
aria-label={`${label}: ${active ? `sorted ${sort.direction}. Activate to sort ${nextDirection}` : `activate to sort ${nextDirection}`}`}
>
<span class="sort-direction" aria-hidden="true">{active ? (sort.direction === 'asc' ? '↑' : '↓') : '↕'}</span>
<span>{label}</span>
</a>
+42
View File
@@ -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 });
});
});
+33
View File
@@ -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);
+7
View File
@@ -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<Buffer<Arr
throw new BackgroundRemovalError('remove.bg could not be reached. Try again or upload a transparent image.');
}
if (!response.ok) throw new BackgroundRemovalError(apiMessage(response.status));
const charged = Number(response.headers.get('X-Credits-Charged'));
if (Number.isFinite(charged) && charged > 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.');
+80
View File
@@ -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
});
});
});
+236
View File
@@ -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<string, unknown> } })?.data?.attributes;
const credits = attributes?.credits as Record<string, unknown> | undefined;
const api = attributes?.api as Record<string, unknown> | 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<typeof parseRemoveBgAccount>) {
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<RemoveBgCreditStatus> {
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();
}
+4 -2
View File
@@ -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;
+30 -7
View File
@@ -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<number|null>;
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);
+9 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
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 KpiFilters from '$lib/components/KpiFilters.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
@@ -28,9 +30,14 @@
}
});
</script>
{#snippet attributeEditor()}
{#if editing}
<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>
{/if}
{/snippet}
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
{#if editing}<div class="dashboard-editor attribute-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/><label class="field attribute-description">DESCRIPTION · OPTIONAL<textarea name="description" bind:value={editing.description} maxlength="1200" placeholder="Explain what this Attribute represents and how players encounter it."></textarea></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
{#if editing && !editing.id}{@render attributeEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each visibleStats as stat}<div class="dashboard-row attribute-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending}><div><strong>{stat.name}</strong><small title={attributeMeta(stat)}>{attributeMeta(stat)}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{/each}</div>
<div class="dashboard-table"><div class="dashboard-row table-heading"><SortableColumnHeader label="ATTRIBUTE" column="name" sort={data.sort}/><SortableColumnHeader label="CLASSIFICATION" column="type" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="CALCULATION" column="calculation" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each visibleStats as stat}<div class="dashboard-row attribute-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending} class:registry-row-editing={editing?.id === stat.id}><div><strong>{stat.name}</strong><small title={attributeMeta(stat)}>{attributeMeta(stat)}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button aria-expanded={editing?.id === stat.id} aria-controls={`attribute-editor-${stat.id}`} onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{#if editing?.id === stat.id}<RegistryInlineEditor id={`attribute-editor-${stat.id}`} label={`Edit ${stat.name}`}>{@render attributeEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+19 -3
View File
@@ -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
};
};
+2 -1
View File
@@ -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<any>(null);
let handledResultToken = $state('');
@@ -46,7 +47,7 @@
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row audit-row table-heading"><span>EVENT</span><span>OPERATOR</span><span>TARGET</span><span>TIME</span><span></span></div>
<div class="dashboard-row audit-row table-heading"><SortableColumnHeader label="EVENT" column="event" sort={data.sort}/><SortableColumnHeader label="OPERATOR" column="operator" sort={data.sort}/><SortableColumnHeader label="TARGET" column="target" sort={data.sort}/><SortableColumnHeader label="TIME" column="time" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.entries as entry}
<details class="audit-entry">
<summary class="dashboard-row audit-row"><div><strong>{String(entry.action).toUpperCase()}</strong><small>{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}</small></div><div><strong>{entry.actor_name}</strong><small>{entry.actor_login ?? entry.actor_id}</small></div><div><strong>{entry.entity_id}</strong><small>EVENT {String(entry.id).slice(0,8).toUpperCase()}</small></div><span>{date(entry.created_at)}</span><b>DETAILS +</b></summary>
+29 -4
View File
@@ -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
};
};
+31 -24
View File
@@ -1,6 +1,8 @@
<script lang="ts">
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 KpiFilters from '$lib/components/KpiFilters.svelte';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import ItemIcon from '$lib/components/ItemIcon.svelte';
@@ -30,36 +32,41 @@
});
</script>
{#snippet brandEditor()}
{#if editing}
<div class="dashboard-editor brand-set-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id} />
<div class="brand-editor-body">
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
<div>
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
{#each [2, 3, 4] as pieces}
{@const effect = effectFor(editing, pieces)}
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`stat${pieces}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/></div><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
{/each}
</div>
</div>
</div>
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}{#if editing.active === 1}<button class="moderation-action danger brand-archive-action" formaction="?/archive" name="id" value={editing.id}><b>▰</b> ARCHIVE SET</button>{:else}<button class="moderation-action restore brand-archive-action" formaction="?/unarchive" name="id" value={editing.id}><b>↥</b> UNARCHIVE SET</button>{/if}{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
</PendingForm>
</div>
{/if}
{/snippet}
<svelte:head><title>Brand sets — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Unavailable definitions'},{value:'complete',label:'COMPLETE',count:data.summary.complete,description:'All three bonuses mapped'}]}/>
{#if editing}
<div class="dashboard-editor brand-set-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id} />
<div class="brand-editor-body">
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
<div>
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
{#each [2, 3, 4] as pieces}
{@const effect = effectFor(editing, pieces)}
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`stat${pieces}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/></div><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
{/each}
</div>
</div>
</div>
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}{#if editing.active === 1}<button class="moderation-action danger brand-archive-action" formaction="?/archive" name="id" value={editing.id}><b>▰</b> ARCHIVE SET</button>{:else}<button class="moderation-action restore brand-archive-action" formaction="?/unarchive" name="id" value={editing.id}><b>↥</b> UNARCHIVE SET</button>{/if}{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
</PendingForm>
</div>
{/if}
{#if editing && !editing.id}{@render brandEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
<div class="dashboard-row brand-set-row table-heading"><SortableColumnHeader label="BRAND SET" column="name" sort={data.sort}/><SortableColumnHeader label="2 PIECES" column="bonus2" sort={data.sort}/><SortableColumnHeader label="3 PIECES" column="bonus3" sort={data.sort}/><SortableColumnHeader label="4+ PIECES" column="bonus4" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.brands as brand}
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending}><div class="registry-icon-identity"><ItemIcon src={brand.iconUrl} alt=""/><div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button onclick={() => edit(brand)}>EDIT</button>{/if}</div>
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1} class:pending-record={brand.pending} class:registry-row-editing={editing?.id === brand.id}><div class="registry-icon-identity"><ItemIcon src={brand.iconUrl} alt=""/><div><strong>{brand.name}</strong><small>{brand.pending ? `PROPOSED BY ${brand.proposerName}` : `${brand.effects.length}/3 EFFECTS CONFIGURED`}</small></div></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class:pending={brand.pending} class="state-dot">{brand.pending ? 'PENDING' : Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if brand.pending}<span></span>{:else}<button aria-expanded={editing?.id === brand.id} aria-controls={`brand-editor-${brand.id}`} onclick={() => edit(brand)}>EDIT</button>{/if}</div>
{#if editing?.id === brand.id}<RegistryInlineEditor id={`brand-editor-${brand.id}`} label={`Edit ${brand.name}`}>{@render brandEditor()}</RegistryInlineEditor>{/if}
{:else}
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
{/each}
+22 -3
View File
@@ -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
};
};
+15 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
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';
import { createDraft } from '$lib/builder/loadout';
@@ -186,6 +188,7 @@
</div>
{/if}
{#snippet calibrationEditor()}
{#if calibrationEditing}
<div class="dashboard-editor calibration-editor">
<div class="editor-title"><div><p class="eyebrow">{calibrationEditing.id ? 'MODIFY CALIBRATION CASE' : 'NEW CALIBRATION CASE'}</p><h3>{calibrationEditing.name || 'FIELD OBSERVATION'}</h3></div><button aria-label="Close calibration editor" onclick={() => calibrationEditing = null}>×</button></div>
@@ -209,6 +212,11 @@
</PendingForm>
</div>
{/if}
{/snippet}
{#if calibrationEditing && !calibrationEditing.id}
{@render calibrationEditor()}
{/if}
<div class="calculation-revision-registry">
<div class="selection-head"><div><span>CALCULATION REVISIONS</span><small>SAVED BUILDS USE THE ACTIVE REVISION</small></div><PendingForm action="?/suggestRevision">{#snippet children(pending)}<button class="secondary" disabled={pending}>{pending ? 'ANALYZING…' : 'GENERATE ASSISTED SUGGESTION'}</button>{/snippet}</PendingForm></div>
@@ -232,9 +240,14 @@
<div class="selection-head"><div><span>CALIBRATION REGISTRY</span><small>OBSERVED GAME VALUES + MODEL COMPARISON</small></div><b>{Number(data.calibrationSummary?.total ?? 0)} CASES</b></div>
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row calibration-row table-heading"><span>CASE</span><span>WEAPON</span><span>OBSERVATIONS</span><span>PREDICTION</span><span>STATE</span><span></span></div>
<div class="dashboard-row calibration-row table-heading"><SortableColumnHeader label="CASE" column="name" sort={data.sort}/><SortableColumnHeader label="WEAPON" column="weapon" sort={data.sort}/><SortableColumnHeader label="OBSERVATIONS" column="observations" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="PREDICTION" column="prediction" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort}/><span></span></div>
{#each data.calibrationCases as item}
<div class="dashboard-row calibration-row"><div><strong>{item.name}</strong><small>{item.game_version || 'GAME VERSION NOT RECORDED'} · {item.creator_name}</small></div><span>{Number(item.selected_weapon) === 0 ? 'PRIMARY' : 'SECONDARY'}</span><span>{[item.body_hit,item.critical_body_hit,item.headshot,item.critical_headshot,item.magazine_seconds,item.reload_seconds].filter((value) => value !== null).length} / 6 CORE</span><span>{item.predicted_json ? 'AVAILABLE' : 'INSUFFICIENT DATA'}</span><b class:configured={item.status === 'ready'} class="state-dot">{item.status.toUpperCase()}</b><button onclick={() => calibrationEditing = calibrationFrom(item)}>EDIT →</button></div>
<div class="dashboard-row calibration-row" class:registry-row-editing={String(calibrationEditing?.id ?? '') === String(item.id)}><div><strong>{item.name}</strong><small>{item.game_version || 'GAME VERSION NOT RECORDED'} · {item.creator_name}</small></div><span>{Number(item.selected_weapon) === 0 ? 'PRIMARY' : 'SECONDARY'}</span><span>{[item.body_hit,item.critical_body_hit,item.headshot,item.critical_headshot,item.magazine_seconds,item.reload_seconds].filter((value) => value !== null).length} / 6 CORE</span><span>{item.predicted_json ? 'AVAILABLE' : 'INSUFFICIENT DATA'}</span><b class:configured={item.status === 'ready'} class="state-dot">{item.status.toUpperCase()}</b><button aria-expanded={String(calibrationEditing?.id ?? '') === String(item.id)} aria-controls={`calibration-editor-${item.id}`} onclick={() => calibrationEditing = calibrationFrom(item)}>EDIT →</button></div>
{#if String(calibrationEditing?.id ?? '') === String(item.id)}
<RegistryInlineEditor id={`calibration-editor-${item.id}`} label={`Edit ${item.name}`}>
{@render calibrationEditor()}
</RegistryInlineEditor>
{/if}
{/each}
</div>
@@ -4,7 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { deserializeProposal, refreshProposalDependencies } from '$lib/server/data-moderation';
import { getPagination } from '$lib/server/pagination';
import { getPagination, getRegistrySort } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
@@ -121,6 +121,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
const module = modules.includes(url.searchParams.get('module') ?? '') ? String(url.searchParams.get('module')) : '';
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
const sort = getRegistrySort(url, {
change: {
asc: 'p.summary COLLATE NOCASE ASC,p.module COLLATE NOCASE ASC,p.action COLLATE NOCASE ASC',
desc: 'p.summary COLLATE NOCASE DESC,p.module COLLATE NOCASE DESC,p.action COLLATE NOCASE DESC'
},
contributor: {
asc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE ASC',
desc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE DESC'
},
submitted: { asc: 'p.created_at ASC', desc: 'p.created_at DESC', defaultDirection: 'desc' },
status: { asc: 'p.status COLLATE NOCASE ASC', desc: 'p.status COLLATE NOCASE DESC' }
}, 'submitted', 'desc');
const conditions = ['p.status=?'];
const args: unknown[] = [status];
if (module) { conditions.push('p.module=?'); args.push(module); }
@@ -135,7 +147,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
${where} ORDER BY p.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
${where} ORDER BY ${sort.orderBy},p.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const proposalsWithSnapshots = await Promise.all(proposals.map(async (proposal) => ({
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
@@ -149,7 +161,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: null
})));
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals: proposalsWithSnapshots, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
return { proposals: proposalsWithSnapshots, pagination, status, module, search, sort, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
};
export const actions: Actions = {
+2 -1
View File
@@ -4,6 +4,7 @@
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
let { data, form } = $props();
let reviewing = $state<any>(null);
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
@@ -52,7 +53,7 @@
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table moderation-queue">
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
<div class="dashboard-row moderation-row table-heading"><SortableColumnHeader label="CHANGE" column="change" sort={data.sort}/><SortableColumnHeader label="CONTRIBUTOR" column="contributor" sort={data.sort}/><SortableColumnHeader label="SUBMITTED" column="submitted" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATUS" column="status" sort={data.sort}/><span></span></div>
{#each data.proposals as proposal}
<div class="dashboard-row moderation-row mobile-detail-row">
<div class="change-identity"><span>{proposal.module === 'skillchips' ? 'CHIP' : proposal.module === 'specializations' ? 'CLASS' : proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}</small></div></div>
+26 -4
View File
@@ -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';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
@@ -34,6 +34,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)).get();
const filter = ['active', 'armored', 'talented'].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, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
slot: { asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
tier: { asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC', desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC' },
ranges: { asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC', desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' },
brands: {
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') 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 === 'armored' ? ["slot IN ('Gloves','Knee Pads','Body Armor')"] : filter === 'talented' ? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)'] : []),
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
@@ -50,13 +61,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) =>
(filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true)
&& (!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
);
).sort((left, right) => {
const valueFor = (item: any) => sort.key === 'ranges' ? Number(item.healthMin ?? 0)
: sort.key === 'brands' ? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds]).filter(Boolean).sort().join(':')
: sort.key === 'state' ? Number(item.active ?? 1)
: item[sort.key] ?? item.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' });
});
const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
@@ -101,7 +122,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
pagination: adjustedPagination,
filter,
search
search,
sort
};
};
+36 -29
View File
@@ -1,6 +1,8 @@
<script lang="ts">
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 KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
@@ -65,6 +67,36 @@
});
</script>
{#snippet gearEditor()}
{#if editing}
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id} />
<div class="gear-identity">
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} required/>
</div>
<div class="gear-ranges">
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
</div>
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
<SearchableAssignment items={gearTalents(editing.slot)} fieldName="talentIds" subjectLabel={`${editing.slot.toUpperCase()} TALENTS`} searchPlaceholder={`Search ${editing.slot.toLocaleLowerCase()} talents…`} emptySelectionLabel="NO TALENTS ASSIGNED" bind:selected={editing.talentIds}/>
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
{/if}
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
</PendingForm>
</div>
{/if}
{/snippet}
<svelte:head><title>Gear — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure shared tactical augments, slot progression, survivability, brands, and talents.</p></div><button class="primary" onclick={() => { editing = blank(); augmentEditing = null; }}>ADD GEAR <span>+</span></button></div>
<details class="gear-augment-registry">
@@ -105,37 +137,12 @@
</div>
{/if}
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL GEAR',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'armored',label:'ARMOR-BEARING',count:data.summary.armored,description:'Gloves, knees, and body armor'},{value:'talented',label:'TALENT-BEARING',count:data.summary.talented,description:'Backpacks and body armor'}]}/>
{#if editing}
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id} />
<div class="gear-identity">
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} required/>
</div>
<div class="gear-ranges">
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
</div>
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
<SearchableAssignment items={gearTalents(editing.slot)} fieldName="talentIds" subjectLabel={`${editing.slot.toUpperCase()} TALENTS`} searchPlaceholder={`Search ${editing.slot.toLocaleLowerCase()} talents…`} emptySelectionLabel="NO TALENTS ASSIGNED" bind:selected={editing.talentIds}/>
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
{/if}
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
</PendingForm>
</div>
{/if}
{#if editing && !editing.id}{@render gearEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
<div class="dashboard-row gear-row table-heading"><SortableColumnHeader label="GEAR" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="TIER" column="tier" sort={data.sort}/><SortableColumnHeader label="BASE RANGES" column="ranges" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="BRAND SETS" column="brands" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.gear as item}
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}><ItemIcon src={brand.iconUrl} alt={brand.name}/></span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}<small> · {Number(item.tier) === 0 ? '1 MOD SLOT' : '2 MOD SLOTS'}</small></span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}><ItemIcon src={brand.iconUrl} alt={brand.name}/></span>{/each}</div><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`gear-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`gear-editor-${item.id}`} label={`Edit ${item.name}`}>{@render gearEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
</div>
+8 -2
View File
@@ -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
};
};
+15 -4
View File
@@ -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()}
<div class="dashboard-editor weapon-mod-editor gear-mod-editor">
<div class="editor-title">
<div><p class="eyebrow">{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}</p><h3>{editing.id ? editing.name : 'GEAR MOD DEFINITION'}</h3></div>
@@ -165,21 +167,30 @@
</div>
</PendingForm>
</div>
{/snippet}
{#if editing && !editing.id}
{@render gearModEditor()}
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row gear-mod-row table-heading"><span>GEAR MOD</span><span>PRIMARY</span><span>SECONDARIES</span><span>QUALITY</span><span>LEVELS</span><span>STATE</span><span></span></div>
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.mods as item}
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}>
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div>
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
<span>STANDARD → HIGH-END</span>
<span>01 → 20</span>
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}
{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`gear-mod-editor-${item.id}`}>EDIT</button>{/if}
</div>
{#if editing?.id === item.id}
<RegistryInlineEditor id={`gear-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
{@render gearModEditor()}
</RegistryInlineEditor>
{/if}
{:else}
<div class="empty-dashboard"><strong>NO GEAR MODS REGISTERED</strong><span>Add the first quality-scaled Gear Mod definition.</span></div>
{/each}
+6 -3
View File
@@ -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 }
};
};
+2 -1
View File
@@ -35,5 +35,6 @@
<section class="metrics-panel"><header><div><p class="eyebrow">HOST CAPACITY</p><h3>OPERATING ENVIRONMENT</h3></div><b>{data.host.cores} CORES</b></header><div class="metric-lines"><div><span>PLATFORM</span><b>{data.host.platform.toUpperCase()} · {data.host.architecture}</b></div><div><span>RELEASE</span><b>{data.host.release}</b></div><div><span>HOST UPTIME</span><b>{duration(data.host.uptimeSeconds)}</b></div><div><span>MEMORY USED</span><b>{data.host.usedMemoryPercent}%</b></div><div><span>MEMORY AVAILABLE</span><b>{bytes(data.host.freeMemory)} / {bytes(data.host.totalMemory)}</b></div><div><span>LOAD AVERAGE</span><b>{data.host.loadAverage.map((value: number) => value.toFixed(2)).join(' · ')}</b></div></div></section>
<section class="metrics-panel"><header><div><p class="eyebrow">EMBEDDED TURSO</p><h3>DATABASE HEALTH</h3></div><b>{data.database.tableCount} TABLES</b></header><div class="metric-lines"><div><span>FILE</span><b>{data.database.filename}</b></div><div><span>ALLOCATED PAGES</span><b>{data.database.pageCount.toLocaleString()}</b></div><div><span>PAGE SIZE</span><b>{bytes(data.database.pageSize)}</b></div><div><span>FREE PAGES</span><b>{data.database.freePages.toLocaleString()} · {data.database.freePercent}%</b></div><div><span>LAST FILE WRITE</span><b>{data.database.modifiedAt ? new Date(data.database.modifiedAt).toLocaleString() : 'UNAVAILABLE'}</b></div><div><span>METRIC QUERY</span><b>{data.collectionMs} ms</b></div></div></section>
<section class="metrics-panel"><header><div><p class="eyebrow">SERVICE ACTIVITY</p><h3>RECORD VOLUME</h3></div><b>LIVE COUNTS</b></header><div class="metric-lines"><div><span>USERS</span><b>{data.counts.users.toLocaleString()}</b></div><div><span>ACTIVE SESSIONS</span><b>{data.counts.active_sessions.toLocaleString()}</b></div><div><span>BUILDS / GUIDES</span><b>{data.counts.builds.toLocaleString()}</b></div><div><span>COMMENTS</span><b>{data.counts.comments.toLocaleString()}</b></div><div><span>GAME-DATA RECORDS</span><b>{data.counts.game_records.toLocaleString()}</b></div><div><span>AUDIT EVENTS</span><b>{data.counts.audit_events.toLocaleString()}</b></div></div></section>
<section class="metrics-panel"><header><div><p class="eyebrow">EXTERNAL IMAGE PROCESSING</p><h3>REMOVE.BG CAPACITY</h3></div><b class:metric-ok={data.services.removeBackground.severity === 'ok'} class:metric-warning={data.services.removeBackground.severity === 'warning'} class:metric-critical={data.services.removeBackground.severity === 'critical'}>{data.services.removeBackground.configured ? data.services.removeBackground.severity.toUpperCase() : 'NOT CONFIGURED'}</b></header><div class="metric-lines"><div><span>PAID CREDITS</span><b>{data.services.removeBackground.total?.toLocaleString() ?? 'UNAVAILABLE'}</b></div><div><span>FREE API CALLS</span><b>{data.services.removeBackground.freeCalls?.toLocaleString() ?? 'UNAVAILABLE'}</b></div><div><span>SUBSCRIPTION / PAY-AS-YOU-GO</span><b>{data.services.removeBackground.subscription?.toLocaleString() ?? '—'} / {data.services.removeBackground.payg?.toLocaleString() ?? '—'}</b></div><div><span>ENTERPRISE</span><b>{data.services.removeBackground.enterprise?.toLocaleString() ?? '—'}</b></div><div><span>LAST ACCOUNT CHECK</span><b>{data.services.removeBackground.checkedAt ? new Date(data.services.removeBackground.checkedAt).toLocaleString() : 'PENDING'}</b></div><div><span>ALERT THRESHOLDS</span><b>≤ {data.services.removeBackground.warningCreditThreshold} CREDITS · ≤ {data.services.removeBackground.warningFreeCallThreshold} FREE</b></div>{#if data.services.removeBackground.error}<div><span>LAST CHECK ERROR</span><b class="metric-critical">{data.services.removeBackground.error}</b></div>{/if}</div></section>
</div>
<p class="metrics-foot">CAPTURED {new Date(data.capturedAt).toLocaleString()} · VALUES ARE PROCESS-LOCAL OBSERVATIONS AND DO NOT REPLACE HOST-LEVEL ALERTING.</p>
<p class="metrics-foot">CAPTURED {new Date(data.capturedAt).toLocaleString()} · RUNTIME VALUES ARE PROCESS-LOCAL OBSERVATIONS AND DO NOT REPLACE HOST-LEVEL ALERTING.</p>
+14 -3
View File
@@ -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
};
};
+8 -2
View File
@@ -1,6 +1,8 @@
<script lang="ts">
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 KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
@@ -128,6 +130,7 @@
{value:'high-end',label:'HIGH-END',count:data.summary.high_end,description:'High-end quality'}
]}/>
{#snippet protocolEditor()}
{#if editing}
<div class="dashboard-editor protocol-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
@@ -162,11 +165,14 @@
</PendingForm>
</div>
{/if}
{/snippet}
{#if editing && !editing.id}{@render protocolEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>
+16 -2
View File
@@ -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, 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';
@@ -16,6 +16,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(url, {
name: { asc: 'roles.name COLLATE NOCASE ASC', desc: 'roles.name COLLATE NOCASE DESC' },
users: { asc: 'user_count ASC', desc: 'user_count DESC', defaultDirection: 'desc' },
permissions: {
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
defaultDirection: 'desc'
},
state: {
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC"
}
}, 'name');
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
const conditions = [
...(permissionPrefix ? [`EXISTS (
@@ -41,7 +54,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const roles = await (await database.prepare(`SELECT roles.*,
(SELECT COUNT(*) FROM user_roles WHERE role_id=roles.id) AS user_count
FROM roles ${where} ORDER BY CASE WHEN roles.id='role-admin' THEN 0 ELSE 1 END,name COLLATE NOCASE
FROM roles ${where} ORDER BY ${sort.orderBy},roles.name COLLATE NOCASE,roles.id
LIMIT ? OFFSET ?`)).all(...filterArgs, pagination.perPage, pagination.offset);
const roleIds = roles.map((role) => String(role.id));
const links = roleIds.length ? await (await database.prepare(`SELECT role_id,permission_id FROM role_permissions
@@ -56,6 +69,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
summary,
filter,
search,
sort,
pagination,
canManageSystemAdmin
};
+21 -16
View File
@@ -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';
let { data, form } = $props();
let editing = $state<any>(null);
@@ -15,19 +17,7 @@
});
</script>
<svelte:head><title>Roles — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every 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.</span></p>
<KpiFilters className="role-kpis" active={data.filter} items={[
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
{value:'data',label:'DATA OPERATIONS',count:data.summary.data,description:'Builder-data capabilities'},
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'},
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'},
{value:'system',label:'SYSTEM CONFIG',count:data.summary.system,description:'Automated system capabilities'}
]}/>
{#if editing}
{#snippet roleEditor()}
<div class="dashboard-editor role-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY ACCESS ROLE' : 'NEW ACCESS ROLE'}</p><h3>{editing.id ? editing.name : 'ROLE DEFINITION'}</h3></div><button aria-label="Close role editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
@@ -44,12 +34,27 @@
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE ROLE →</button></div>
</PendingForm>
</div>
{/if}
{/snippet}
<svelte:head><title>Roles — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ACCESS POLICY REGISTRY</p><h2>ROLES</h2><p>Compose reusable access roles from discrete SHD.build capabilities.</p></div><button class="primary" onclick={() => editing = blank()}>ADD ROLE <span>+</span></button></div>
<p class="access-baseline-note"><b>REGISTERED AGENT BASELINE</b><span>Every 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.</span></p>
<KpiFilters className="role-kpis" active={data.filter} items={[
{value:'',label:'TOTAL ROLES',count:data.summary.total,description:'Show all role definitions'},
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
{value:'data',label:'DATA OPERATIONS',count:data.summary.data,description:'Builder-data capabilities'},
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'},
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'},
{value:'system',label:'SYSTEM CONFIG',count:data.summary.system,description:'Automated system capabilities'}
]}/>
{#if editing && !editing.id}{@render roleEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row role-row table-heading"><span>ROLE</span><span>ASSIGNED USERS</span><span>DISCRETE PERMISSIONS</span><span>STATE</span><span></span></div>
<div class="dashboard-row role-row table-heading"><SortableColumnHeader label="ROLE" column="name" sort={data.sort}/><SortableColumnHeader label="ASSIGNED USERS" column="users" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="DISCRETE PERMISSIONS" column="permissions" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort}/><span></span></div>
{#each data.roles as role}
<div class="dashboard-row role-row"><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}</span>{#if role.protected}<span></span>{:else}<button onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}</div>
<div class="dashboard-row role-row" class:registry-row-editing={editing?.id === role.id}><div><strong>{role.name}</strong><small>{role.description ?? 'NO DESCRIPTION'}</small></div><span>{role.user_count} USER{Number(role.user_count) === 1 ? '' : 'S'}</span><div class="role-permission-summary">{#each role.permissionIds as permissionId}<b>{permissionName(permissionId)}</b>{:else}<small>NO PERMISSIONS</small>{/each}</div><span class:configured={!role.protected}>{role.protected ? 'SYSTEM PROTECTED' : 'EDITABLE'}</span>{#if role.protected}<span></span>{:else}<button aria-expanded={editing?.id === role.id} aria-controls={`role-editor-${role.id}`} onclick={() => editing = {...role, permissionIds:[...role.permissionIds]}}>EDIT</button>{/if}</div>
{#if editing?.id === role.id}<RegistryInlineEditor id={`role-editor-${role.id}`} label={`Edit ${role.name} role`}>{@render roleEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO ROLES MATCH THIS FILTER</strong><span>Select another access domain or create a role.</span></div>{/each}
</div>
+11 -3
View File
@@ -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
};
};
+8 -2
View File
@@ -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}
<div class="dashboard-editor skill-chip-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY CHIP SET' : 'NEW CHIP SET'}</p><h3>{editing.id ? editing.name : 'SKILL CHIP DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
@@ -109,11 +112,14 @@
</PendingForm>
</div>
{/if}
{/snippet}
{#if editing && !editing.id}{@render skillChipEditor()}{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>ABILITY</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="ROLL POOLS" column="rollPools" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
</div>
@@ -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
};
};
+2 -1
View File
@@ -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 @@
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row specialization-row table-heading"><span>SPECIALIZATION</span><span>FOCUSES</span><span>ABILITIES</span><span>READINESS</span><span>STATE</span><span></span></div>
<div class="dashboard-row specialization-row table-heading"><SortableColumnHeader label="SPECIALIZATION" column="name" sort={data.sort}/><SortableColumnHeader label="FOCUSES" column="focuses" sort={data.sort}/><SortableColumnHeader label="ABILITIES" column="abilities" sort={data.sort}/><SortableColumnHeader label="READINESS" column="readiness" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.specializations as item}
<div class="dashboard-row specialization-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:selected={selectedSpecializationId === item.id}>
<div class="specialization-row-identity"><ItemIcon src={item.iconUrl} alt={item.name}/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.description || String(item.id).slice(0,8).toUpperCase()}</small></div></div>
+10 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
let { data, form } = $props();
let editing = $state<any>(null);
let consumedToken = $state<string | null>(null);
@@ -13,12 +14,8 @@
});
</script>
<svelte:head><title>Automated system messages — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>AUTOMATED MESSAGES</h2><p>Control event-driven ISAC notifications, delivery timing, content, and action callouts.</p></div></div>
<div class="module-kpis"><div><span>DEFINITIONS</span><strong>{data.messages.length}</strong><small>Automated messages</small></div><div><span>ACTIVE</span><strong>{data.messages.filter((item: any) => item.enabled).length}</strong><small>Enabled triggers</small></div><div><span>EVENT TYPES</span><strong>{data.events.length}</strong><small>System lifecycle events</small></div></div>
{#if editing}
<section class="horizontal-editor system-message-editor">
{#snippet messageEditor()}
<section class="dashboard-editor horizontal-editor system-message-editor">
<div class="editor-title"><div><p class="eyebrow">EDIT AUTOMATED MESSAGE</p><h3>{editing.name}</h3></div><button type="button" aria-label="Cancel editing automated message" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
@@ -40,11 +37,16 @@
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE MESSAGE →</button></div>
</PendingForm>
</section>
{/if}
{/snippet}
<svelte:head><title>Automated system messages — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>AUTOMATED MESSAGES</h2><p>Control event-driven ISAC notifications, delivery timing, content, and action callouts.</p></div></div>
<div class="module-kpis"><div><span>DEFINITIONS</span><strong>{data.messages.length}</strong><small>Automated messages</small></div><div><span>ACTIVE</span><strong>{data.messages.filter((item: any) => item.enabled).length}</strong><small>Enabled triggers</small></div><div><span>EVENT TYPES</span><strong>{data.events.length}</strong><small>System lifecycle events</small></div></div>
<section class="dashboard-table system-message-table">
<div class="dashboard-row heading"><span>MESSAGE</span><span>TRIGGER</span><span>DELIVERY</span><span>ACTIVITY</span><span></span></div>
{#each data.messages as message}
<div class="dashboard-row"><div><strong>{message.name}</strong><small>{message.description}</small></div><div><strong>{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}</strong><small>{message.enabled ? 'ENABLED' : 'DISABLED'}</small></div><div><strong>{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}</strong><small>{message.title}</small></div><div><strong>{message.delivery_count} SENT</strong><small>LAST · {date(message.last_delivered_at)}</small></div><button class="table-action" onclick={() => edit(message)}>EDIT →</button></div>
<div class="dashboard-row" class:registry-row-editing={editing?.id === message.id}><div><strong>{message.name}</strong><small>{message.description}</small></div><div><strong>{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}</strong><small>{message.enabled ? 'ENABLED' : 'DISABLED'}</small></div><div><strong>{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}</strong><small>{message.title}</small></div><div><strong>{message.delivery_count} SENT</strong><small>LAST · {date(message.last_delivered_at)}</small></div><button class="table-action" aria-expanded={editing?.id === message.id} aria-controls={`system-message-editor-${message.id}`} onclick={() => edit(message)}>EDIT →</button></div>
{#if editing?.id === message.id}<RegistryInlineEditor id={`system-message-editor-${message.id}`} label={`Edit ${message.name} automated message`}>{@render messageEditor()}</RegistryInlineEditor>{/if}
{/each}
</section>
+11 -3
View File
@@ -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
};
};
+9 -1
View File
@@ -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 @@
<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>
{/if}
{#snippet talentEditor()}
{#if talent}
<div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
@@ -97,6 +100,11 @@
</PendingForm>
</div>
{/if}
{/snippet}
{#if talent && !talent.id}
{@render talentEditor()}
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+40 -5
View File
@@ -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<string, any>) => {
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,
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
let { data } = $props();
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
</script>
@@ -9,7 +10,7 @@
<KpiFilters active={data.filter} items={[{value:'',label:'RESULTS',count:data.summary.results,description:'Show current query'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Active within 30 days'},{value:'inactive',label:'INACTIVE',count:data.summary.inactive,description:'No activity for 30 days'},{value:'restricted',label:'RESTRICTED',count:data.summary.restricted,description:'Blocked, banned, or deleted'}]}/>
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
<div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>ROLES</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
<div class="dashboard-row user-row table-heading"><SortableColumnHeader label="USER" column="user" sort={data.sort}/><SortableColumnHeader label="ACCESS" column="access" sort={data.sort}/><SortableColumnHeader label="ROLES" column="roles" sort={data.sort}/><SortableColumnHeader label="LAST LOGIN" column="lastLogin" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="LAST ACTIVE" column="lastActive" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="CONTENT" column="content" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.users as user}<div class="dashboard-row user-row mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
</div>
+12 -3
View File
@@ -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
};
};
+14 -3
View File
@@ -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()}
<div class="dashboard-editor weapon-mod-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}</p><h3>{editing.id ? editing.name : 'WEAPON MOD DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
@@ -146,12 +148,21 @@
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE WEAPON MOD' : 'UNARCHIVE WEAPON MOD'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE WEAPON MOD →</button></div></div>
</PendingForm>
</div>
{/snippet}
{#if editing && !editing.id}
{@render weaponModEditor()}
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table weapon-mod-dashboard-table">
<div class="dashboard-row weapon-mod-row table-heading"><span>WEAPON MOD</span><span>SLOT</span><span>PRIMARY</span><span>SECONDARIES</span><span>COMPATIBILITY</span><span>MOD EFFECT</span><span>STATE</span><span></span></div>
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.mods as item}
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === item.id}
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
{@render weaponModEditor()}
</RegistryInlineEditor>
{/if}
{:else}<div class="empty-dashboard"><strong>NO WEAPON MODS REGISTERED</strong><span>Add the first slot-compatible Weapon Mod definition.</span></div>{/each}
</div>
+34 -3
View File
@@ -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 }))
};
+14 -3
View File
@@ -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 @@
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => editing = blank()}>ADD WEAPON <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL WEAPONS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'standard',label:'STANDARD',count:data.summary.standard,description:'Non-exotic weapons'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic weapons'}]}/>
{#if editing}
{#snippet weaponEditor()}
<div class="dashboard-editor weapon-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save" enctype="multipart/form-data">
@@ -160,12 +162,21 @@
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0}>SAVE WEAPON →</button></div></div>
</PendingForm>
</div>
{/snippet}
{#if editing && !editing.id}
{@render weaponEditor()}
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
<div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)}>EDIT</button>{/if}</div>
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === weapon.id}
<RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}>
{@render weaponEditor()}
</RegistryInlineEditor>
{/if}
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
</div>