From ddec42afd3c28daa05189d5abe6ba881d96b5cff Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 03:30:59 -0400 Subject: [PATCH] Add interactive ISAC registry filters --- docs/DESIGN_SYSTEM.md | 16 +++++++++++ src/app.css | 1 + src/lib/components/KpiFilters.svelte | 23 ++++++++++++++++ src/routes/admin/+layout.svelte | 2 +- src/routes/admin/brands/+page.server.ts | 15 +++++++--- src/routes/admin/brands/+page.svelte | 3 +- src/routes/admin/gear/+page.server.ts | 14 +++++++--- src/routes/admin/gear/+page.svelte | 3 +- src/routes/admin/stats/+page.server.ts | 9 ++++-- src/routes/admin/stats/+page.svelte | 3 +- src/routes/admin/talents/+page.server.ts | 14 ++++++++-- src/routes/admin/talents/+page.svelte | 3 +- src/routes/admin/users/+page.server.ts | 35 +++++++++++++----------- src/routes/admin/users/+page.svelte | 3 +- src/routes/admin/weapons/+page.server.ts | 13 ++++++--- src/routes/admin/weapons/+page.svelte | 3 +- 16 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 src/lib/components/KpiFilters.svelte diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 2ac1ab8..d705824 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -356,3 +356,19 @@ returns to page one. Summary metrics remain global or filter-global and must never silently describe only the visible page. On narrow screens, retain previous, next, current page, and page-size controls before optional page numbers. + +### Registry metric filters + +The KPI row above an ISAC registry is an operational filter surface, not passive +decoration. Use `KpiFilters.svelte` when each metric describes a meaningful +record subset. The first card returns to the complete current registry or search; +the remaining cards filter by useful classifications such as active state, +category, equipment behavior, or restriction state. + +Filtering is server-backed and occurs before pagination. Filter links preserve +compatible search and page-size parameters, reset the page number, remain +bookmarkable, and expose the selected state with both orange emphasis and +`aria-current`. Cards require pointer, hover, focus-visible, pressed, and +textually identifiable active states. Counts describe the full search scope, +not merely the visible page. Do not use aggregate facts that cannot filter the +affected registry—replace them with actionable subsets. diff --git a/src/app.css b/src/app.css index a1c1dff..30765d4 100644 --- a/src/app.css +++ b/src/app.css @@ -103,4 +103,5 @@ @media(max-width:900px){.registry-pagination{grid-template-columns:1fr}.pagination-range,.pagination-pages,.pagination-sizes{justify-content:center}}@media(max-width:560px){.pagination-pages a:not(:first-child):not(:last-child):not(.active),.pagination-gap{display:none}.pagination-sizes>span{display:none}} @media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}} .legal-table-wrap{overflow-x:auto;margin:18px 0}.legal-table{width:100%;min-width:720px;border-collapse:collapse;border:1px solid var(--shd-border);background:#0b0e0f}.legal-table th,.legal-table td{padding:12px 14px;border:1px solid var(--shd-border);text-align:left;vertical-align:top}.legal-table th{color:var(--shd-orange);background:#111516;font-size:7px;letter-spacing:1px}.legal-table td{color:#a1a7a8;font:12px/1.55 Georgia,serif}.legal-table code,.legal-document code{color:#d9dedf;font:11px Consolas,monospace}.cookie-notice{position:fixed;z-index:1000;right:24px;bottom:24px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:22px;width:min(620px,calc(100vw - 48px));padding:18px 20px;border:1px solid var(--shd-orange);background:rgba(10,13,14,.98);box-shadow:0 14px 48px #000c,4px 0 0 var(--shd-orange)}.cookie-notice .eyebrow{margin:0 0 8px}.cookie-notice div>p:not(.eyebrow){margin:0;color:var(--shd-text-muted);font:12px/1.5 Georgia,serif}.cookie-notice a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.cookie-notice button{min-width:84px}@media(max-width:600px){.cookie-notice{right:12px;bottom:12px;grid-template-columns:1fr;width:calc(100vw - 24px)}.cookie-notice button{width:100%}} +.kpi-filters{height:auto}.kpi-filters>a{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid;color:inherit;cursor:pointer}.kpi-filters>a:hover,.kpi-filters>a:focus-visible{border-color:#8a672b;border-top-color:var(--shd-orange);background:#171a1b}.kpi-filters>a.active{border-color:#8a672b;border-top-color:var(--shd-orange);background:#211a0e;box-shadow:inset 0 -2px #f6a821}.kpi-filters>a:active{transform:translateY(1px);filter:brightness(.88)}.kpi-filters>a span{font-size:7px;letter-spacing:1.3px;color:#737a7c}.kpi-filters>a strong{font-size:23px;line-height:1.1;margin:3px 0}.kpi-filters>a small{font-size:7px;color:#555d5f}.kpi-filters>a.active span,.kpi-filters>a.active strong{color:var(--shd-orange)} diff --git a/src/lib/components/KpiFilters.svelte b/src/lib/components/KpiFilters.svelte new file mode 100644 index 0000000..f54917c --- /dev/null +++ b/src/lib/components/KpiFilters.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 399be38..4fd03c1 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -24,7 +24,7 @@ {/if} {#if can('data.manage')} - + diff --git a/src/routes/admin/brands/+page.server.ts b/src/routes/admin/brands/+page.server.ts index 1af5214..886fa45 100644 --- a/src/routes/admin/brands/+page.server.ts +++ b/src/routes/admin/brands/+page.server.ts @@ -12,10 +12,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => { await requirePermission(await getSessionUser(cookies), 'data.manage'); const database = await getDatabase(); const summary = await (await database.prepare(`SELECT COUNT(*) AS total, - SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active FROM brand_sets`)).get(); - const pagination = getPagination(url, Number(summary?.total ?? 0)); + SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, + SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, + 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 where = filter === 'active' ? 'WHERE active=1' : filter === 'archived' ? 'WHERE active=0' + : filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : ''; + const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(); + const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [brands, effects, stats, effectSummary] = await Promise.all([ - (await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset), + (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), (await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(), (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get() @@ -24,7 +30,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })), stats, summary: { ...summary, effects: Number(effectSummary?.total ?? 0) }, - pagination + pagination, + filter }; }; diff --git a/src/routes/admin/brands/+page.svelte b/src/routes/admin/brands/+page.svelte index 35ffb6a..f3ece1e 100644 --- a/src/routes/admin/brands/+page.svelte +++ b/src/routes/admin/brands/+page.svelte @@ -1,6 +1,7 @@ Stats & attributes — SHD.build admin

