diff --git a/database/schema.sql b/database/schema.sql index bcd3301..4678db5 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -202,7 +202,7 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS data_change_proposals ( id TEXT PRIMARY KEY, - module TEXT NOT NULL CHECK(module IN ('stats','talents','brands','gear','weapons','protocols')), + module TEXT NOT NULL CHECK(module IN ('attributes','talents','brands','gear','weapons','protocols','skillchips')), action TEXT NOT NULL, entity_id TEXT, draft_entity_id TEXT, @@ -498,8 +498,59 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects ( UNIQUE(talent_id, stat_attribute_id), UNIQUE(talent_id, sort_order) ); +CREATE TABLE IF NOT EXISTS skill_chip_sets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses ( + id TEXT PRIMARY KEY, + skill_chip_set_id TEXT NOT NULL UNIQUE REFERENCES skill_chip_sets(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + CHECK(max_value >= min_value) +); +CREATE TABLE IF NOT EXISTS skill_chip_primary_options ( + skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + PRIMARY KEY(skill_chip_set_id, stat_attribute_id) +); +CREATE TABLE IF NOT EXISTS skill_chip_secondary_options ( + skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + PRIMARY KEY(skill_chip_set_id, stat_attribute_id) +); +CREATE TABLE IF NOT EXISTS skill_chip_talents ( + id TEXT PRIMARY KEY, + skill_chip_set_id TEXT NOT NULL UNIQUE REFERENCES skill_chip_sets(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description_template TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_talents_name_nocase_idx ON skill_chip_talents(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS skill_chip_talent_triggers ( + talent_id TEXT NOT NULL REFERENCES skill_chip_talents(id) ON DELETE CASCADE, + trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), + PRIMARY KEY(talent_id, trigger_id) +); +CREATE TABLE IF NOT EXISTS skill_chip_talent_effects ( + id TEXT PRIMARY KEY, + talent_id TEXT NOT NULL REFERENCES skill_chip_talents(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + min_value REAL NOT NULL, + max_value REAL NOT NULL, + sort_order INTEGER NOT NULL CHECK(sort_order BETWEEN 1 AND 12), + CHECK(max_value >= min_value), + UNIQUE(talent_id, stat_attribute_id), + UNIQUE(talent_id, sort_order) +); CREATE TABLE IF NOT EXISTS game_data_icons ( - entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol')), + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')), entity_id TEXT NOT NULL, storage_key TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL CHECK(byte_size > 0), diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 65f9ec1..d0105ff 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -251,7 +251,7 @@ squeezed beside long module headings on mobile. unrestricted administrative role; `Community Oversight` is reserved for community content moderation and does not imply ISAC access. Operational ISAC access is delegated separately through scoped roles and permissions. -- Roles & Permissions is a separate User Operations registry. Role definitions +- Roles is a separate User Operations registry. Role definitions compose discrete capabilities through a dense permission catalogue with an explicit selected count and textual `GRANTED` / `NOT GRANTED` states. The built-in Admin role is visible but system-protected. Delegated access managers @@ -751,3 +751,35 @@ when the complete option set is small enough to scan comfortably. Dropdowns remain appropriate for long, dynamic, or search-oriented lists. Both modes share hover, pressed, focus-visible, selected, disabled, responsive, and 44-pixel mobile-target behavior so feature modules do not reimplement it. + +### Skill Chip data console + +Skill Chip editing follows the dense ISAC staged-definition pattern: identity +and icon first, the 2-piece numeric bonus second, mutually exclusive primary +and secondary roll pools next, and the 3-piece talent last. Superior blue and +High-end yellow communicate the only supported qualities; orange remains +reserved for actions and current interactive state. A four-step segmented +preview exposes the linear zero-to-three High-end interpolation without storing +duplicate values. At narrow widths the roll pools and talent columns stack, +preview segments become a two-by-two grid, and interactive targets remain at +least 44 pixels tall. + +### ISAC record identity controls + +Game-data icon editors use the shared `--isac-icon-editor-width` footprint +(150 pixels on desktop) regardless of module. Identity layouts allocate that +fixed column to `IconUploadField`, give the record name the flexible dominant +column, and stack both controls at mobile widths. Do not enlarge an upload +drop-zone to fill spare identity-row space. + +The primary `name` field in every ISAC data editor is a header-level input: +46 pixels tall with larger, heavier text. This creates a consistent first +reading point for long registries while keeping secondary metadata compact. + +`SearchableAssignment` is the standard relationship picker for long attribute, +talent, trigger, and compatibility lists. Multiple mode manages additive sets; +single mode presents the same searchable selected/result treatment for one +relationship. Prefer these modes over native selects once a source registry is +large enough that scanning a dropdown is inefficient. The selected record, +pending-contribution state, keyboard focus, and remove interaction must remain +explicit in both modes. diff --git a/docs/architecture.md b/docs/architecture.md index 431d992..6a0781b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -193,9 +193,27 @@ talent effects use `base × (1 + upgrades / 5)` for zero to five upgrades. Server actions validate all relationships and append the full owned graph to the existing `game_data` audit history. +# Skill Chip quality scaling + +Skill Chip sets support Superior and High-end chip instances. Each set owns one +2-piece bonus, distinct primary and secondary stat-option pools, and one +3-piece talent with reusable trigger links and normalized effect rows. Primary +and secondary options reference active attributes that define Skill Chip +minimum and maximum roll values; a configured chip rolls one primary and two +distinct secondary stats from those separate pools. + +Both set-bonus progression systems use the number of equipped High-end chips as +the interpolation step. For `h` High-end chips, where `h` is zero through +three, a configured minimum and maximum resolve as +`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce +the minimum, while three produce the maximum. The editor stores only endpoints +and previews all four states, preventing duplicated tier data from diverging. +Skill Chip mutations, owned relationships, and safe icon metadata are captured +in the `game_data` audit stream. + # Game-data icon storage -Talents, brand sets, and OS Protocols reference uploaded artwork through the +Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork through the polymorphic `game_data_icons` metadata table. Original uploads are never retained. The shared server pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active or externally referenced SVG content, and uses diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 2f90033..bd0b1bd 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -93,6 +93,58 @@ for (const [name, definition] of [ await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL"); await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); +const proposalDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" +)).get(); +if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'attributes'")) { + console.log('Renaming the moderated Stats module to Attributes.'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec(`CREATE TABLE data_change_proposals_expanded ( + id TEXT PRIMARY KEY, + module TEXT NOT NULL CHECK(module IN ('attributes','talents','brands','gear','weapons','protocols','skillchips')), + action TEXT NOT NULL, + entity_id TEXT, + draft_entity_id TEXT, + applied_entity_id TEXT, + summary TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')), + proposed_by TEXT NOT NULL REFERENCES users(id), + reviewed_by TEXT REFERENCES users(id), + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT, + applied_at TEXT + )`); + await database.exec(`INSERT INTO data_change_proposals_expanded + SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at + FROM data_change_proposals`); + await database.exec('DROP TABLE data_change_proposals'); + await database.exec('ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals'); + await database.exec('PRAGMA foreign_keys=ON'); +} +await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)'); +await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)'); +await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL'); + +const iconDefinition = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'" +)).get(); +if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'skill_chip'")) { + console.log('Adding Skill Chip icon storage support.'); + await database.exec(`CREATE TABLE game_data_icons_expanded ( + entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')), + entity_id TEXT NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + byte_size INTEGER NOT NULL CHECK(byte_size > 0), + updated_at TEXT NOT NULL, + PRIMARY KEY(entity_type, entity_id) + )`); + await database.exec(`INSERT INTO game_data_icons_expanded SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`); + await database.exec('DROP TABLE game_data_icons'); + await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons'); +} + console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { diff --git a/src/app.css b/src/app.css index 15dfd41..45d555d 100644 --- a/src/app.css +++ b/src/app.css @@ -23,6 +23,7 @@ --shd-gap-md:12px; --shd-gap-lg:20px; --shd-control-height:40px; + --isac-icon-editor-width:150px; --shd-header-height:76px; --shd-display-scale:1; --shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease; @@ -126,8 +127,8 @@ .protocol-profile-registry{margin-bottom:10px;border:1px solid var(--shd-border);background:#0d1011}.protocol-profile-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.protocol-profile-registry>summary::-webkit-details-marker{display:none}.protocol-profile-registry>summary:hover,.protocol-profile-registry>summary:focus-visible{background:#171b1c}.protocol-profile-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.protocol-profile-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.protocol-profile-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.protocol-profile-registry[open]>summary i{transform:rotate(45deg)}.protocol-profile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;padding:8px}.protocol-profile-grid>button{min-width:0;padding:7px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.protocol-profile-grid>button:hover,.protocol-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.protocol-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.protocol-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.protocol-profile-grid header{display:flex;align-items:center;gap:6px;padding-bottom:6px;border-bottom:1px solid #292e2f}.protocol-profile-grid header .game-icon{width:23px;height:23px}.protocol-profile-grid header div{display:grid;gap:2px}.protocol-profile-grid header b{font-size:6px;text-transform:uppercase}.protocol-profile-grid header strong{color:#d7d9d5;font-size:8px}.protocol-profile-grid header>span{margin-left:auto;color:#6b7274;font-size:5px;font-weight:900;letter-spacing:.5px}.protocol-profile-grid>button.configured header>span{color:var(--shd-success)}.protocol-profile-grid>button>div{display:grid;gap:4px;margin-top:6px}.protocol-profile-grid>button>div span{display:flex;justify-content:space-between;gap:6px}.protocol-profile-grid>button>div small{overflow:hidden;color:#626a6c;font-size:5px;text-overflow:ellipsis;white-space:nowrap}.protocol-profile-grid>button>div b{color:#b8bdbe;font-size:6px}.protocol-profile-editor{border-top-color:var(--shd-success)}.shared-secondary-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.selected-secondary-profile{margin:8px 0;border:1px solid #36563f;background:#101713}.selected-secondary-profile.unconfigured{border-color:#65403a;background:#1c1111}.selected-secondary-profile header{display:flex;justify-content:space-between;padding:7px 9px;border-bottom:1px solid currentColor;color:var(--shd-success);font-size:6px;letter-spacing:.8px}.selected-secondary-profile.unconfigured header{color:var(--shd-danger)}.selected-secondary-profile>div{display:grid;grid-template-columns:repeat(3,1fr)}.selected-secondary-profile>div span{display:flex;justify-content:space-between;padding:8px 9px;border-left:1px solid #2b3d30}.selected-secondary-profile>div span:first-child{border-left:0}.selected-secondary-profile small{color:#6f7b72;font-size:6px}.selected-secondary-profile b{font-size:8px}.selected-secondary-profile p{margin:0;padding:10px;color:#a87870;font-size:8px}.protocol-primary-editor{display:grid;grid-template-columns:1fr 180px 180px;gap:7px;margin-top:7px;padding:8px;border:1px solid var(--shd-border);background:#101314}.protocol-primary-editor .field{margin:0}.protocol-primary-editor>div{display:grid;align-content:center;gap:4px;padding:0 10px;border-left:2px solid var(--shd-orange)}.protocol-primary-editor>div span,.protocol-primary-editor>div small{color:#626a6c;font-size:6px}.protocol-primary-editor>div b{color:var(--shd-orange);font-size:15px}.protocol-row{grid-template-columns:1.2fr .65fr .8fr 1fr 1.1fr .55fr .3fr}@media(max-width:1100px){.protocol-profile-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:1000px){.protocol-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.protocol-primary-editor{grid-template-columns:1fr 1fr}.protocol-primary-editor>div{grid-column:1/3}}@media(max-width:650px){.protocol-profile-grid,.selected-secondary-profile>div,.protocol-primary-editor,.shared-secondary-editor{grid-template-columns:1fr}.selected-secondary-profile>div span{border-left:0;border-top:1px solid #2b3d30}.protocol-primary-editor>div{grid-column:auto}} .weapon-exotic-talent{margin:10px 0;padding:10px;border:1px solid #765426;border-top:2px solid var(--shd-orange);background:#151208}.exotic-talent-identity{grid-template-columns:minmax(180px,.65fr) minmax(320px,1.35fr)!important}.exotic-talent-identity textarea{min-height:64px}.exotic-talent-identity small{display:block;margin-top:5px;color:#69665e;font-size:7px}.weapon-exotic-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:8px;margin-top:8px}.exotic-effect-list{display:grid;gap:5px}.exotic-effect-row{display:grid;grid-template-columns:28px minmax(150px,1fr) 85px 85px 28px;gap:5px;align-items:center}.exotic-effect-row>strong{height:32px;display:grid;place-items:center;background:#211a0e;color:var(--shd-orange);font-size:8px}.exotic-effect-row select,.exotic-effect-row input{height:32px;min-width:0;margin:0;padding:0 7px;font-size:8px}.exotic-effect-row button{height:32px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.exotic-effect-row button:disabled{opacity:.35;cursor:not-allowed}@media(max-width:1050px){.weapon-exotic-grid{grid-template-columns:1fr}}@media(max-width:700px){.exotic-talent-identity{grid-template-columns:1fr!important}.exotic-effect-row{grid-template-columns:28px 1fr 1fr}.exotic-effect-row select{grid-column:2/4}.exotic-effect-row button{grid-column:3}} .protocol-identity-grid{grid-template-columns:minmax(280px,1.35fr) minmax(420px,1fr);gap:12px}.protocol-classification-stack{display:grid;gap:7px}.protocol-quality-picker{min-width:0;margin:0;padding:0;border:0}.protocol-quality-picker legend{color:var(--shd-text-muted);font-size:8px;font-weight:bold;letter-spacing:1px}.protocol-quality-picker>div{height:32px;display:grid;grid-template-columns:repeat(4,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.protocol-quality-picker label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.protocol-quality-picker label:first-child{border-left:0}.protocol-quality-picker input{position:absolute;opacity:0;pointer-events:none}.protocol-quality-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:4px}.protocol-quality-picker span i{width:5px;height:5px;background:currentColor}.protocol-quality-picker b{font-size:6px;text-transform:uppercase}.protocol-quality-picker label:hover span{background:#171b1c;filter:brightness(1.2)}.protocol-quality-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.protocol-quality-picker input:checked+span{background:#171b1c;box-shadow:inset 0 -2px currentColor}.protocol-quality-picker input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}@media(max-width:1000px){.protocol-identity-grid{grid-template-columns:1fr}}@media(max-width:650px){.protocol-quality-picker b{font-size:5px}} -.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:150px minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{max-width:none}.icon-upload-preview{height:70px}} -.brand-editor-body{display:grid;grid-template-columns:122px minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}} +.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:9px;align-items:start}.icon-editor-identity>.field{margin:0}.talent-icon-identity>.form-grid,.exotic-icon-identity>.form-grid{min-width:0}.icon-upload-field{width:var(--isac-icon-editor-width);max-width:100%;min-width:0;margin:0;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.icon-upload-field legend{padding:0 5px;color:var(--shd-text-muted);font-size:7px;font-weight:900;letter-spacing:.8px}.icon-upload-preview{height:86px;display:grid;place-items:center;margin-bottom:6px;border:1px solid #292f30;background:#111415}.icon-upload-preview img{width:72px;height:72px;object-fit:contain}.icon-upload-preview>span{color:#596164;font-size:7px;letter-spacing:.8px}.icon-upload-field>label{min-height:30px;display:grid;place-items:center;border:1px solid #424849;background:#15191a;color:#d7d9d5;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.icon-upload-field>label:hover,.icon-upload-field>label:focus-within{border-color:var(--shd-orange);color:var(--shd-orange)}.icon-upload-field>label:active{transform:translateY(1px);filter:brightness(.88)}.icon-upload-field>label input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.icon-upload-field>label.icon-remove{min-height:24px;display:flex;gap:5px;margin-top:5px;border-color:#563734;background:#1d1212;color:var(--shd-danger)}.icon-upload-field>label.icon-remove input{width:auto;margin:0;accent-color:var(--shd-danger)}.icon-upload-field>small{display:block;margin-top:5px;color:#596164;font-size:5px;line-height:1.4}.dashboard-editor input[name=name]{min-height:46px;padding:9px 11px;font-size:15px;font-weight:800;letter-spacing:.35px}.registry-icon-identity{display:flex!important;align-items:center;gap:7px;min-width:0}.registry-icon-identity>img,.registry-icon-identity>span{flex:0 0 30px;width:30px;height:30px;object-fit:contain}.registry-icon-identity>span{display:grid;place-items:center;border:1px solid #303536;color:#596164}.registry-icon-identity>div{display:grid;min-width:0}.registry-icon-identity>div strong,.registry-icon-identity>div small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(max-width:700px){.icon-editor-identity,.talent-icon-identity,.exotic-icon-identity{grid-template-columns:1fr}.icon-upload-field{width:100%;max-width:none}.icon-upload-preview{height:70px}} +.brand-editor-body{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(0,1fr);gap:10px;margin-top:8px;align-items:start}.brand-editor-body>div{min-width:0}.brand-set-editor .brand-name{max-width:none;margin:0 0 7px}.brand-set-editor .icon-upload-field{padding:6px}.brand-set-editor .icon-upload-preview{height:58px;margin-bottom:5px}.brand-set-editor .icon-upload-preview img{width:50px;height:50px}.brand-set-editor .icon-upload-field>label{min-height:25px}.brand-set-editor .icon-upload-field>label.icon-remove{min-height:22px}.brand-set-editor .effect-band{padding:7px}.brand-set-editor .form-actions{margin-top:10px;padding-top:10px}.brand-archive-action{min-height:40px;padding:0 13px}.brand-archive-action>b{font-size:9px;transform:rotate(90deg)}@media(max-width:850px){.brand-editor-body{grid-template-columns:1fr}.brand-set-editor .icon-upload-field{width:100%;display:grid;grid-template-columns:70px 1fr;gap:5px}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:1/3}.brand-set-editor .icon-upload-preview{grid-row:1/3;height:55px;margin:0}.brand-set-editor .icon-upload-field>label{min-height:25px}}@media(max-width:560px){.brand-set-editor .icon-upload-field{grid-template-columns:1fr}.brand-set-editor .icon-upload-field legend,.brand-set-editor .icon-upload-field>small{grid-column:auto}.brand-set-editor .icon-upload-preview{grid-row:auto}.brand-archive-action{width:100%}} .brand-archive-action.restore>b{transform:none}.moderation-action.restore{border:1px solid #3d6849;background:#111d15;color:var(--shd-success)}.moderation-action.restore:hover,.moderation-action.restore:focus-visible{border-color:#69b77d;background:#17271c;color:#a4e5b6} .pending-reference{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap} .data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:6px;font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)} @@ -150,6 +151,7 @@ .notification-filter-console{flex:1;display:grid!important;grid-template-columns:minmax(520px,1fr) minmax(190px,.34fr);align-items:end!important;gap:8px!important}.notification-filter-console>input{display:none}.notification-filter-group{min-width:0;margin:0;padding:0;border:0}.notification-filter-group legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.notification-filter-group>div{height:40px;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.notification-filter-group label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.notification-filter-group label:first-child{border-left:0}.notification-filter-group input{position:absolute;opacity:0;pointer-events:none}.notification-filter-group span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.notification-filter-group span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.notification-filter-group b{overflow:hidden;text-overflow:ellipsis;color:inherit;font-size:6px;letter-spacing:.6px;white-space:nowrap}.notification-filter-group label:hover span{background:#171b1c;color:#c4c8c9}.notification-filter-group label:active span{transform:translateY(1px);filter:brightness(.88)}.notification-filter-group input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.notification-filter-group input:checked+span i{background:currentColor}.notification-filter-group input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.state-filter-group>div{grid-template-columns:repeat(2,minmax(0,1fr))}@media(max-width:900px){.notification-filter-console{grid-template-columns:1fr}.notification-filter-group>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-group span{min-height:44px}.state-filter-group>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-group>div{grid-template-columns:repeat(2,1fr)}} .segmented-choice{min-width:0;margin:0;padding:0;border:0}.segmented-choice legend{margin-bottom:5px;color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.segmented-choice>div{height:32px;display:grid;grid-template-columns:repeat(var(--segmented-columns),minmax(0,1fr));border:1px solid #363b3c;background:#0d0f10}.segmented-choice label{min-width:0;border-left:1px solid var(--shd-border);cursor:pointer}.segmented-choice label:first-child{border-left:0}.segmented-choice input{position:absolute;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}.segmented-choice span{height:100%;display:flex;align-items:center;justify-content:center;gap:5px;color:#777f81;transition:var(--shd-transition)}.segmented-choice span i{width:5px;height:5px;border:1px solid currentColor;background:transparent}.segmented-choice .game-icon{width:18px;height:18px;opacity:.55}.segmented-choice b{overflow:hidden;color:inherit;font-size:6px;letter-spacing:.6px;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.segmented-choice label:hover span{background:#171b1c;color:#c4c8c9}.segmented-choice label:active span{transform:translateY(1px);filter:brightness(.88)}.segmented-choice input:checked+span{background:#171b1c;color:var(--shd-orange);box-shadow:inset 0 -2px currentColor}.segmented-choice input:checked+span i{background:currentColor}.segmented-choice input:checked+span .game-icon{opacity:1}.segmented-choice input:focus-visible+span{outline:2px solid var(--shd-orange);outline-offset:2px}.segmented-choice input:disabled+span{opacity:.45;cursor:not-allowed}.protocol-classification-stack .segmented-choice legend{font-size:8px}.protocol-classification-stack .segmented-choice>div{margin-top:5px}.protocol-classification-stack .quality-standard i,.protocol-classification-stack .quality-specialized i,.protocol-classification-stack .quality-superior i,.protocol-classification-stack .quality-highend i{background:currentColor} .notification-filter-console{min-width:0;grid-template-columns:minmax(0,3fr) minmax(180px,1fr)}.notification-filter-console .segmented-choice>div{height:40px}@media(max-width:900px){.notification-toolbar .notification-filter-console{grid-template-columns:1fr}.notification-filter-console .segmented-choice>div{height:auto;grid-template-columns:repeat(3,1fr)}.notification-filter-console .segmented-choice span{min-height:44px}.notification-filter-console .segmented-choice:last-child>div{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.notification-filter-console .segmented-choice>div{grid-template-columns:repeat(2,1fr)}} +.skill-chip-editor{border-top-color:var(--quality-superior)}.skill-chip-identity{display:grid;grid-template-columns:var(--isac-icon-editor-width) minmax(360px,1fr) minmax(220px,.55fr);align-items:start;gap:10px}.skill-chip-identity>.field{margin:0}.quality-scope{min-height:62px;display:grid;align-content:center;gap:5px;padding:7px 10px;border:1px solid var(--shd-border);background:#0d1011}.quality-scope>span{color:var(--shd-text-muted);font-size:6px;font-weight:900;letter-spacing:1px}.quality-scope>div{display:flex;gap:7px}.quality-scope b{padding:4px 6px;border:1px solid currentColor;font-size:6px;letter-spacing:.7px}.quality-scope small{color:#626a6c;font-size:6px}.skill-chip-bonus,.skill-chip-talent{margin-top:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.skill-chip-bonus{border-left:2px solid var(--quality-superior)}.skill-chip-bonus-grid{display:grid;grid-template-columns:minmax(360px,1fr) minmax(260px,.65fr);gap:8px;margin-top:7px}.skill-chip-range-fields{display:grid;grid-template-columns:1fr 1fr;gap:7px}.skill-chip-range-fields .field{margin:0}.skill-chip-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:10px}.skill-chip-pools>div{min-width:0}.skill-chip-talent{border-left:2px solid var(--quality-high-end)}.skill-chip-talent-identity{display:grid;grid-template-columns:.7fr 1.3fr;gap:8px}.skill-chip-talent-identity textarea{min-height:66px}.skill-chip-preview{display:grid;grid-template-columns:minmax(360px,.9fr) minmax(260px,1.1fr);align-items:end;gap:8px;margin:4px 0 10px}.skill-chip-preview>div{min-height:42px;display:grid;align-content:center;gap:5px;padding:7px 10px;border:1px solid var(--shd-border);background:#111415}.skill-chip-preview>div span{color:var(--quality-high-end);font-size:6px;font-weight:900;letter-spacing:.8px}.skill-chip-preview p{margin:0;color:#c4c9ca;font:10px/1.45 Georgia,serif}.skill-chip-talent-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px}.skill-chip-effects{display:grid;border:1px solid var(--shd-border)}.skill-chip-effects>div{display:grid;grid-template-columns:27px minmax(140px,1fr) 75px 75px 27px;align-items:center;gap:5px;padding:5px;border-top:1px solid #282e2f}.skill-chip-effects>div:first-child{border-top:0}.skill-chip-effects strong{display:grid;place-items:center;height:100%;background:#171b1c;color:var(--shd-orange);font-size:7px}.skill-chip-effects select,.skill-chip-effects input{min-width:0;margin:0;padding:7px;font-size:8px}.skill-chip-effects button{height:28px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.skill-chip-effects button:disabled{opacity:.3;cursor:not-allowed}.skill-chip-row{min-width:1080px;grid-template-columns:1.15fr .45fr 1.1fr 1.1fr .85fr .5fr .3fr}.skill-chip-quality-pair{display:flex;gap:4px}.skill-chip-quality-pair b{padding:3px 4px;border:1px solid currentColor;font-size:5px}.skill-chip-row>div:nth-child(4){display:grid;gap:3px}.skill-chip-row>div:nth-child(4) small{color:#626a6c;font-size:6px}@media(max-width:1050px){.skill-chip-identity{grid-template-columns:var(--isac-icon-editor-width) 1fr}.quality-scope{grid-column:1/3}.skill-chip-pools,.skill-chip-talent-grid,.skill-chip-preview{grid-template-columns:1fr}}@media(max-width:700px){.skill-chip-identity,.skill-chip-bonus-grid,.skill-chip-range-fields{grid-template-columns:1fr}.quality-scope{grid-column:auto}.skill-chip-preview .segmented-choice>div{height:auto;grid-template-columns:repeat(2,1fr)}.skill-chip-preview .segmented-choice span{min-height:44px}.skill-chip-effects>div{grid-template-columns:27px 1fr}.skill-chip-effects input,.skill-chip-effects button{grid-column:2}.skill-chip-pools{grid-template-columns:1fr}} .profile-metrics>a{display:flex;flex-direction:column;justify-content:center;padding:0 16px;border-left:1px solid var(--shd-border);color:inherit}.profile-metrics>a:hover,.profile-metrics>a:focus-visible{background:#171b1c}.profile-metrics>a strong{color:var(--shd-orange)}.profile-notifications{border-top:2px solid #4f4329}.profile-notification-list{display:grid;border:1px solid var(--shd-border)}.profile-notification-list>a{min-width:0;display:grid;grid-template-columns:145px minmax(0,1fr) 55px;align-items:center;gap:10px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0f1213;color:#aeb4b5}.profile-notification-list>a:first-child{border-top:0}.profile-notification-list>a.unread{background:#151718;box-shadow:inset 2px 0 var(--shd-orange)}.profile-notification-list>a.mandatory{box-shadow:inset 2px 0 #68a9c4}.profile-notification-list>a:hover,.profile-notification-list>a:focus-visible{background:#191d1e}.profile-notification-list>a>div,.profile-notification-list>a>span{min-width:0;display:grid;gap:3px}.profile-notification-list>a b{color:var(--shd-orange);font-size:6px}.profile-notification-list>a strong{overflow:hidden;font-size:8px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.profile-notification-list>a em{justify-self:end;color:var(--shd-orange);font-size:6px;font-style:normal;font-weight:900}.profile-empty.compact{min-height:90px}@media(max-width:700px){.profile-notification-list>a{grid-template-columns:1fr auto}.profile-notification-list>a>div{grid-column:1/3}.profile-notification-list>a>span small{white-space:normal}.profile-metrics>a{min-height:55px;border-top:1px solid var(--shd-border)}} .profile-metrics>a{min-width:77px;height:59px;align-items:center;padding:7px;border:1px solid var(--shd-border);background:#111415} .notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:5px;white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:5px;font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:6px;letter-spacing:1px}.notification-detail-sender b{font-size:10px}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:6px;letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:7px}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 Georgia,serif}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}} diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte index 50ac066..5ba65d2 100644 --- a/src/lib/components/SearchableAssignment.svelte +++ b/src/lib/components/SearchableAssignment.svelte @@ -3,6 +3,8 @@ let { items, selected = $bindable([]), + value = $bindable(''), + mode = 'multiple', excludedIds = [], fieldName, emptyLabel = 'NO MATCHING RECORDS', @@ -13,6 +15,8 @@ }: { items: Item[]; selected?: string[]; + value?: string; + mode?: 'multiple' | 'single'; excludedIds?: string[]; fieldName: string; emptyLabel?: string; @@ -22,22 +26,24 @@ emptySelectionLabel?: string; } = $props(); let query = $state(''); + const selectedIds = $derived(mode === 'single' ? (value ? [value] : []) : selected); const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase()); const available = $derived(items.filter((item) => !excludedIds.includes(item.id) && (!normalizedQuery || item.name.toLocaleLowerCase().includes(normalizedQuery)) )); const visible = $derived(available.slice(0, 30)); - const selectedItems = $derived(selected.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item))); + const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item))); const toggle = (id: string) => { - selected = selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id]; + if (mode === 'single') value = value === id ? '' : id; + else selected = selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id]; };
- {#each selected as id}{/each} + {#each selectedIds as id}{/each}
-
ASSIGNED{selected.length}
+
{mode === 'single' ? 'SELECTED' : 'ASSIGNED'}{selectedIds.length}
{#each selectedItems as item} @@ -50,8 +56,8 @@
{available.length} MATCHES{#if available.length > visible.length}SHOWING FIRST {visible.length}{/if}
{#each visible as item} - {:else}

{emptyLabel}

diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 392a250..8b0b6b2 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -27,7 +27,7 @@ export async function recordIsacAudit(input: { } export async function auditRecord(table: string, id: string): Promise | null> { - if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'os_protocols', 'roles'].includes(table)) { + if (!['stat_attributes', 'talents', 'talent_triggers', 'brand_sets', 'gear', 'weapons', 'os_protocols', 'skill_chip_sets', 'roles'].includes(table)) { throw new Error('Unsupported audit table'); } const database = await getDatabase(); diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index 71e4b20..248b30d 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -5,7 +5,7 @@ import type { AuthUser } from '$lib/server/auth/session'; import { recordIsacAudit } from '$lib/server/audit'; import { getContributorMode } from '$lib/server/contributor-mode'; -export const DATA_MODULES = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'] as const; +export const DATA_MODULES = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'] as const; export type DataModule = typeof DATA_MODULES[number]; type SerializedEntry = diff --git a/src/lib/server/game-data-icons.ts b/src/lib/server/game-data-icons.ts index ef3600e..175bff9 100644 --- a/src/lib/server/game-data-icons.ts +++ b/src/lib/server/game-data-icons.ts @@ -3,13 +3,13 @@ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; -export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol'] as const; +export const GAME_ICON_TYPES = ['talent', 'brand_set', 'os_protocol', 'skill_chip'] as const; export type GameDataIconType = (typeof GAME_ICON_TYPES)[number]; export const MAX_GAME_ICON_BYTES = 2 * 1024 * 1024; export const MAX_GAME_ICON_PIXELS = 12_000_000; export const GAME_ICON_SIZE = 256; const ENTITY_ID_PATTERN = /^[0-9a-f-]{36}$/i; -const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol)-[0-9a-f-]{36}\.webp$/i; +const STORAGE_KEY_PATTERN = /^(talent|brand_set|os_protocol|skill_chip)-[0-9a-f-]{36}\.webp$/i; export class GameDataIconValidationError extends Error {} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 369807e..6231e04 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -10,16 +10,17 @@ const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-')); let readOnlyBlocked = $state(false); const dataSections = [ - { href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR', module: 'stats' }, - { href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT', module: 'talents' }, + { href: '/admin/attributes', label: 'Attributes', code: 'ATTR', module: 'attributes' }, + { href: '/admin/talents', label: 'Talents', code: 'TLNT', module: 'talents' }, { href: '/admin/brands', label: 'Brand sets', code: 'BRND', module: 'brands' }, { href: '/admin/gear', label: 'Gear', code: 'GEAR', module: 'gear' }, { href: '/admin/weapons', label: 'Weapons', code: 'WPN', module: 'weapons' }, - { href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' } + { href: '/admin/protocols', label: 'OS Protocols', code: 'OSPR', module: 'protocols' }, + { href: '/admin/skillchips', label: 'Skill Chips', code: 'CHIP', module: 'skillchips' } ]; const userSections = [ { href: '/admin/users', label: 'User operations', code: 'USER', permission: 'users.view' }, - { href: '/admin/roles', label: 'Roles & permissions', code: 'ROLE', permission: 'users.roles' } + { href: '/admin/roles', label: 'Roles', code: 'ROLE', permission: 'users.roles' } ]; const visibleUserSections = $derived(userSections.filter((section) => can(section.permission))); const oversightSections = [ diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index 442308a..69856d3 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -7,7 +7,7 @@ export const load: PageServerLoad = async ({ cookies }) => { const permissions = await getPermissions(await getSessionUser(cookies)); if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/admin/users'); if (permissions.includes('users.roles')) redirect(303, '/admin/roles'); - if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/stats'); + if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/admin/attributes'); if (permissions.includes('audit.view')) redirect(303, '/admin/audit'); if (permissions.includes('metrics.view')) redirect(303, '/admin/metrics'); redirect(303, '/account'); diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/attributes/+page.server.ts similarity index 100% rename from src/routes/admin/stats/+page.server.ts rename to src/routes/admin/attributes/+page.server.ts diff --git a/src/routes/admin/stats/+page.svelte b/src/routes/admin/attributes/+page.svelte similarity index 93% rename from src/routes/admin/stats/+page.svelte rename to src/routes/admin/attributes/+page.svelte index f205860..418aa7c 100644 --- a/src/routes/admin/stats/+page.svelte +++ b/src/routes/admin/attributes/+page.svelte @@ -14,8 +14,8 @@ } }); -Stats & attributes — SHD.build admin -

ATTRIBUTE REGISTRY

STATS & ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

+Attributes — SHD.build admin +

ATTRIBUTE REGISTRY

ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

UNIT
{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} diff --git a/src/routes/admin/contributor-guidelines/+page.server.ts b/src/routes/admin/contributor-guidelines/+page.server.ts index 6fdd9f8..9b946e7 100644 --- a/src/routes/admin/contributor-guidelines/+page.server.ts +++ b/src/routes/admin/contributor-guidelines/+page.server.ts @@ -8,8 +8,8 @@ import { recordIsacAudit } from '$lib/server/audit'; import type { Actions, PageServerLoad } from './$types'; const safeReturn = (value: FormDataEntryValue | null) => { - const path = String(value ?? '/admin/stats'); - return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/stats'; + const path = String(value ?? '/admin/attributes'); + return path.startsWith('/admin/') && !path.startsWith('//') ? path : '/admin/attributes'; }; export const load: PageServerLoad = async ({ cookies, url }) => { @@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { if (!user) redirect(303, '/sign-in'); const permissions = await getPermissions(user); const eligible = permissions.includes('*') || permissions.includes('data.propose'); - if (!eligible) redirect(303, '/admin/stats'); + if (!eligible) redirect(303, '/admin/attributes'); return { contributorMode: await getContributorMode(user.id), returnTo: safeReturn(url.searchParams.get('returnTo')) diff --git a/src/routes/admin/data-moderation/+page.server.ts b/src/routes/admin/data-moderation/+page.server.ts index af9d122..45b3e14 100644 --- a/src/routes/admin/data-moderation/+page.server.ts +++ b/src/routes/admin/data-moderation/+page.server.ts @@ -10,7 +10,7 @@ import type { Actions, PageServerLoad } from './$types'; import { createNotification } from '$lib/server/notifications'; const statuses = ['pending', 'approved', 'rejected', 'failed']; -const modules = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols']; +const modules = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips']; type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string }; diff --git a/src/routes/admin/data-moderation/+page.svelte b/src/routes/admin/data-moderation/+page.svelte index 8885ce7..d6b5132 100644 --- a/src/routes/admin/data-moderation/+page.svelte +++ b/src/routes/admin/data-moderation/+page.svelte @@ -12,6 +12,7 @@ entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry ); const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase(); + const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase(); const openReview = (proposal: any) => { reviewing = proposal; decision = 'approve'; @@ -42,7 +43,7 @@ ]}/>
- +
@@ -51,7 +52,7 @@
CHANGECONTRIBUTORSUBMITTEDSTATUS
{#each data.proposals as proposal}
-
{proposal.module.slice(0,4).toUpperCase()}
{proposal.summary}{proposal.module.toUpperCase()} / {operationLabel(proposal.action)}
+
{proposal.module === 'skillchips' ? 'CHIP' : proposal.module.slice(0,4).toUpperCase()}
{proposal.summary}{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}
{proposal.proposer_name}AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}
{date(proposal.created_at)} {proposal.status.toUpperCase()} @@ -65,7 +66,7 @@