Refine ISAC selection controls and icons
@@ -755,13 +755,29 @@ rail and filled square indicator; deselected segments remain readable and
|
|||||||
available. The channel rail collapses from six to three and then two columns as
|
available. The channel rail collapses from six to three and then two columns as
|
||||||
space narrows, with minimum 44-pixel mobile targets.
|
space narrows, with minimum 44-pixel mobile targets.
|
||||||
|
|
||||||
Use the shared `SegmentedChoice` component for compact, finite option sets.
|
Use the shared `SegmentedChoice` component for every static, scannable ISAC
|
||||||
Radio mode represents one required choice; checkbox mode represents additive
|
choice set containing two through six options. Radio mode represents one
|
||||||
independent choices. Prefer this visible segmented treatment over a dropdown
|
required choice; checkbox mode represents additive independent choices. This
|
||||||
when the complete option set is small enough to scan comfortably. Dropdowns
|
rule applies equally to editors and registry filters: do not introduce a native
|
||||||
remain appropriate for long, dynamic, or search-oriented lists. Both modes
|
dropdown merely to save vertical space. Use the shared `SearchableAssignment`
|
||||||
share hover, pressed, focus-visible, selected, disabled, responsive, and
|
component in single or multiple mode when there are seven or more options, when
|
||||||
44-pixel mobile-target behavior so feature modules do not reimplement it.
|
the options come from a growing data registry, or when search materially helps
|
||||||
|
recognition. Compact mode is the standard treatment for filter bars and dense
|
||||||
|
repeated rows; full mode is used for primary relationships.
|
||||||
|
|
||||||
|
Native selects are not a default ISAC control. Retain one only when a documented
|
||||||
|
browser-native behavior cannot be represented accessibly by either shared
|
||||||
|
component. Such an exception must be called out in this design system in the
|
||||||
|
same change. Both shared choice patterns expose explicit selection, hover,
|
||||||
|
pressed, focus-visible, disabled, empty/all, responsive, and 44-pixel
|
||||||
|
mobile-target states so modules do not reimplement them.
|
||||||
|
|
||||||
|
`SegmentedChoice` supports two restrained density variants. Icon-only segments
|
||||||
|
may omit visible text when a canonical, unambiguous game icon exists; they must
|
||||||
|
retain an accessible label and hover title. Text-only segments may omit the
|
||||||
|
default square indicator when the selected background, orange text, and lower
|
||||||
|
rail already communicate state clearly. Do not remove both visible text and an
|
||||||
|
icon from the same option.
|
||||||
|
|
||||||
### Skill Chip data console
|
### Skill Chip data console
|
||||||
|
|
||||||
@@ -810,6 +826,28 @@ 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.
|
||||||
|
|
||||||
|
Gear identity editing uses the shared segmented radio treatment for both Gear
|
||||||
|
slot and item tier. Show all six slots together and all three tiers together so
|
||||||
|
contributors can verify classification without opening a menu. The record name
|
||||||
|
remains the dominant text field; at medium widths it occupies a full row above
|
||||||
|
the two selectors. On narrow screens the slot selector becomes a three-by-two
|
||||||
|
grid, both selectors use at least 44-pixel targets, and changing slot clears
|
||||||
|
incompatible Talent relationships exactly as the former selector did. Gear
|
||||||
|
slot segments use icon-only controls with accessible slot names; registry labels
|
||||||
|
retain icon and text for rapid table scanning. Tier segments use the compact
|
||||||
|
game notation `T0`, `T1`, and `T2`, omit the redundant square indicator, and
|
||||||
|
rely on the selected rail and color; do not expand these to “Tier 0” inside
|
||||||
|
dense segmented controls. On desktop, icon-only Gear slot segments are square
|
||||||
|
32-pixel cells so their classification rail stays compact and the adjacent
|
||||||
|
record-name input receives the dominant width. Increase those cells to square
|
||||||
|
44-pixel targets at mobile sizes without reintroducing visible slot text.
|
||||||
|
|
||||||
|
Root-level static PNGs are interface icons and use content-fitted transparent
|
||||||
|
bounds. Run `npm run assets:trim-icons` after adding or replacing them. The
|
||||||
|
normalizer trims fully transparent padding, restores a two-pixel transparent
|
||||||
|
safety margin so antialiased edges never touch the canvas, preserves RGBA PNG
|
||||||
|
output, and leaves portrait/avatar and SVG asset classes untouched.
|
||||||
|
|
||||||
### Shared equipment profiles
|
### Shared equipment profiles
|
||||||
|
|
||||||
Configuration inherited by every item of a finite equipment type belongs in a
|
Configuration inherited by every item of a finite equipment type belongs in a
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
||||||
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
||||||
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
||||||
|
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
||||||
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
||||||
"env:decrypt": "node scripts/env-files.mjs decrypt",
|
"env:decrypt": "node scripts/env-files.mjs decrypt",
|
||||||
"env:encrypt:development": "dotenvx encrypt -f .env.development",
|
"env:encrypt:development": "dotenvx encrypt -f .env.development",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import { readdir, rename, unlink } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const iconDirectory = resolve('./static');
|
||||||
|
const iconFiles = (await readdir(iconDirectory))
|
||||||
|
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
|
||||||
|
for (const file of iconFiles) {
|
||||||
|
const source = join(iconDirectory, file);
|
||||||
|
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
||||||
|
const before = await sharp(source).metadata();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sharp(source)
|
||||||
|
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.extend({
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
bottom: 2,
|
||||||
|
left: 2,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
|
.toFile(temporary);
|
||||||
|
|
||||||
|
if (!result.width || !result.height || result.channels !== 4) {
|
||||||
|
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rename(temporary, source);
|
||||||
|
console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`);
|
||||||
|
} catch (error) {
|
||||||
|
await unlink(temporary).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`);
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
--shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease;
|
--shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease;
|
||||||
}
|
}
|
||||||
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none}
|
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none}
|
||||||
|
.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
|
||||||
@media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}}
|
@media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}}
|
||||||
@media(min-width:2800px){:root{--shd-display-scale:1.3}}
|
@media(min-width:2800px){:root{--shd-display-scale:1.3}}
|
||||||
@media(min-width:3400px){:root{--shd-display-scale:1.5}}
|
@media(min-width:3400px){:root{--shd-display-scale:1.5}}
|
||||||
@@ -146,7 +147,7 @@
|
|||||||
.registry-icon-rail{display:flex!important;align-items:center;gap:4px}.registry-icon-rail>span{width:30px;height:30px;display:grid;place-items:center;border:1px solid #353b3c;background:#0d1011}.registry-icon-rail img{width:26px;height:26px;object-fit:contain}.registry-icon-rail i{color:var(--shd-orange);font-size:7px;font-style:normal;font-weight:900;letter-spacing:.3px}.registry-icon-rail>span:hover{border-color:#8a672b;background:#171b1c}
|
.registry-icon-rail{display:flex!important;align-items:center;gap:4px}.registry-icon-rail>span{width:30px;height:30px;display:grid;place-items:center;border:1px solid #353b3c;background:#0d1011}.registry-icon-rail img{width:26px;height:26px;object-fit:contain}.registry-icon-rail i{color:var(--shd-orange);font-size:7px;font-style:normal;font-weight:900;letter-spacing:.3px}.registry-icon-rail>span:hover{border-color:#8a672b;background:#171b1c}
|
||||||
.gear-fixed-augment{min-height:70px;display:grid;align-content:center;gap:4px;margin-top:7px;padding:8px;border:1px solid #34473a;border-left:2px solid var(--shd-success);background:#101713}.gear-fixed-augment span,.gear-fixed-augment small{color:#718078;font-size:6px;letter-spacing:.6px}.gear-fixed-augment b{color:var(--shd-success);font-size:17px}
|
.gear-fixed-augment{min-height:70px;display:grid;align-content:center;gap:4px;margin-top:7px;padding:8px;border:1px solid #34473a;border-left:2px solid var(--shd-success);background:#101713}.gear-fixed-augment span,.gear-fixed-augment small{color:#718078;font-size:6px;letter-spacing:.6px}.gear-fixed-augment b{color:var(--shd-success);font-size:17px}
|
||||||
.gear-tier-costs{margin:8px 0 0;padding:7px;border:1px solid #303536}.gear-tier-costs legend{padding:0 5px;color:#697174;font-size:6px;font-weight:900;letter-spacing:.7px}.gear-tier-costs>div{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.gear-tier-costs .field{margin:0}.gear-augment-profile-grid>button>div em{white-space:nowrap}@media(max-width:560px){.gear-tier-costs>div{grid-template-columns:1fr 1fr 1fr}}
|
.gear-tier-costs{margin:8px 0 0;padding:7px;border:1px solid #303536}.gear-tier-costs legend{padding:0 5px;color:#697174;font-size:6px;font-weight:900;letter-spacing:.7px}.gear-tier-costs>div{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.gear-tier-costs .field{margin:0}.gear-augment-profile-grid>button>div em{white-space:nowrap}@media(max-width:560px){.gear-tier-costs>div{grid-template-columns:1fr 1fr 1fr}}
|
||||||
.gear-identity{grid-template-columns:2fr 1fr .65fr}
|
.gear-identity{grid-template-columns:minmax(360px,1fr) 200px minmax(150px,180px);align-items:end}.gear-identity>.field,.gear-identity>.segmented-choice{margin:0}.gear-identity .segmented-choice legend{font-size:7px}.gear-identity .segmented-choice>div{margin-top:5px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(6,32px);width:max-content}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:32px}@media(max-width:900px){.gear-identity{grid-template-columns:1fr 1fr}.gear-identity>.field{grid-column:1/3}}@media(max-width:700px){.gear-identity{grid-template-columns:1fr}.gear-identity>.field{grid-column:auto}.gear-identity .segmented-choice>div{height:auto}.gear-identity .segmented-choice span{min-height:44px}.gear-identity .segmented-choice:first-of-type>div{grid-template-columns:repeat(3,44px)}.gear-identity .segmented-choice:first-of-type label,.gear-identity .segmented-choice:first-of-type span{width:44px}}
|
||||||
.builder-field-range{float:right;color:#5f6769;font-size:6px}.builder-scaling-note{margin:7px 0 0;padding:7px 9px;border-left:2px solid #667174;background:#101415;color:#727a7c;font-size:7px;line-height:1.45}
|
.builder-field-range{float:right;color:#5f6769;font-size:6px}.builder-scaling-note{margin:7px 0 0;padding:7px 9px;border-left:2px solid #667174;background:#101415;color:#727a7c;font-size:7px;line-height:1.45}
|
||||||
|
|
||||||
/* Builder workbench */
|
/* Builder workbench */
|
||||||
@@ -163,6 +164,9 @@
|
|||||||
.system-message-body:not(.expanded)>p,.system-message-body:not(.expanded) .system-message-detail>p,.notification-body-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.notification-actions{display:flex;align-items:center;gap:7px}.notification-actions .notification-response-action{min-height:28px;display:inline-flex;align-items:center;padding:0 10px;border:1px solid #6b5329;background:#1b160d;color:var(--shd-orange);transition:var(--shd-transition)}.notification-actions .notification-response-action:hover{border-color:var(--shd-orange);background:#241b0d;color:#fff}.notification-actions .notification-response-action:active{transform:translateY(1px);filter:brightness(.9)}.notification-actions .notification-response-action:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.profile-anchor{position:relative;top:calc(-1 * var(--shd-header-height));visibility:hidden}@media(max-width:700px){.notification-actions{align-items:stretch;flex-direction:column}.notification-actions .notification-response-action{min-height:44px;justify-content:center}}
|
.system-message-body:not(.expanded)>p,.system-message-body:not(.expanded) .system-message-detail>p,.notification-body-preview{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.notification-actions{display:flex;align-items:center;gap:7px}.notification-actions .notification-response-action{min-height:28px;display:inline-flex;align-items:center;padding:0 10px;border:1px solid #6b5329;background:#1b160d;color:var(--shd-orange);transition:var(--shd-transition)}.notification-actions .notification-response-action:hover{border-color:var(--shd-orange);background:#241b0d;color:#fff}.notification-actions .notification-response-action:active{transform:translateY(1px);filter:brightness(.9)}.notification-actions .notification-response-action:focus-visible{outline:2px solid var(--shd-orange);outline-offset:2px}.profile-anchor{position:relative;top:calc(-1 * var(--shd-header-height));visibility:hidden}@media(max-width:700px){.notification-actions{align-items:stretch;flex-direction:column}.notification-actions .notification-response-action{min-height:44px;justify-content:center}}
|
||||||
.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)}}
|
.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}
|
.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}
|
||||||
|
.segmented-choice span.icon-only{gap:0}.segmented-choice span.icon-only .game-icon{width:24px;height:24px}
|
||||||
|
.dashboard-editor .form-grid>.segmented-choice{align-self:end;margin:8px 0}.dashboard-editor .form-grid>.segmented-choice legend,.talent-identity .segmented-choice legend,.weapon-identity-grid .segmented-choice legend{font-size:8px}.talent-identity{display:grid;grid-template-columns:minmax(260px,1fr) minmax(430px,1.5fr)!important;align-items:end;gap:9px}.talent-identity>.field{margin:0}.weapon-identity-grid{grid-template-columns:minmax(230px,1.1fr) minmax(390px,1.75fr) minmax(180px,.7fr) minmax(220px,.9fr) minmax(145px,.6fr);align-items:end}.weapon-identity-grid>.field,.weapon-identity-grid>.segmented-choice{margin:0}.history-filter{align-items:end;grid-template-columns:minmax(180px,1fr) minmax(250px,.9fr) 80px}.history-filter .segmented-choice>div{height:34px}.audit-filter>.assignment-picker.compact{height:40px}.moderation-filter{grid-template-columns:minmax(220px,1fr) minmax(280px,.9fr) auto}@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:1fr 1fr}.weapon-identity-grid>.weapon-name,.weapon-identity-grid>.segmented-choice:first-of-type{grid-column:1/3}}@media(max-width:700px){.talent-identity,.weapon-identity-grid{grid-template-columns:1fr!important}.weapon-identity-grid>.weapon-name,.weapon-identity-grid>.segmented-choice:first-of-type{grid-column:auto}.talent-identity .segmented-choice>div,.weapon-identity-grid .segmented-choice>div,.dashboard-editor .form-grid>.segmented-choice>div{height:auto}.talent-identity .segmented-choice span,.weapon-identity-grid .segmented-choice span,.dashboard-editor .form-grid>.segmented-choice span{min-height:44px}.talent-identity .segmented-choice>div{grid-template-columns:repeat(2,1fr)}.weapon-identity-grid>.segmented-choice:first-of-type>div{grid-template-columns:repeat(3,1fr)}}@media(max-width:650px){.history-filter{grid-template-columns:1fr}.history-filter .segmented-choice>div{height:auto}.history-filter .segmented-choice span{min-height:44px}.moderation-filter{grid-template-columns:1fr}}
|
||||||
|
.gear-slot-label{display:flex!important;align-items:center;gap:5px}.gear-slot-label .game-icon{width:20px;height:20px}
|
||||||
.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)}}
|
.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}}
|
.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{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)}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
icon?: GameIconName;
|
icon?: GameIconName;
|
||||||
|
iconOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
selected = $bindable([]),
|
selected = $bindable([]),
|
||||||
columns = options.length,
|
columns = options.length,
|
||||||
required = false,
|
required = false,
|
||||||
|
showIndicator = true,
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
legend: string;
|
legend: string;
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
selected?: string[];
|
selected?: string[];
|
||||||
columns?: number;
|
columns?: number;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
showIndicator?: boolean;
|
||||||
onchange?: (values: string[]) => void;
|
onchange?: (values: string[]) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -55,9 +58,9 @@
|
|||||||
{required}
|
{required}
|
||||||
onchange={(event) => change(option.value, event.currentTarget.checked)}
|
onchange={(event) => change(option.value, event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<span class={option.className ?? ''}>
|
<span class:icon-only={option.iconOnly} class={option.className ?? ''} title={option.iconOnly ? option.label : undefined}>
|
||||||
{#if option.icon}<GameIcon name={option.icon} decorative/>{:else}<i aria-hidden="true"></i>{/if}
|
{#if option.icon}<GameIcon name={option.icon} decorative/>{:else if showIndicator}<i aria-hidden="true"></i>{/if}
|
||||||
<b>{option.label}</b>
|
<b class:sr-only={option.iconOnly}>{option.label}</b>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ export const gameIcons = {
|
|||||||
tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' },
|
tech_operator: { src: '/tech_operator.png', label: 'Tech Operator specialization' },
|
||||||
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
|
vanguard: { src: '/vanguard.png', label: 'Vanguard specialization' },
|
||||||
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
|
legendary: { src: '/legendary.png', label: 'Legendary difficulty' },
|
||||||
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' }
|
shd_ticket: { src: '/shd_ticket.png', label: 'SHD ticket' },
|
||||||
|
holster: { src: '/holster.png', label: 'Holster gear slot' },
|
||||||
|
gloves: { src: '/gloves.png', label: 'Gloves gear slot' },
|
||||||
|
backpack: { src: '/backpack.png', label: 'Backpack gear slot' },
|
||||||
|
knee_pads: { src: '/knee_pads.png', label: 'Knee Pads gear slot' },
|
||||||
|
body_armor: { src: '/body_armor.png', label: 'Body Armor gear slot' },
|
||||||
|
mask: { src: '/mask.png', label: 'Mask gear slot' }
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type GameIconName = keyof typeof gameIcons;
|
export type GameIconName = keyof typeof gameIcons;
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let editing = $state<any>(null);
|
let editing = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
|
const typeOptions = ['Offensive', 'Defensive'].map((value) => ({ value, label: value }));
|
||||||
|
const categoryOptions = ['Weapon', 'Skill', 'Character'].map((value) => ({ value, label: value }));
|
||||||
|
const calculationOptions = ['Additive', 'Multiplicative'].map((value) => ({ value, label: value }));
|
||||||
|
const unitOptions = [{value:'',label:'None'},{value:'%',label:'%'},{value:'s',label:'s'},{value:'Shots',label:'Shots'},{value:'m',label:'m'}];
|
||||||
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
|
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
|
||||||
const value = (input: unknown) => input ?? '';
|
const value = (input: unknown) => input ?? '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -17,6 +22,6 @@
|
|||||||
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
<svelte:head><title>Attributes — SHD.build admin</title></svelte:head>
|
||||||
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
|
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
|
||||||
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
|
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL RECORDS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to systems'},{value:'offensive',label:'OFFENSIVE',count:data.summary.offensive,description:'Damage-oriented'},{value:'defensive',label:'DEFENSIVE',count:data.summary.defensive,description:'Survival-oriented'}]}/>
|
||||||
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><label class="field">TYPE<select name="type" bind:value={editing.type}><option>Offensive</option><option>Defensive</option></select></label><label class="field">CATEGORY<select name="category" bind:value={editing.category}><option>Weapon</option><option>Skill</option><option>Character</option></select></label><label class="field">CALCULATION<select name="calculationType" bind:value={editing.calculation_type}><option>Additive</option><option>Multiplicative</option></select></label><fieldset class="unit-field"><legend>UNIT</legend><div class="unit-picker">{#each [['','NONE'],['%','%'],['s','s'],['Shots','SHOTS'],['m','m']] as option}<label><input type="radio" name="unit" value={option[0]} bind:group={editing.unit}/><span>{option[1]}</span></label>{/each}</div></fieldset>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
|
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><SegmentedChoice legend="TYPE" name="type" options={typeOptions} bind:value={editing.type} required/><SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} required/><SegmentedChoice legend="CALCULATION" name="calculationType" options={calculationOptions} bind:value={editing.calculation_type} required/><SegmentedChoice legend="UNIT" name="unit" options={unitOptions} bind:value={editing.unit}/>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
|
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit || 'NO UNIT'}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
|
||||||
|
|||||||
@@ -2,9 +2,18 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let confirming = $state<any>(null);
|
let confirming = $state<any>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
|
const categoryOptions = $derived([
|
||||||
|
{id:'user',name:'USER OPERATIONS · ALL'},
|
||||||
|
{id:'access_policy',name:'ACCESS POLICY · ALL'},
|
||||||
|
...data.categories
|
||||||
|
.filter((item) => !['user', 'access_policy'].includes(String(item.entity_type)))
|
||||||
|
.map((item) => ({id:String(item.entity_type),name:`${String(item.entity_type).replaceAll('_',' ').toUpperCase()} · ${item.count}`}))
|
||||||
|
]);
|
||||||
|
const actionOptions = $derived(data.actions.map((item) => ({id:String(item.action),name:`${String(item.action).toUpperCase()} · ${item.count}`})));
|
||||||
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(String(value)));
|
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(String(value)));
|
||||||
const pretty = (value: unknown) => {
|
const pretty = (value: unknown) => {
|
||||||
if (!value) return 'NO SNAPSHOT';
|
if (!value) return 'NO SNAPSHOT';
|
||||||
@@ -29,8 +38,8 @@
|
|||||||
]}/>
|
]}/>
|
||||||
<form class="audit-filter">
|
<form class="audit-filter">
|
||||||
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
|
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
|
||||||
<select name="category"><option value="">ALL CATEGORIES</option><option value="user" selected={data.filters.category === 'user'}>USER OPERATIONS · ALL</option><option value="access_policy" selected={data.filters.category === 'access_policy'}>ACCESS POLICY · ALL</option>{#each data.categories as item}<option value={item.entity_type} selected={data.filters.category === item.entity_type}>{String(item.entity_type).replaceAll('_',' ').toUpperCase()} · {item.count}</option>{/each}</select>
|
<SearchableAssignment items={categoryOptions} mode="single" compact fieldName="category" value={data.filters.category} subjectLabel="AUDIT CATEGORY" searchPlaceholder="Search categories…" emptySelectionLabel="ALL CATEGORIES"/>
|
||||||
<select name="action"><option value="">ALL ACTIONS</option>{#each data.actions as item}<option value={item.action} selected={data.filters.action === item.action}>{String(item.action).toUpperCase()} · {item.count}</option>{/each}</select>
|
<SearchableAssignment items={actionOptions} mode="single" compact fieldName="action" value={data.filters.action} subjectLabel="AUDIT ACTION" searchPlaceholder="Search actions…" emptySelectionLabel="ALL ACTIONS"/>
|
||||||
<button class="secondary">SEARCH AUDIT</button>
|
<button class="secondary">SEARCH AUDIT</button>
|
||||||
</form>
|
</form>
|
||||||
<RegistryPagination pagination={data.pagination}/>
|
<RegistryPagination pagination={data.pagination}/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let reviewing = $state<any>(null);
|
let reviewing = $state<any>(null);
|
||||||
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
);
|
);
|
||||||
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||||||
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase();
|
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase();
|
||||||
|
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
|
||||||
const openReview = (proposal: any) => {
|
const openReview = (proposal: any) => {
|
||||||
reviewing = proposal;
|
reviewing = proposal;
|
||||||
decision = 'approve';
|
decision = 'approve';
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
]}/>
|
]}/>
|
||||||
<form class="audit-filter moderation-filter">
|
<form class="audit-filter moderation-filter">
|
||||||
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
|
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
|
||||||
<select name="module"><option value="">ALL DATA MODULES</option>{#each data.modules as module}<option value={module} selected={data.module === module}>{moduleLabel(module)}</option>{/each}</select>
|
<SearchableAssignment items={moduleOptions} mode="single" compact fieldName="module" value={data.module} subjectLabel="DATA MODULE" searchPlaceholder="Search data modules…" emptySelectionLabel="ALL DATA MODULES"/>
|
||||||
<input type="hidden" name="status" value={data.status}/>
|
<input type="hidden" name="status" value={data.status}/>
|
||||||
<button class="secondary">SEARCH QUEUE →</button>
|
<button class="secondary">SEARCH QUEUE →</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
|
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Editor = { id: string; name: string; slot: string; tier: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
|
type Editor = { id: string; name: string; slot: string; tier: string; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
|
||||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||||
|
const slotIcon = (slot: string): GameIconName => slot.toLocaleLowerCase().replaceAll(' ', '_') as GameIconName;
|
||||||
|
const slotOptions = slots.map((slot) => ({ value: slot, label: slot, icon: slotIcon(slot), iconOnly: true }));
|
||||||
|
const tierOptions = [0, 1, 2].map((tier) => ({ value: String(tier), label: `T${tier}` }));
|
||||||
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
|
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
|
||||||
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, 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', 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 augmentEditing = $state<any | null>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
@@ -21,7 +27,7 @@
|
|||||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${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, tier: String(item.tier), 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 baseTargets = (slot: string) => ['Gloves', 'Knee Pads', 'Body Armor'].includes(slot) ? 'BASE HEALTH + ARMOR' : 'BASE HEALTH';
|
const baseTargets = (slot: string) => ['Gloves', 'Knee Pads', 'Body Armor'].includes(slot) ? 'BASE HEALTH + ARMOR' : 'BASE HEALTH';
|
||||||
@@ -84,11 +90,11 @@
|
|||||||
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}</span></header>
|
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}</span></header>
|
||||||
{#if index === 0}
|
{#if index === 0}
|
||||||
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName="statId1" mode="single" compact subjectLabel="AUGMENT 1 ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
|
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName="statId1" mode="single" compact subjectLabel="AUGMENT 1 ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
|
||||||
<label class="field">VALUE<input name="value1" type="number" step="any" bind:value={augment.value} required/></label>
|
<label class="field">VALUE<input name="value1" type="number" step="any" bind:value={augment.value} onfocus={(event) => event.currentTarget.select()} required/></label>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="gear-fixed-augment"><span>{baseTargets(augmentEditing.slot)}</span><b>+{augment.value}%</b><small>APPLIES TO THIS ITEM'S CONFIGURED BASE VALUE</small></div>
|
<div class="gear-fixed-augment"><span>{baseTargets(augmentEditing.slot)}</span><b>+{augment.value}%</b><small>APPLIES TO THIS ITEM'S CONFIGURED BASE VALUE</small></div>
|
||||||
{/if}
|
{/if}
|
||||||
<fieldset class="gear-tier-costs"><legend>UNLOCK COST BY ITEM TIER</legend><div>{#each [0,1,2] as tier}<label class="field">TIER {tier}<input name={`unlockCost${index + 1}Tier${tier}`} type="number" min="0" step="1" bind:value={augment.costs[tier]} required/></label>{/each}</div></fieldset>
|
<fieldset class="gear-tier-costs"><legend>UNLOCK COST BY ITEM TIER</legend><div>{#each [0,1,2] as tier}<label class="field">TIER {tier}<input name={`unlockCost${index + 1}Tier${tier}`} type="number" min="0" step="1" bind:value={augment.costs[tier]} onfocus={(event) => event.currentTarget.select()} required/></label>{/each}</div></fieldset>
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -105,12 +111,12 @@
|
|||||||
<input type="hidden" name="id" value={editing.id} />
|
<input type="hidden" name="id" value={editing.id} />
|
||||||
<div class="gear-identity">
|
<div class="gear-identity">
|
||||||
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||||
<label class="field">SLOT<select name="slot" value={editing.slot} onchange={(event) => changeSlot(event.currentTarget.value)}>{#each slots as slot}<option>{slot}</option>{/each}</select></label>
|
<SegmentedChoice legend="SLOT" name="slot" options={slotOptions} bind:value={editing.slot} onchange={(values) => changeSlot(values[0])} required/>
|
||||||
<label class="field">TIER<select name="tier" bind:value={editing.tier}><option value={0}>0</option><option value={1}>1</option><option value={2}>2</option></select></label>
|
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} showIndicator={false} required/>
|
||||||
</div>
|
</div>
|
||||||
<div class="gear-ranges">
|
<div class="gear-ranges">
|
||||||
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
|
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} onfocus={(event) => event.currentTarget.select()} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} onfocus={(event) => event.currentTarget.select()} required /></label></div>
|
||||||
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
|
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} onfocus={(event) => event.currentTarget.select()} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} onfocus={(event) => event.currentTarget.select()} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
|
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
|
||||||
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
|
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
|
||||||
@@ -129,6 +135,6 @@
|
|||||||
<div class="dashboard-table">
|
<div class="dashboard-table">
|
||||||
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
|
||||||
{#each data.gear as item}
|
{#each data.gear as item}
|
||||||
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span>{item.slot}</span><span>T{item.tier}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
|
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span class="gear-slot-label"><GameIcon name={slotIcon(item.slot)} decorative/>{item.slot}</span><span>T{item.tier}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
|
||||||
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
type Effect = { statId: string; valueI: string; valueII: string };
|
type Effect = { statId: string; valueI: string; valueII: string };
|
||||||
|
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
|
||||||
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
let iconResetToken = $state(0);
|
let iconResetToken = $state(0);
|
||||||
@@ -81,7 +83,7 @@
|
|||||||
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
|
||||||
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
<PendingForm action="?/saveTalent" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
|
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
|
||||||
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Backpack</option><option>Body Armor</option></select></label></div></div>
|
<div class="talent-icon-identity"><IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/><div class="talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/></div></div>
|
||||||
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={talent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="durationSeconds" type="number" min="0.01" step="any" bind:value={talent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="maxStacks" type="number" min="1" step="1" bind:value={talent.max_stacks} placeholder="NOT DEFINED"/></label></div>
|
<div class="talent-mechanics-grid"><label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={talent.cooldown_seconds} placeholder="NOT DEFINED"/></label><label class="field">DURATION · SECONDS<input name="durationSeconds" type="number" min="0.01" step="any" bind:value={talent.duration_seconds} placeholder="NOT DEFINED"/></label><label class="field">MAX STACKS<input name="maxStacks" type="number" min="1" step="1" bind:value={talent.max_stacks} placeholder="NOT DEFINED"/></label></div>
|
||||||
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small></label>
|
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small></label>
|
||||||
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let roleIds = $state<string[]>([]);
|
let roleIds = $state<string[]>([]);
|
||||||
@@ -8,6 +9,9 @@
|
|||||||
let removalTarget = $state<string | null>(null);
|
let removalTarget = $state<string | null>(null);
|
||||||
let operationsPanel = $state<'access' | 'authentication' | 'moderation'>('access');
|
let operationsPanel = $state<'access' | 'authentication' | 'moderation'>('access');
|
||||||
let accessConfirmationOpen = $state(false);
|
let accessConfirmationOpen = $state(false);
|
||||||
|
const accessStateOptions = [{value:'active',label:'Active'},{value:'blocked',label:'Blocked indefinitely'},{value:'banned',label:'Temporarily banned'}];
|
||||||
|
const loginStatusOptions = [{value:'',label:'All results'},{value:'success',label:'Successful'},{value:'failed',label:'Failed'}];
|
||||||
|
const visibilityOptions = [{value:'',label:'All visibility'},{value:'draft',label:'Draft'},{value:'private',label:'Private'},{value:'public',label:'Public'}];
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.accessSaved) {
|
if (form?.accessSaved) {
|
||||||
accessConfirmationOpen = false;
|
accessConfirmationOpen = false;
|
||||||
@@ -98,7 +102,7 @@
|
|||||||
<div class:isac-panel-hidden={operationsPanel !== 'moderation'}>
|
<div class:isac-panel-hidden={operationsPanel !== 'moderation'}>
|
||||||
<hr/>
|
<hr/>
|
||||||
{#if can('users.moderate') && !data.profile.deleted_at}
|
{#if can('users.moderate') && !data.profile.deleted_at}
|
||||||
<PendingForm action="?/moderate"><label class="field">ACCESS STATE<select name="state"><option value="active">Active</option><option value="blocked">Blocked indefinitely</option><option value="banned">Temporarily banned</option></select></label><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger moderation-action" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
|
<PendingForm action="?/moderate"><SegmentedChoice legend="ACCESS STATE" name="state" options={accessStateOptions} value="active" required/><label class="field">BAN EXPIRATION<input name="bannedUntil" type="datetime-local"/></label><label class="field">REASON<textarea name="reason" placeholder="Required for blocks and bans"></textarea></label>{#if data.isSelf}<p class="error">ISAC will not permit self-restriction.</p>{/if}{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}<div class="form-actions"><button class="danger moderation-action" disabled={data.isSelf}>APPLY ACCOUNT STATE</button></div></PendingForm>
|
||||||
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
|
{:else}<p class="console-note">Moderation permission is required to restrict this account.</p>{/if}
|
||||||
{#if can('users.delete') && !data.profile.deleted_at}<hr/><details class="danger-zone"><summary>SOFT-DELETE ACCOUNT</summary><p>Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.</p><PendingForm action="?/deleteAccount"><label class="field">DELETION REASON<textarea name="reason" required></textarea></label>{#if form?.adminDeleteError}<p class="error">{form.adminDeleteError}</p>{/if}<button class="danger console-action moderation-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
|
{#if can('users.delete') && !data.profile.deleted_at}<hr/><details class="danger-zone"><summary>SOFT-DELETE ACCOUNT</summary><p>Authored content remains available under a deleted-agent identity. Sign-in credentials, sessions, username, and Discord identity are released.</p><PendingForm action="?/deleteAccount"><label class="field">DELETION REASON<textarea name="reason" required></textarea></label>{#if form?.adminDeleteError}<p class="error">{form.adminDeleteError}</p>{/if}<button class="danger console-action moderation-action" disabled={data.isSelf}>DELETE ACCOUNT</button></PendingForm></details>{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-history-registries">
|
<div class="user-history-registries">
|
||||||
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY · SENSITIVE METADATA RETAINED 30 DAYS</span><b>{data.historyPagination.login.total} EVENTS</b></div><form class="history-filter"><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="loginQ" value={data.historyFilters.loginQuery} placeholder="PROVIDER, IP, CLIENT, OS, OR FAILURE"/><select name="loginStatus"><option value="">ALL RESULTS</option><option value="success" selected={data.historyFilters.loginStatus === 'success'}>SUCCESSFUL</option><option value="failed" selected={data.historyFilters.loginStatus === 'failed'}>FAILED</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list login-investigation-list">{#each data.logins as login}<div><div><strong>{login.provider.toUpperCase()}</strong><small>{login.client_name ?? 'METADATA PURGED'} · {login.operating_system ?? 'UNKNOWN OS'} · {login.device_type ?? 'UNKNOWN DEVICE'}</small></div><div><span>{login.ip_address ?? 'IP PURGED'}{login.location_country ? ` · ${[login.location_city,login.location_region,login.location_country].filter(Boolean).join(', ')}` : ''}</span><small>{login.failure_reason ? `REASON: ${login.failure_reason}` : login.credential_id ? `CREDENTIAL: ${login.credential_id}` : 'NO FAILURE'}{login.risk_flags ? ` · FLAGS: ${login.risk_flags}` : ''}</small></div><div><span>{date(login.created_at)}</span><small>{login.session_ended_at ? `SESSION ENDED ${date(login.session_ended_at)} · ${login.session_end_reason}` : login.session_id ? (login.session_expires_at ? `SESSION EXPIRES ${date(login.session_expires_at)}` : 'SESSION REVOKED') : 'NO SESSION CREATED'}</small></div><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS MATCH THESE FILTERS</p>{/each}</div></section>
|
<section class="history-registry"><div class="history-head"><span>LOGIN HISTORY · SENSITIVE METADATA RETAINED 30 DAYS</span><b>{data.historyPagination.login.total} EVENTS</b></div><form class="history-filter"><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="loginQ" value={data.historyFilters.loginQuery} placeholder="PROVIDER, IP, CLIENT, OS, OR FAILURE"/><SegmentedChoice legend="RESULT" name="loginStatus" options={loginStatusOptions} value={data.historyFilters.loginStatus}/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.login} namespace="login"/><div class="history-list login-investigation-list">{#each data.logins as login}<div><div><strong>{login.provider.toUpperCase()}</strong><small>{login.client_name ?? 'METADATA PURGED'} · {login.operating_system ?? 'UNKNOWN OS'} · {login.device_type ?? 'UNKNOWN DEVICE'}</small></div><div><span>{login.ip_address ?? 'IP PURGED'}{login.location_country ? ` · ${[login.location_city,login.location_region,login.location_country].filter(Boolean).join(', ')}` : ''}</span><small>{login.failure_reason ? `REASON: ${login.failure_reason}` : login.credential_id ? `CREDENTIAL: ${login.credential_id}` : 'NO FAILURE'}{login.risk_flags ? ` · FLAGS: ${login.risk_flags}` : ''}</small></div><div><span>{date(login.created_at)}</span><small>{login.session_ended_at ? `SESSION ENDED ${date(login.session_ended_at)} · ${login.session_end_reason}` : login.session_id ? (login.session_expires_at ? `SESSION EXPIRES ${date(login.session_expires_at)}` : 'SESSION REVOKED') : 'NO SESSION CREATED'}</small></div><b class:restricted={!Number(login.succeeded)}>{Number(login.succeeded) ? 'SUCCESS' : 'FAILED'}</b></div>{:else}<p>NO LOGIN EVENTS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||||
<section class="history-registry"><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.historyPagination.build.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="buildQ" value={data.historyFilters.buildQuery} placeholder="TITLE OR SLUG"/><select name="buildVisibility"><option value="">ALL VISIBILITY</option><option value="draft" selected={data.historyFilters.buildVisibility === 'draft'}>DRAFT</option><option value="private" selected={data.historyFilters.buildVisibility === 'private'}>PRIVATE</option><option value="public" selected={data.historyFilters.buildVisibility === 'public'}>PUBLIC</option></select><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.build} namespace="build"/><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS MATCH THESE FILTERS</p>{/each}</div></section>
|
<section class="history-registry"><div class="history-head"><span>BUILD & GUIDE HISTORY</span><b>{data.historyPagination.build.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="commentQ" value={data.historyFilters.commentQuery}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="commentPerPage" value={data.historyPagination.comment.perPage}/><input name="buildQ" value={data.historyFilters.buildQuery} placeholder="TITLE OR SLUG"/><SegmentedChoice legend="VISIBILITY" name="buildVisibility" options={visibilityOptions} value={data.historyFilters.buildVisibility}/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.build} namespace="build"/><div class="history-list">{#each data.builds as build}<div><strong>{build.title}</strong><span>{date(build.updated_at)}</span><b>{build.visibility.toUpperCase()}</b></div>{:else}<p>NO BUILDS MATCH THESE FILTERS</p>{/each}</div></section>
|
||||||
<section class="history-registry"><div class="history-head"><span>COMMENT HISTORY</span><b>{data.historyPagination.comment.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input name="commentQ" value={data.historyFilters.commentQuery} placeholder="COMMENT OR BUILD TITLE"/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.comment} namespace="comment"/><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS MATCH THIS SEARCH</p>{/each}</div></section>
|
<section class="history-registry"><div class="history-head"><span>COMMENT HISTORY</span><b>{data.historyPagination.comment.total} RECORDS</b></div><form class="history-filter"><input type="hidden" name="loginQ" value={data.historyFilters.loginQuery}/><input type="hidden" name="loginStatus" value={data.historyFilters.loginStatus}/><input type="hidden" name="buildQ" value={data.historyFilters.buildQuery}/><input type="hidden" name="buildVisibility" value={data.historyFilters.buildVisibility}/><input type="hidden" name="loginPerPage" value={data.historyPagination.login.perPage}/><input type="hidden" name="buildPerPage" value={data.historyPagination.build.perPage}/><input name="commentQ" value={data.historyFilters.commentQuery} placeholder="COMMENT OR BUILD TITLE"/><button class="secondary">FILTER</button></form><RegistryPagination pagination={data.historyPagination.comment} namespace="comment"/><div class="history-list comment-history">{#each data.comments as comment}<div><strong>{comment.title}</strong><span>{comment.body}</span><b>{date(comment.created_at)}</b></div>{:else}<p>NO COMMENTS MATCH THIS SEARCH</p>{/each}</div></section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,21 +6,25 @@
|
|||||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||||
import type { GameIconName } from '$lib/game-icons';
|
import type { GameIconName } from '$lib/game-icons';
|
||||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||||
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||||
|
const categoryOptions = categories.map((value) => ({ value, label: value }));
|
||||||
|
const tierOptions = [0, 1, 2].map((value) => ({ value: String(value), label: `T${value}` }));
|
||||||
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
|
||||||
const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
|
const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
|
||||||
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
|
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
|
||||||
|
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value) }));
|
||||||
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 });
|
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 });
|
||||||
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
|
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
|
||||||
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: 0, damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
|
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
|
||||||
let editing = $state<any | null>(null);
|
let editing = $state<any | null>(null);
|
||||||
let handledResultToken = $state('');
|
let handledResultToken = $state('');
|
||||||
let iconResetToken = $state(0);
|
let iconResetToken = $state(0);
|
||||||
let openedRequestedWeapon = $state(false);
|
let openedRequestedWeapon = $state(false);
|
||||||
const edit = (weapon: any) => {
|
const edit = (weapon: any) => {
|
||||||
const tiers = [0, 1, 2].map((tier) => ({ ...blankTier(tier), ...(weapon.tiers.find((item: any) => Number(item.tier) === tier) ?? {}) }));
|
const tiers = [0, 1, 2].map((tier) => ({ ...blankTier(tier), ...(weapon.tiers.find((item: any) => Number(item.tier) === tier) ?? {}) }));
|
||||||
editing = { ...weapon, tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
|
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
|
||||||
};
|
};
|
||||||
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
|
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
|
||||||
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
|
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
|
||||||
@@ -88,9 +92,9 @@
|
|||||||
<input type="hidden" name="id" value={editing.id}/>
|
<input type="hidden" name="id" value={editing.id}/>
|
||||||
<div class="weapon-identity-grid">
|
<div class="weapon-identity-grid">
|
||||||
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
|
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
|
||||||
<label class="field">CATEGORY<select name="category" value={editing.category} onchange={(event) => setCategory(event.currentTarget.value)}>{#each categories as category}<option>{category}</option>{/each}</select></label>
|
<SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/>
|
||||||
<label class="field">NATIVE TIER<select name="nativeTier" bind:value={editing.native_tier}><option value={0}>Tier 0</option><option value={1}>Tier 1</option><option value={2}>Tier 2</option></select></label>
|
<SegmentedChoice legend="NATIVE TIER" name="nativeTier" options={tierOptions} bind:value={editing.native_tier} showIndicator={false} required/>
|
||||||
<fieldset class="field damage-type-picker"><legend>DAMAGE TYPE</legend><div>{#each damageTypes as damageType}<label title={`${damageType} damage`}><input name="damageType" type="radio" value={damageType} bind:group={editing.damage_type}/><span><GameIcon name={iconName(damageType)} decorative/><b>{damageType}</b></span></label>{/each}</div></fieldset>
|
<SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/>
|
||||||
<label class="field exotic-toggle">EXOTIC STATUS<span><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/> EXOTIC WEAPON</span></label>
|
<label class="field exotic-toggle">EXOTIC STATUS<span><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/> EXOTIC WEAPON</span></label>
|
||||||
</div>
|
</div>
|
||||||
{#if Number(editing.is_exotic)}
|
{#if Number(editing.is_exotic)}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 24 KiB |