ATTRIBUTE REGISTRY

STATS & ATTRIBUTES

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

-
TOTAL RECORDS{data.summary.total}All definitions
ACTIVE{data.summary.active}Available to systems
OFFENSIVE{data.summary.offensive}Damage-oriented
DEFENSIVE{data.summary.defensive}Survival-oriented
+ {#if editing}

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

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

UNIT
{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if}
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each data.stats as stat}
{stat.name}{stat.unit || 'NO UNIT'}
{stat.type}{stat.category}{stat.calculation_type}{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}
{/each}
diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index d20c556..a1bde54 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -12,9 +12,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => { await requirePermission(await getSessionUser(cookies), 'data.manage'); const db = await getDatabase(); const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get(); - const pagination = getPagination(url, Number(totalRow?.total ?? 0)); + const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; + const where = filter === 'slot1' ? 'WHERE EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)' + : filter === 'slot2' ? 'WHERE EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=2)' + : filter === 'exotic' ? "WHERE EXISTS (SELECT 1 FROM talent_weapon_scopes WHERE talent_id=talents.id AND scope='Exotic')" + : filter === 'backpack' ? "WHERE category='Backpack'" + : filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : ''; + const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(); + const pagination = getPagination(url, Number(filteredRow?.total ?? 0)); const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots] = await Promise.all([ - (await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset), + (await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), (await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(), (await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(), (await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), @@ -67,7 +74,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { backpack: Number(gearSummary?.backpack ?? 0), bodyArmor: Number(gearSummary?.body_armor ?? 0) }, - pagination + pagination, + filter }; }; diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index 0718cf3..d50d500 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -1,6 +1,7 @@ User operations — ISAC

USER OPERATIONS

AGENT REGISTRY

Identity, authentication methods, access state, recent activity, and community contribution overview.

-
RESULTS{data.summary.results}Current query
ACTIVE{data.summary.active}Active within 30 days
INACTIVE{data.summary.inactive}No activity for 30 days
RESTRICTED{data.summary.restricted}Blocked or banned
+
USERACCESSROLESLAST LOGINLAST ACTIVECONTENT
diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts index f8e517d..96b2480 100644 --- a/src/routes/admin/weapons/+page.server.ts +++ b/src/routes/admin/weapons/+page.server.ts @@ -15,10 +15,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const database = await getDatabase(); const summary = await (await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, - COUNT(DISTINCT category) AS categories FROM weapons`)).get(); - const pagination = getPagination(url, Number(summary?.total ?? 0)); + SUM(CASE WHEN is_exotic=0 THEN 1 ELSE 0 END) AS standard, + SUM(CASE WHEN is_exotic=1 THEN 1 ELSE 0 END) AS exotic FROM weapons`)).get(); + const filter = ['active', 'standard', 'exotic'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : ''; + const where = filter === 'active' ? 'WHERE active=1' : filter === 'standard' ? 'WHERE is_exotic=0' : filter === 'exotic' ? 'WHERE is_exotic=1' : ''; + const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(); + const pagination = getPagination(url, Number(filtered?.total ?? 0)); const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, exoticTalents, stats] = await Promise.all([ - (await database.prepare('SELECT * FROM weapons ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset), + (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(), @@ -53,7 +57,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { exoticTalents, stats, summary, - pagination + pagination, + filter }; }; diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte index 44a27fb..1158bc9 100644 --- a/src/routes/admin/weapons/+page.svelte +++ b/src/routes/admin/weapons/+page.svelte @@ -2,6 +2,7 @@ import PendingForm from '$lib/components/PendingForm.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import RegistryPagination from '$lib/components/RegistryPagination.svelte'; + import KpiFilters from '$lib/components/KpiFilters.svelte'; let { data, form } = $props(); const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const damageTypes = ['Blast', 'Pierce', 'Shredding']; @@ -48,7 +49,7 @@ Weapons — SHD.build admin

ARMAMENT DEFINITION REGISTRY

WEAPONS

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

-
TOTAL WEAPONS{data.summary.total}All definitions
ACTIVE{data.summary.active}Available to builder
CATEGORIES{data.summary.categories}/6Types represented
WEAPON TALENTS{data.talent1Options.length + data.talent2Options.length}Slotted options
+ {#if editing}