feat: refine ISAC media and notification workflows

This commit is contained in:
JB
2026-07-26 20:36:30 -04:00
parent 338ea8761d
commit 6ea45c05fa
37 changed files with 1002 additions and 138 deletions
+24 -14
View File
@@ -316,22 +316,33 @@
background:var(--shd-surface);
}
.compact-status>span{
min-height:36px;
display:flex;
align-items:center;
gap:5px;
padding:0 10px;
position:relative;
min-width:105px;
min-height:48px;
display:grid;
align-content:center;
gap:3px;
padding:7px 10px;
border-left:1px solid var(--shd-border);
color:var(--shd-text-dim);
color:var(--shd-text);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.8px;
white-space:nowrap;
}
.compact-status>span:first-child{border-left:0}
.compact-status strong{color:var(--shd-text);font-size:inherit;letter-spacing:inherit}
.compact-status small{
color:var(--shd-text-dim);
font-size:inherit;
font-weight:900;
letter-spacing:1.1px;
line-height:1;
}
.compact-status strong{color:var(--shd-text);font-size:inherit;letter-spacing:inherit;line-height:1.05}
.compact-status i{
position:static;
position:absolute;
top:8px;
right:8px;
width:6px;
height:6px;
border-radius:50%;
@@ -395,7 +406,8 @@
@media(max-width:900px){
.compact-status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%}
.compact-status>span:nth-child(3){border-left:0;border-top:1px solid var(--shd-border)}
.compact-status>span:nth-child(4){border-top:1px solid var(--shd-border)}
.compact-status>span:nth-child(n+4){border-top:1px solid var(--shd-border)}
.compact-status .data-mode-status{grid-column:1/-1}
}
@media(max-width:650px){
.registry-pagination:not(:has(.pagination-pages)){grid-template-columns:1fr}
@@ -403,11 +415,12 @@
}
/* State the consequence of an ISAC data mutation before an editor is opened. */
.module-toolbar>div:first-child:last-child{display:block}
.mutation-policy-strip{
display:flex;
justify-content:flex-end;
margin-bottom:-38px;
padding:7px 205px 0 0;
min-height:34px;
padding:0 2px 8px;
pointer-events:none;
}
.mutation-consequence{
@@ -430,15 +443,12 @@
}
.mutation-consequence span{color:var(--shd-text-muted)}
.mutation-consequence.direct b{border-color:var(--shd-success);color:var(--shd-success)}
.admin-content:has(.calculation-actions) .mutation-policy-strip{padding-right:380px}
@media(max-width:1100px){
.mutation-policy-strip{
justify-content:flex-start;
margin:0;
padding:8px 0;
border-bottom:1px solid var(--shd-border);
}
.admin-content:has(.calculation-actions) .mutation-policy-strip{padding-right:0}
}
@media(max-width:600px){
.mutation-consequence{align-items:flex-start;flex-direction:column;gap:5px}
+1 -1
View File
@@ -73,7 +73,7 @@
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
<input type="search" name={searchKey} value={searchValue} maxlength="80" placeholder={searchPlaceholder} aria-label={`${searchPlaceholder} — results update as you type`} oninput={(event) => scheduleSearch(event.currentTarget.value)}/>
<span class="pagination-search-state" class:pending={searchPending} aria-live="polite">{searchPending ? 'SEARCHING…' : 'LIVE SEARCH'}</span>
{#if searchPending}<span class="pagination-search-state pending" aria-live="polite">SEARCHING…</span>{/if}
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
</form>
{/if}
+1 -3
View File
@@ -20,7 +20,6 @@
selected = $bindable([]),
columns = options.length,
required = false,
showIndicator = true,
onchange
}: {
legend: string;
@@ -31,7 +30,6 @@
selected?: string[];
columns?: number;
required?: boolean;
showIndicator?: boolean;
onchange?: (values: string[]) => void;
} = $props();
@@ -61,7 +59,7 @@
onchange={(event) => change(option.value, event.currentTarget.checked)}
/>
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
{#if option.icon}<GameIcon name={option.icon} decorative/>{:else if showIndicator}<i aria-hidden="true"></i>{/if}
{#if option.icon}<GameIcon name={option.icon} decorative/>{/if}
<b class:sr-only={option.iconOnly}>{option.label}</b>
</span>
</label>
@@ -72,7 +72,6 @@
options={effectTypes}
bind:value={effect.type}
onchange={(values) => changeType(index, values[0])}
showIndicator={false}
required
/>
{#if effect.type === 'Attribute'}
@@ -92,7 +91,7 @@
/>
</label>
<label class="field">VALUE<input type="number" step="any" bind:value={effect.value} placeholder="OPTIONAL"/></label>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit} showIndicator={false}/>
<SegmentedChoice legend="UNIT" name={`${idPrefix}-unit-${index}`} options={units} bind:value={effect.unit}/>
</div>
</section>
{:else}
+157 -4
View File
@@ -1,4 +1,5 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
@@ -14,6 +15,24 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
export const GAME_ICON_SIZE = 256;
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
const LIBRARY_CATEGORY: Record<GameDataIconType, string> = {
talent: 'Talents',
brand_set: 'Brands',
os_protocol: 'OS Protocols',
skill_chip: 'Skill Chips',
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations'
};
const LIBRARY_LABEL: Record<GameDataIconType, string> = {
talent: 'Talent',
brand_set: 'Brand Set',
os_protocol: 'OS Protocol',
skill_chip: 'Skill Chip',
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node'
};
export class GameDataIconValidationError extends Error {}
@@ -95,7 +114,118 @@ export async function processGameDataIconUpload(file: File): Promise<Buffer> {
}
}
export async function saveGameDataIcon(database: Database, type: GameDataIconType, entityId: string, output: Buffer): Promise<string> {
const normalizeTags = (values: unknown[]) => [...new Set(values
.flatMap((value) => String(value ?? '').split(','))
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
.filter(Boolean))].slice(0, 20);
async function libraryMetadata(database: Database, type: GameDataIconType, entityId: string) {
switch (type) {
case 'talent': {
const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope
FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id
WHERE talent.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] };
}
case 'brand_set': {
const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['gear', 'set bonus'] };
}
case 'os_protocol': {
const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId);
return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] };
}
case 'skill_chip': {
const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
FROM skill_chip_sets chip
LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id
LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE chip.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] };
}
case 'specialization': {
const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId);
return { name: row?.name, tags: ['class'] };
}
case 'specialization_ability': {
const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
FROM specialization_abilities ability
JOIN specializations specialization ON specialization.id=ability.specialization_id
WHERE ability.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
}
case 'specialization_node': {
const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
specialization.name AS specialization_name
FROM specialization_tree_nodes node
JOIN specialization_focuses focus ON focus.id=node.focus_id
JOIN specializations specialization ON specialization.id=focus.specialization_id
WHERE node.id=?`)).get(entityId);
return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] };
}
}
return { name: null, tags: [] };
}
async function uniqueLibraryName(database: Database, preferred: string, type: GameDataIconType, entityId: string) {
for (const candidate of [
preferred,
`${preferred} · ${LIBRARY_LABEL[type]}`,
`${preferred} · ${entityId.slice(0, 8)}`
]) {
const normalized = candidate.slice(0, 80);
const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized);
if (!conflict) return normalized;
}
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
}
async function registerLibraryIcon(
database: Database,
type: GameDataIconType,
entityId: string,
key: string,
byteSize: number,
updatedAt: string,
actorId: string
) {
const existing = await (await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?'
)).get(type, entityId);
const metadata = await libraryMetadata(database, type, entityId);
const preferredName = String(metadata.name ?? `${LIBRARY_LABEL[type]} ${entityId.slice(0, 8)}`);
const derivedTags = normalizeTags(['isac', 'game data', type.replaceAll('_', ' '), preferredName, ...metadata.tags]);
if (existing) {
const previousTags = (() => {
try { return JSON.parse(String(existing.tags_json)); } catch { return []; }
})().filter((tag: unknown) => String(tag).toLocaleLowerCase() !== 'vest');
await (await database.prepare(`UPDATE managed_images SET
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256,updated_at=?
WHERE id=?`)).run(
LIBRARY_CATEGORY[type], JSON.stringify(normalizeTags([...previousTags, ...derivedTags])),
key, key, byteSize, updatedAt, existing.id
);
return String(existing.id);
}
const name = await uniqueLibraryName(database, preferredName, type, entityId);
const id = randomUUID();
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,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
id, name, LIBRARY_CATEGORY[type], JSON.stringify(derivedTags), 1, 0, key, key, byteSize, 256, 256,
type, entityId, actorId, updatedAt, updatedAt
);
return id;
}
export async function saveGameDataIcon(
database: Database,
type: GameDataIconType,
entityId: string,
output: Buffer,
actorId: string
): Promise<string> {
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
@@ -104,12 +234,14 @@ export async function saveGameDataIcon(database: Database, type: GameDataIconTyp
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(type, entityId, key, output.length, now);
await registerLibraryIcon(database, type, entityId, key, output.length, now, actorId);
return now;
}
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
const icon = await (await database.prepare('SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId);
await (await database.prepare('DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?')).run(type, entityId);
await (await database.prepare('DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?')).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
@@ -137,11 +269,32 @@ export async function readIconMutation(form: FormData): Promise<{ output: Buffer
return { output: null, remove };
}
export async function applyIconMutation(database: Database, type: GameDataIconType, entityId: string, mutation: { output: Buffer | null; remove: boolean }): Promise<void> {
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output);
export async function applyIconMutation(
database: Database,
type: GameDataIconType,
entityId: string,
mutation: { output: Buffer | null; remove: boolean },
actorId: string
): Promise<void> {
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
else {
const icon = await (await database.prepare(
'SELECT storage_key,byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?'
)).get(type, entityId);
if (typeof icon?.storage_key === 'string') {
await registerLibraryIcon(
database, type, entityId, icon.storage_key, Number(icon.byte_size), String(icon.updated_at), actorId
);
}
}
}
export async function gameDataIconMetadata(database: Database, type: GameDataIconType, entityId: string) {
return await (await database.prepare('SELECT byte_size,updated_at FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(type, entityId) ?? null;
return await (await database.prepare(`SELECT icon.byte_size,icon.updated_at,
image.id AS library_image_id,image.category AS library_category,image.tags_json AS library_tags_json
FROM game_data_icons icon
LEFT JOIN managed_images image
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
WHERE icon.entity_type=? AND icon.entity_id=?`)).get(type, entityId) ?? null;
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const MANAGED_IMAGE_CATEGORIES = [
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;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
@@ -1,7 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import { getPermissions } from '$lib/server/auth/authorization';
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
@@ -11,30 +11,32 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const filtering = url.searchParams.get('filtering') === '1';
const categories = filtering
? url.searchParams.getAll('channel').filter((value) => NOTIFICATION_CATEGORIES.includes(value as any))
: [...NOTIFICATION_CATEGORIES];
const states = filtering
? url.searchParams.getAll('state').filter((value) => value === 'unread' || value === 'read')
: ['unread', 'read'];
const requestedState = String(url.searchParams.get('state') ?? 'all');
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
const { search, pattern } = getRegistrySearch(url);
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
if (categories.length === 0 || states.length === 0) {
conditions.push('1=0');
} else {
if (categories.length < NOTIFICATION_CATEGORIES.length) {
conditions.push(`n.category IN (${categories.map(() => '?').join(',')})`);
args.push(...categories);
}
if (states.length === 1) conditions.push(states[0] === 'unread' ? 'n.read_at IS NULL' : 'n.read_at IS NOT NULL');
if (state === 'unread') conditions.push('n.read_at IS NULL');
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
if (search) {
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
OR n.body LIKE ? ESCAPE '\\' COLLATE NOCASE
OR n.source LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
args.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const orderBy = {
newest: 'n.created_at DESC, n.id DESC',
oldest: 'n.created_at ASC, n.id ASC',
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, n.created_at DESC, n.id DESC'
}[sort];
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
${where} ORDER BY n.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
const permissions = await getPermissions(user);
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
@@ -59,7 +61,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return { notifications, preferences, oversightPreferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
return { notifications, preferences, oversightPreferences, pagination, filters: { state, sort, search }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
};
export const actions: Actions = {
+466 -15
View File
@@ -4,10 +4,13 @@
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { tick } from 'svelte';
let { data, form } = $props();
let settingsOpen = $state(false);
let consumedPreferenceSaveToken = $state<string | null>(null);
let selected = $state<any>(null);
let detailDialog = $state<HTMLDivElement>();
let detailTrigger: HTMLButtonElement | null = null;
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)));
@@ -53,7 +56,43 @@
browserPermission = await Notification.requestPermission();
}
function applyFilters() {
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
document.querySelector<HTMLFormElement>('.notification-query-console')?.requestSubmit();
}
async function openDetails(notification: any, trigger: HTMLButtonElement) {
detailTrigger = trigger;
selected = notification;
await tick();
detailDialog?.querySelector<HTMLButtonElement>('.notification-detail-close')?.focus();
}
async function closeDetails() {
selected = null;
await tick();
detailTrigger?.focus();
}
function handleDetailKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
void closeDetails();
return;
}
if (event.key !== 'Tab') return;
const dialog = detailDialog;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')];
if (!focusable.length) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
$effect(() => {
const token = form?.notificationPreferenceSaveToken;
@@ -123,32 +162,73 @@
</section>
{/if}
<div class="notification-toolbar">
<form class="notification-filter-console">
<input type="hidden" name="filtering" value="1"/>
<SegmentedChoice legend="CHANNELS" name="channel" mode="checkbox" options={Object.entries(labels).map(([value, label]) => ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/>
<SegmentedChoice legend="MESSAGE STATE" name="state" mode="checkbox" options={[{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} selected={data.filters.states} onchange={applyFilters}/>
<form class="notification-query-console">
{#if data.filters.search}<input type="hidden" name="q" value={data.filters.search}/>{/if}
<input type="hidden" name="perPage" value={data.pagination.perPage}/>
<SegmentedChoice legend="SHOW" name="state" options={[{value:'all',label:'ALL'},{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} value={data.filters.state} onchange={applyFilters}/>
<SegmentedChoice legend="SORT" name="sort" options={[{value:'newest',label:'NEWEST'},{value:'oldest',label:'OLDEST'},{value:'unread',label:'UNREAD FIRST'}]} value={data.filters.sort} onchange={applyFilters}/>
</form>
{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}
</div>
<RegistryPagination pagination={data.pagination}/>
<RegistryPagination pagination={data.pagination} search={data.filters.search} searchPlaceholder="SEARCH MESSAGES"/>
<section class="notification-list">
{#each data.notifications as notification}
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if storedActions(notification).length}{#each storedActions(notification) as callout}<a class="notification-response-action" href={callout.href}>{callout.label} →</a>{/each}{:else if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
<div>
<div class="notification-message-header">
<div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div>
<div class="notification-message-meta">
<time>{date(notification.created_at)}</time>
{#if !notification.read_at}
<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button class="notification-read-action">MARK READ</button></PendingForm>
{:else}
<span class="notification-read-state">READ</span>
{/if}
</div>
</div>
{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}
<footer>
<div class="notification-actions">
<button class="notification-detail-action" type="button" onclick={(event) => openDetails(notification, event.currentTarget)}>VIEW DETAILS</button>
{#if storedActions(notification).length}
{#each storedActions(notification) as callout}<a class="notification-response-action" href={callout.href}>{callout.label} →</a>{/each}
{:else if action(notification)}
<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>
{/if}
</div>
</footer>
</div>
</article>
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the current search and message state.</p></div>{/each}
</section>
</main>
{#if selected}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title">
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button aria-label="Close notification details" onclick={() => selected = null}>×</button></div>
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
<div class="form-actions notification-detail-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if storedActions(selected).length}{#each storedActions(selected) as callout}<a class="primary button" href={callout.href}>{callout.label} →</a>{/each}{:else if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
<div class="isac-modal-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closeDetails(); }}>
<div bind:this={detailDialog} class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title" tabindex="-1" onkeydown={handleDetailKeydown}>
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button class="notification-detail-close" aria-label="Close notification details" onclick={closeDetails}>×</button></div>
<div class="notification-detail-content">
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p class="notification-detail-body">{selected.body}</p>{/if}
{#if storedActions(selected).length || action(selected)}
<section class="notification-detail-callouts" aria-label="Message actions">
<span>AVAILABLE ACTIONS</span>
<div>
{#if storedActions(selected).length}
{#each storedActions(selected) as callout}<a href={callout.href}>{callout.label} →</a>{/each}
{:else if action(selected)}
<a href={action(selected)?.href}>{action(selected)?.label} →</a>
{/if}
</div>
</section>
{/if}
</div>
<footer class="notification-detail-utility">
{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{:else}<span>MESSAGE READ</span>{/if}
<button type="button" class="secondary" onclick={closeDetails}>CLOSE</button>
</footer>
</div>
</div>
{/if}
@@ -168,6 +248,7 @@
.notification-message-header h2 {
margin: 0;
font-size: var(--shd-type-supporting);
line-height: 1.2;
}
@@ -175,4 +256,374 @@
line-height: 1;
white-space: nowrap;
}
.notification-message-header > div:first-child,
.notification-message-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.notification-message-meta {
flex: 0 0 auto;
}
.notification-read-action,
.notification-read-state {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border: 1px solid var(--shd-border-strong);
background: #111516;
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .7px;
white-space: nowrap;
}
.notification-read-action {
cursor: pointer;
}
.notification-read-action:hover,
.notification-read-action:focus-visible {
border-color: var(--shd-orange);
background: #1b160d;
color: var(--shd-text);
}
.notification-read-action:active {
transform: translateY(1px);
filter: brightness(.88);
}
.notification-read-state {
color: var(--shd-text-muted);
}
.notification-query-console {
min-width: 0;
flex: 1;
display: grid !important;
grid-template-columns: repeat(2, minmax(260px, 320px));
align-items: end !important;
gap: 8px !important;
}
.notification-query-console > input {
display: none;
}
.notification-query-console :global(.segmented-choice > div) {
height: 36px;
}
.notification-list article p,
.notification-list article :global(.system-message-body > p) {
font-size: var(--shd-type-body);
line-height: 1.6;
}
.notification-list article :global(.system-message-body:not(.expanded) > p),
.notification-list article :global(.system-message-body:not(.expanded) .system-message-detail > p),
.notification-body-preview {
-webkit-line-clamp: 4;
line-clamp: 4;
}
.notification-list article footer {
display: block;
margin-top: 10px;
}
.notification-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
align-items: stretch;
gap: 7px;
}
.notification-actions .notification-detail-action,
.notification-actions .notification-response-action {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .7px;
white-space: nowrap;
transition: var(--shd-transition);
}
.notification-actions .notification-detail-action {
border: 1px solid var(--shd-border-strong);
background: #111516;
color: var(--shd-text-muted);
cursor: pointer;
}
.notification-actions .notification-detail-action:hover,
.notification-actions .notification-detail-action:focus-visible {
border-color: var(--shd-text-muted);
background: #181c1d;
color: var(--shd-text);
}
.notification-actions .notification-detail-action:active,
.notification-actions .notification-response-action:active {
transform: translateY(1px);
filter: brightness(.9);
}
.notification-actions .notification-detail-action:focus-visible,
.notification-actions .notification-response-action:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.notification-detail-modal {
width: min(840px, calc(100vw - 24px));
max-height: min(820px, calc(100vh - 32px));
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 0;
overflow: hidden;
}
.notification-detail-sender {
padding: 14px 16px;
}
.notification-detail-sender > .notification-detail-close {
width: 40px;
height: 40px;
border-color: var(--shd-border-strong);
font-size: 18px;
transition: var(--shd-transition);
}
.notification-detail-sender > .notification-detail-close:hover,
.notification-detail-sender > .notification-detail-close:focus-visible {
border-color: var(--shd-orange);
background: #1b160d;
color: var(--shd-orange);
}
.notification-detail-sender > .notification-detail-close:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.notification-detail-sender > .notification-detail-close:active {
transform: translateY(1px);
filter: brightness(.88);
}
.notification-detail-content {
min-height: 0;
overflow-y: auto;
padding: 0 16px 16px;
overscroll-behavior: contain;
scrollbar-color: var(--shd-border-strong) var(--shd-surface);
}
.notification-detail-content h2 {
margin: 18px 0 10px;
font-size: clamp(22px, 3vw, 30px);
line-height: 1.1;
letter-spacing: -.4px;
}
.notification-detail-body {
min-height: 110px;
margin: 0;
padding: 16px;
border-left: 2px solid var(--shd-border-strong);
background: #0e1112;
color: var(--shd-text);
font: var(--shd-type-body)/1.7 var(--shd-font-editorial);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.notification-detail-content :global(.system-message-body.expanded),
.notification-detail-content :global(.system-message-body.expanded p) {
overflow-wrap: anywhere;
}
.notification-detail-callouts {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--shd-border);
}
.notification-detail-callouts > span {
display: block;
margin-bottom: 7px;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: 1px;
}
.notification-detail-callouts > div {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
gap: 7px;
}
.notification-detail-callouts a {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border: 1px solid #6b5329;
background: #1b160d;
color: var(--shd-orange);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .7px;
text-align: center;
transition: var(--shd-transition);
}
.notification-detail-callouts a:hover,
.notification-detail-callouts a:focus-visible {
border-color: var(--shd-orange);
background: #241b0d;
color: var(--shd-text);
}
.notification-detail-callouts a:active {
transform: translateY(1px);
filter: brightness(.9);
}
.notification-detail-utility {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 16px;
border-top: 1px solid var(--shd-border);
background: #0d1011;
}
.notification-detail-utility :global(form) {
margin: 0;
}
.notification-detail-utility button {
min-width: 132px;
min-height: 40px;
margin: 0;
}
.notification-detail-utility > span {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 900;
letter-spacing: .8px;
}
@media (max-width: 1000px) {
.notification-toolbar {
grid-template-columns: 1fr;
}
.notification-query-console {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 700px) {
.notification-query-console {
grid-template-columns: 1fr;
}
.notification-query-console :global(.segmented-choice > div) {
height: auto;
}
.notification-query-console :global(.segmented-choice span) {
min-height: 44px;
}
.notification-message-header {
align-items: stretch;
}
.notification-message-header > div:first-child {
align-items: flex-start;
flex-direction: column;
}
.notification-message-meta {
width: 100%;
justify-content: space-between;
}
.notification-read-action,
.notification-read-state {
min-height: 44px;
}
.notification-actions {
grid-template-columns: 1fr;
}
.notification-actions .notification-detail-action,
.notification-actions .notification-response-action {
min-height: 44px;
}
.notification-detail-modal {
width: calc(100vw - 12px);
max-height: calc(100dvh - 12px);
}
.notification-detail-sender {
grid-template-columns: 40px minmax(0, 1fr) 44px;
padding: 10px;
}
.notification-detail-sender > img {
width: 40px;
height: 40px;
}
.notification-detail-sender > .notification-detail-close {
width: 44px;
height: 44px;
}
.notification-detail-content {
padding: 0 10px 12px;
}
.notification-detail-content h2 {
font-size: 22px;
}
.notification-detail-callouts > div {
grid-template-columns: 1fr;
}
.notification-detail-callouts a,
.notification-detail-utility button {
min-height: 44px;
}
.notification-detail-utility {
padding: 9px 10px;
}
}
</style>
+3 -3
View File
@@ -375,7 +375,7 @@
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} showIndicator={false} required/>
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
@@ -418,7 +418,7 @@
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))} showIndicator={false}/></article>
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
@@ -458,7 +458,7 @@
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} showIndicator={false} required/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
+4
View File
@@ -4,6 +4,7 @@ import { redirect } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import type { LayoutServerLoad } from './$types';
import { getContributorMode } from '$lib/server/contributor-mode';
export const load: LayoutServerLoad = async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
@@ -23,9 +24,12 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
const pendingAvatarReviews = canReviewAvatars
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
: 0;
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
return {
permissions,
contributorMode,
buildNumber,
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
oversightReviewCounts: {
data: canApproveData ? pendingProposalTotal : 0,
+14 -6
View File
@@ -7,9 +7,9 @@
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
const guidelinesPage = $derived(page.url.pathname === '/isac/contributor-guidelines');
const dataReadOnly = $derived(can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled) && !guidelinesPage);
const dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
const isacNode = 'NYC-01';
let readOnlyBlocked = $state(false);
const dataSections = [
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
@@ -26,6 +26,8 @@
{ href: '/isac/skillchips', label: 'Skill Chips', module: 'skillchips' }
];
const dataModulePage = $derived(dataSections.some((section) => page.url.pathname === section.href));
const dataContextPage = $derived(dataModulePage || guidelinesPage);
const dataReadOnly = $derived(dataModulePage && can('data.view') && !can('data.manage') && (!can('data.propose') || !contributorEnabled));
const userSections = [
{ href: '/isac/users', label: 'User operations', permission: 'users.view' },
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
@@ -78,7 +80,13 @@
<main class="admin-page">
<header class="admin-command">
<div class="command-title"><p class="eyebrow"><span></span> INTELLIGENT SYSTEM ANALYTIC COMPUTER</p><h1>ISAC</h1><p>SHD.build operations and intelligence control surface</p></div>
<div class="command-status compact-status" aria-label="ISAC system status"><span><i></i>SYSTEM <strong>ONLINE</strong></span><span>ACCESS <strong>AUTHORIZED</strong></span><span>ENV <strong>LOCAL</strong></span><span class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:oversight={dataModeClass === 'oversight'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}>MODE <strong>{dataMode}</strong></span></div>
<div class="command-status compact-status" aria-label="ISAC system status">
<span><i></i><small>SYSTEM</small><strong>ONLINE</strong></span>
<span><small>ACCESS</small><strong>AUTHORIZED</strong></span>
<span><small>NODE</small><strong>{isacNode}</strong></span>
<span><small>BUILD</small><strong>{data.buildNumber}</strong></span>
{#if dataContextPage}<span class="data-mode-status" class:admin={dataModeClass === 'admin'} class:data-manager={dataModeClass === 'data-manager'} class:oversight={dataModeClass === 'oversight'} class:contributor={dataModeClass === 'contributor'} class:read-only={dataModeClass === 'read-only'}><small>MODE</small><strong>{dataMode}</strong></span>{/if}
</div>
</header>
<div class="admin-dashboard">
<aside class="admin-sidebar">
@@ -109,7 +117,7 @@
<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><span>{section.label}</span><b>›</b></a>
{/each}</nav>{/if}
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
<div class="sidebar-foot"><span>ISAC NODE</span><strong>{isacNode}</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
@@ -117,10 +125,10 @@
readOnlyBlocked = true;
}
}}>
{#if page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
{#if dataModulePage && page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
{:else if contributorEnabled && !guidelinesPage && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
{#if readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{:else if dataModulePage && contributorEnabled && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
{#if dataModulePage && readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
{@render children()}
</section>
+1 -1
View File
@@ -95,7 +95,7 @@ export const actions: Actions = {
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
}
await applyIconMutation(database, 'brand_set', brandId, iconMutation);
await applyIconMutation(database, 'brand_set', brandId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), icon: await gameDataIconMetadata(database, 'brand_set', brandId), effects } });
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
+2 -2
View File
@@ -177,7 +177,7 @@
<label class="field">HARD CAP<input type="number" min="0" step="any" bind:value={profile.hardCap} placeholder="NONE"/></label>
<label class="field">CURVE PARAMETER<input type="number" min="0.01" step="any" bind:value={profile.curveParameter} disabled={profile.curveType === 'none'} placeholder="1"/></label>
</div>
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType} showIndicator={false}/>
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType}/>
</section>
{/each}</div>
{#if form?.revisionError}<p class="error">{form.revisionError}</p>{/if}
@@ -191,7 +191,7 @@
<div class="editor-title"><div><p class="eyebrow">{calibrationEditing.id ? 'MODIFY CALIBRATION CASE' : 'NEW CALIBRATION CASE'}</p><h3>{calibrationEditing.name || 'FIELD OBSERVATION'}</h3></div><button aria-label="Close calibration editor" onclick={() => calibrationEditing = null}>×</button></div>
<PendingForm action="?/saveCalibration">
<input type="hidden" name="id" value={calibrationEditing.id}/>
<div class="calibration-identity"><label class="field isac-record-name">CASE NAME<input name="name" bind:value={calibrationEditing.name} maxlength="120" required/></label><SegmentedChoice legend="CASE STATE" name="status" options={stateOptions} bind:value={calibrationEditing.status} showIndicator={false}/><SegmentedChoice legend="MEASURED WEAPON" name="selectedWeapon" options={weaponOptions} bind:value={calibrationEditing.selectedWeapon} showIndicator={false}/><label class="field">GAME VERSION<input name="gameVersion" bind:value={calibrationEditing.gameVersion}/></label><label class="field">OBSERVED DATE<input name="observedAt" type="date" bind:value={calibrationEditing.observedAt}/></label></div>
<div class="calibration-identity"><label class="field isac-record-name">CASE NAME<input name="name" bind:value={calibrationEditing.name} maxlength="120" required/></label><SegmentedChoice legend="CASE STATE" name="status" options={stateOptions} bind:value={calibrationEditing.status}/><SegmentedChoice legend="MEASURED WEAPON" name="selectedWeapon" options={weaponOptions} bind:value={calibrationEditing.selectedWeapon}/><label class="field">GAME VERSION<input name="gameVersion" bind:value={calibrationEditing.gameVersion}/></label><label class="field">OBSERVED DATE<input name="observedAt" type="date" bind:value={calibrationEditing.observedAt}/></label></div>
<label class="field calibration-snapshot">LOADOUT SNAPSHOT · JSON<textarea name="loadoutSnapshot" bind:value={calibrationEditing.loadoutSnapshot} required spellcheck="false"></textarea></label>
<div class="calibration-observations">
<label class="field">BODY HIT<input name="bodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.bodyHit}/></label>
+1 -1
View File
@@ -113,7 +113,7 @@
<div class="gear-identity">
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} showIndicator={false} required/>
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} required/>
</div>
<div class="gear-ranges">
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
+1 -1
View File
@@ -125,7 +125,7 @@
<div class="weapon-mod-preview">
<div>
<label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label>
<SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/>
<SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview}/>
</div>
{#if previewRow}
<div class="weapon-mod-preview-values">
+4 -2
View File
@@ -41,11 +41,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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}
FROM managed_images LEFT 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();
COUNT(DISTINCT category) AS categories,
COALESCE(SUM(CASE WHEN source_entity_type IS NOT NULL THEN 1 ELSE 0 END),0) AS linked
FROM managed_images`)).get();
return {
categories: MANAGED_IMAGE_CATEGORIES,
images: images.map((image) => ({
+6 -2
View File
@@ -12,6 +12,7 @@
const value = Number(bytes ?? 0);
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
};
const sourceLabel = (value: unknown) => String(value ?? '').replaceAll('_', ' ').toLocaleUpperCase();
$effect(() => {
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
consumedToken = form.resultToken;
@@ -32,7 +33,7 @@
</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>ICONS</span><strong>{Number(data.stats.icons)}</strong><small>{Number(data.stats.linked)} linked to ISAC data</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>
@@ -81,7 +82,10 @@
{#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>
<header>
<div><strong>{image.name}</strong><span>{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}</span></div>
<b>{image.source_entity_type ? 'ASSIGNED ICON' : 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>
+1 -1
View File
@@ -216,7 +216,7 @@ export const actions: Actions = {
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder);
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation);
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
return { protocolSaved: true, protocolMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
+1 -1
View File
@@ -238,7 +238,7 @@ export const actions: Actions = {
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO skill_chip_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
await (await database.prepare('DELETE FROM skill_chip_talent_effects WHERE talent_id=?')).run(talentId);
for (const effect of effects) await (await database.prepare('INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.minValue, effect.maxValue, effect.sortOrder);
await applyIconMutation(database, 'skill_chip', setId, iconMutation);
await applyIconMutation(database, 'skill_chip', setId, iconMutation, operator.id);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: await snapshot(setId) });
@@ -459,7 +459,7 @@ export const actions: Actions = {
'INSERT INTO specializations(id,name,description,created_at,updated_at) VALUES(?,?,?,?,?)'
)).run(specializationId, name, description || null, now, now);
}
await applyIconMutation(database, 'specialization', specializationId, iconMutation);
await applyIconMutation(database, 'specialization', specializationId, iconMutation, operator.id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
@@ -627,7 +627,7 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation);
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, operator.id);
await refreshSpecializationFocuses(database, specializationId);
await recordIsacAudit({
actorId: operator.id,
@@ -933,7 +933,7 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation);
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id);
const readiness = await refreshSpecializationFocusReady(database, focusId);
await recordIsacAudit({
actorId: operator.id,
+2 -2
View File
@@ -517,7 +517,7 @@
<div class="specialization-node-position">
<label class="field">ROW<input name="rowNumber" type="number" min="1" max="20" step="1" bind:value={nodeEditing.rowNumber} required/></label>
<label class="field">COLUMN<input name="columnNumber" type="number" min="1" max="12" step="1" bind:value={nodeEditing.columnNumber} required/></label>
<SegmentedChoice legend="MAXIMUM RANK" name="maxRank" options={rankOptions} bind:value={nodeEditing.maxRank} onchange={(values) => changeMaxRank(values[0])} showIndicator={false} required/>
<SegmentedChoice legend="MAXIMUM RANK" name="maxRank" options={rankOptions} bind:value={nodeEditing.maxRank} onchange={(values) => changeMaxRank(values[0])} required/>
</div>
</div>
</div>
@@ -550,7 +550,7 @@
<div>
<div class="field"><span>ATTRIBUTE PER STACK</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={modifier.statId}/></div>
<label class="field">VALUE PER STACK<input type="number" step="any" bind:value={modifier.valuePerStack} required/></label>
<SegmentedChoice legend="UNIT" name={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}-unit`} options={unitOptions} columns={5} bind:value={modifier.unit} showIndicator={false}/>
<SegmentedChoice legend="UNIT" name={`rank-${rank.rank}-combat-${combatIndex}-modifier-${modifierIndex}-unit`} options={unitOptions} columns={5} bind:value={modifier.unit}/>
<button type="button" class="moderation-action danger" disabled={combatEffect.modifiers.length <= 1} onclick={() => removeCombatModifier(combatEffect, modifierIndex)}>REMOVE</button>
</div>
{/each}
+1 -1
View File
@@ -231,7 +231,7 @@ export const actions: Actions = {
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
}
await applyIconMutation(db, 'talent', talentId, iconMutation);
await applyIconMutation(db, 'talent', talentId, iconMutation, operator.id);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
+3 -3
View File
@@ -92,14 +92,14 @@
<label class="field weapon-mod-name">MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
<label class="field exotic-toggle weapon-mod-exotic-toggle">EXOTIC STATUS<span title="Exotic Weapon Mod"><input type="checkbox" checked={isExotic} onchange={(event)=>setMode(event.currentTarget.checked?'exotic':'regular')}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON MOD</b></span></label>
{#if isExotic}<div class="weapon-mod-exotic-quality"><GameIcon name="exotic" decorative/><span><b>EXOTIC QUALITY</b><small>UNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS</small></span></div>{:else}<div class="weapon-mod-primary-types"><span>PRIMARY ATTRIBUTE OPTIONS</span><div>{#each ['firepower','toughness','engineering'] as primary}<b><GameIcon name={primary as GameIconName} decorative/>{primary.toUpperCase()}</b>{/each}</div><small>Selected by the agent while configuring a build.</small></div>{/if}
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required showIndicator={false}/>
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required/>
</div>
<div class="weapon-mod-compatibility">
<div>
<div class="selection-head"><div><span>{isExotic ? 'EXOTIC WEAPON LOCK' : 'WEAPON CATEGORY COMPATIBILITY'}</span><small>{isExotic ? 'PRE-EQUIPPED · CANNOT BE REMOVED' : 'SLOT TYPE AND CATEGORY MUST BOTH MATCH'}</small></div><b>{isExotic ? (editing.exotic_weapon_id ? 1 : 0) : editing.compatibleCategories.length}</b></div>
{#if isExotic}<SearchableAssignment items={exoticWeapons} fieldName="exoticWeaponId" mode="single" subjectLabel="EXOTIC WEAPON" searchPlaceholder="Search exotic weapons…" bind:value={editing.exotic_weapon_id}/>
{:else}<SegmentedChoice legend="COMPATIBLE WEAPON CATEGORIES" name="compatibleCategories" mode="checkbox" options={categoryOptions} bind:selected={editing.compatibleCategories} columns={6} showIndicator={false}/>{/if}
{:else}<SegmentedChoice legend="COMPATIBLE WEAPON CATEGORIES" name="compatibleCategories" mode="checkbox" options={categoryOptions} bind:selected={editing.compatibleCategories} columns={6}/>{/if}
</div>
</div>
@@ -117,7 +117,7 @@
{/each}
</div>
<div class="weapon-mod-preview">
<div><label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label><SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/></div>
<div><label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label><SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview}/></div>
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span></div>{/if}
</div>
</section>
+1 -1
View File
@@ -364,7 +364,7 @@ export const actions: Actions = {
for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation);
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation, operator.id);
}
if (id) {
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
+1 -1
View File
@@ -98,7 +98,7 @@
<div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/>
<SegmentedChoice legend="NATIVE TIER" name="nativeTier" options={tierOptions} bind:value={editing.native_tier} showIndicator={false} required/>
<SegmentedChoice legend="NATIVE TIER" name="nativeTier" options={tierOptions} bind:value={editing.native_tier} required/>
<SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/>
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
</div>