Add shared gear tactical augment profiles

This commit is contained in:
JB
2026-07-25 16:46:01 -04:00
parent 10ab65e0b5
commit 095b697b08
5 changed files with 156 additions and 2 deletions
+16
View File
@@ -382,6 +382,22 @@ CREATE TABLE IF NOT EXISTS gear_talents (
talent_id TEXT NOT NULL REFERENCES talents(id), talent_id TEXT NOT NULL REFERENCES talents(id),
PRIMARY KEY(gear_id, talent_id) PRIMARY KEY(gear_id, talent_id)
); );
CREATE TABLE IF NOT EXISTS gear_tactical_augments (
id TEXT PRIMARY KEY,
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
value REAL NOT NULL,
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(gear_slot, augment_slot),
CHECK(
(gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4)
OR
(gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)
)
);
CREATE TABLE IF NOT EXISTS weapons ( CREATE TABLE IF NOT EXISTS weapons (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
+16
View File
@@ -798,3 +798,19 @@ augmentation rows: its selected record and search field remain inline while
the filtered result registry opens as an anchored overlay. The compact overlay the filtered result registry opens as an anchored overlay. The compact overlay
must layer above adjacent rows, retain pending-record labels, and collapse to must layer above adjacent rows, retain pending-record labels, and collapse to
one column on narrow screens. one column on narrow screens.
### Shared equipment profiles
Configuration inherited by every item of a finite equipment type belongs in a
collapsible shared-profile registry above the item table. Each profile card
states its type, required slot count, configured state, and a compact summary;
selecting it opens a separate dense editor and closes the individual-record
editor. Successful saves close the profile editor and return to the registry.
Gear Tactical Augment profiles use this pattern for all six gear types.
Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee
Pads, and Masks expose three. Every slot contains exactly one searchable
Attribute relationship, one numeric effect value, and one non-negative
whole-number unlock cost. At narrow widths profile cards and augment editor
cards reduce from multi-column grids to a single column while retaining
44-pixel interactive targets.
+3
View File
@@ -139,6 +139,9 @@
.command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:7px;font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:6px}.proposal-correction-head>b{color:var(--shd-orange);font-size:7px}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:5px}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:6px}.immutable-proposal-file b{color:var(--shd-text);font-size:8px}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}} .command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:7px;font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:6px}.proposal-correction-head>b{color:var(--shd-orange);font-size:7px}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:5px}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:6px}.immutable-proposal-file b{color:var(--shd-text);font-size:8px}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}}
.mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:5px;letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:6px;letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:6px;letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:10px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:6px;letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:11px}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}.site-header .brand-name small{display:none}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}} .mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:5px;letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:6px;letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:6px;letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:10px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:6px;letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:11px}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}.site-header .brand-name small{display:none}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}}
@media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}} @media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}}
/* Gear shared Tactical Augment profiles */
.gear-augment-registry{margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.gear-augment-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.gear-augment-registry>summary::-webkit-details-marker{display:none}.gear-augment-registry>summary:hover,.gear-augment-registry>summary:focus-visible{background:#171b1c}.gear-augment-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.gear-augment-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.gear-augment-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.gear-augment-registry[open]>summary i{transform:rotate(45deg)}.gear-augment-profile-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.gear-augment-profile-grid>button{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.gear-augment-profile-grid>button:hover,.gear-augment-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.gear-augment-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.gear-augment-profile-grid header{display:flex;justify-content:space-between;align-items:center;padding-bottom:6px;border-bottom:1px solid #292e2f}.gear-augment-profile-grid header div{display:grid;gap:2px}.gear-augment-profile-grid header strong{color:#d7d9d5;font-size:9px}.gear-augment-profile-grid header small,.gear-augment-profile-grid header>span{color:#626a6c;font-size:6px;letter-spacing:.6px}.gear-augment-profile-grid>button.configured header>span{color:var(--shd-success)}.gear-augment-profile-grid>button>div{display:grid;margin-top:5px}.gear-augment-profile-grid>button>div span{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:6px;padding:5px 2px;border-top:1px solid #232829}.gear-augment-profile-grid>button>div span:first-child{border-top:0}.gear-augment-profile-grid>button>div small{color:var(--shd-orange);font-size:6px}.gear-augment-profile-grid>button>div b{overflow:hidden;color:#aeb4b5;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.gear-augment-profile-grid>button>div em{color:#626a6c;font-size:6px;font-style:normal}.gear-augment-editor{border-top-color:var(--shd-success)}.gear-augment-slots{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.gear-augment-slots section{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#101314}.gear-augment-slots section>header{display:flex;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid #2a2f30}.gear-augment-slots section>header strong{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.gear-augment-slots section>header span{color:#596164;font-size:6px}.gear-augment-slots .field{margin:7px 0 0}.gear-augment-slots .field>span,.gear-augment-slots label{font-size:7px}@media(max-width:1100px){.gear-augment-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-augment-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.gear-augment-profile-grid,.gear-augment-slots{grid-template-columns:1fr}.gear-augment-profile-grid>button{min-height:44px}}
.mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}} .mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}}
.avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}} .avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}}
.assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.55px} .assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.55px}
+64 -1
View File
@@ -10,6 +10,12 @@ import { getPendingEntities } from '$lib/server/data-moderation';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
async function augmentProfileSnapshot(slot: string) {
const database = await getDatabase();
return (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
}
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies)); const user = await requireDataView(await getSessionUser(cookies));
@@ -23,12 +29,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : ''; : filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(); const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
const pagination = getPagination(url, Number(filtered?.total ?? 0)); const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [gear, brandLinks, talentLinks, brands, talents, pending] = await Promise.all([ const [gear, brandLinks, talentLinks, brands, talents, augmentRows, stats, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(), (await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(), (await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(), (await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
getPendingEntities(user.id, [ getPendingEntities(user.id, [
{ module: 'brands', action: 'save', kind: 'brand' }, { module: 'brands', action: 'save', kind: 'brand' },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] } { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
@@ -44,6 +52,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
})), })),
brands: [...pendingBrands, ...brands], brands: [...pendingBrands, ...brands],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))], talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
augments: augmentRows.filter((augment) => augment.gear_slot === slot)
})),
stats,
summary, summary,
pagination, pagination,
filter filter
@@ -58,6 +72,55 @@ const readRange = (form: FormData, prefix: string) => {
}; };
export const actions: Actions = { export const actions: Actions = {
saveAugmentProfile: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const slot = String(form.get('slot') ?? '');
if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
const requiredSlots = augmentSlotCount(slot);
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
slot: index + 1,
statId: String(form.get(`statId${index + 1}`) ?? ''),
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
rawUnlockCost: String(form.get(`unlockCost${index + 1}`) ?? '').trim(),
value: Number(form.get(`value${index + 1}`)),
unlockCost: Number(form.get(`unlockCost${index + 1}`))
}));
if (augments.some((augment) => !augment.statId || !augment.rawValue || !Number.isFinite(augment.value))) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires exactly one attribute and value.' });
}
if (augments.some((augment) => !augment.rawUnlockCost || !Number.isInteger(augment.unlockCost) || augment.unlockCost < 0)) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires a non-negative whole-number unlock cost.' });
}
const database = await getDatabase();
const statIds = [...new Set(augments.map((augment) => augment.statId))];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' });
const before = await augmentProfileSnapshot(slot);
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
for (const augment of augments) {
await (await database.prepare(`INSERT INTO gear_tactical_augments
(id,gear_slot,augment_slot,stat_attribute_id,value,unlock_cost,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(randomUUID(), slot, augment.slot, augment.statId, augment.value, augment.unlockCost, now, now);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created',
entityId: slot,
before,
after: await augmentProfileSnapshot(slot)
});
return { augmentProfileSaved: true, resultToken: randomUUID() };
},
save: async ({ request, cookies }) => { save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData(); const form = await request.formData();
+57 -1
View File
@@ -9,12 +9,35 @@
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] });
let editing = $state<Editor | null>(null); let editing = $state<Editor | null>(null);
let augmentEditing = $state<any | null>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '—';
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = stat(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', '); const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', ');
const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; }; const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; };
const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot); const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot);
const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; }; const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; };
const editAugments = (profile: any) => {
augmentEditing = {
...profile,
augments: Array.from({ length: profile.requiredSlots }, (_, index) => {
const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1);
return augment ? { statId: augment.stat_attribute_id, value: augment.value, unlockCost: augment.unlock_cost } : { statId: '', value: '', unlockCost: '' };
})
};
editing = null;
};
$effect(() => { $effect(() => {
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
augmentEditing = null;
}
if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) { if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
editing = form.gearMode === 'add' ? blank() : null; editing = form.gearMode === 'add' ? blank() : null;
@@ -23,7 +46,40 @@
</script> </script>
<svelte:head><title>Gear — SHD.build admin</title></svelte:head> <svelte:head><title>Gear — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div> <div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure shared tactical augments, slot progression, survivability, brands, and talents.</p></div><button class="primary" onclick={() => { editing = blank(); augmentEditing = null; }}>ADD GEAR <span>+</span></button></div>
<details class="gear-augment-registry">
<summary class="selection-head"><div><span>SHARED TACTICAL AUGMENT PROFILES</span><small>ONE PROFILE PER GEAR TYPE · REUSED BY EVERY MATCHING ITEM</small></div><div><b>{data.augmentProfiles.filter((profile) => profile.augments.length === profile.requiredSlots).length} / 6 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
<div class="gear-augment-profile-grid">
{#each data.augmentProfiles as profile}
<button type="button" class:configured={profile.augments.length === profile.requiredSlots} onclick={() => editAugments(profile)}>
<header><div><strong>{profile.slot}</strong><small>{profile.requiredSlots} AUGMENT SLOTS</small></div><span>{profile.augments.length === profile.requiredSlots ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each Array(profile.requiredSlots) as _, index}{@const augment = profile.augments.find((item) => Number(item.augment_slot) === index + 1)}<span><small>0{index + 1}</small><b>{augment ? `${stat(augment.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(augment.value, augment.stat_attribute_id)}` : 'NOT SET'}</b><em>{augment ? `${augment.unlock_cost} COST` : '—'}</em></span>{/each}</div>
</button>
{/each}
</div>
</details>
{#if augmentEditing}
<div class="dashboard-editor gear-augment-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED TACTICAL AUGMENTS</p><h3>{augmentEditing.slot.toUpperCase()} PROFILE</h3></div><button aria-label="Close tactical augment editor" onclick={() => augmentEditing = null}>×</button></div>
<PendingForm action="?/saveAugmentProfile">
<input type="hidden" name="slot" value={augmentEditing.slot}/>
<input type="hidden" name="name" value={`${augmentEditing.slot} tactical augments`}/>
<p class="console-note">These {augmentEditing.requiredSlots} augment slots apply to every {augmentEditing.slot} item. Each slot grants exactly one attribute and unlocks at its configured tactical-augment cost.</p>
<div class="gear-augment-slots">
{#each augmentEditing.augments as augment: any, index}
<section>
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>SHARED SLOT</span></header>
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`statId${index + 1}`} mode="single" compact subjectLabel={`AUGMENT ${index + 1} ATTRIBUTE`} searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
<label class="field">VALUE<input name={`value${index + 1}`} type="number" step="any" bind:value={augment.value} required/></label>
<label class="field">UNLOCK COST<input name={`unlockCost${index + 1}`} type="number" min="0" step="1" bind:value={augment.unlockCost} required/></label>
</section>
{/each}
</div>
{#if form?.augmentProfileError}<p class="error">{form.augmentProfileError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => augmentEditing = null}>CANCEL</button><button class="primary">SAVE SHARED PROFILE →</button></div>
</PendingForm>
</div>
{/if}
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL GEAR',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'armored',label:'ARMOR-BEARING',count:data.summary.armored,description:'Gloves, knees, and body armor'},{value:'talented',label:'TALENT-BEARING',count:data.summary.talented,description:'Backpacks and body armor'}]}/> <KpiFilters active={data.filter} items={[{value:'',label:'TOTAL GEAR',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'armored',label:'ARMOR-BEARING',count:data.summary.armored,description:'Gloves, knees, and body armor'},{value:'talented',label:'TALENT-BEARING',count:data.summary.talented,description:'Backpacks and body armor'}]}/>
{#if editing} {#if editing}
<div class="dashboard-editor"> <div class="dashboard-editor">