Files
shd.build/src/routes/admin/gear/+page.svelte
T

141 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
let { data, form } = $props();
type Editor = { id: string; name: string; slot: string; tier: string; 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 slotIcon = (slot: string): GameIconName => slot.toLocaleLowerCase().replaceAll(' ', '_') as GameIconName;
const slotOptions = slots.map((slot) => ({ value: slot, label: slot, icon: slotIcon(slot), iconOnly: true }));
const tierOptions = [0, 1, 2].map((tier) => ({ value: String(tier), label: `T${tier}` }));
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: '0', 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 augmentEditing = $state<any | null>(null);
let handledResultToken = $state('');
const compatibleBrands = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)).filter(Boolean);
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = stat(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', ');
const edit = (item: Editor) => { editing = { ...item, tier: String(item.tier), 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: [] }; };
const baseTargets = (slot: string) => ['Gloves', 'Knee Pads', 'Body Armor'].includes(slot) ? 'BASE HEALTH + ARMOR' : 'BASE HEALTH';
const fixedPercentage = (slot: string, augmentSlot: number) => ['Backpack', 'Body Armor'].includes(slot)
? [0, 0, 40, 70, 100][augmentSlot]
: [0, 0, 50, 100][augmentSlot];
const editAugments = (profile: any) => {
augmentEditing = {
...profile,
augments: Array.from({ length: profile.requiredSlots }, (_, index) => {
const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1);
return augment
? {
effectType: augment.effect_type, statId: augment.stat_attribute_id ?? '', value: augment.value,
costs: Object.fromEntries([0,1,2].map((tier) => [tier, augment.costs.find((cost: any) => Number(cost.gear_tier) === tier)?.unlock_cost ?? '']))
}
: {
effectType: index === 0 ? 'Custom' : 'Base Percentage', statId: '',
value: index === 0 ? '' : fixedPercentage(profile.slot, index + 1), costs: { 0: '', 1: '', 2: '' }
};
})
};
editing = null;
};
$effect(() => {
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
augmentEditing = null;
}
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 shared tactical augments, slot progression, survivability, brands, and talents.</p></div><button class="primary" onclick={() => { editing = blank(); augmentEditing = null; }}>ADD GEAR <span>+</span></button></div>
<details class="gear-augment-registry">
<summary class="selection-head"><div><span>SHARED TACTICAL AUGMENT PROFILES</span><small>ONE PROFILE PER GEAR TYPE · REUSED BY EVERY MATCHING ITEM</small></div><div><b>{data.augmentProfiles.filter((profile) => profile.augments.length === profile.requiredSlots).length} / 6 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
<div class="gear-augment-profile-grid">
{#each data.augmentProfiles as profile}
<button type="button" class:configured={profile.augments.length === profile.requiredSlots} onclick={() => editAugments(profile)}>
<header><div><strong>{profile.slot}</strong><small>{profile.requiredSlots} AUGMENT SLOTS</small></div><span>{profile.augments.length === profile.requiredSlots ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each Array(profile.requiredSlots) as _, index}{@const augment = profile.augments.find((item) => Number(item.augment_slot) === index + 1)}<span><small>0{index + 1}</small><b>{index === 0 ? (augment ? `${stat(augment.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(augment.value, augment.stat_attribute_id)}` : 'CUSTOM NOT SET') : `+${fixedPercentage(profile.slot,index + 1)}% ${baseTargets(profile.slot)}`}</b><em>{augment ? [0,1,2].map((tier) => `T${tier} ${augment.costs.find((cost: any) => Number(cost.gear_tier) === tier)?.unlock_cost ?? '—'}`).join(' · ') : 'TIER COSTS NOT SET'}</em></span>{/each}</div>
</button>
{/each}
</div>
</details>
{#if augmentEditing}
<div class="dashboard-editor gear-augment-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED TACTICAL AUGMENTS</p><h3>{augmentEditing.slot.toUpperCase()} PROFILE</h3></div><button aria-label="Close tactical augment editor" onclick={() => augmentEditing = null}>×</button></div>
<PendingForm action="?/saveAugmentProfile">
<input type="hidden" name="slot" value={augmentEditing.slot}/>
<input type="hidden" name="name" value={`${augmentEditing.slot} tactical augments`}/>
<p class="console-note">Augment 01 grants a custom attribute. Later slots increase {baseTargets(augmentEditing.slot).toLocaleLowerCase()} on the equipped item itself, before those item values enter agent totals.</p>
<div class="gear-augment-slots">
{#each augmentEditing.augments as augment: any, index}
<section>
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}</span></header>
{#if index === 0}
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName="statId1" mode="single" compact subjectLabel="AUGMENT 1 ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
<label class="field">VALUE<input name="value1" type="number" step="any" bind:value={augment.value} onfocus={(event) => event.currentTarget.select()} required/></label>
{:else}
<div class="gear-fixed-augment"><span>{baseTargets(augmentEditing.slot)}</span><b>+{augment.value}%</b><small>APPLIES TO THIS ITEM'S CONFIGURED BASE VALUE</small></div>
{/if}
<fieldset class="gear-tier-costs"><legend>UNLOCK COST BY ITEM TIER</legend><div>{#each [0,1,2] as tier}<label class="field">TIER {tier}<input name={`unlockCost${index + 1}Tier${tier}`} type="number" min="0" step="1" bind:value={augment.costs[tier]} onfocus={(event) => event.currentTarget.select()} required/></label>{/each}</div></fieldset>
</section>
{/each}
</div>
{#if form?.augmentProfileError}<p class="error">{form.augmentProfileError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => augmentEditing = null}>CANCEL</button><button class="primary">SAVE SHARED PROFILE →</button></div>
</PendingForm>
</div>
{/if}
<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}
<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>
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} showIndicator={false} required/>
</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} onfocus={(event) => event.currentTarget.select()} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} onfocus={(event) => event.currentTarget.select()} 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} onfocus={(event) => event.currentTarget.select()} 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} onfocus={(event) => event.currentTarget.select()} 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>
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
{#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>
<SearchableAssignment items={gearTalents(editing.slot)} fieldName="talentIds" subjectLabel={`${editing.slot.toUpperCase()} TALENTS`} searchPlaceholder={`Search ${editing.slot.toLocaleLowerCase()} talents…`} emptySelectionLabel="NO TALENTS ASSIGNED" bind:selected={editing.talentIds}/>
{#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}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</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 class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}</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><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><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>