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
+1
View File
@@ -792,3 +792,4 @@
.dashboard-row>.table-action:active{transform:none}
}
.image-library-editor{margin:10px 0}.image-library-form{display:grid;grid-template-columns:minmax(330px,.8fr) minmax(440px,1.2fr);gap:12px;margin-top:12px}.image-library-upload .image-drop-input{height:100%;min-height:168px}.image-library-upload .image-upload-preview{width:112px;height:112px}.image-library-metadata{display:grid;grid-template-columns:1fr 1fr;align-content:start;gap:8px}.image-library-metadata .field{margin:0}.image-library-metadata .full,.image-processing-options{grid-column:1/-1}.image-processing-options{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:#0d1011}.image-processing-options label{min-height:58px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;padding:9px;cursor:pointer}.image-processing-options label+label{border-left:1px solid var(--shd-border)}.image-processing-options input{width:auto;margin:0;accent-color:var(--shd-orange)}.image-processing-options span{display:grid;gap:3px}.image-processing-options b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-processing-options small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.image-processing-options label:has(input:checked){background:#1b170e;box-shadow:inset 0 -2px var(--shd-orange)}.image-processing-options label.disabled{opacity:.42;cursor:not-allowed}.image-metadata-edit{display:grid;grid-template-columns:72px 1fr 1fr 1.4fr;align-items:end;gap:9px;margin-top:10px}.image-metadata-edit>img{width:72px;height:72px;object-fit:contain;padding:5px;border:1px solid var(--shd-border);background:#090b0c}.image-metadata-edit .field{margin:0}.image-library-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:8px;margin-top:8px}.image-library-grid article{min-width:0;border:1px solid var(--shd-border);border-top:2px solid #4a5152;background:var(--shd-surface);transition:var(--shd-transition)}.image-library-grid article.icon{border-top-color:var(--shd-orange)}.image-library-grid article:hover{border-color:#596061;background:#15191a}.image-library-preview{height:150px;display:grid;place-items:center;padding:14px;border-bottom:1px solid var(--shd-border);background:linear-gradient(135deg,#0a0c0d 25%,#101314 25%,#101314 50%,#0a0c0d 50%,#0a0c0d 75%,#101314 75%);background-size:16px 16px}.image-library-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain}.image-library-grid header{position:static;height:auto;min-height:54px;display:flex;align-items:center;justify-content:space-between;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:transparent}.image-library-grid header>div{min-width:0;display:grid;gap:3px}.image-library-grid header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.image-library-grid header span,.image-library-grid header b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-library-grid header span{color:var(--shd-text-muted)}.image-library-grid header b{color:var(--shd-orange)}.image-library-tags{min-height:42px;display:flex;align-content:flex-start;flex-wrap:wrap;gap:4px;padding:8px 10px}.image-library-tags span{width:max-content;padding:3px 5px;border:1px solid #3c4243;color:#92999b;font-size:var(--shd-type-telemetry)}.image-library-tags small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}.image-library-grid footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-top:1px solid var(--shd-border)}.image-library-grid footer span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.image-library-grid footer button{border:0;background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.image-library-empty{grid-column:1/-1;min-height:180px;display:grid;place-content:center;gap:6px;border:1px dashed var(--shd-border);color:var(--shd-text-muted);text-align:center}.image-library-empty strong{color:var(--shd-text)}@media(max-width:1100px){.image-library-grid{grid-template-columns:repeat(3,minmax(180px,1fr))}}@media(max-width:800px){.image-library-form{grid-template-columns:1fr}.image-library-grid{grid-template-columns:repeat(2,minmax(150px,1fr))}.image-metadata-edit{grid-template-columns:72px 1fr}.image-metadata-edit .field:last-child{grid-column:1/-1}}@media(max-width:560px){.image-library-metadata,.image-processing-options,.image-library-grid{grid-template-columns:1fr}.image-library-metadata .full,.image-processing-options{grid-column:auto}.image-processing-options label+label{border-left:0;border-top:1px solid var(--shd-border)}.image-library-preview{height:132px}.image-metadata-edit{grid-template-columns:1fr}.image-metadata-edit>img{width:100%;height:96px}}
+2 -2
View File
@@ -9,7 +9,7 @@
<fieldset class="icon-upload-field">
<legend>{label}</legend>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif,image/jpeg" {currentUrl} placeholderUrl="/missing-item-icon.svg" {resetToken} emptyLabel="ICON AWAITING CONTRIBUTION" browseLabel={currentUrl ? 'REPLACE ICON' : 'ADD ICON'}/>
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
<small>OPTIONAL · MAY BE CONTRIBUTED LATER · TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
<small>OPTIONAL · SVG, PNG, WEBP, AVIF, OR JPEG · OPAQUE BACKGROUNDS REMOVED WHEN NEEDED · 2 MiB MAX</small>
</fieldset>
+21 -8
View File
@@ -2,6 +2,7 @@ import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const;
export type GameDataIconType = (typeof GAME_ICON_TYPES)[number];
@@ -42,7 +43,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_GAME_ICON_BYTES) {
throw new GameDataIconValidationError('Choose a transparent icon no larger than 2 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
let input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) {
@@ -55,17 +56,28 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
throw new GameDataIconValidationError('SVG icons cannot contain scripts, embedded images, or external resources.');
}
}
const image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
const metadata = await image.metadata();
if (!metadata.format || !['png', 'webp', 'avif', 'svg'].includes(metadata.format) || !metadata.hasAlpha) {
throw new GameDataIconValidationError('Use an SVG, PNG, WebP, or AVIF icon with a transparent background.');
let image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
let metadata = await image.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new GameDataIconValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF icon.');
}
const stats = await image.stats();
if (stats.isOpaque) {
throw new GameDataIconValidationError('The icon must contain transparent background pixels around the artwork.');
let stats = await image.stats();
if (!metadata.hasAlpha || stats.isOpaque) {
input = await removeBackgroundPreview(input);
image = sharp(input, { limitInputPixels: MAX_GAME_ICON_PIXELS, failOn: 'warning' });
metadata = await image.metadata();
stats = await image.stats();
}
if (!metadata.hasAlpha || stats.isOpaque) {
throw new GameDataIconValidationError('The processed icon must contain transparent background pixels.');
}
return await image
.rotate()
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 })
.extend({
top: 2, right: 2, bottom: 2, left: 2,
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.resize(GAME_ICON_SIZE, GAME_ICON_SIZE, {
fit: 'contain',
position: 'centre',
@@ -75,6 +87,7 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
.toBuffer();
} catch (error) {
if (error instanceof GameDataIconValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new GameDataIconValidationError(error.message);
throw new GameDataIconValidationError('That icon could not be processed. Use a transparent SVG, PNG, WebP, or AVIF file.');
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const MANAGED_IMAGE_CATEGORIES = [
'General', 'Interface', 'Gear', 'Weapons', 'Weapon Mods', 'Gear Mods',
'Talents', 'Brands', 'OS Protocols', 'Skill Chips', 'System'
] as const;
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export function managedImageStorageDirectory() {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
}
const storagePath = (key: string) => {
if (!KEY_PATTERN.test(key)) throw new Error('Invalid managed image storage key');
return path.join(managedImageStorageDirectory(), key);
};
export function managedImageUrl(id: string, updatedAt?: unknown) {
if (!ID_PATTERN.test(id)) return '';
const version = typeof updatedAt === 'string' ? `?v=${encodeURIComponent(updatedAt)}` : '';
return `/images/library/${id}${version}`;
}
function validateSvg(input: Buffer) {
const source = input.toString('utf8');
if (/<(?:script|foreignObject|image|iframe|object|embed)\b/i.test(source)
|| /<!ENTITY\b/i.test(source)
|| /\b(?:href|xlink:href)\s*=\s*["'](?!#)/i.test(source)
|| /@import\b/i.test(source)
|| /url\(\s*["']?(?!#)/i.test(source)) {
throw new ManagedImageValidationError('SVG files cannot contain scripts, embedded images, or external resources.');
}
}
export async function processManagedImage(file: File, icon: boolean, forceWhite: boolean) {
if (!file.size || file.size > MAX_MANAGED_IMAGE_BYTES) {
throw new ManagedImageValidationError('Choose an image no larger than 5 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const looksLikeSvg = file.type === 'image/svg+xml' || input.subarray(0, 512).toString('utf8').toLowerCase().includes('<svg');
if (looksLikeSvg) validateSvg(input);
let processedInput = input;
let source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
let metadata = await source.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif', 'svg'].includes(metadata.format)) {
throw new ManagedImageValidationError('Use a JPEG, SVG, PNG, WebP, or AVIF image.');
}
const sourceStats = await source.stats();
if (icon && (!metadata.hasAlpha || sourceStats.isOpaque)) {
processedInput = await removeBackgroundPreview(processedInput);
source = sharp(processedInput, { limitInputPixels: MAX_PIXELS, failOn: 'warning' });
metadata = await source.metadata();
}
let pipeline = source.rotate();
if (metadata.hasAlpha) {
pipeline = pipeline.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: 8 });
}
pipeline = pipeline.resize(icon ? 256 : 1600, icon ? 256 : 1600, {
fit: 'inside',
withoutEnlargement: !icon
});
if (icon && forceWhite) {
const normalized = await pipeline
.resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
for (let offset = 0; offset < normalized.data.length; offset += normalized.info.channels) {
normalized.data[offset] = 255;
normalized.data[offset + 1] = 255;
normalized.data[offset + 2] = 255;
}
pipeline = sharp(normalized.data, {
raw: { width: normalized.info.width, height: normalized.info.height, channels: normalized.info.channels }
});
}
const output = await pipeline.webp({ quality: icon ? 90 : 84, alphaQuality: 100, effort: 5 }).toBuffer();
const result = await sharp(output).metadata();
return { output, width: result.width ?? 0, height: result.height ?? 0 };
} catch (error) {
if (error instanceof ManagedImageValidationError) throw error;
if (error instanceof BackgroundRemovalError) throw new ManagedImageValidationError(error.message);
throw new ManagedImageValidationError('That image could not be processed.');
}
}
export async function saveManagedImageFile(id: string, output: Buffer) {
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
const key = `library-${id}.webp`;
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
return key;
}
export async function readManagedImage(database: Database, id: string) {
if (!ID_PATTERN.test(id)) return null;
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
if (typeof row?.storage_key !== 'string') return null;
try {
return await readFile(storagePath(row.storage_key));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
+48
View File
@@ -0,0 +1,48 @@
import sharp from 'sharp';
const REMOVE_BG_ENDPOINT = 'https://api.remove.bg/v1.0/removebg';
const REMOVE_BG_PREVIEW_EDGE = 1500;
export class BackgroundRemovalError extends Error {}
function apiMessage(status: number) {
if (status === 400) return 'remove.bg could not identify a removable foreground in that image.';
if (status === 402) return 'remove.bg preview credits are currently unavailable.';
if (status === 403) return 'The remove.bg API key was rejected. Update the server configuration.';
if (status === 429) return 'remove.bg is temporarily rate limited. Wait briefly and try again.';
return 'Background removal is temporarily unavailable. Try again or upload an image with transparency.';
}
export async function removeBackgroundPreview(input: Buffer): Promise<Buffer<ArrayBuffer>> {
const apiKey = process.env.REMOVE_BG_API_KEY?.trim();
if (!apiKey) {
throw new BackgroundRemovalError(
'This icon needs background removal, but remove.bg is not configured. Add REMOVE_BG_API_KEY or upload a transparent image.'
);
}
const prepared = await sharp(input, { failOn: 'warning' })
.rotate()
.resize(REMOVE_BG_PREVIEW_EDGE, REMOVE_BG_PREVIEW_EDGE, { fit: 'inside', withoutEnlargement: true })
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
const form = new FormData();
form.set('size', 'preview');
form.set('format', 'png');
form.set('image_file', new File([new Uint8Array(prepared)], 'icon-source.png', { type: 'image/png' }));
let response: Response;
try {
response = await fetch(REMOVE_BG_ENDPOINT, {
method: 'POST',
headers: { 'X-Api-Key': apiKey },
body: form,
signal: AbortSignal.timeout(45_000)
});
} catch {
throw new BackgroundRemovalError('remove.bg could not be reached. Try again or upload a transparent image.');
}
if (!response.ok) throw new BackgroundRemovalError(apiMessage(response.status));
const output = Buffer.from(await response.arrayBuffer());
const metadata = await sharp(output, { failOn: 'warning' }).metadata();
if (!metadata.hasAlpha) throw new BackgroundRemovalError('remove.bg returned an image without transparency.');
return output;
}
+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>