feat: refine ISAC media and notification workflows

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