diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml index b35c51e..8d5a20e 100644 --- a/.gitea/workflows/deploy-production.yml +++ b/.gitea/workflows/deploy-production.yml @@ -43,6 +43,7 @@ jobs: CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }} CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }} COMMIT_SHA: ${{ gitea.sha }} + BUILD_NUMBER: ${{ gitea.run_number }} run: | set -eu : "${DOTENV_PRIVATE_KEY_PRODUCTION:?DOTENV_PRIVATE_KEY_PRODUCTION is required}" @@ -53,7 +54,7 @@ jobs: cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$deployment_path/" DOTENV_PRIVATE_KEY_PRODUCTION="$DOTENV_PRIVATE_KEY_PRODUCTION" npx dotenvx decrypt -f .env.production --stdout > "$deployment_path/.env.production.runtime" printf '\nSCHEDULER_TOKEN=%s\n' "$SCHEDULER_TOKEN_PRODUCTION" >> "$deployment_path/.env.production.runtime" - printf 'SHD_IMAGE=shd-build-production:%s\nSHD_PRODUCTION_PORT=%s\n' "$COMMIT_SHA" "$production_port" > "$deployment_path/.env.deploy" + printf 'SHD_IMAGE=shd-build-production:%s\nSHD_PRODUCTION_PORT=%s\nSHD_BUILD_NUMBER=%s\n' "$COMMIT_SHA" "$production_port" "$BUILD_NUMBER" > "$deployment_path/.env.deploy" chmod 600 "$deployment_path/.env.production.runtime" "$deployment_path/.env.deploy" chmod 755 "$deployment_path/deploy-production.sh" "$deployment_path/bootstrap-production.sh" "$deployment_path/sync-production-isac-to-staging.sh" diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml index 9956210..e2e4d8c 100644 --- a/.gitea/workflows/deploy-staging.yml +++ b/.gitea/workflows/deploy-staging.yml @@ -42,6 +42,7 @@ jobs: CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }} COMMIT_SHA: ${{ gitea.sha }} + BUILD_NUMBER: ${{ gitea.run_number }} run: | set -eu deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}" @@ -49,7 +50,7 @@ jobs: mkdir -p "$deployment_path" cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$deployment_path/" DOTENV_PRIVATE_KEY_STAGING="$DOTENV_PRIVATE_KEY_STAGING" npx dotenvx decrypt -f .env.staging --stdout > "$deployment_path/.env.staging.runtime" - printf 'SHD_IMAGE=shd-build-stage:%s\nSHD_STAGE_PORT=%s\n' "$COMMIT_SHA" "$stage_port" > "$deployment_path/.env.deploy" + printf 'SHD_IMAGE=shd-build-stage:%s\nSHD_STAGE_PORT=%s\nSHD_BUILD_NUMBER=%s\n' "$COMMIT_SHA" "$stage_port" "$BUILD_NUMBER" > "$deployment_path/.env.deploy" chmod 600 "$deployment_path/.env.staging.runtime" "$deployment_path/.env.deploy" chmod 755 "$deployment_path/deploy-staging.sh" "$deployment_path/bootstrap-staging.sh" diff --git a/database/schema.sql b/database/schema.sql index 554217f..5c4a452 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1081,9 +1081,14 @@ CREATE TABLE IF NOT EXISTS managed_images ( byte_size INTEGER NOT NULL CHECK(byte_size > 0), width INTEGER NOT NULL CHECK(width > 0), height INTEGER NOT NULL CHECK(height > 0), + source_entity_type TEXT, + source_entity_id TEXT, created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS managed_images_name_nocase_idx ON managed_images(name COLLATE NOCASE); CREATE INDEX IF NOT EXISTS managed_images_category_idx ON managed_images(category); +CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx + ON managed_images(source_entity_type,source_entity_id) + WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL; diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml index a483c8a..3fe9530 100644 --- a/deploy/compose.production.yml +++ b/deploy/compose.production.yml @@ -15,6 +15,7 @@ services: TDR_DATABASE_PATH: /app/data/shd-build-production.db TDR_AVATAR_STORAGE_PATH: /app/data/avatars TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons + SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0} APP_ORIGIN: https://shd.build ORIGIN: https://shd.build PASSKEY_RP_ID: shd.build diff --git a/deploy/compose.staging.yml b/deploy/compose.staging.yml index e95c53f..e2ecb61 100644 --- a/deploy/compose.staging.yml +++ b/deploy/compose.staging.yml @@ -15,6 +15,7 @@ services: TDR_DATABASE_PATH: /app/data/shd-build-staging.db TDR_AVATAR_STORAGE_PATH: /app/data/avatars TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons + SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0} ports: - "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000" volumes: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6c889e4..77c711c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -94,7 +94,9 @@ In `https://git.jb.codes/jb/shd.build/settings/actions`: `.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual dispatches. The trusted staging runner builds each commit-tagged image locally, -so the workflow needs no registry credentials. +so the workflow needs no registry credentials. Gitea assigns +`gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives the +next integer, while retries of the same run retain their number. ## Deployment lifecycle @@ -217,7 +219,8 @@ Add these repository Action secrets: Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default `/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The `deploy-production.yml` workflow deploys production on pushes to `main` and -manual dispatches using the trusted `stage-deploy` host runner. +manual dispatches using the trusted `stage-deploy` host runner. Its Gitea +workflow run number supplies the production `SHD_BUILD_NUMBER`. Prepare the host directory once from an interactive SSH session: diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 84e0d3d..b65edb4 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -756,10 +756,10 @@ pagination controls to preserve the rail's shape. Registries with a meaningful name, title, or agent-identity column place the shared server-side search inside this pagination rail. Results update after a short typing debounce, so do not place a redundant Search button beside the -field. Label the behavior as `LIVE SEARCH`, replace it with `SEARCHING…` while -navigation is pending, and provide an explicit clear action whenever a query is -active. Search resets the page number while preserving the selected KPI filter -and row-count preset; page navigation preserves the query. Search applies to +field or label the field as live search. Show `SEARCHING…` only while navigation +is pending, and provide an explicit clear action whenever a query is active. +Search resets the page number while preserving the selected KPI filter and +row-count preset; page navigation preserves the query. Search applies to the complete filtered registry before pagination, including visible pending proposals, and never only to the current page. Specialized oversight and history searches may remain in their richer filter consoles when they search @@ -826,19 +826,24 @@ changes to orange, explains that saves enter moderation, and offers a `RETURN TO READ-ONLY` toggle. Disabling is immediate and reversible and does not withdraw existing proposals. Use text for every mode state; color alone is insufficient. At narrow widths, stack the explanation and full-width toggle. +Show contributor access banners only on Data Management modules. Show the +`DATA MODE` status on those modules and the contributor-guidelines page. Omit +data-mode controls and language from User Operations, System Oversight, and +System Configuration because their permissions govern access independently. Read-only mode is an inspection surface: hide add controls, row edit actions, mutation forms, and editors opened through URL state. Do not merely dim or disable them. Registry navigation, KPI filters, pagination, search, evidence, and other non-mutating inspection controls remain visible and interactive. -The ISAC command-status cluster includes an explicit `DATA MODE` cell alongside -system, access, and environment. It reports the highest effective data authority -in this order: `ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then -`READ-ONLY`. Use a neutral treatment for read-only, orange for contributor, -blue for Oversight authority, and green for direct-management authority. The -label and value are mandatory because color is supplemental. At medium widths -the four cells become a two-column grid; on narrow screens the cluster spans -the command header width. +The ISAC command-status cluster includes system state, access state, the themed +ISAC node identifier, the CI build number, and an explicit `DATA MODE` cell. +The data-mode cell reports the highest effective authority in this order: +`ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then `READ-ONLY`. Use a +neutral treatment for read-only, orange for contributor, blue for Oversight +authority, and green for direct-management authority. Every cell requires a +label and value because color provides supplemental information. At medium +widths, arrange the cells in a two-column grid and let the data-mode cell span +both columns. On narrow screens, the cluster spans the command header width. ### Oversight proposal corrections @@ -1063,8 +1068,15 @@ must use their named component classes rather than the global application `VIEW DETAILS` opens the standard in-page modal rather than navigating immediately. The modal repeats sender, source, category, semantic tone, time, -full message body, read control, and optional related-page destination. This -keeps review context in the inbox and makes outbound navigation deliberate. +full message body, read control, and related-page destinations. Keep the sender +header and utility footer fixed while the message and action area scrolls. +Group destination actions beneath an `AVAILABLE ACTIONS` label in a regular +grid; reserve the footer for message state and closing the modal. Opening the +modal moves focus to its close control. Trap keyboard focus inside it, support +Escape and backdrop dismissal, and return focus to the originating `VIEW +DETAILS` control after closing. Inbox cards show up to four lines of body copy +at reading size before truncation. Place the per-message read control beside the +timestamp so message state remains separate from workflow actions. ISAC message content uses the structured system-transmission body. A compact blue `ISAC TRANSMISSION` kicker precedes the summary, and recognized supporting @@ -1074,20 +1086,20 @@ same hierarchy at reading size. Community messages remain conversational body copy; do not apply the system-transmission frame to agent comments. Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete -message when preview copy is truncated to two lines. A separate bordered orange -response action opens the known workflow and uses specific -language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or -`VIEW BUILD & REPLY`; never substitute a generic destination label when the -notification type is known. The same response label appears in the detail -modal. On mobile, response actions become full-width 44-pixel targets. +message when its four-line preview truncates longer copy. A separate bordered +orange response action opens the known workflow and uses specific language such +as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or `VIEW BUILD & REPLY`; never +substitute a generic destination label when the notification type is known. +Lay out these controls in an auto-fitting grid so each label remains intact. +The detail modal uses the same response labels in a three-column grid where +space permits. On mobile, response actions become full-width 44-pixel targets. -Inbox filtering uses additive segmented selectors derived from the OS Protocol -quality control. Channels and message states are independent checkbox-backed -segments, all selected by default, and each selection change applies -immediately without a submit button. Selected segments use the orange lower -rail and filled square indicator; deselected segments remain readable and -available. The channel rail collapses from six to three and then two columns as -space narrows, with minimum 44-pixel mobile targets. +Inbox query controls use two compact radio-backed segmented selectors. `SHOW` +switches between all, unread, and read messages. `SORT` offers newest, oldest, +and unread-first order. Give both selectors the same compact width. Each +selection applies without a submit button. Live search matches message titles, +bodies, and sources across the paginated result set. At narrow widths, the +selectors stack and retain 44-pixel targets. Use the shared `SegmentedChoice` component for every static, scannable ISAC choice set containing two through six options. Radio mode represents one @@ -1115,10 +1127,10 @@ unfocusable and unchanged. `SegmentedChoice` supports two restrained density variants. Icon-only segments may omit visible text when a canonical, unambiguous game icon exists; they must -retain an accessible label and hover title. Text-only segments may omit the -default square indicator when the selected background, orange text, and lower -rail communicate state. Do not remove both visible text and an -icon from the same option. +retain an accessible label and hover title. Do not render generic dots, squares, +checkbox facsimiles, or radio facsimiles inside segments. The selected +background, orange text, and lower rail communicate state. Keep visible text or +an icon in every option. ### Skill Chip data console @@ -1174,8 +1186,8 @@ grid, both selectors use at least 44-pixel targets, and changing slot clears incompatible Talent relationships exactly as the former selector did. Gear slot segments use icon-only controls with accessible slot names; registry labels retain icon and text for rapid table scanning. Tier segments use the compact -game notation `T0`, `T1`, and `T2`, omit the redundant square indicator, and -rely on the selected rail and color; do not expand these to “Tier 0” inside +game notation `T0`, `T1`, and `T2`, and rely on the selected rail and color; do +not expand these to “Tier 0” inside dense segmented controls. On desktop, icon-only Gear slot segments are square 32-pixel cells so their classification rail stays compact and the adjacent record-name input receives the dominant width. Increase those cells to square @@ -1555,9 +1567,11 @@ choices are remembered locally for that browser. Searching the rail temporarily reveals matching modules across all authorized categories without changing the remembered category state. -Use the compact command-status strip for persistent system, access, environment, -and data-mode context. These values remain textual and must not rely on color -alone. Registry search remains available for a single page of results, while +Use the compact command-status strip for persistent system, access, ISAC node, +and CI build number context. Add the data-mode cell only in the Data Management +context defined above. Keep each label above its value so the visible cells +share one alignment. Text must communicate every state without relying on +color. Registry search remains available for a single page of results, while page navigation and row-count controls appear only when the result set spans multiple pages. This keeps expert lookup fast without presenting inactive controls to contributors and first-time Oversight operators. @@ -1765,3 +1779,16 @@ Assisted suggestions create a draft revision for review and leave activation to an authorized operator. Use monospaced text for raw loadout and prediction snapshots; labels, findings, and errors stay in the standard operational typeface. + +### Image Library assignment state + +An ISAC game-data editor registers each uploaded icon in the Image Library. The +library record and editor share one processed file. Its card uses `ASSIGNED +ICON`, names the owning entity type beside the category, and carries searchable +tags for `isac`, `game data`, the module, the entity name, and entity context +such as quality, focus, ability, or Talent category. + +Operators may rename, recategorize, and add tags to the library record. Replacing +the icon from its game-data editor keeps that metadata and refreshes the context +tags and file facts. Removing the source icon removes its linked library record. +General library uploads retain the `ICON` or `IMAGE` state. diff --git a/docs/architecture.md b/docs/architecture.md index d8c00fe..dc889d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -367,6 +367,19 @@ time by the static `/missing-item-icon.svg` asset through the shared `MISSING_ITEM_ICON_URL` constant and `ItemIcon` component. The placeholder does not enter `game_data_icons`. A later edit or proposal can attach the real icon without recreating the canonical item. + +`source_entity_type` and `source_entity_id` link each `game_data_icons` row to a +`managed_images` record. Both records share one storage key and one WebP file. +The managed record adds the searchable library name, category, tags, creator, +dimensions, and assignment state. Game-data icon saves upsert this record, keep +operator-edited metadata, and merge current entity-context tags. Removing a +source icon removes the linked library record and shared file. + +The additive database migration registers older game-data icons and selects an +administrator or the earliest active account as the migration actor. ISAC and +managed-image exports accept linked storage keys. Environment synchronization +keeps the library metadata and one copy of each file. + # Oversight notification dispatch New Data Change and Avatar Oversight review items are routed only to agents who diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 566ae81..2da21c1 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -39,6 +39,7 @@ const statements = schema for (const statement of statements) { // This index is deferred until the additive legacy-column migration below. if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue; + if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue; await database.exec(statement); } @@ -290,6 +291,168 @@ if (typeof specializationIconDefinition?.sql === 'string' && !specializationIcon await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons'); } +const managedImageColumns = await (await database.prepare("PRAGMA table_info('managed_images')")).all(); +for (const [name, definition] of [ + ['source_entity_type', 'TEXT'], + ['source_entity_id', 'TEXT'] +]) { + if (!managedImageColumns.some((column) => column.name === name)) { + console.log(`Linking Image Library assets to ISAC entities: ${name}.`); + await database.exec(`ALTER TABLE managed_images ADD COLUMN ${name} ${definition}`); + } +} +await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx + ON managed_images(source_entity_type,source_entity_id) + WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL`); + +const iconLibraryCreator = await (await database.prepare(`SELECT users.id + FROM users + LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id + LEFT JOIN user_roles ON user_roles.user_id=users.id + LEFT JOIN roles ON roles.id=user_roles.role_id + LEFT JOIN role_permissions ON role_permissions.role_id=roles.id + LEFT JOIN permissions ON permissions.id=role_permissions.permission_id + WHERE deleted_accounts.user_id IS NULL + GROUP BY users.id,users.created_at + ORDER BY MAX(CASE WHEN roles.name='Admin' THEN 1 + WHEN permissions.name='*' THEN 1 ELSE 0 END) DESC, + users.created_at ASC LIMIT 1`)).get(); + +const iconLibraryCategory = { + talent: 'Talents', + brand_set: 'Brands', + os_protocol: 'OS Protocols', + skill_chip: 'Skill Chips', + specialization: 'Specializations', + specialization_ability: 'Specializations', + specialization_node: 'Specializations' +}; +const iconLibraryLabel = { + 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' +}; +const normalizeLibraryTags = (values) => [...new Set(values + .flatMap((value) => String(value ?? '').split(',')) + .map((value) => value.normalize('NFKC').trim().toLocaleLowerCase()) + .filter(Boolean))].slice(0, 20); +const linkedIconMetadata = async (entityType, entityId) => { + switch (entityType) { + 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'] }; + } + default: + return { name: null, tags: [] }; + } +}; +const uniqueLibraryName = async (preferred, entityType, entityId) => { + const candidates = [ + preferred, + `${preferred} · ${iconLibraryLabel[entityType] ?? 'ISAC Asset'}`, + `${preferred} · ${entityId.slice(0, 8)}` + ]; + for (const candidate of candidates) { + 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)}`; +}; + +if (iconLibraryCreator?.id) { + const unregisteredIcons = await (await database.prepare(`SELECT icon.* + 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 image.id IS NULL + ORDER BY icon.entity_type,icon.entity_id`)).all(); + if (unregisteredIcons.length) console.log(`Registering ${unregisteredIcons.length} existing ISAC icons in the Image Library.`); + for (const icon of unregisteredIcons) { + const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id)); + const label = iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'; + const preferredName = String(metadata.name ?? `${label} ${String(icon.entity_id).slice(0, 8)}`); + const name = await uniqueLibraryName(preferredName, String(icon.entity_type), String(icon.entity_id)); + const tags = normalizeLibraryTags([ + 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags + ]); + const now = String(icon.updated_at ?? new Date().toISOString()); + 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(lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2) + ||'-'||substr('89ab',abs(random())%4+1,1)||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(6))), + ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + name, iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), 1, 0, + icon.storage_key, icon.storage_key, icon.byte_size, 256, 256, + icon.entity_type, icon.entity_id, iconLibraryCreator.id, now, now + ); + } + const linkedIcons = await (await database.prepare(`SELECT icon.*,image.id AS library_image_id,image.tags_json + FROM game_data_icons icon + JOIN managed_images image + ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id + ORDER BY icon.entity_type,icon.entity_id`)).all(); + for (const icon of linkedIcons) { + const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id)); + const preferredName = String(metadata.name ?? `${iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'} ${String(icon.entity_id).slice(0, 8)}`); + let existingTags = []; + try { existingTags = JSON.parse(String(icon.tags_json)); } catch { /* replace malformed legacy tags below */ } + existingTags = existingTags.filter((tag) => String(tag).toLocaleLowerCase() !== 'vest'); + const tags = normalizeLibraryTags([ + ...existingTags, 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags + ]); + await (await database.prepare(`UPDATE managed_images SET + category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256 + WHERE id=?`)).run( + iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), + icon.storage_key, icon.storage_key, icon.byte_size, icon.library_image_id + ); + } +} + const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all(); if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) { console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.'); diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs index 999be90..e940511 100644 --- a/scripts/isac-data-export.mjs +++ b/scripts/isac-data-export.mjs @@ -23,12 +23,15 @@ for (const name of ISAC_DATA_TABLES) { } const icons = []; +const exportedStorageKeys = new Set(); const iconTable = tables.find((table) => table.name === 'game_data_icons'); const managedImageTable = tables.find((table) => table.name === 'managed_images'); for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) { if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) { throw new Error(`Invalid managed asset storage key "${String(row.storage_key)}".`); } + if (exportedStorageKeys.has(row.storage_key)) continue; + exportedStorageKeys.add(row.storage_key); const content = await readFile(join(iconDirectory, row.storage_key)); icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') }); } diff --git a/scripts/managed-images-export.mjs b/scripts/managed-images-export.mjs index 35a7f8e..1cb51b4 100644 --- a/scripts/managed-images-export.mjs +++ b/scripts/managed-images-export.mjs @@ -10,7 +10,8 @@ const database = await connect(databasePath); const rows = await (await database.prepare('SELECT * FROM managed_images ORDER BY created_at')).all(); const images = []; for (const row of rows) { - if (typeof row.storage_key !== 'string' || !/^library-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) { + if (typeof row.storage_key !== 'string' + || !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) { throw new Error(`Invalid managed-image storage key "${String(row.storage_key)}".`); } images.push({ row, contentBase64: (await readFile(join(storageDirectory, row.storage_key))).toString('base64') }); diff --git a/scripts/managed-images-import.mjs b/scripts/managed-images-import.mjs index de57ef6..ad15b2c 100644 --- a/scripts/managed-images-import.mjs +++ b/scripts/managed-images-import.mjs @@ -17,10 +17,16 @@ if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !A } for (const image of bundle.images) { if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id) - || typeof image.row.storage_key !== 'string' || !/^library-[0-9a-f-]{36}\.webp$/i.test(image.row.storage_key) + || typeof image.row.storage_key !== 'string' + || !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(image.row.storage_key) || typeof image.contentBase64 !== 'string') { throw new Error('The bundle contains an invalid managed image.'); } + if (image.row.source_entity_type && ( + typeof image.row.source_entity_id !== 'string' + || !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) + || !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`) + )) throw new Error('The bundle contains an invalid linked ISAC image.'); } const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { @@ -47,7 +53,7 @@ for (const image of bundle.images) { const row = image.row; const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id); if (existing && !updateExisting) { - const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height'] + const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height','source_entity_type','source_entity_id'] .every((key) => String(existing[key]) === String(row[key])); if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`); unchanged++; @@ -57,6 +63,12 @@ for (const image of bundle.images) { if (!creator) throw new Error(`Image "${row.name}" references a creator that does not exist in the target database.`); const nameConflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?')).get(row.name, row.id); if (nameConflict) throw new Error(`Image name "${row.name}" is already assigned to a different target asset.`); + if (row.source_entity_type && row.source_entity_id) { + const sourceConflict = await (await database.prepare( + 'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?' + )).get(row.source_entity_type, row.source_entity_id, row.id); + if (sourceConflict) throw new Error(`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`); + } preflight.push({ image, existing }); } @@ -65,22 +77,24 @@ try { for (const { image, existing } of preflight) { const row = image.row; await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), { - mode: 0o600, flag: existing ? 'w' : 'wx' + mode: 0o600, flag: existing || row.source_entity_type ? 'w' : 'wx' }); if (existing) { await (await database.prepare(`UPDATE managed_images SET name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?, - byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)).run( + byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)).run( row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename, - row.byte_size,row.width,row.height,row.updated_at,row.id + row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,row.updated_at,row.id ); updated++; } else { await (await database.prepare(`INSERT INTO managed_images - (id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + (id,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( row.id,row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename, - row.byte_size,row.width,row.height,row.created_by,row.created_at,row.updated_at + row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null, + row.created_by,row.created_at,row.updated_at ); inserted++; } diff --git a/src/app.css b/src/app.css index afb4781..8f802b3 100644 --- a/src/app.css +++ b/src/app.css @@ -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} diff --git a/src/lib/components/RegistryPagination.svelte b/src/lib/components/RegistryPagination.svelte index e711db1..2497b0b 100644 --- a/src/lib/components/RegistryPagination.svelte +++ b/src/lib/components/RegistryPagination.svelte @@ -73,7 +73,7 @@ {/if} diff --git a/src/lib/components/SegmentedChoice.svelte b/src/lib/components/SegmentedChoice.svelte index 59a1c33..3f46abc 100644 --- a/src/lib/components/SegmentedChoice.svelte +++ b/src/lib/components/SegmentedChoice.svelte @@ -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)} /> - {#if option.icon}{:else if showIndicator}{/if} + {#if option.icon}{/if} {option.label} diff --git a/src/lib/components/StructuredEffectEditor.svelte b/src/lib/components/StructuredEffectEditor.svelte index 51f546c..24c34a6 100644 --- a/src/lib/components/StructuredEffectEditor.svelte +++ b/src/lib/components/StructuredEffectEditor.svelte @@ -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 @@ /> - + {:else} diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts index 0d4204c..7125521 100644 --- a/src/lib/server/game-data-icons.ts +++ b/src/lib/server/game-data-icons.ts @@ -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 = { + 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 = { + 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 { } } -export async function saveGameDataIcon(database: Database, type: GameDataIconType, entityId: string, output: Buffer): Promise { +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 { 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 { 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 { - 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 { + 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; } diff --git a/src/lib/server/managed-images.ts b/src/lib/server/managed-images.ts index 1ae55e9..f47395b 100644 --- a/src/lib/server/managed-images.ts +++ b/src/lib/server/managed-images.ts @@ -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 {} diff --git a/src/routes/account/notifications/+page.server.ts b/src/routes/account/notifications/+page.server.ts index 6475897..b967d35 100644 --- a/src/routes/account/notifications/+page.server.ts +++ b/src/routes/account/notifications/+page.server.ts @@ -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 = { diff --git a/src/routes/account/notifications/+page.svelte b/src/routes/account/notifications/+page.svelte index b723715..9d31cff 100644 --- a/src/routes/account/notifications/+page.svelte +++ b/src/routes/account/notifications/+page.svelte @@ -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(null); let selected = $state(null); + let detailDialog = $state(); + let detailTrigger: HTMLButtonElement | null = null; let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission); const labels: Record = { 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('.notification-filter-console')?.requestSubmit(); + document.querySelector('.notification-query-console')?.requestSubmit(); + } + async function openDetails(notification: any, trigger: HTMLButtonElement) { + detailTrigger = trigger; + selected = notification; + await tick(); + detailDialog?.querySelector('.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('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 @@ {/if}
-
- - ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/> - + + {#if data.filters.search}{/if} + + + {#if data.unread}{/if}
- +
{#each data.notifications as notification}
{notification.source}{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}
-
{#if severity(notification)}{severity(notification)?.toUpperCase()}{/if}

{notification.title}

{#if isIsac(notification)}{:else}

{notification.body}

{/if}
{#if storedActions(notification).length}{#each storedActions(notification) as callout}{callout.label} →{/each}{:else if action(notification)}{action(notification)?.label} →{/if}
{#if !notification.read_at}{:else}READ{/if}
+
+
+
{#if severity(notification)}{severity(notification)?.toUpperCase()}{/if}

{notification.title}

+
+ + {#if !notification.read_at} + + {:else} + READ + {/if} +
+
+ {#if isIsac(notification)}{:else}

{notification.body}

{/if} +
+
+ + {#if storedActions(notification).length} + {#each storedActions(notification) as callout}{callout.label} →{/each} + {:else if action(notification)} + {action(notification)?.label} → + {/if} +
+
+
- {:else}
◇NO NOTIFICATIONS

No messages match the selected channel and state.

{/each} + {:else}
◇NO NOTIFICATIONS

No messages match the current search and message state.

{/each}
{#if selected} - {#if form?.revisionError}

{form.revisionError}

{/if} @@ -191,7 +191,7 @@

{calibrationEditing.id ? 'MODIFY CALIBRATION CASE' : 'NEW CALIBRATION CASE'}

{calibrationEditing.name || 'FIELD OBSERVATION'}

-
+
diff --git a/src/routes/isac/gear/+page.svelte b/src/routes/isac/gear/+page.svelte index 77ec45e..3bddad0 100644 --- a/src/routes/isac/gear/+page.svelte +++ b/src/routes/isac/gear/+page.svelte @@ -113,7 +113,7 @@
changeSlot(values[0])} required/> - +
HPBASE HEALTH
diff --git a/src/routes/isac/gearmods/+page.svelte b/src/routes/isac/gearmods/+page.svelte index ab44124..9ede477 100644 --- a/src/routes/isac/gearmods/+page.svelte +++ b/src/routes/isac/gearmods/+page.svelte @@ -125,7 +125,7 @@
- ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/> + ({ value:quality,label:quality }))} bind:value={qualityPreview}/>
{#if previewRow}
diff --git a/src/routes/isac/images/+page.server.ts b/src/routes/isac/images/+page.server.ts index e7d5bda..44369ec 100644 --- a/src/routes/isac/images/+page.server.ts +++ b/src/routes/isac/images/+page.server.ts @@ -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) => ({ diff --git a/src/routes/isac/images/+page.svelte b/src/routes/isac/images/+page.svelte index e7b0b53..3144cf8 100644 --- a/src/routes/isac/images/+page.svelte +++ b/src/routes/isac/images/+page.svelte @@ -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 @@
ASSETS{Number(data.stats.total)}Managed files
-
ICONS{Number(data.stats.icons)}Normalized assets
+
ICONS{Number(data.stats.icons)}{Number(data.stats.linked)} linked to ISAC data
CATEGORIES{Number(data.stats.categories)}Assignments in use
STORAGE{size(data.stats.bytes)}Processed WebP
@@ -81,7 +82,10 @@ {#each data.images as image}
Preview of {image.name}
-
{image.name}{image.category}
{image.is_icon ? 'ICON' : 'IMAGE'}
+
+
{image.name}{image.category}{image.source_entity_type ? ` · ${sourceLabel(image.source_entity_type)}` : ''}
+ {image.source_entity_type ? 'ASSIGNED ICON' : image.is_icon ? 'ICON' : 'IMAGE'} +
{#each image.tags as tag}{tag}{/each}{#if !image.tags.length}NO TAGS{/if}
{image.width} × {image.height} · {size(image.byte_size)}
diff --git a/src/routes/isac/protocols/+page.server.ts b/src/routes/isac/protocols/+page.server.ts index 67d179d..45489e3 100644 --- a/src/routes/isac/protocols/+page.server.ts +++ b/src/routes/isac/protocols/+page.server.ts @@ -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() }; }, diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index dd019a9..f912241 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -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) }); diff --git a/src/routes/isac/specializations/+page.server.ts b/src/routes/isac/specializations/+page.server.ts index ec7478f..57475aa 100644 --- a/src/routes/isac/specializations/+page.server.ts +++ b/src/routes/isac/specializations/+page.server.ts @@ -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, diff --git a/src/routes/isac/specializations/+page.svelte b/src/routes/isac/specializations/+page.svelte index b9633be..a3482eb 100644 --- a/src/routes/isac/specializations/+page.svelte +++ b/src/routes/isac/specializations/+page.svelte @@ -517,7 +517,7 @@
- changeMaxRank(values[0])} showIndicator={false} required/> + changeMaxRank(values[0])} required/>
@@ -550,7 +550,7 @@
ATTRIBUTE PER STACK
- +
{/each} diff --git a/src/routes/isac/talents/+page.server.ts b/src/routes/isac/talents/+page.server.ts index 9daa18e..7dd6f7f 100644 --- a/src/routes/isac/talents/+page.server.ts +++ b/src/routes/isac/talents/+page.server.ts @@ -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() }; } diff --git a/src/routes/isac/weaponmods/+page.svelte b/src/routes/isac/weaponmods/+page.svelte index 2570dd4..c8273a4 100644 --- a/src/routes/isac/weaponmods/+page.svelte +++ b/src/routes/isac/weaponmods/+page.svelte @@ -92,14 +92,14 @@ {#if isExotic}
EXOTIC QUALITYUNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS
{:else}
PRIMARY ATTRIBUTE OPTIONS
{#each ['firepower','toughness','engineering'] as primary}{primary.toUpperCase()}{/each}
Selected by the agent while configuring a build.
{/if} - +
{isExotic ? 'EXOTIC WEAPON LOCK' : 'WEAPON CATEGORY COMPATIBILITY'}{isExotic ? 'PRE-EQUIPPED · CANNOT BE REMOVED' : 'SLOT TYPE AND CATEGORY MUST BOTH MATCH'}
{isExotic ? (editing.exotic_weapon_id ? 1 : 0) : editing.compatibleCategories.length}
{#if isExotic} - {:else}{/if} + {:else}{/if}
@@ -117,7 +117,7 @@ {/each}
-
({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/>
+
({ value:quality,label:quality }))} bind:value={qualityPreview}/>
{#if previewRow}
SELECTED PRIMARY · LEVEL {levelPreview}{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}
SECONDARY ATTRIBUTE POOL{editing.secondaryRanges.length} OPTIONS
{/if}
diff --git a/src/routes/isac/weapons/+page.server.ts b/src/routes/isac/weapons/+page.server.ts index 93939c2..2155ab2 100644 --- a/src/routes/isac/weapons/+page.server.ts +++ b/src/routes/isac/weapons/+page.server.ts @@ -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); diff --git a/src/routes/isac/weapons/+page.svelte b/src/routes/isac/weapons/+page.svelte index 848a115..fab9bd7 100644 --- a/src/routes/isac/weapons/+page.svelte +++ b/src/routes/isac/weapons/+page.svelte @@ -98,7 +98,7 @@
setCategory(values[0])} required/> - +