63 lines
7.2 KiB
Svelte
63 lines
7.2 KiB
Svelte
<script lang="ts">
|
||
import PendingForm from '$lib/components/PendingForm.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'];
|
||
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
|
||
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] });
|
||
let editing = $state<Editor | null>(null);
|
||
let handledResultToken = $state('');
|
||
const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', ');
|
||
const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; };
|
||
const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot);
|
||
const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; };
|
||
$effect(() => {
|
||
if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||
handledResultToken = form.resultToken;
|
||
editing = form.gearMode === 'add' ? blank() : null;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<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>
|
||
{#if editing}
|
||
<div class="dashboard-editor">
|
||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||
<PendingForm action="?/save">
|
||
<input type="hidden" name="id" value={editing.id} />
|
||
<div class="gear-identity">
|
||
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||
<label class="field">SLOT<select name="slot" value={editing.slot} onchange={(event) => changeSlot(event.currentTarget.value)}>{#each slots as slot}<option>{slot}</option>{/each}</select></label>
|
||
<label class="field">TIER<select name="tier" bind:value={editing.tier}><option value={0}>0</option><option value={1}>1</option><option value={2}>2</option></select></label>
|
||
<label class="field">LEVEL<input name="level" type="number" min="1" max="50" step="1" bind:value={editing.level} required /></label>
|
||
</div>
|
||
<div class="gear-ranges">
|
||
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
|
||
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
|
||
</div>
|
||
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
|
||
<div class="selection-grid">
|
||
{#each data.brands as brand}<label><input type="checkbox" name="brandIds" value={brand.id} bind:group={editing.brandIds} /><span>{brand.name}</span><small>{editing.brandIds.includes(brand.id) ? 'LINKED' : 'AVAILABLE'}</small></label>{/each}
|
||
</div>
|
||
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
|
||
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
|
||
<div class="selection-grid talent-selection">
|
||
{#each gearTalents(editing.slot) as talent}<label><input type="checkbox" name="talentIds" value={talent.id} bind:group={editing.talentIds} /><span>{talent.name}</span><small>{editing.talentIds.includes(talent.id) ? 'EQUIPPED' : 'AVAILABLE'}</small></label>{/each}
|
||
</div>
|
||
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
|
||
{/if}
|
||
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
|
||
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
|
||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
|
||
</PendingForm>
|
||
</div>
|
||
{/if}
|
||
<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}
|
||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span>{item.slot}</span><span>T{item.tier} · LVL {item.level}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><span title={brandNames(item.brandIds)}>{item.brandIds.length} COMPATIBLE</span><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
|
||
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
||
</div>
|