feat: add managed ISAC image library
This commit is contained in:
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user