feat: add managed ISAC image library

This commit is contained in:
JB
2026-07-26 13:46:44 -04:00
parent e8ed35e35f
commit 63b04d26a9
25 changed files with 705 additions and 57 deletions
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
let { data, form } = $props();
let adding = $state(false);
let editing = $state<any>(null);
let isIcon = $state(true);
let resetToken = $state(0);
let consumedToken = $state<string | null>(null);
const size = (bytes: unknown) => {
const value = Number(bytes ?? 0);
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
};
$effect(() => {
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
consumedToken = form.resultToken;
if (form.editorMode === 'edit') editing = null;
else {
adding = true;
isIcon = true;
resetToken += 1;
}
}
});
</script>
<svelte:head><title>Image library — ISAC</title></svelte:head>
<div class="module-toolbar">
<div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>IMAGE LIBRARY</h2><p>Process, classify, and locate reusable SHD.build visual assets.</p></div>
<div><button class="primary" type="button" onclick={() => { adding = true; editing = null; }}>UPLOAD IMAGE +</button></div>
</div>
<div class="module-kpis">
<div><span>ASSETS</span><strong>{Number(data.stats.total)}</strong><small>Managed files</small></div>
<div><span>ICONS</span><strong>{Number(data.stats.icons)}</strong><small>Normalized assets</small></div>
<div><span>CATEGORIES</span><strong>{Number(data.stats.categories)}</strong><small>Assignments in use</small></div>
<div><span>STORAGE</span><strong>{size(data.stats.bytes)}</strong><small>Processed WebP</small></div>
</div>
{#if adding}
<section class="horizontal-editor image-library-editor">
<div class="editor-title"><div><p class="eyebrow">NEW MANAGED ASSET</p><h3>UPLOAD + PROCESS</h3></div><button type="button" aria-label="Cancel image upload" onclick={() => adding = false}>×</button></div>
<PendingForm action="?/upload" enctype="multipart/form-data" successMessage="Image added to the library.">
<div class="image-library-form">
<div class="image-library-upload"><ImageUploadInput name="image" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" required {resetToken} emptyLabel="SELECT IMAGE"/></div>
<div class="image-library-metadata">
<label class="field">ASSET NAME<input name="name" maxlength="80" required placeholder="e.g. Weapon critical hit"/></label>
<label class="field">ASSIGNMENT CATEGORY<select name="category" required>{#each data.categories as category}<option>{category}</option>{/each}</select></label>
<label class="field full">TAGS · COMMA SEPARATED<input name="tags" maxlength="620" placeholder="critical, weapon, offensive"/></label>
<div class="image-processing-options">
<label><input name="isIcon" type="checkbox" bind:checked={isIcon}/><span><b>NORMALIZE AS ICON</b><small>Remove an opaque background when needed, trim margins, and fit within 256 × 256.</small></span></label>
<label class:disabled={!isIcon}><input name="forceWhite" type="checkbox" checked disabled={!isIcon}/><span><b>FORCE WHITE</b><small>Replace visible icon color while preserving transparency.</small></span></label>
</div>
</div>
</div>
{#if form?.imageError}<p class="error">{form.imageError}</p>{/if}
<div class="form-actions"><button class="secondary" type="button" onclick={() => adding = false}>CANCEL</button><button class="primary">PROCESS + SAVE →</button></div>
</PendingForm>
</section>
{/if}
{#if editing}
<section class="horizontal-editor image-library-editor compact">
<div class="editor-title"><div><p class="eyebrow">EDIT LIBRARY METADATA</p><h3>{editing.name}</h3></div><button type="button" aria-label="Cancel image editing" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/update" successMessage="Image metadata updated.">
<input type="hidden" name="id" value={editing.id}/>
<div class="image-metadata-edit">
<img src={editing.url} alt=""/>
<label class="field">ASSET NAME<input name="name" value={editing.name} maxlength="80" required/></label>
<label class="field">ASSIGNMENT CATEGORY<select name="category">{#each data.categories as category}<option selected={category === editing.category}>{category}</option>{/each}</select></label>
<label class="field">TAGS<input name="tags" value={editing.tags.join(', ')} maxlength="620"/></label>
</div>
{#if form?.imageError}<p class="error">{form.imageError}</p>{/if}
<div class="form-actions"><button class="secondary" type="button" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE CHANGES →</button></div>
</PendingForm>
</section>
{/if}
<RegistryPagination pagination={data.pagination} search={data.search} searchPlaceholder="SEARCH NAMES, TAGS, OR CATEGORIES"/>
<section class="image-library-grid" aria-label="Managed image library">
{#each data.images as image}
<article class:icon={image.is_icon}>
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}"/></div>
<header><div><strong>{image.name}</strong><span>{image.category}</span></div><b>{image.is_icon ? 'ICON' : 'IMAGE'}</b></header>
<div class="image-library-tags">{#each image.tags as tag}<span>{tag}</span>{/each}{#if !image.tags.length}<small>NO TAGS</small>{/if}</div>
<footer><span>{image.width} × {image.height} · {size(image.byte_size)}</span><button class="table-action" type="button" onclick={() => { editing = image; adding = false; }}>EDIT →</button></footer>
</article>
{:else}
<div class="image-library-empty"><strong>NO MATCHING ASSETS</strong><span>Upload the first image or revise the registry search.</span></div>
{/each}
</section>