feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s

This commit is contained in:
jb
2026-08-03 01:23:20 -04:00
parent d9924a95b9
commit 57068b61cd
90 changed files with 9635 additions and 1270 deletions
@@ -0,0 +1,212 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
type SlotIcon = {
id: string;
name: string;
iconUrl: string;
updatedAt: string;
};
let {
slots,
error = '',
saved = false,
resultToken = ''
}: {
slots: SlotIcon[];
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
let editing = $state<SlotIcon | null>(null);
let handledResultToken = $state('');
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = null;
}
});
</script>
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
<div class="selection-head">
<div>
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
</div>
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
</div>
<div class="slot-icon-grid">
{#each slots as slot}
<button
type="button"
class:configured={Boolean(slot.iconUrl)}
aria-pressed={editing?.id === slot.id}
onclick={() => editing = slot}
>
<span class="slot-icon-preview">
{#if slot.iconUrl}
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
<b>EDIT</b>
</button>
{/each}
</div>
</section>
{#if editing}
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
<div class="editor-title">
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
<input type="hidden" name="slotType" value={editing.name}/>
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
<div class="slot-icon-editor-body">
<div class="slot-icon-editor-identity">
<span class="slot-icon-preview large">
{#if editing.iconUrl}
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
</div>
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
</PendingForm>
</section>
{/if}
<style>
.slot-icon-registry {
margin: 10px 0;
padding: 10px;
border: 1px solid var(--shd-border);
border-left: 2px solid var(--shd-info);
background: #0d1011;
}
.slot-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.slot-icon-grid > button {
min-height: 62px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px;
border: 1px solid var(--shd-border);
background: #101415;
color: var(--shd-text);
text-align: left;
cursor: pointer;
}
.slot-icon-grid > button:hover,
.slot-icon-grid > button:focus-visible,
.slot-icon-grid > button[aria-pressed='true'] {
border-color: var(--shd-orange);
background: #191712;
}
.slot-icon-grid > button:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.slot-icon-grid > button > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 3px;
}
.slot-icon-grid strong,
.slot-icon-grid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-icon-grid strong {
font-size: var(--shd-type-label);
letter-spacing: .04em;
}
.slot-icon-grid small,
.slot-icon-grid > button > b {
color: var(--shd-text-muted);
font-size: var(--shd-type-micro);
letter-spacing: .07em;
}
.slot-icon-grid > button.configured small,
.slot-icon-grid > button.configured > b {
color: var(--shd-info);
}
.slot-icon-preview {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #313839;
background: #090b0c;
}
.slot-icon-preview i {
color: #586164;
font-size: 20px;
font-style: normal;
font-weight: 900;
}
.slot-icon-editor {
border-top-color: var(--shd-info);
}
.slot-icon-editor-body {
display: grid;
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
gap: 12px;
}
.slot-icon-editor-identity {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--shd-border);
background: #0d1011;
}
.slot-icon-preview.large {
width: 76px;
height: 76px;
flex: 0 0 76px;
}
.slot-icon-editor-identity > div {
display: grid;
gap: 5px;
}
.slot-icon-editor-identity small,
.slot-icon-editor-identity p {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.slot-icon-editor-identity p {
margin: 0;
}
@media (max-width: 900px) {
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.slot-icon-editor-body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.slot-icon-grid { grid-template-columns: 1fr; }
}
</style>