feat: refine ISAC media and notification workflows
This commit is contained in:
+24
-14
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() };
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() };
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user