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
+16
View File
@@ -0,0 +1,16 @@
import { getDatabase } from '$lib/server/database';
import { readManagedImage } from '$lib/server/managed-images';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const image = await readManagedImage(await getDatabase(), params.id);
if (!image) error(404, 'Image not found');
return new Response(new Uint8Array(image), {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'x-content-type-options': 'nosniff'
}
});
};
+1
View File
@@ -36,6 +36,7 @@
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const systemSections = [
{ href: '/isac/images', label: 'Image library', permission: 'images.manage' },
{ href: '/isac/system-messages', label: 'Automated messages', permission: 'system.notifications.manage' },
{ href: '/isac/metrics', label: 'System metrics', permission: 'metrics.view' }
];
+1
View File
@@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account');
+126
View File
@@ -0,0 +1,126 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import {
MANAGED_IMAGE_CATEGORIES,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
saveManagedImageFile
} from '$lib/server/managed-images';
import type { Actions, PageServerLoad } from './$types';
const normalizeTags = (value: unknown) => [...new Set(String(value ?? '')
.split(',').map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase()).filter(Boolean))]
.slice(0, 20);
const readMetadata = (form: FormData) => {
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? '').trim();
const tags = normalizeTags(form.get('tags'));
if (name.length < 2 || name.length > 80) return { error: 'Name must be 2–80 characters.' } as const;
if (!MANAGED_IMAGE_CATEGORIES.includes(category as (typeof MANAGED_IMAGE_CATEGORIES)[number])) return { error: 'Choose a valid assignment category.' } as const;
if (tags.some((tag) => tag.length > 30)) return { error: 'Tags must be 30 characters or fewer.' } as const;
return { name, category, tags } as const;
};
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'images.manage');
const database = await getDatabase();
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
const perPage = [10, 25, 50, 100].includes(requestedPerPage) ? requestedPerPage : 25;
const requestedPage = Math.max(1, Number(url.searchParams.get('page') ?? 1) || 1);
const where = query ? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE` : '';
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const args = query ? [escaped, escaped, escaped] : [];
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM managed_images ${where}`)).get(...args))?.count ?? 0);
const pageCount = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(requestedPage, pageCount);
const images = await (await database.prepare(`SELECT managed_images.*, users.username AS creator_username
FROM managed_images JOIN users ON users.id=managed_images.created_by ${where}
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)).all(...args, perPage, (page - 1) * perPage);
const stats = await (await database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(is_icon),0) AS icons, COALESCE(SUM(byte_size),0) AS bytes,
COUNT(DISTINCT category) AS categories FROM managed_images`)).get();
return {
categories: MANAGED_IMAGE_CATEGORIES,
images: images.map((image) => ({
...image,
tags: JSON.parse(String(image.tags_json)),
url: managedImageUrl(String(image.id), image.updated_at)
})),
stats,
search: query,
pagination: { page, perPage, total, pageCount }
};
};
export const actions: Actions = {
upload: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const form = await request.formData();
const metadata = readMetadata(form);
if ('error' in metadata) return fail(400, { imageError: metadata.error });
const file = form.get('image');
if (!(file instanceof File) || !file.size) return fail(400, { imageError: 'Choose an image to upload.' });
const isIcon = form.get('isIcon') === 'on';
const forceWhite = isIcon && form.get('forceWhite') === 'on';
try {
const processed = await processManagedImage(file, isIcon, forceWhite);
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const now = new Date().toISOString();
const after = {
id, ...metadata, is_icon: isIcon ? 1 : 0, force_white: forceWhite ? 1 : 0,
storage_key: storageKey, original_filename: file.name, byte_size: processed.output.length,
width: processed.width, height: processed.height, created_by: operator.id,
created_at: now, updated_at: now
};
const database = await getDatabase();
await (await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id, metadata.name, metadata.category, JSON.stringify(metadata.tags), isIcon ? 1 : 0,
forceWhite ? 1 : 0, storageKey, file.name, processed.output.length,
processed.width, processed.height, operator.id, now, now
);
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.created', entityId: id, before: null, after
});
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
} catch (error) {
if (error instanceof ManagedImageValidationError) return fail(400, { imageError: error.message });
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
},
update: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const metadata = readMetadata(form);
if ('error' in metadata) return fail(400, { imageError: metadata.error });
const database = await getDatabase();
const before = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
if (!before) return fail(404, { imageError: 'Image not found.' });
const updatedAt = new Date().toISOString();
try {
await (await database.prepare('UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?'))
.run(metadata.name, metadata.category, JSON.stringify(metadata.tags), updatedAt, id);
const after = { ...before, name: metadata.name, category: metadata.category, tags_json: JSON.stringify(metadata.tags), updated_at: updatedAt };
await recordIsacAudit({
actorId: operator.id, category: 'system_configuration',
action: 'image.updated', entityId: id, before, after
});
return { imageSaved: true, editorMode: 'edit', resultToken: randomUUID() };
} catch (error) {
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
throw error;
}
}
};
+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>