checkpoint ISAC registries and system monitoring
This commit is contained in:
@@ -41,3 +41,6 @@ SMTP_FROM=SHD.build <no-reply@shd.build>
|
|||||||
# Long random bearer token used only by the internal container scheduler.
|
# Long random bearer token used only by the internal container scheduler.
|
||||||
SCHEDULER_TOKEN=
|
SCHEDULER_TOKEN=
|
||||||
REMOVE_BG_API_KEY=
|
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
@@ -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((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)
|
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 (
|
CREATE TABLE IF NOT EXISTS talents (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
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
|
CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx
|
||||||
ON managed_images(source_entity_type,source_entity_id)
|
ON managed_images(source_entity_type,source_entity_id)
|
||||||
WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL;
|
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);
|
||||||
|
|||||||
@@ -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
|
then pass through local trimming, white normalization, WebP conversion, and
|
||||||
storage.
|
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
|
```sh
|
||||||
npm run images:export
|
npm run images:export
|
||||||
```
|
```
|
||||||
|
|||||||
+57
-14
@@ -414,6 +414,12 @@ squeezed beside long module headings on mobile.
|
|||||||
- The administrative system is named ISAC. Separate navigation into explicit
|
- The administrative system is named ISAC. Separate navigation into explicit
|
||||||
operational domains such as User Operations and Data Management; visibility
|
operational domains such as User Operations and Data Management; visibility
|
||||||
must reflect server-provided capabilities.
|
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`,
|
- ISAC module navigation uses complete, game-facing names such as `Weapons`,
|
||||||
`Talents`, and `OS Protocols`. Do not expose internal abbreviations such as
|
`Talents`, and `OS Protocols`. Do not expose internal abbreviations such as
|
||||||
`WPN`, `TLNT`, or `OSPR`. Canonical game icons may supplement visible labels
|
`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
|
Each provides context-appropriate filters and namespaced pagination controls
|
||||||
so changing one history page does not reset the others.
|
so changing one history page does not reset the others.
|
||||||
- Each module starts with `module-toolbar`, followed by `module-kpis`.
|
- 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
|
- Optional record descriptions use a full-width narrative field beneath the
|
||||||
identity controls. Preserve the same full-width placement in Oversight
|
identity controls. Preserve the same full-width placement in Oversight
|
||||||
before/after previews so reviewers can compare meaning without reading raw
|
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,
|
restrained ten-second interval. Metrics must not expose environment values,
|
||||||
credentials, full filesystem paths, or other deployment secrets. The
|
credentials, full filesystem paths, or other deployment secrets. The
|
||||||
`metrics.view` permission is independent of mutation access and is included
|
`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,
|
- Account deletion uses a collapsed danger zone, explicit identity confirmation,
|
||||||
and plain-language retention guidance. Soft-deleted accounts retain authored
|
and plain-language retention guidance. Soft-deleted accounts retain authored
|
||||||
content under a deleted-agent identity while releasing credentials, usernames,
|
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
|
Large relationship sets use the searchable assignment control instead of
|
||||||
checkbox walls. It keeps assigned records visible as removable chips, filters
|
checkbox walls. It keeps assigned records visible as removable chips, filters
|
||||||
available records by name, renders at most thirty results at once, and exposes
|
available records by identity, renders at most thirty results at once, and
|
||||||
counts for both assignments and matches. Mutually exclusive pools pass their
|
exposes counts for both assignments and matches. When records can share a name,
|
||||||
assigned IDs to one another as exclusions so invalid combinations cannot be
|
show the identity-defining qualifier below it in both selected chips and search
|
||||||
selected. Keyboard focus, pressed state, empty results, and overflow remain
|
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.
|
explicit.
|
||||||
Where a relationship has a global slot invariant, filter each assignment
|
Where a relationship has a global slot invariant, filter each assignment
|
||||||
control at the data boundary instead of presenting cross-slot choices. Weapon
|
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
|
previous, next, current page, and page-size controls before optional page
|
||||||
numbers.
|
numbers.
|
||||||
|
|
||||||
When only one page exists, omit page navigation and row-count presets; retain
|
The row-count presets remain available even when the current result fits on one
|
||||||
only the useful result range and any registry search. Do not render disabled
|
page. Omit only the page navigation when there is no second page. Do not render
|
||||||
pagination controls to preserve the rail's shape.
|
disabled pagination controls merely to preserve the rail's shape.
|
||||||
|
|
||||||
Registries with a meaningful name, title, or agent-identity column place the
|
Registries with a meaningful name, title, or agent-identity column place the
|
||||||
shared server-side search inside this pagination rail. Results update after a
|
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
|
usable width, and search, clear, previous, and next remain explicit keyboard
|
||||||
and touch targets.
|
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
|
### Registry metric filters
|
||||||
|
|
||||||
The KPI row above an ISAC registry is an operational filter surface, not passive
|
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,
|
Large many-to-many selections use the shared searchable assignment control,
|
||||||
including weapon talent pools, gear talent compatibility, and compatible brand
|
including weapon talent pools, gear talent compatibility, and compatible brand
|
||||||
sets. The control keeps assigned records visible as removable chips, filters
|
sets. The control keeps assigned records visible as removable chips, filters
|
||||||
available records by name, limits the rendered result set, and submits canonical
|
available records by their displayed identity, limits the rendered result set,
|
||||||
hidden inputs. Configure its subject labels and empty-state copy for the entity
|
and submits canonical hidden inputs. Configure its subject labels, qualifiers,
|
||||||
being assigned rather than embedding talent-specific language in the shared
|
and empty-state copy for the entity being assigned rather than embedding
|
||||||
component. Pending contributor records retain their orange treatment and named
|
talent-specific language in the shared component. Pending contributor records
|
||||||
proposer in both search results and dependency workflows.
|
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
|
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
|
domains need first-class visibility. It retains the standard compact KPI height
|
||||||
|
|||||||
@@ -182,6 +182,15 @@ and historical analysis should eventually use a dedicated external telemetry
|
|||||||
system. This surface deliberately omits secrets, environment-variable values,
|
system. This surface deliberately omits secrets, environment-variable values,
|
||||||
and full database paths.
|
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
|
# Weapon-owned exotic talents
|
||||||
|
|
||||||
Standard weapon talent pools remain globally reusable and classified by Talent
|
Standard weapon talent pools remain globally reusable and classified by Talent
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
|
"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: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: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",
|
"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: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",
|
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs",
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
@@ -619,6 +619,31 @@ for (const [name, definition] of [
|
|||||||
await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`);
|
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();
|
const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all();
|
||||||
for (const [name, definition] of [
|
for (const [name, definition] of [
|
||||||
|
|||||||
@@ -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
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>
|
||||||
@@ -87,6 +87,6 @@
|
|||||||
{/each}
|
{/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>
|
<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>
|
||||||
<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}
|
{/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>
|
</nav>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 {
|
let {
|
||||||
items,
|
items,
|
||||||
selected = $bindable<string[]>([]),
|
selected = $bindable<string[]>([]),
|
||||||
@@ -33,10 +33,12 @@
|
|||||||
const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase());
|
const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase());
|
||||||
const available = $derived(items.filter((item) =>
|
const available = $derived(items.filter((item) =>
|
||||||
!excludedIds.includes(item.id)
|
!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 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 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) => {
|
const toggle = (id: string) => {
|
||||||
if (mode === 'single') {
|
if (mode === 'single') {
|
||||||
value = value === id ? '' : id;
|
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-summary"><span>{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}</span><b>{selectedIds.length}</b></div>
|
||||||
<div class="assignment-chips">
|
<div class="assignment-chips">
|
||||||
{#each selectedItems as item}
|
{#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}
|
{:else}
|
||||||
<small>{emptySelectionLabel}</small>
|
<small>{emptySelectionLabel}</small>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
<div class="assignment-results" role="list">
|
<div class="assignment-results" role="list">
|
||||||
{#each visible as item}
|
{#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)}>
|
<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>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p>{emptyLabel}</p>
|
<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>
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,39 @@ import type { URL } from 'node:url';
|
|||||||
|
|
||||||
export const PAGE_SIZE_PRESETS = [10, 25, 50, 100] as const;
|
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 = '') {
|
export function getRegistrySearch(url: URL, namespace = '') {
|
||||||
const key = namespace ? `${namespace}Q` : 'q';
|
const key = namespace ? `${namespace}Q` : 'q';
|
||||||
const search = String(url.searchParams.get(key) ?? '').normalize('NFKC').trim().slice(0, 80);
|
const search = String(url.searchParams.get(key) ?? '').normalize('NFKC').trim().slice(0, 80);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import sharp from 'sharp';
|
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_ENDPOINT = 'https://api.remove.bg/v1.0/removebg';
|
||||||
const REMOVE_BG_PREVIEW_EDGE = 1500;
|
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.');
|
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));
|
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 output = Buffer.from(await response.arrayBuffer());
|
||||||
const metadata = await sharp(output, { failOn: 'warning' }).metadata();
|
const metadata = await sharp(output, { failOn: 'warning' }).metadata();
|
||||||
if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.');
|
if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.');
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import { runRemoveBgCreditMonitor } from '$lib/server/remove-bg-monitor';
|
||||||
|
|
||||||
function mailTransport() {
|
function mailTransport() {
|
||||||
if (!env.SMTP_HOST || !env.SMTP_FROM) return null;
|
if (!env.SMTP_HOST || !env.SMTP_FROM) return null;
|
||||||
@@ -159,14 +160,15 @@ async function deliverDailyDigests() {
|
|||||||
return sent;
|
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() {
|
export function runScheduledNotificationTasks() {
|
||||||
if (running) return running;
|
if (running) return running;
|
||||||
running = (async () => {
|
running = (async () => {
|
||||||
const system = await deliverSystemMessages();
|
const system = await deliverSystemMessages();
|
||||||
const oversight = await deliverOversightMessages();
|
const oversight = await deliverOversightMessages();
|
||||||
const digests = await deliverDailyDigests();
|
const digests = await deliverDailyDigests();
|
||||||
return { system, oversight, digests };
|
const serviceAlerts = await runRemoveBgCreditMonitor();
|
||||||
|
return { system, oversight, digests, serviceAlerts };
|
||||||
})()
|
})()
|
||||||
.finally(() => { running = null; });
|
.finally(() => { running = null; });
|
||||||
return running;
|
return running;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
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();
|
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 filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
|
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
|
||||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||||
@@ -42,21 +49,26 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const visiblePending = pending.filter((stat) =>
|
const visiblePending = pending.filter((stat) =>
|
||||||
(filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true)
|
(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()))
|
&& (!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 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 pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||||
const stats = databaseLimit
|
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 {
|
return {
|
||||||
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
|
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
|
||||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
|
||||||
pagination,
|
pagination,
|
||||||
filter,
|
filter,
|
||||||
search
|
search,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export const actions: Actions = {
|
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 (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 (!['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.' });
|
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>;
|
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.' });
|
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();
|
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 now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
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);
|
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);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -28,9 +30,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
<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>
|
<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'}]}/>
|
<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}/>
|
<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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { recordIsacAudit } from '$lib/server/audit';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
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 query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
|
||||||
const category = url.searchParams.get('category')?.trim() ?? '';
|
const category = url.searchParams.get('category')?.trim() ?? '';
|
||||||
const action = url.searchParams.get('action')?.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 conditions: string[] = [];
|
||||||
const args: unknown[] = [];
|
const args: unknown[] = [];
|
||||||
if (query) {
|
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
|
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
|
FROM audit_log JOIN users ON users.id=audit_log.actor_id
|
||||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.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
|
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)) : [];
|
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)));
|
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
|
||||||
return {
|
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)) })),
|
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let confirming = $state<any>(null);
|
let confirming = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#each data.entries as entry}
|
||||||
<details class="audit-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>
|
<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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
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();
|
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 filter = ['active', 'archived', 'complete'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
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'] : []),
|
...(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"] : [])
|
...(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 where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const args = search ? [pattern] : [];
|
const args = search ? [pattern] : [];
|
||||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(...args);
|
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 pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||||
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
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 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 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(),
|
(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) },
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) },
|
||||||
pagination,
|
pagination,
|
||||||
filter,
|
filter,
|
||||||
search
|
search,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
@@ -30,36 +32,41 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
<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>
|
<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'}]}/>
|
<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}
|
{#if editing && !editing.id}{@render brandEditor()}{/if}
|
||||||
<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}
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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}
|
{:else}
|
||||||
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { requireDataView, requirePermission } from '$lib/server/auth/authorizati
|
|||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { recordIsacAudit } from '$lib/server/audit';
|
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';
|
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||||
|
|
||||||
const operators = new Set([
|
const operators = new Set([
|
||||||
@@ -62,6 +62,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
await requireDataView(await getSessionUser(cookies));
|
await requireDataView(await getSessionUser(cookies));
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 where = search ? "WHERE calibration.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
|
||||||
const args = search ? [pattern] : [];
|
const args = search ? [pattern] : [];
|
||||||
const total = Number((await (await database.prepare(
|
const total = Number((await (await database.prepare(
|
||||||
@@ -77,7 +95,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
FROM calculation_calibration_cases calibration
|
FROM calculation_calibration_cases calibration
|
||||||
LEFT JOIN users user ON user.id=calibration.created_by
|
LEFT JOIN users user ON user.id=calibration.created_by
|
||||||
LEFT JOIN user_profiles profile ON profile.user_id=user.id
|
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),
|
)).all(...args, pagination.perPage, pagination.offset),
|
||||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN status='ready' THEN 1 ELSE 0 END) AS ready,
|
SUM(CASE WHEN status='ready' THEN 1 ELSE 0 END) AS ready,
|
||||||
@@ -95,7 +113,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
calibrationCases,
|
calibrationCases,
|
||||||
calibrationSummary,
|
calibrationSummary,
|
||||||
pagination,
|
pagination,
|
||||||
search
|
search,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import { createDraft } from '$lib/builder/loadout';
|
import { createDraft } from '$lib/builder/loadout';
|
||||||
@@ -186,6 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet calibrationEditor()}
|
||||||
{#if calibrationEditing}
|
{#if calibrationEditing}
|
||||||
<div class="dashboard-editor calibration-editor">
|
<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>
|
<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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if calibrationEditing && !calibrationEditing.id}
|
||||||
|
{@render calibrationEditor()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="calculation-revision-registry">
|
<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>
|
<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>
|
<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}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
|
|||||||
import { requirePermission } from '$lib/server/auth/authorization';
|
import { requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { deserializeProposal, refreshProposalDependencies } from '$lib/server/data-moderation';
|
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 { recordIsacAudit } from '$lib/server/audit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { createNotification } from '$lib/server/notifications';
|
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 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 module = modules.includes(url.searchParams.get('module') ?? '') ? String(url.searchParams.get('module')) : '';
|
||||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
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 conditions = ['p.status=?'];
|
||||||
const args: unknown[] = [status];
|
const args: unknown[] = [status];
|
||||||
if (module) { conditions.push('p.module=?'); args.push(module); }
|
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 user_profiles profile ON profile.user_id=proposer.id
|
||||||
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
|
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
|
||||||
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
|
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) => ({
|
const proposalsWithSnapshots = await Promise.all(proposals.map(async (proposal) => ({
|
||||||
...proposal,
|
...proposal,
|
||||||
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
|
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
|
||||||
@@ -149,7 +161,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
: null
|
: null
|
||||||
})));
|
})));
|
||||||
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
|
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let reviewing = $state<any>(null);
|
let reviewing = $state<any>(null);
|
||||||
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<div class="dashboard-table moderation-queue">
|
<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}
|
{#each data.proposals as proposal}
|
||||||
<div class="dashboard-row moderation-row mobile-detail-row">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
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();
|
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 filter = ['active', 'armored', 'talented'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
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)'] : []),
|
...(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"] : [])
|
...(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) =>
|
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)
|
(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()))
|
&& (!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 adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
|
||||||
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
|
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
|
||||||
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
|
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
|
||||||
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
|
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
|
||||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
|
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,brand_set_id FROM gear_brand_sets')).all(),
|
||||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).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(),
|
(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 },
|
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
|
||||||
pagination: adjustedPagination,
|
pagination: adjustedPagination,
|
||||||
filter,
|
filter,
|
||||||
search
|
search,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
@@ -65,6 +67,36 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
<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>
|
<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">
|
<details class="gear-augment-registry">
|
||||||
@@ -105,37 +137,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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'}]}/>
|
<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}
|
{#if editing && !editing.id}{@render gearEditor()}{/if}
|
||||||
<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}
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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}
|
{: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>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { requireDataView, requirePermission } from '$lib/server/auth/authorizati
|
|||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
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 { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -38,6 +38,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||||
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
|
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
|
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
|
||||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
...(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 databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
|
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
|
||||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear_mods ${where}
|
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_quality_values')).all(),
|
||||||
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
|
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
|
||||||
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
|
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
|
||||||
@@ -98,6 +103,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
},
|
},
|
||||||
filter,
|
filter,
|
||||||
pagination,
|
pagination,
|
||||||
|
sort,
|
||||||
search
|
search
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.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 SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@
|
|||||||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
{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="dashboard-editor weapon-mod-editor gear-mod-editor">
|
||||||
<div class="editor-title">
|
<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>
|
<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>
|
</div>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if editing && !editing.id}
|
||||||
|
{@render gearModEditor()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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>
|
<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><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>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
|
||||||
<span>STANDARD → HIGH-END</span>
|
<span>STANDARD → HIGH-END</span>
|
||||||
<span>01 → 20</span>
|
<span>01 → 20</span>
|
||||||
<b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>
|
<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>
|
</div>
|
||||||
|
{#if editing?.id === item.id}
|
||||||
|
<RegistryInlineEditor id={`gear-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
|
||||||
|
{@render gearModEditor()}
|
||||||
|
</RegistryInlineEditor>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty-dashboard"><strong>NO GEAR MODS REGISTERED</strong><span>Add the first quality-scaled Gear Mod definition.</span></div>
|
<div class="empty-dashboard"><strong>NO GEAR MODS REGISTERED</strong><span>Add the first quality-scaled Gear Mod definition.</span></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { basename, resolve } from 'node:path';
|
|||||||
import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os';
|
import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os';
|
||||||
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getRemoveBgCreditStatus, refreshRemoveBgCredits } from '$lib/server/remove-bg-monitor';
|
||||||
|
|
||||||
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||||
loopDelay.enable();
|
loopDelay.enable();
|
||||||
@@ -31,7 +32,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
previousElu = performance.eventLoopUtilization();
|
previousElu = performance.eventLoopUtilization();
|
||||||
const memory = process.memoryUsage();
|
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_count')).get(),
|
||||||
(await database.prepare('PRAGMA page_size')).get(),
|
(await database.prepare('PRAGMA page_size')).get(),
|
||||||
(await database.prepare('PRAGMA freelist_count')).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 audit_log) AS audit_events,
|
||||||
(SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+
|
(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()),
|
(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 pages = numericPragma(pageCount);
|
||||||
const bytesPerPage = numericPragma(pageSize);
|
const bytesPerPage = numericPragma(pageSize);
|
||||||
@@ -86,6 +88,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
|||||||
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
|
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
|
||||||
tableCount: Number(tableCount?.count ?? 0)
|
tableCount: Number(tableCount?.count ?? 0)
|
||||||
},
|
},
|
||||||
counts
|
counts,
|
||||||
|
services: { removeBackground }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">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">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">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>
|
</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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
|||||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -57,6 +57,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const args: unknown[] = [];
|
const args: unknown[] = [];
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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}`);
|
if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`);
|
||||||
else if (qualities.some((quality) => quality.toLowerCase() === filter)) {
|
else if (qualities.some((quality) => quality.toLowerCase() === filter)) {
|
||||||
conditions.push('quality=?');
|
conditions.push('quality=?');
|
||||||
@@ -82,7 +93,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
]);
|
]);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons, pending] = await Promise.all([
|
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_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_profiles ORDER BY quality,primary_attribute_type')).all(),
|
||||||
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).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: [] })),
|
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
|
||||||
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
|
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
|
||||||
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
|
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import GameIcon from '$lib/components/GameIcon.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'}
|
{value:'high-end',label:'HIGH-END',count:data.summary.high_end,description:'High-end quality'}
|
||||||
]}/>
|
]}/>
|
||||||
|
|
||||||
|
{#snippet protocolEditor()}
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor protocol-editor">
|
<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>
|
<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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#if editing && !editing.id}{@render protocolEditor()}{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table protocol-dashboard-table">
|
<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}
|
{#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}
|
{: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>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -16,6 +16,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
|
||||||
const conditions = [
|
const conditions = [
|
||||||
...(permissionPrefix ? [`EXISTS (
|
...(permissionPrefix ? [`EXISTS (
|
||||||
@@ -41,7 +54,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const roles = await (await database.prepare(`SELECT roles.*,
|
const roles = await (await database.prepare(`SELECT roles.*,
|
||||||
(SELECT COUNT(*) FROM user_roles WHERE role_id=roles.id) AS user_count
|
(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);
|
LIMIT ? OFFSET ?`)).all(...filterArgs, pagination.perPage, pagination.offset);
|
||||||
const roleIds = roles.map((role) => String(role.id));
|
const roleIds = roles.map((role) => String(role.id));
|
||||||
const links = roleIds.length ? await (await database.prepare(`SELECT role_id,permission_id FROM role_permissions
|
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,
|
summary,
|
||||||
filter,
|
filter,
|
||||||
search,
|
search,
|
||||||
|
sort,
|
||||||
pagination,
|
pagination,
|
||||||
canManageSystemAdmin
|
canManageSystemAdmin
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.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 { data, form } = $props();
|
||||||
let editing = $state<any>(null);
|
let editing = $state<any>(null);
|
||||||
@@ -15,19 +17,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Roles — ISAC</title></svelte:head>
|
{#snippet roleEditor()}
|
||||||
<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}
|
|
||||||
<div class="dashboard-editor role-editor">
|
<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>
|
<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">
|
<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>
|
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE ROLE →</button></div>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</div>
|
</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}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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}
|
{: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>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
|
|||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
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 { refreshAbilityFocuses } from '$lib/server/specialization-readiness';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
@@ -48,6 +48,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||||
const filter = ['active', 'archived', 'configured', 'assigned'].includes(requestedFilter) ? requestedFilter : '';
|
const filter = ['active', 'archived', 'configured', 'assigned'].includes(requestedFilter) ? requestedFilter : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
|
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
|
||||||
: filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL']
|
: 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 pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, abilities, specializations, abilityChipCounts, icons] = await Promise.all([
|
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_two_piece_bonuses')).all(),
|
||||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||||
(await database.prepare('SELECT * FROM skill_chip_secondary_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,
|
assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length,
|
||||||
pending: currentPendingSets.length
|
pending: currentPendingSets.length
|
||||||
},
|
},
|
||||||
filter, pagination, search
|
filter, pagination, search, sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||||||
]}/>
|
]}/>
|
||||||
|
|
||||||
|
{#snippet skillChipEditor()}
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor skill-chip-editor">
|
<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>
|
<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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#if editing && !editing.id}{@render skillChipEditor()}{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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}
|
{: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>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { deserializeProposal, getPendingEntities } from '$lib/server/data-moderation';
|
||||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import {
|
import {
|
||||||
@@ -179,6 +179,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
? String(url.searchParams.get('filter'))
|
? String(url.searchParams.get('filter'))
|
||||||
: '';
|
: '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(filter === 'active' ? ['specializations.active=1']
|
...(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)']
|
: 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 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([
|
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(
|
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) : [],
|
)).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_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(),
|
(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,
|
pagination,
|
||||||
filter,
|
filter,
|
||||||
search
|
search,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
@@ -592,7 +593,7 @@
|
|||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table">
|
<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}
|
{#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="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>
|
<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>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let editing = $state<any>(null);
|
let editing = $state<any>(null);
|
||||||
let consumedToken = $state<string | null>(null);
|
let consumedToken = $state<string | null>(null);
|
||||||
@@ -13,12 +14,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Automated system messages — ISAC</title></svelte:head>
|
{#snippet messageEditor()}
|
||||||
<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>
|
<section class="dashboard-editor horizontal-editor system-message-editor">
|
||||||
<div class="module-kpis"><div><span>DEFINITIONS</span><strong>{data.messages.length}</strong><small>Automated messages</small></div><div><span>ACTIVE</span><strong>{data.messages.filter((item: any) => item.enabled).length}</strong><small>Enabled triggers</small></div><div><span>EVENT TYPES</span><strong>{data.events.length}</strong><small>System lifecycle events</small></div></div>
|
|
||||||
|
|
||||||
{#if editing}
|
|
||||||
<section class="horizontal-editor system-message-editor">
|
|
||||||
<div class="editor-title"><div><p class="eyebrow">EDIT AUTOMATED MESSAGE</p><h3>{editing.name}</h3></div><button type="button" aria-label="Cancel editing automated message" onclick={() => editing = null}>×</button></div>
|
<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">
|
<PendingForm action="?/save">
|
||||||
<input type="hidden" name="id" value={editing.id}/>
|
<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>
|
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE MESSAGE →</button></div>
|
||||||
</PendingForm>
|
</PendingForm>
|
||||||
</section>
|
</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">
|
<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>
|
<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}
|
{#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}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
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 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 filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(filter === 'slot1' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)']
|
...(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)']
|
: 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 filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(...args);
|
||||||
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
|
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
|
||||||
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
|
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 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 * 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(),
|
(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,
|
pagination,
|
||||||
filter,
|
filter,
|
||||||
search
|
search,
|
||||||
|
sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
import ItemIcon from '$lib/components/ItemIcon.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 SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
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>
|
<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}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet talentEditor()}
|
||||||
{#if talent}
|
{#if talent}
|
||||||
<div class="dashboard-editor">
|
<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>
|
<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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if talent && !talent.id}
|
||||||
|
{@render talentEditor()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<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>
|
||||||
|
|||||||
@@ -2,17 +2,31 @@ import { requirePermission } from '$lib/server/auth/authorization';
|
|||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import type { PageServerLoad } from './$types';
|
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 }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
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 database = await getDatabase();
|
||||||
const summaryRows = await (await database.prepare(`
|
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,
|
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
|
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 user_profiles ON user_profiles.user_id=users.id
|
||||||
LEFT JOIN deleted_accounts ON deleted_accounts.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 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
|
const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive
|
||||||
: filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true);
|
: 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 pagination = getPagination(url, filteredStates.length);
|
||||||
const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id));
|
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,
|
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,
|
users.role,users.discord_id,users.created_at,
|
||||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
(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 user_profiles ON user_profiles.user_id=users.id
|
||||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||||
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
|
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
|
||||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
|
||||||
`)).all(...pageIds) : [];
|
`)).all(...pageIds) : [];
|
||||||
|
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
|
||||||
|
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
|
||||||
return {
|
return {
|
||||||
users: users.map((user) => {
|
users: users.map((user) => {
|
||||||
return { ...user, ...state(user) };
|
return { ...user, ...state(user) };
|
||||||
}),
|
}),
|
||||||
query,
|
query,
|
||||||
filter,
|
filter,
|
||||||
|
sort,
|
||||||
summary: {
|
summary: {
|
||||||
results: summaryRows.length,
|
results: summaryRows.length,
|
||||||
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
|
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
|
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||||
</script>
|
</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'}]}/>
|
<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"/>
|
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
|
||||||
<div class="dashboard-table">
|
<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>
|
{#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}
|
{:else}<div class="empty-dashboard"><strong>NO USERS FOUND</strong><span>Adjust the username search and try again.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { requireDataView, requirePermission } from '$lib/server/auth/authorizati
|
|||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
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 { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -49,6 +49,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||||
const filter = ['active', 'archived', 'regular', 'exotic'].includes(requestedFilter) ? requestedFilter : '';
|
const filter = ['active', 'archived', 'regular', 'exotic'].includes(requestedFilter) ? requestedFilter : '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(filter === 'active' ? ['mods.active=1'] : filter === 'archived' ? ['mods.active=0']
|
...(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'] : []),
|
: 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([
|
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
|
(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}
|
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_compatible_categories')).all(),
|
||||||
(await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(),
|
(await database.prepare('SELECT * FROM weapon_mod_quality_values')).all(),
|
||||||
(await database.prepare('SELECT * FROM weapon_mod_secondary_options')).all(),
|
(await database.prepare('SELECT * FROM weapon_mod_secondary_options')).all(),
|
||||||
@@ -130,7 +139,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
qualities,
|
qualities,
|
||||||
weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })),
|
weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })),
|
||||||
summary: { ...summary, total: Number(summary?.total ?? 0) + currentPendingMods.length, pending: currentPendingMods.length },
|
summary: { ...summary, total: Number(summary?.total ?? 0) + currentPendingMods.length, pending: currentPendingMods.length },
|
||||||
filter, pagination, search
|
filter, pagination, search, sort
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.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 SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||||||
]}/>
|
]}/>
|
||||||
|
|
||||||
{#if editing}
|
{#snippet weaponModEditor()}
|
||||||
<div class="dashboard-editor weapon-mod-editor">
|
<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>
|
<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">
|
<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>
|
<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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if editing && !editing.id}
|
||||||
|
{@render weaponModEditor()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table weapon-mod-dashboard-table">
|
<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}
|
{#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}
|
{:else}<div class="empty-dashboard"><strong>NO WEAPON MODS REGISTERED</strong><span>Add the first slot-compatible Weapon Mod definition.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
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 { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
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 filter = ['active', 'standard', 'exotic'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const requestedWeaponId = url.searchParams.get('weapon')?.trim() ?? '';
|
const requestedWeaponId = url.searchParams.get('weapon')?.trim() ?? '';
|
||||||
const { search, pattern } = getRegistrySearch(url);
|
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 = [
|
const conditions = [
|
||||||
...(requestedWeaponId ? ['id=?'] : filter === 'active' ? ['active=1'] : filter === 'standard' ? ['is_exotic=0'] : filter === 'exotic' ? ['is_exotic=1'] : []),
|
...(requestedWeaponId ? ['id=?'] : filter === 'active' ? ['active=1'] : filter === 'standard' ? ['is_exotic=0'] : filter === 'exotic' ? ['is_exotic=1'] : []),
|
||||||
...(!requestedWeaponId && search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
...(!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 filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
||||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
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([
|
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_tier_ranges ORDER BY tier')).all(),
|
||||||
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).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(),
|
(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),
|
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
|
||||||
augmentEffects: [],
|
augmentEffects: [],
|
||||||
exoticTalent: null
|
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 {
|
return {
|
||||||
weapons: [...pendingWeapons, ...weapons.map((weapon) => {
|
weapons: [...pendingWeapons, ...weapons.map((weapon) => {
|
||||||
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
|
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
|
||||||
@@ -145,6 +175,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
pagination,
|
pagination,
|
||||||
filter,
|
filter,
|
||||||
search,
|
search,
|
||||||
|
sort,
|
||||||
requestedWeaponId
|
requestedWeaponId
|
||||||
,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name }))
|
,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name }))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.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 KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
import type { GameIconName } from '$lib/game-icons';
|
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>
|
<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'}]}/>
|
<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="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>
|
<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">
|
<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>
|
<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>
|
</PendingForm>
|
||||||
</div>
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if editing && !editing.id}
|
||||||
|
{@render weaponEditor()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||||
<div class="dashboard-table weapon-dashboard-table">
|
<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}
|
{#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}
|
{: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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user