Add interactive ISAC registry filters
This commit is contained in:
@@ -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
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -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: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}}
|
@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%}}
|
.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)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
type Item = { value: string; label: string; count: string | number; description: string };
|
||||||
|
let { items, active = '', className = '' }: { items: Item[]; active?: string; className?: string } = $props();
|
||||||
|
|
||||||
|
function href(value: string) {
|
||||||
|
const params = new URLSearchParams(page.url.searchParams);
|
||||||
|
if (value) params.set('filter', value);
|
||||||
|
else params.delete('filter');
|
||||||
|
params.delete('page');
|
||||||
|
const query = params.toString();
|
||||||
|
return `${page.url.pathname}${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class={`module-kpis kpi-filters ${className}`} aria-label="Registry filters">
|
||||||
|
{#each items as item}
|
||||||
|
<a href={href(item.value)} class:active={active === item.value} aria-current={active === item.value ? 'true' : undefined}>
|
||||||
|
<span>{item.label}</span><strong>{item.count}</strong><small>{item.description}</small>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<nav aria-label="User operations">{#each userSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
|
<nav aria-label="User operations">{#each userSections as section}<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}</nav>
|
||||||
{/if}
|
{/if}
|
||||||
{#if can('data.manage')}
|
{#if can('data.manage')}
|
||||||
<div class="sidebar-label section-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
|
<div class="sidebar-label" class:section-label={can('users.view')}><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
|
||||||
<nav aria-label="Data management">
|
<nav aria-label="Data management">
|
||||||
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}
|
{#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -12,10 +12,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
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();
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
const pagination = getPagination(url, Number(summary?.total ?? 0));
|
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([
|
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 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()
|
||||||
@@ -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) })),
|
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
|
||||||
stats,
|
stats,
|
||||||
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
|
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
|
||||||
pagination
|
pagination,
|
||||||
|
filter
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||||
type Editor = { id: string; name: string; effects: Effect[] };
|
type Editor = { id: string; name: string; effects: Effect[] };
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
|
|
||||||
<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>
|
||||||
<div class="module-kpis"><div><span>TOTAL SETS</span><strong>{data.summary.total}</strong><small>All brand definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to builder</small></div><div><span>SET EFFECTS</span><strong>{data.summary.effects}</strong><small>Mapped thresholds</small></div><div><span>ATTRIBUTES</span><strong>{data.stats.length}</strong><small>Available effect targets</small></div></div>
|
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Unavailable definitions'},{value:'complete',label:'COMPLETE',count:data.summary.complete,description:'All three bonuses mapped'}]}/>
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor">
|
<div class="dashboard-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>
|
<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>
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
COUNT(DISTINCT slot) AS slots FROM gear`)).get();
|
SUM(CASE WHEN slot IN ('Gloves','Knee Pads','Body Armor') THEN 1 ELSE 0 END) AS armored,
|
||||||
const pagination = getPagination(url, Number(summary?.total ?? 0));
|
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 where = filter === 'active' ? 'WHERE active=1' : filter === 'armored' ? "WHERE slot IN ('Gloves','Knee Pads','Body Armor')"
|
||||||
|
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
|
||||||
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
|
||||||
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
|
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
|
||||||
(await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset),
|
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||||
(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(),
|
||||||
@@ -32,7 +37,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
brands,
|
brands,
|
||||||
talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category })),
|
talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category })),
|
||||||
summary,
|
summary,
|
||||||
pagination
|
pagination,
|
||||||
|
filter
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Editor = { id: string; name: string; slot: string; tier: number; level: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
|
type Editor = { id: string; name: string; slot: string; tier: number; level: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
|
||||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
|
|
||||||
<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 slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div>
|
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div>
|
||||||
<div class="module-kpis"><div><span>TOTAL GEAR</span><strong>{data.summary.total}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to builder</small></div><div><span>SLOTS</span><strong>{data.summary.slots}/6</strong><small>Slots represented</small></div><div><span>BRAND SETS</span><strong>{data.brands.length}</strong><small>Compatibility options</small></div></div>
|
<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}
|
||||||
<div class="dashboard-editor">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
|
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
|
||||||
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 pagination = getPagination(url, Number(summary?.total ?? 0));
|
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset);
|
const where = filter === 'active' ? 'WHERE active=1' : filter === 'offensive' ? "WHERE type='Offensive'" : filter === 'defensive' ? "WHERE type='Defensive'" : '';
|
||||||
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })), summary, pagination };
|
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get();
|
||||||
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||||
|
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset);
|
||||||
|
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })), summary, pagination, filter };
|
||||||
};
|
};
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
save: async ({ request, cookies }) => {
|
save: async ({ request, cookies }) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let editing = $state<any>(null);
|
let editing = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
|
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
|
||||||
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & 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>STATS & 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-kpis"><div><span>TOTAL RECORDS</span><strong>{data.summary.total}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.summary.offensive}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.summary.defensive}</strong><small>Survival-oriented</small></div></div>
|
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
|
||||||
{#if editing}<div class="dashboard-editor"><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><label class="field">TYPE<select name="type" bind:value={editing.type}><option>Offensive</option><option>Defensive</option></select></label><label class="field">CATEGORY<select name="category" bind:value={editing.category}><option>Weapon</option><option>Skill</option><option>Character</option></select></label><label class="field">CALCULATION<select name="calculationType" bind:value={editing.calculation_type}><option>Additive</option><option>Multiplicative</option></select></label><fieldset class="unit-field"><legend>UNIT</legend><div class="unit-picker">{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}<label><input type="radio" name="unit" value={option[0]} bind:group={editing.unit}/><span>{option[1]}</span></label>{/each}</div></fieldset>{#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}<div class="dashboard-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><label class="field">TYPE<select name="type" bind:value={editing.type}><option>Offensive</option><option>Defensive</option></select></label><label class="field">CATEGORY<select name="category" bind:value={editing.category}><option>Weapon</option><option>Skill</option><option>Character</option></select></label><label class="field">CALCULATION<select name="calculationType" bind:value={editing.calculation_type}><option>Additive</option><option>Multiplicative</option></select></label><fieldset class="unit-field"><legend>UNIT</legend><div class="unit-picker">{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}<label><input type="radio" name="unit" value={option[0]} bind:group={editing.unit}/><span>{option[1]}</span></label>{/each}</div></fieldset>{#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}
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<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 data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit || 'NO UNIT'}</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="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
|
<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 data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit || 'NO UNIT'}</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="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
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 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([
|
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 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(),
|
||||||
@@ -67,7 +74,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
backpack: Number(gearSummary?.backpack ?? 0),
|
backpack: Number(gearSummary?.backpack ?? 0),
|
||||||
bodyArmor: Number(gearSummary?.body_armor ?? 0)
|
bodyArmor: Number(gearSummary?.body_armor ?? 0)
|
||||||
},
|
},
|
||||||
pagination
|
pagination,
|
||||||
|
filter
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Effect = { statId: string; valueI: string; valueII: string };
|
type Effect = { statId: string; valueI: string; valueII: string };
|
||||||
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
|
|
||||||
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
|
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
|
||||||
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
|
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
|
||||||
<div class="module-kpis talent-kpis"><div><span>TALENTS</span><strong>{data.summary.total}</strong><small>Registered definitions</small></div><div><span>TOP / TALENT 1</span><strong>{data.summary.slot1}</strong><small>First-slot weapon talents</small></div><div><span>BOTTOM / TALENT 2</span><strong>{data.summary.slot2}</strong><small>Second-slot weapon talents</small></div><div><span>EXOTIC</span><strong>{data.summary.exotic}</strong><small>Exotic-only talents</small></div><div><span>BACKPACK</span><strong>{data.summary.backpack}</strong><small>Backpack talents</small></div><div><span>BODY ARMOR</span><strong>{data.summary.bodyArmor}</strong><small>Body armor talents</small></div></div>
|
<KpiFilters className="talent-kpis" active={data.filter} items={[{value:'',label:'TALENTS',count:data.summary.total,description:'Show all definitions'},{value:'slot1',label:'TOP / TALENT 1',count:data.summary.slot1,description:'First-slot weapon talents'},{value:'slot2',label:'BOTTOM / TALENT 2',count:data.summary.slot2,description:'Second-slot weapon talents'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic-only talents'},{value:'backpack',label:'BACKPACK',count:data.summary.backpack,description:'Backpack talents'},{value:'body-armor',label:'BODY ARMOR',count:data.summary.bodyArmor,description:'Body armor talents'}]}/>
|
||||||
|
|
||||||
{#if trigger}
|
{#if trigger}
|
||||||
<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>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const summaryRows = await (await database.prepare(`
|
const summaryRows = await (await database.prepare(`
|
||||||
SELECT user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
SELECT users.id,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
|
||||||
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
|
||||||
@@ -19,8 +19,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
`)).all(pattern, pattern);
|
`)).all(pattern, pattern);
|
||||||
const pagination = getPagination(url, summaryRows.length);
|
const now = Date.now();
|
||||||
const users = await (await database.prepare(`
|
const state = (user: Record<string, unknown>) => {
|
||||||
|
const activity = user.last_active_at ?? user.last_login_at;
|
||||||
|
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||||
|
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
|
||||||
|
return { inactive, accessState };
|
||||||
|
};
|
||||||
|
const summaryStates = summaryRows.map((user) => ({ ...user, ...state(user) }));
|
||||||
|
const filter = ['active', 'inactive', 'restricted'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||||
|
const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive
|
||||||
|
: filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true);
|
||||||
|
const pagination = getPagination(url, filteredStates.length);
|
||||||
|
const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id));
|
||||||
|
const users = pageIds.length ? await (await database.prepare(`
|
||||||
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
|
||||||
@@ -34,25 +47,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
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
|
||||||
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
|
||||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
||||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
||||||
LIMIT ? OFFSET ?
|
`)).all(...pageIds) : [];
|
||||||
`)).all(pattern, pattern, pagination.perPage, pagination.offset);
|
|
||||||
const now = Date.now();
|
|
||||||
const state = (user: Record<string, unknown>) => {
|
|
||||||
const activity = user.last_active_at ?? user.last_login_at;
|
|
||||||
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
|
||||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
|
||||||
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
|
|
||||||
return { inactive, accessState };
|
|
||||||
};
|
|
||||||
const summaryStates = summaryRows.map(state);
|
|
||||||
return {
|
return {
|
||||||
users: users.map((user) => {
|
users: users.map((user) => {
|
||||||
return { ...user, ...state(user) };
|
return { ...user, ...state(user) };
|
||||||
}),
|
}),
|
||||||
query,
|
query,
|
||||||
|
filter,
|
||||||
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,11 +1,12 @@
|
|||||||
<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';
|
||||||
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>
|
||||||
<svelte:head><title>User operations — ISAC</title></svelte:head>
|
<svelte:head><title>User operations — ISAC</title></svelte:head>
|
||||||
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, authentication methods, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
|
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, authentication methods, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
|
||||||
<div class="module-kpis"><div><span>RESULTS</span><strong>{data.summary.results}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.summary.inactive}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.summary.restricted}</strong><small>Blocked or banned</small></div></div>
|
<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}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<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"><span>USER</span><span>ACCESS</span><span>ROLES</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||||
COUNT(DISTINCT category) AS categories FROM weapons`)).get();
|
SUM(CASE WHEN is_exotic=0 THEN 1 ELSE 0 END) AS standard,
|
||||||
const pagination = getPagination(url, Number(summary?.total ?? 0));
|
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([
|
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 * 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,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(),
|
(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,
|
exoticTalents,
|
||||||
stats,
|
stats,
|
||||||
summary,
|
summary,
|
||||||
pagination
|
pagination,
|
||||||
|
filter
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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 KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
|
|
||||||
<svelte:head><title>Weapons — SHD.build admin</title></svelte:head>
|
<svelte:head><title>Weapons — SHD.build admin</title></svelte:head>
|
||||||
<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>
|
||||||
<div class="module-kpis"><div><span>TOTAL WEAPONS</span><strong>{data.summary.total}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to builder</small></div><div><span>CATEGORIES</span><strong>{data.summary.categories}/6</strong><small>Types represented</small></div><div><span>WEAPON TALENTS</span><strong>{data.talent1Options.length + data.talent2Options.length}</strong><small>Slotted options</small></div></div>
|
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL WEAPONS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'standard',label:'STANDARD',count:data.summary.standard,description:'Non-exotic weapons'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic weapons'}]}/>
|
||||||
|
|
||||||
{#if editing}
|
{#if editing}
|
||||||
<div class="dashboard-editor weapon-editor">
|
<div class="dashboard-editor weapon-editor">
|
||||||
|
|||||||
Reference in New Issue
Block a user