Add brand set and gear administration

This commit is contained in:
JB
2026-07-24 22:52:49 -04:00
parent b430a291fa
commit e605e4c0ee
10 changed files with 334 additions and 9 deletions
+57
View File
@@ -0,0 +1,57 @@
import { requireAdmin } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
const thresholds = [2, 3, 4] as const;
export const load: PageServerLoad = async () => {
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(),
(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()
]);
return {
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
stats
};
};
export const actions: Actions = {
save: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
const effects = thresholds.map((pieces) => ({ pieces, statId: String(form.get(`stat${pieces}`) ?? ''), value: Number(form.get(`value${pieces}`)) }));
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) {
return fail(400, { brandError: 'Every threshold needs an attribute and a non-negative value.' });
}
const database = await getDatabase();
if (await (await database.prepare('SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
return fail(409, { brandError: 'That brand set already exists.' });
}
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
const brandId = id || randomUUID(), now = new Date().toISOString();
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
for (const effect of effects) {
await (await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
}
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
requireAdmin(await getSessionUser(cookies));
const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
return { brandArchived: true };
}
};
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props();
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
type Editor = { id: string; name: string; effects: Effect[] };
const blank = (): Editor => ({ id: '', name: '', effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
let editing = $state<Editor | null>(null);
let handledResultToken = $state('');
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
$effect(() => {
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.brandMode === 'add' ? blank() : null;
}
});
</script>
<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>
{#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>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id} />
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<div class="effect-editor">
{#each [2, 3, 4] as pieces}
{@const effect = effectFor(editing, pieces)}
<div class="effect-band"><strong>{pieces}<small>{pieces === 4 ? '+ PIECES' : ' PIECES'}</small></strong><label class="field">ATTRIBUTE<select name={`stat${pieces}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit})</option>{/each}</select></label><label class="field">VALUE<input name={`value${pieces}`} type="number" step="any" min="0" bind:value={effect.value} required /></label></div>
{/each}
</div>
{#if form?.brandError}<p class="error">{form.brandError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE SET</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE BRAND SET →</button></div></div>
</PendingForm>
</div>
{/if}
<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}
<div class="dashboard-row brand-set-row" class:archived={Number(brand.active) !== 1}><div><strong>{brand.name}</strong><small>{brand.effects.length}/3 EFFECTS CONFIGURED</small></div>{#each [2, 3, 4] as pieces}{@const effect = effectFor(brand, pieces)}<span class:configured={effect.stat_attribute_id}>{effect.stat_attribute_id ? formatEffect(effect) : 'NOT CONFIGURED'}</span>{/each}<b class="state-dot">{Number(brand.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(brand)}>EDIT</button></div>
{:else}
<div class="empty-dashboard"><strong>NO BRAND SETS REGISTERED</strong><span>Add the first set to begin mapping equipment synergy bonuses.</span></div>
{/each}
</div>