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_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
||||||
CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
|
CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
: "${DOTENV_PRIVATE_KEY_PRODUCTION:?DOTENV_PRIVATE_KEY_PRODUCTION is required}"
|
: "${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/"
|
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"
|
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 '\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 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"
|
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_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }}
|
CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
||||||
@@ -49,7 +50,7 @@ jobs:
|
|||||||
mkdir -p "$deployment_path"
|
mkdir -p "$deployment_path"
|
||||||
cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$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"
|
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 600 "$deployment_path/.env.staging.runtime" "$deployment_path/.env.deploy"
|
||||||
chmod 755 "$deployment_path/deploy-staging.sh" "$deployment_path/bootstrap-staging.sh"
|
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),
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
width INTEGER NOT NULL CHECK(width > 0),
|
width INTEGER NOT NULL CHECK(width > 0),
|
||||||
height INTEGER NOT NULL CHECK(height > 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_by TEXT NOT NULL REFERENCES users(id),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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 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 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_DATABASE_PATH: /app/data/shd-build-production.db
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
||||||
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
APP_ORIGIN: https://shd.build
|
APP_ORIGIN: https://shd.build
|
||||||
ORIGIN: https://shd.build
|
ORIGIN: https://shd.build
|
||||||
PASSKEY_RP_ID: shd.build
|
PASSKEY_RP_ID: shd.build
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
TDR_DATABASE_PATH: /app/data/shd-build-staging.db
|
TDR_DATABASE_PATH: /app/data/shd-build-staging.db
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
||||||
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000"
|
- "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000"
|
||||||
volumes:
|
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
|
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
|
||||||
dispatches. The trusted staging runner builds each commit-tagged image locally,
|
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
|
## Deployment lifecycle
|
||||||
|
|
||||||
@@ -217,7 +219,8 @@ Add these repository Action secrets:
|
|||||||
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
|
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
|
||||||
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
|
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
|
||||||
`deploy-production.yml` workflow deploys production on pushes to `main` and
|
`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:
|
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
|
Registries with a meaningful name, title, or agent-identity column place the
|
||||||
shared server-side search inside this pagination rail. Results update after a
|
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
|
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
|
field or label the field as live search. Show `SEARCHING…` only while navigation
|
||||||
navigation is pending, and provide an explicit clear action whenever a query is
|
is pending, and provide an explicit clear action whenever a query is active.
|
||||||
active. Search resets the page number while preserving the selected KPI filter
|
Search resets the page number while preserving the selected KPI filter and
|
||||||
and row-count preset; page navigation preserves the query. Search applies to
|
row-count preset; page navigation preserves the query. Search applies to
|
||||||
the complete filtered registry before pagination, including visible pending
|
the complete filtered registry before pagination, including visible pending
|
||||||
proposals, and never only to the current page. Specialized oversight and
|
proposals, and never only to the current page. Specialized oversight and
|
||||||
history searches may remain in their richer filter consoles when they search
|
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
|
`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
|
withdraw existing proposals. Use text for every mode state; color alone is
|
||||||
insufficient. At narrow widths, stack the explanation and full-width toggle.
|
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,
|
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
|
mutation forms, and editors opened through URL state. Do not merely dim or
|
||||||
disable them. Registry navigation, KPI filters, pagination, search, evidence,
|
disable them. Registry navigation, KPI filters, pagination, search, evidence,
|
||||||
and other non-mutating inspection controls remain visible and interactive.
|
and other non-mutating inspection controls remain visible and interactive.
|
||||||
|
|
||||||
The ISAC command-status cluster includes an explicit `DATA MODE` cell alongside
|
The ISAC command-status cluster includes system state, access state, the themed
|
||||||
system, access, and environment. It reports the highest effective data authority
|
ISAC node identifier, the CI build number, and an explicit `DATA MODE` cell.
|
||||||
in this order: `ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then
|
The data-mode cell reports the highest effective authority in this order:
|
||||||
`READ-ONLY`. Use a neutral treatment for read-only, orange for contributor,
|
`ADMIN`, `OVERSIGHT`, `DATA MANAGER`, `CONTRIBUTOR`, then `READ-ONLY`. Use a
|
||||||
blue for Oversight authority, and green for direct-management authority. The
|
neutral treatment for read-only, orange for contributor, blue for Oversight
|
||||||
label and value are mandatory because color is supplemental. At medium widths
|
authority, and green for direct-management authority. Every cell requires a
|
||||||
the four cells become a two-column grid; on narrow screens the cluster spans
|
label and value because color provides supplemental information. At medium
|
||||||
the command header width.
|
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
|
### 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
|
`VIEW DETAILS` opens the standard in-page modal rather than navigating
|
||||||
immediately. The modal repeats sender, source, category, semantic tone, time,
|
immediately. The modal repeats sender, source, category, semantic tone, time,
|
||||||
full message body, read control, and optional related-page destination. This
|
full message body, read control, and related-page destinations. Keep the sender
|
||||||
keeps review context in the inbox and makes outbound navigation deliberate.
|
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
|
ISAC message content uses the structured system-transmission body. A compact
|
||||||
blue `ISAC TRANSMISSION` kicker precedes the summary, and recognized supporting
|
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.
|
copy; do not apply the system-transmission frame to agent comments.
|
||||||
|
|
||||||
Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete
|
Inbox entries expose two distinct actions. `VIEW DETAILS` opens the complete
|
||||||
message when preview copy is truncated to two lines. A separate bordered orange
|
message when its four-line preview truncates longer copy. A separate bordered
|
||||||
response action opens the known workflow and uses specific
|
orange response action opens the known workflow and uses specific language such
|
||||||
language such as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or
|
as `UPLOAD NEW AVATAR`, `REVISE CONTRIBUTION`, or `VIEW BUILD & REPLY`; never
|
||||||
`VIEW BUILD & REPLY`; never substitute a generic destination label when the
|
substitute a generic destination label when the notification type is known.
|
||||||
notification type is known. The same response label appears in the detail
|
Lay out these controls in an auto-fitting grid so each label remains intact.
|
||||||
modal. On mobile, response actions become full-width 44-pixel targets.
|
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
|
Inbox query controls use two compact radio-backed segmented selectors. `SHOW`
|
||||||
quality control. Channels and message states are independent checkbox-backed
|
switches between all, unread, and read messages. `SORT` offers newest, oldest,
|
||||||
segments, all selected by default, and each selection change applies
|
and unread-first order. Give both selectors the same compact width. Each
|
||||||
immediately without a submit button. Selected segments use the orange lower
|
selection applies without a submit button. Live search matches message titles,
|
||||||
rail and filled square indicator; deselected segments remain readable and
|
bodies, and sources across the paginated result set. At narrow widths, the
|
||||||
available. The channel rail collapses from six to three and then two columns as
|
selectors stack and retain 44-pixel targets.
|
||||||
space narrows, with minimum 44-pixel mobile targets.
|
|
||||||
|
|
||||||
Use the shared `SegmentedChoice` component for every static, scannable ISAC
|
Use the shared `SegmentedChoice` component for every static, scannable ISAC
|
||||||
choice set containing two through six options. Radio mode represents one
|
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
|
`SegmentedChoice` supports two restrained density variants. Icon-only segments
|
||||||
may omit visible text when a canonical, unambiguous game icon exists; they must
|
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
|
retain an accessible label and hover title. Do not render generic dots, squares,
|
||||||
default square indicator when the selected background, orange text, and lower
|
checkbox facsimiles, or radio facsimiles inside segments. The selected
|
||||||
rail communicate state. Do not remove both visible text and an
|
background, orange text, and lower rail communicate state. Keep visible text or
|
||||||
icon from the same option.
|
an icon in every option.
|
||||||
|
|
||||||
### Skill Chip data console
|
### 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
|
incompatible Talent relationships exactly as the former selector did. Gear
|
||||||
slot segments use icon-only controls with accessible slot names; registry labels
|
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
|
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
|
game notation `T0`, `T1`, and `T2`, and rely on the selected rail and color; do
|
||||||
rely on the selected rail and color; do not expand these to “Tier 0” inside
|
not expand these to “Tier 0” inside
|
||||||
dense segmented controls. On desktop, icon-only Gear slot segments are square
|
dense segmented controls. On desktop, icon-only Gear slot segments are square
|
||||||
32-pixel cells so their classification rail stays compact and the adjacent
|
32-pixel cells so their classification rail stays compact and the adjacent
|
||||||
record-name input receives the dominant width. Increase those cells to square
|
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
|
reveals matching modules across all authorized categories without changing the
|
||||||
remembered category state.
|
remembered category state.
|
||||||
|
|
||||||
Use the compact command-status strip for persistent system, access, environment,
|
Use the compact command-status strip for persistent system, access, ISAC node,
|
||||||
and data-mode context. These values remain textual and must not rely on color
|
and CI build number context. Add the data-mode cell only in the Data Management
|
||||||
alone. Registry search remains available for a single page of results, while
|
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
|
page navigation and row-count controls appear only when the result set spans
|
||||||
multiple pages. This keeps expert lookup fast without presenting inactive
|
multiple pages. This keeps expert lookup fast without presenting inactive
|
||||||
controls to contributors and first-time Oversight operators.
|
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
|
to an authorized operator. Use monospaced text for raw loadout and prediction
|
||||||
snapshots; labels, findings, and errors stay in the standard operational
|
snapshots; labels, findings, and errors stay in the standard operational
|
||||||
typeface.
|
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
|
`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
|
not enter `game_data_icons`. A later edit or proposal can attach the real icon
|
||||||
without recreating the canonical item.
|
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
|
# Oversight notification dispatch
|
||||||
|
|
||||||
New Data Change and Avatar Oversight review items are routed only to agents who
|
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) {
|
for (const statement of statements) {
|
||||||
// This index is deferred until the additive legacy-column migration below.
|
// 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 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);
|
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');
|
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();
|
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
|
||||||
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
|
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
|
||||||
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
|
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 icons = [];
|
||||||
|
const exportedStorageKeys = new Set();
|
||||||
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
||||||
const managedImageTable = tables.find((table) => table.name === 'managed_images');
|
const managedImageTable = tables.find((table) => table.name === 'managed_images');
|
||||||
for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) {
|
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)) {
|
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)}".`);
|
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));
|
const content = await readFile(join(iconDirectory, row.storage_key));
|
||||||
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
|
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 rows = await (await database.prepare('SELECT * FROM managed_images ORDER BY created_at')).all();
|
||||||
const images = [];
|
const images = [];
|
||||||
for (const row of rows) {
|
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)}".`);
|
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') });
|
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) {
|
for (const image of bundle.images) {
|
||||||
if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id)
|
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') {
|
|| typeof image.contentBase64 !== 'string') {
|
||||||
throw new Error('The bundle contains an invalid managed image.');
|
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'], {
|
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
@@ -47,7 +53,7 @@ for (const image of bundle.images) {
|
|||||||
const row = image.row;
|
const row = image.row;
|
||||||
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
|
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
|
||||||
if (existing && !updateExisting) {
|
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]));
|
.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.`);
|
if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`);
|
||||||
unchanged++;
|
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.`);
|
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);
|
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 (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 });
|
preflight.push({ image, existing });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,22 +77,24 @@ try {
|
|||||||
for (const { image, existing } of preflight) {
|
for (const { image, existing } of preflight) {
|
||||||
const row = image.row;
|
const row = image.row;
|
||||||
await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), {
|
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) {
|
if (existing) {
|
||||||
await (await database.prepare(`UPDATE managed_images SET
|
await (await database.prepare(`UPDATE managed_images SET
|
||||||
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
|
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.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++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
await (await database.prepare(`INSERT INTO managed_images
|
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)
|
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
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.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++;
|
inserted++;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-14
@@ -316,22 +316,33 @@
|
|||||||
background:var(--shd-surface);
|
background:var(--shd-surface);
|
||||||
}
|
}
|
||||||
.compact-status>span{
|
.compact-status>span{
|
||||||
min-height:36px;
|
position:relative;
|
||||||
display:flex;
|
min-width:105px;
|
||||||
align-items:center;
|
min-height:48px;
|
||||||
gap:5px;
|
display:grid;
|
||||||
padding:0 10px;
|
align-content:center;
|
||||||
|
gap:3px;
|
||||||
|
padding:7px 10px;
|
||||||
border-left:1px solid var(--shd-border);
|
border-left:1px solid var(--shd-border);
|
||||||
color:var(--shd-text-dim);
|
color:var(--shd-text);
|
||||||
font-size:var(--shd-type-telemetry);
|
font-size:var(--shd-type-telemetry);
|
||||||
font-weight:900;
|
font-weight:900;
|
||||||
letter-spacing:.8px;
|
letter-spacing:.8px;
|
||||||
white-space:nowrap;
|
white-space:nowrap;
|
||||||
}
|
}
|
||||||
.compact-status>span:first-child{border-left:0}
|
.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{
|
.compact-status i{
|
||||||
position:static;
|
position:absolute;
|
||||||
|
top:8px;
|
||||||
|
right:8px;
|
||||||
width:6px;
|
width:6px;
|
||||||
height:6px;
|
height:6px;
|
||||||
border-radius:50%;
|
border-radius:50%;
|
||||||
@@ -395,7 +406,8 @@
|
|||||||
@media(max-width:900px){
|
@media(max-width:900px){
|
||||||
.compact-status{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%}
|
.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(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){
|
@media(max-width:650px){
|
||||||
.registry-pagination:not(:has(.pagination-pages)){grid-template-columns:1fr}
|
.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. */
|
/* 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{
|
.mutation-policy-strip{
|
||||||
display:flex;
|
display:flex;
|
||||||
justify-content:flex-end;
|
justify-content:flex-end;
|
||||||
margin-bottom:-38px;
|
min-height:34px;
|
||||||
padding:7px 205px 0 0;
|
padding:0 2px 8px;
|
||||||
pointer-events:none;
|
pointer-events:none;
|
||||||
}
|
}
|
||||||
.mutation-consequence{
|
.mutation-consequence{
|
||||||
@@ -430,15 +443,12 @@
|
|||||||
}
|
}
|
||||||
.mutation-consequence span{color:var(--shd-text-muted)}
|
.mutation-consequence span{color:var(--shd-text-muted)}
|
||||||
.mutation-consequence.direct b{border-color:var(--shd-success);color:var(--shd-success)}
|
.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){
|
@media(max-width:1100px){
|
||||||
.mutation-policy-strip{
|
.mutation-policy-strip{
|
||||||
justify-content:flex-start;
|
justify-content:flex-start;
|
||||||
margin:0;
|
|
||||||
padding:8px 0;
|
padding:8px 0;
|
||||||
border-bottom:1px solid var(--shd-border);
|
border-bottom:1px solid var(--shd-border);
|
||||||
}
|
}
|
||||||
.admin-content:has(.calculation-actions) .mutation-policy-strip{padding-right:0}
|
|
||||||
}
|
}
|
||||||
@media(max-width:600px){
|
@media(max-width:600px){
|
||||||
.mutation-consequence{align-items:flex-start;flex-direction:column;gap:5px}
|
.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(); }}>
|
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
|
||||||
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
|
{#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)}/>
|
<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}
|
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
selected = $bindable([]),
|
selected = $bindable([]),
|
||||||
columns = options.length,
|
columns = options.length,
|
||||||
required = false,
|
required = false,
|
||||||
showIndicator = true,
|
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
legend: string;
|
legend: string;
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
selected?: string[];
|
selected?: string[];
|
||||||
columns?: number;
|
columns?: number;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
showIndicator?: boolean;
|
|
||||||
onchange?: (values: string[]) => void;
|
onchange?: (values: string[]) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -61,7 +59,7 @@
|
|||||||
onchange={(event) => change(option.value, event.currentTarget.checked)}
|
onchange={(event) => change(option.value, event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
|
<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>
|
<b class:sr-only={option.iconOnly}>{option.label}</b>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
options={effectTypes}
|
options={effectTypes}
|
||||||
bind:value={effect.type}
|
bind:value={effect.type}
|
||||||
onchange={(values) => changeType(index, values[0])}
|
onchange={(values) => changeType(index, values[0])}
|
||||||
showIndicator={false}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{#if effect.type === 'Attribute'}
|
{#if effect.type === 'Attribute'}
|
||||||
@@ -92,7 +91,7 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">VALUE<input type="number" step="any" bind:value={effect.value} placeholder="OPTIONAL"/></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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Database } from '@tursodatabase/database';
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
@@ -14,6 +15,24 @@ export const MAX_GAME_ICON_PIXELS = 12_000_000;
|
|||||||
export const GAME_ICON_SIZE = 256;
|
export const GAME_ICON_SIZE = 256;
|
||||||
const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
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 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 {}
|
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);
|
const key = storageKey(type, entityId);
|
||||||
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
|
||||||
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
|
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
|
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`))
|
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
|
||||||
.run(type, entityId, key, output.length, now);
|
.run(type, entityId, key, output.length, now);
|
||||||
|
await registerLibraryIcon(database, type, entityId, key, output.length, now, actorId);
|
||||||
return now;
|
return now;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGameDataIcon(database: Database, type: GameDataIconType, entityId: string): Promise<void> {
|
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);
|
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 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') {
|
if (typeof icon?.storage_key === 'string') {
|
||||||
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
|
await unlink(storagePath(icon.storage_key)).catch((error: NodeJS.ErrnoException) => {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
if (error.code !== 'ENOENT') throw error;
|
||||||
@@ -137,11 +269,32 @@ export async function readIconMutation(form: FormData): Promise<{ output: Buffer
|
|||||||
return { output: null, remove };
|
return { output: null, remove };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyIconMutation(database: Database, type: GameDataIconType, entityId: string, mutation: { output: Buffer | null; remove: boolean }): Promise<void> {
|
export async function applyIconMutation(
|
||||||
if (mutation.output) await saveGameDataIcon(database, type, entityId, mutation.output);
|
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 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) {
|
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;
|
export const MAX_MANAGED_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
const MAX_PIXELS = 24_000_000;
|
const MAX_PIXELS = 24_000_000;
|
||||||
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
|
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 {}
|
export class ManagedImageValidationError extends Error {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
||||||
import { getPermissions } from '$lib/server/auth/authorization';
|
import { getPermissions } from '$lib/server/auth/authorization';
|
||||||
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
|
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);
|
const user = await getSessionUser(cookies);
|
||||||
if (!user) redirect(303, '/sign-in');
|
if (!user) redirect(303, '/sign-in');
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const filtering = url.searchParams.get('filtering') === '1';
|
const requestedState = String(url.searchParams.get('state') ?? 'all');
|
||||||
const categories = filtering
|
const state = ['all', 'unread', 'read'].includes(requestedState) ? requestedState : 'all';
|
||||||
? url.searchParams.getAll('channel').filter((value) => NOTIFICATION_CATEGORIES.includes(value as any))
|
const requestedSort = String(url.searchParams.get('sort') ?? 'newest');
|
||||||
: [...NOTIFICATION_CATEGORIES];
|
const sort = ['newest', 'oldest', 'unread'].includes(requestedSort) ? requestedSort : 'newest';
|
||||||
const states = filtering
|
const { search, pattern } = getRegistrySearch(url);
|
||||||
? url.searchParams.getAll('state').filter((value) => value === 'unread' || value === 'read')
|
|
||||||
: ['unread', 'read'];
|
|
||||||
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
||||||
const args: unknown[] = [user.id];
|
const args: unknown[] = [user.id];
|
||||||
if (categories.length === 0 || states.length === 0) {
|
if (state === 'unread') conditions.push('n.read_at IS NULL');
|
||||||
conditions.push('1=0');
|
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
|
||||||
} else {
|
if (search) {
|
||||||
if (categories.length < NOTIFICATION_CATEGORIES.length) {
|
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
conditions.push(`n.category IN (${categories.map(() => '?').join(',')})`);
|
OR n.body LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
args.push(...categories);
|
OR n.source LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
||||||
}
|
args.push(pattern, pattern, pattern);
|
||||||
if (states.length === 1) conditions.push(states[0] === 'unread' ? 'n.read_at IS NULL' : 'n.read_at IS NOT NULL');
|
|
||||||
}
|
}
|
||||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
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 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 pagination = getPagination(url, total);
|
||||||
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
|
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
|
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 preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
|
||||||
const permissions = await getPermissions(user);
|
const permissions = await getPermissions(user);
|
||||||
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
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
|
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
|
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);
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
|
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let settingsOpen = $state(false);
|
let settingsOpen = $state(false);
|
||||||
let consumedPreferenceSaveToken = $state<string | null>(null);
|
let consumedPreferenceSaveToken = $state<string | null>(null);
|
||||||
let selected = $state<any>(null);
|
let selected = $state<any>(null);
|
||||||
|
let detailDialog = $state<HTMLDivElement>();
|
||||||
|
let detailTrigger: HTMLButtonElement | null = null;
|
||||||
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
|
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 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)));
|
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();
|
browserPermission = await Notification.requestPermission();
|
||||||
}
|
}
|
||||||
function applyFilters() {
|
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(() => {
|
$effect(() => {
|
||||||
const token = form?.notificationPreferenceSaveToken;
|
const token = form?.notificationPreferenceSaveToken;
|
||||||
@@ -123,32 +162,73 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="notification-toolbar">
|
<div class="notification-toolbar">
|
||||||
<form class="notification-filter-console">
|
<form class="notification-query-console">
|
||||||
<input type="hidden" name="filtering" value="1"/>
|
{#if data.filters.search}<input type="hidden" name="q" value={data.filters.search}/>{/if}
|
||||||
<SegmentedChoice legend="CHANNELS" name="channel" mode="checkbox" options={Object.entries(labels).map(([value, label]) => ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/>
|
<input type="hidden" name="perPage" value={data.pagination.perPage}/>
|
||||||
<SegmentedChoice legend="MESSAGE STATE" name="state" mode="checkbox" options={[{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} selected={data.filters.states} onchange={applyFilters}/>
|
<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>
|
</form>
|
||||||
{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}
|
{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}
|
||||||
</div>
|
</div>
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination} search={data.filters.search} searchPlaceholder="SEARCH MESSAGES"/>
|
||||||
<section class="notification-list">
|
<section class="notification-list">
|
||||||
{#each data.notifications as notification}
|
{#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'}>
|
<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 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>
|
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="isac-modal-backdrop" role="presentation">
|
<div class="isac-modal-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closeDetails(); }}>
|
||||||
<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 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 aria-label="Close notification details" onclick={() => selected = null}>×</button></div>
|
<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-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>
|
<div class="notification-detail-content">
|
||||||
<h2 id="notification-detail-title">{selected.title}</h2>
|
<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>
|
||||||
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
|
<h2 id="notification-detail-title">{selected.title}</h2>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -168,6 +248,7 @@
|
|||||||
|
|
||||||
.notification-message-header h2 {
|
.notification-message-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: var(--shd-type-supporting);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,4 +256,374 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -375,7 +375,7 @@
|
|||||||
{ value:'private', label:'PRIVATE' },
|
{ value:'private', label:'PRIVATE' },
|
||||||
{ value:'unlisted', label:'UNLISTED' },
|
{ value:'unlisted', label:'UNLISTED' },
|
||||||
{ value:'public', label:'PUBLIC' }
|
{ 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>
|
<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}
|
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -418,7 +418,7 @@
|
|||||||
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
|
{#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>
|
<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}
|
{#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}
|
{/each}
|
||||||
</div></section>
|
</div></section>
|
||||||
{/each}
|
{/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}
|
{#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">
|
<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)}/>
|
<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 class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
|
||||||
</div>
|
</div>
|
||||||
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
|
{@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 { getDatabase } from '$lib/server/database';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||||
const user = await getSessionUser(cookies);
|
const user = await getSessionUser(cookies);
|
||||||
if (!user) redirect(303, '/sign-in');
|
if (!user) redirect(303, '/sign-in');
|
||||||
@@ -23,9 +24,12 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
|
|||||||
const pendingAvatarReviews = canReviewAvatars
|
const pendingAvatarReviews = canReviewAvatars
|
||||||
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
|
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
|
||||||
|
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
|
||||||
return {
|
return {
|
||||||
permissions,
|
permissions,
|
||||||
contributorMode,
|
contributorMode,
|
||||||
|
buildNumber,
|
||||||
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
|
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
|
||||||
oversightReviewCounts: {
|
oversightReviewCounts: {
|
||||||
data: canApproveData ? pendingProposalTotal : 0,
|
data: canApproveData ? pendingProposalTotal : 0,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
|
const canSeeData = $derived(['data.view', 'data.manage', 'data.propose', 'data.approve'].some(can));
|
||||||
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
|
const contributorEnabled = $derived(Boolean(data.contributorMode?.enabled));
|
||||||
const guidelinesPage = $derived(page.url.pathname === '/isac/contributor-guidelines');
|
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 dataMode = $derived(can('*') ? 'ADMIN' : can('data.approve') ? 'OVERSIGHT' : can('data.manage') ? 'DATA MANAGER' : contributorEnabled ? 'CONTRIBUTOR' : 'READ-ONLY');
|
||||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||||
|
const isacNode = 'NYC-01';
|
||||||
let readOnlyBlocked = $state(false);
|
let readOnlyBlocked = $state(false);
|
||||||
const dataSections = [
|
const dataSections = [
|
||||||
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
{ href: '/isac/skillchips', label: 'Skill Chips', module: 'skillchips' }
|
{ href: '/isac/skillchips', label: 'Skill Chips', module: 'skillchips' }
|
||||||
];
|
];
|
||||||
const dataModulePage = $derived(dataSections.some((section) => page.url.pathname === section.href));
|
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 = [
|
const userSections = [
|
||||||
{ href: '/isac/users', label: 'User operations', permission: 'users.view' },
|
{ href: '/isac/users', label: 'User operations', permission: 'users.view' },
|
||||||
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
|
{ href: '/isac/roles', label: 'Roles', permission: 'users.roles' }
|
||||||
@@ -78,7 +80,13 @@
|
|||||||
<main class="admin-page">
|
<main class="admin-page">
|
||||||
<header class="admin-command">
|
<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-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>
|
</header>
|
||||||
<div class="admin-dashboard">
|
<div class="admin-dashboard">
|
||||||
<aside class="admin-sidebar">
|
<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>
|
<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><span>{section.label}</span><b>›</b></a>
|
||||||
{/each}</nav>{/if}
|
{/each}</nav>{/if}
|
||||||
{/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>
|
</aside>
|
||||||
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
|
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
|
||||||
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
|
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
|
||||||
@@ -117,10 +125,10 @@
|
|||||||
readOnlyBlocked = true;
|
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>
|
{#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}
|
{: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 readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/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}
|
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</section>
|
</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`
|
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);
|
)).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 } });
|
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() };
|
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">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>
|
<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>
|
</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>
|
</section>
|
||||||
{/each}</div>
|
{/each}</div>
|
||||||
{#if form?.revisionError}<p class="error">{form.revisionError}</p>{/if}
|
{#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>
|
<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">
|
<PendingForm action="?/saveCalibration">
|
||||||
<input type="hidden" name="id" value={calibrationEditing.id}/>
|
<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>
|
<label class="field calibration-snapshot">LOADOUT SNAPSHOT · JSON<textarea name="loadoutSnapshot" bind:value={calibrationEditing.loadoutSnapshot} required spellcheck="false"></textarea></label>
|
||||||
<div class="calibration-observations">
|
<div class="calibration-observations">
|
||||||
<label class="field">BODY HIT<input name="bodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.bodyHit}/></label>
|
<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">
|
<div class="gear-identity">
|
||||||
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
<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="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>
|
||||||
<div class="gear-ranges">
|
<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>
|
<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 class="weapon-mod-preview">
|
||||||
<div>
|
<div>
|
||||||
<label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label>
|
<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>
|
</div>
|
||||||
{#if previewRow}
|
{#if previewRow}
|
||||||
<div class="weapon-mod-preview-values">
|
<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 pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||||
const page = Math.min(requestedPage, pageCount);
|
const page = Math.min(requestedPage, pageCount);
|
||||||
const images = await (await database.prepare(`SELECT managed_images.*, users.username AS creator_username
|
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);
|
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,
|
const stats = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||||
COALESCE(SUM(is_icon),0) AS icons, COALESCE(SUM(byte_size),0) AS bytes,
|
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 {
|
return {
|
||||||
categories: MANAGED_IMAGE_CATEGORIES,
|
categories: MANAGED_IMAGE_CATEGORIES,
|
||||||
images: images.map((image) => ({
|
images: images.map((image) => ({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
const value = Number(bytes ?? 0);
|
const value = Number(bytes ?? 0);
|
||||||
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
return value < 1024 * 1024 ? `${(value / 1024).toFixed(0)} KiB` : `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
};
|
};
|
||||||
|
const sourceLabel = (value: unknown) => String(value ?? '').replaceAll('_', ' ').toLocaleUpperCase();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
|
if (form?.imageSaved && form?.resultToken && form.resultToken !== consumedToken) {
|
||||||
consumedToken = form.resultToken;
|
consumedToken = form.resultToken;
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="module-kpis">
|
<div class="module-kpis">
|
||||||
<div><span>ASSETS</span><strong>{Number(data.stats.total)}</strong><small>Managed files</small></div>
|
<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>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><span>STORAGE</span><strong>{size(data.stats.bytes)}</strong><small>Processed WebP</small></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
{#each data.images as image}
|
{#each data.images as image}
|
||||||
<article class:icon={image.is_icon}>
|
<article class:icon={image.is_icon}>
|
||||||
<div class="image-library-preview"><img src={image.url} alt="Preview of {image.name}"/></div>
|
<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>
|
<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>
|
<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>
|
</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);
|
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);
|
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);
|
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) });
|
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() };
|
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);
|
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);
|
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);
|
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);
|
await refreshAbilityFocuses(database, abilityId);
|
||||||
if (previousAbilityId && previousAbilityId !== abilityId) await refreshAbilityFocuses(database, previousAbilityId);
|
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) });
|
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(?,?,?,?,?)'
|
'INSERT INTO specializations(id,name,description,created_at,updated_at) VALUES(?,?,?,?,?)'
|
||||||
)).run(specializationId, name, description || null, now, now);
|
)).run(specializationId, name, description || null, now, now);
|
||||||
}
|
}
|
||||||
await applyIconMutation(database, 'specialization', specializationId, iconMutation);
|
await applyIconMutation(database, 'specialization', specializationId, iconMutation, operator.id);
|
||||||
await recordIsacAudit({
|
await recordIsacAudit({
|
||||||
actorId: operator.id,
|
actorId: operator.id,
|
||||||
category: 'game_data',
|
category: 'game_data',
|
||||||
@@ -627,7 +627,7 @@ export const actions: Actions = {
|
|||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation);
|
await applyIconMutation(database, 'specialization_ability', abilityId, iconMutation, operator.id);
|
||||||
await refreshSpecializationFocuses(database, specializationId);
|
await refreshSpecializationFocuses(database, specializationId);
|
||||||
await recordIsacAudit({
|
await recordIsacAudit({
|
||||||
actorId: operator.id,
|
actorId: operator.id,
|
||||||
@@ -933,7 +933,7 @@ export const actions: Actions = {
|
|||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation);
|
await applyIconMutation(database, 'specialization_node', nodeId, iconMutation, operator.id);
|
||||||
const readiness = await refreshSpecializationFocusReady(database, focusId);
|
const readiness = await refreshSpecializationFocusReady(database, focusId);
|
||||||
await recordIsacAudit({
|
await recordIsacAudit({
|
||||||
actorId: operator.id,
|
actorId: operator.id,
|
||||||
|
|||||||
@@ -517,7 +517,7 @@
|
|||||||
<div class="specialization-node-position">
|
<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">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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
<div>
|
<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>
|
<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>
|
<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>
|
<button type="button" class="moderation-action danger" disabled={combatEffect.modifiers.length <= 1} onclick={() => removeCombatModifier(combatEffect, modifierIndex)}>REMOVE</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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 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);
|
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 } });
|
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() };
|
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 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>
|
<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}
|
{#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>
|
||||||
|
|
||||||
<div class="weapon-mod-compatibility">
|
<div class="weapon-mod-compatibility">
|
||||||
<div>
|
<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>
|
<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}/>
|
{#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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="weapon-mod-preview">
|
<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}
|
{#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>
|
</div>
|
||||||
</section>
|
</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(?,?,?,?,?)'))
|
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);
|
.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) {
|
if (id) {
|
||||||
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
|
const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id);
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
<div class="weapon-identity-grid">
|
<div class="weapon-identity-grid">
|
||||||
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
|
<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="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/>
|
<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>
|
<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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user