Add pagination to ISAC registries
This commit is contained in:
@@ -4,20 +4,27 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
const thresholds = [2, 3, 4] as const;
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const database = await getDatabase();
|
||||
const [brands, effects, stats] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE')).all(),
|
||||
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));
|
||||
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 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()
|
||||
]);
|
||||
return {
|
||||
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
|
||||
stats
|
||||
stats,
|
||||
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
|
||||
pagination
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data, form } = $props();
|
||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||
type Editor = { id: string; name: string; effects: Effect[] };
|
||||
@@ -20,7 +21,7 @@
|
||||
|
||||
<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-kpis"><div><span>TOTAL SETS</span><strong>{data.brands.length}</strong><small>All brand definitions</small></div><div><span>ACTIVE</span><strong>{data.brands.filter((brand) => Number(brand.active) === 1).length}</strong><small>Available to builder</small></div><div><span>SET EFFECTS</span><strong>{data.brands.reduce((total, brand) => total + brand.effects.length, 0)}</strong><small>Mapped thresholds</small></div><div><span>ATTRIBUTES</span><strong>{data.stats.length}</strong><small>Available effect targets</small></div></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>
|
||||
{#if editing}
|
||||
<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>
|
||||
@@ -38,6 +39,7 @@
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<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>
|
||||
{#each data.brands as brand}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
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,
|
||||
COUNT(DISTINCT slot) AS slots FROM gear`)).get();
|
||||
const pagination = getPagination(url, Number(summary?.total ?? 0));
|
||||
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE')).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 gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
@@ -25,7 +30,9 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||
})),
|
||||
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,
|
||||
pagination
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
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[] };
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
@@ -21,7 +22,7 @@
|
||||
|
||||
<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-kpis"><div><span>TOTAL GEAR</span><strong>{data.gear.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.gear.filter((item) => Number(item.active) === 1).length}</strong><small>Available to builder</small></div><div><span>SLOTS</span><strong>{new Set(data.gear.map((item) => item.slot)).size}/6</strong><small>Slots represented</small></div><div><span>BRAND SETS</span><strong>{data.brands.length}</strong><small>Compatibility options</small></div></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>
|
||||
{#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>
|
||||
@@ -54,6 +55,7 @@
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>PROGRESSION</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
||||
{#each data.gear as item}
|
||||
|
||||
@@ -4,12 +4,18 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const database = await getDatabase();
|
||||
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all();
|
||||
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })) };
|
||||
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 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();
|
||||
const pagination = getPagination(url, Number(summary?.total ?? 0));
|
||||
const stats = await (await database.prepare(`SELECT * FROM stat_attributes 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 };
|
||||
};
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data, form } = $props();
|
||||
let editing = $state<any>(null);
|
||||
let handledResultToken = $state('');
|
||||
@@ -14,6 +15,7 @@
|
||||
</script>
|
||||
<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-kpis"><div><span>TOTAL RECORDS</span><strong>{data.stats.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.stats.filter((item)=>Number(item.active)===1).length}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Offensive').length}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Defensive').length}</strong><small>Survival-oriented</small></div></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>
|
||||
{#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}/>
|
||||
<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>
|
||||
|
||||
@@ -4,14 +4,17 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
type SharedEffect = { statId: string; valueI: number; valueII: number };
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
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 [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')).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 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(),
|
||||
@@ -21,6 +24,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
|
||||
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all()
|
||||
]);
|
||||
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
|
||||
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
|
||||
(await db.prepare(`SELECT
|
||||
SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1,
|
||||
SUM(CASE WHEN slot=2 THEN 1 ELSE 0 END) AS slot2 FROM weapon_talent_slots`)).get(),
|
||||
(await db.prepare("SELECT COUNT(*) AS total FROM talent_weapon_scopes WHERE scope='Exotic'")).get(),
|
||||
(await db.prepare(`SELECT
|
||||
SUM(CASE WHEN category='Backpack' THEN 1 ELSE 0 END) AS backpack,
|
||||
SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor
|
||||
FROM talents`)).get()
|
||||
]);
|
||||
return {
|
||||
talents: talents.map((talent) => ({
|
||||
...talent,
|
||||
@@ -43,7 +57,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
}))
|
||||
})),
|
||||
triggers,
|
||||
stats
|
||||
stats,
|
||||
summary: {
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
triggers: Number(triggerSummary?.total ?? 0),
|
||||
slot1: Number(slotSummary?.slot1 ?? 0),
|
||||
slot2: Number(slotSummary?.slot2 ?? 0),
|
||||
exotic: Number(exoticSummary?.total ?? 0),
|
||||
backpack: Number(gearSummary?.backpack ?? 0),
|
||||
bodyArmor: Number(gearSummary?.body_armor ?? 0)
|
||||
},
|
||||
pagination
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data, form } = $props();
|
||||
type Effect = { statId: string; valueI: string; valueII: string };
|
||||
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
||||
@@ -28,6 +29,10 @@
|
||||
const addEffect = () => talent.effects = [...talent.effects, { statId: '', valueI: '', valueII: '' }];
|
||||
const removeEffect = (index: number) => talent.effects = talent.effects.filter((_: Effect, row: number) => row !== index);
|
||||
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
|
||||
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
|
||||
const description = tiers.find((item) => item.tier === name)?.description;
|
||||
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
|
||||
};
|
||||
const displayValue = (effect: Effect, tier: 'I' | 'II') => {
|
||||
const stat = data.stats.find((item) => item.id === effect.statId);
|
||||
const value = tier === 'I' ? effect.valueI : effect.valueII;
|
||||
@@ -51,7 +56,7 @@
|
||||
|
||||
<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-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TOP / TALENT 1</span><strong>{data.talents.filter((item) => item.category === 'Weapon Talent 1').length}</strong><small>First-slot weapon talents</small></div><div><span>BOTTOM / TALENT 2</span><strong>{data.talents.filter((item) => item.category === 'Weapon Talent 2').length}</strong><small>Second-slot weapon talents</small></div><div><span>EXOTIC</span><strong>{data.talents.filter((item) => item.category === 'Exotic Weapon').length}</strong><small>Exotic-only talents</small></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>
|
||||
|
||||
{#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>
|
||||
@@ -75,4 +80,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers, 'I')}>{configured(item.tiers, 'I') ? 'CONFIGURED' : 'MISSING'}</span><span class:configured={configured(item.tiers, 'II')}>{configured(item.tiers, 'II') ? 'CONFIGURED' : 'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={() => editTalent(item)}>EDIT</button></div>{/each}</div>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<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"><div><strong>{item.name}</strong><small>Tiered talent</small></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="state-dot">ACTIVE</b><button onclick={() => editTalent(item)}>EDIT</button></div>{/each}</div>
|
||||
|
||||
@@ -2,12 +2,24 @@ import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const database = await getDatabase();
|
||||
const summaryRows = await (await database.prepare(`
|
||||
SELECT user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at,
|
||||
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
`)).all(pattern, pattern);
|
||||
const pagination = getPagination(url, summaryRows.length);
|
||||
const users = await (await database.prepare(`
|
||||
SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
@@ -22,16 +34,28 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
|
||||
LIMIT 200
|
||||
`)).all(pattern, pattern);
|
||||
LIMIT ? OFFSET ?
|
||||
`)).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 {
|
||||
users: users.map((user) => {
|
||||
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();
|
||||
return { ...user, inactive, accessState: user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
|
||||
return { ...user, ...state(user) };
|
||||
}),
|
||||
query
|
||||
query,
|
||||
summary: {
|
||||
results: summaryRows.length,
|
||||
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
|
||||
inactive: summaryStates.filter((user) => user.inactive).length,
|
||||
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE').length
|
||||
},
|
||||
pagination
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data } = $props();
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
</script>
|
||||
<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, 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.users.length}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.users.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.users.filter((user) => user.inactive).length}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.users.filter((user) => user.accessState !== 'ACTIVE').length}</strong><small>Blocked or banned</small></div></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>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
|
||||
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username} · {user.discord_id ? 'DISCORD LINKED' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{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={`/admin/users/${user.id}`}>VIEW</a></div>
|
||||
|
||||
@@ -4,16 +4,21 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
|
||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||
const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const;
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
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,
|
||||
COUNT(DISTINCT category) AS categories FROM weapons`)).get();
|
||||
const pagination = getPagination(url, Number(summary?.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')).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 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(),
|
||||
@@ -46,7 +51,9 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
talent1Options,
|
||||
talent2Options,
|
||||
exoticTalents,
|
||||
stats
|
||||
stats,
|
||||
summary,
|
||||
pagination
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
let { data, form } = $props();
|
||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||
@@ -47,7 +48,7 @@
|
||||
|
||||
<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-kpis"><div><span>TOTAL WEAPONS</span><strong>{data.weapons.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.weapons.filter((weapon) => Number(weapon.active) === 1).length}</strong><small>Available to builder</small></div><div><span>CATEGORIES</span><strong>{new Set(data.weapons.map((weapon) => weapon.category)).size}/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>
|
||||
<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>
|
||||
|
||||
{#if editing}
|
||||
<div class="dashboard-editor weapon-editor">
|
||||
@@ -107,6 +108,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<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>
|
||||
{#each data.weapons as weapon}
|
||||
|
||||
Reference in New Issue
Block a user