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