Add pagination to ISAC registries

This commit is contained in:
JB
2026-07-25 01:03:56 -04:00
parent e528f18d8f
commit c286d1783d
16 changed files with 192 additions and 31 deletions
+17
View File
@@ -239,3 +239,20 @@ creating a new record keeps the editor open with a fresh blank definition for
rapid consecutive entry. Failed validation leaves the current editor and rapid consecutive entry. Failed validation leaves the current editor and
entered values intact. Use a unique action result token so reactive handling entered values intact. Use a unique action result token so reactive handling
runs exactly once and does not create update loops. runs exactly once and does not create update loops.
Tiered-definition registries should show their rendered tier effects when the
text is operationally useful. Use compact wrapping description cells rather
than replacing meaningful data with a generic “configured” label; retain an
explicit textual missing state when a tier has no rendered description.
### Registry pagination
Every primary ISAC registry uses server-side pagination. The shared control
appears immediately above and visually attached to its table. It shows the
visible record range and total, previous/next navigation, a compact
window of page numbers, and row-count presets of 10, 25, 50, and 100. Existing
filter query parameters must survive page navigation, while changing page size
returns to page one. Summary metrics remain global or filter-global and must
never silently describe only the visible page. On narrow screens, retain
previous, next, current page, and page-size controls before optional page
numbers.
+6
View File
@@ -75,10 +75,16 @@
.gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d} .gear-identity{display:grid;grid-template-columns:2fr 1fr .65fr .65fr;gap:8px}.gear-ranges{display:grid;grid-template-columns:1fr 1fr;gap:8px}.range-band{grid-template-columns:58px 1fr 1fr}.effect-band.unavailable{opacity:.4}.selection-head{display:flex;justify-content:space-between;align-items:end;margin:14px 0 6px}.selection-head>div{display:grid}.selection-head span{font-size:8px;font-weight:bold;letter-spacing:1.2px}.selection-head small{font-size:7px;color:#697174;margin-top:3px}.selection-head b{color:#f6a821;font-size:8px;letter-spacing:1px}.selection-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid #303536}.selection-grid label{min-height:38px;display:grid;grid-template-columns:20px 1fr auto;align-items:center;gap:5px;padding:6px 8px;border:1px solid transparent;border-right-color:#282d2e;border-bottom-color:#282d2e;cursor:pointer;font-size:9px}.selection-grid label:hover{background:#171b1c;border-color:#4b4f50}.selection-grid input{width:auto;margin:0;accent-color:#f6a821}.selection-grid small{font-size:6px;color:#646c6e;letter-spacing:.8px}.selection-grid input:checked~small{color:#75c08c}.gear-row{min-width:1050px;grid-template-columns:1.2fr .8fr .8fr 1.35fr .8fr .6fr .3fr}.gear-row>span small{font-size:8px;color:#858b8d}
.unit-field{min-width:0;margin:8px 0;padding:0;border:0}.unit-field legend{font-size:8px;font-weight:bold;letter-spacing:1px;color:#858b8d}.unit-picker{display:grid;grid-template-columns:repeat(5,1fr);height:32px;margin-top:5px;border:1px solid #424849}.unit-picker label{display:grid;place-items:center;border-left:1px solid #303536;cursor:pointer}.unit-picker label:first-child{border-left:0}.unit-picker input{position:absolute;opacity:0;pointer-events:none}.unit-picker span{width:100%;height:100%;display:grid;place-items:center;color:#777f81;font-size:8px;font-weight:bold}.unit-picker label:hover span{background:#191d1e;color:#ecebe5}.unit-picker input:checked+span{background:#2b2111;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.unit-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px} .unit-field{min-width:0;margin:8px 0;padding:0;border:0}.unit-field legend{font-size:8px;font-weight:bold;letter-spacing:1px;color:#858b8d}.unit-picker{display:grid;grid-template-columns:repeat(5,1fr);height:32px;margin-top:5px;border:1px solid #424849}.unit-picker label{display:grid;place-items:center;border-left:1px solid #303536;cursor:pointer}.unit-picker label:first-child{border-left:0}.unit-picker input{position:absolute;opacity:0;pointer-events:none}.unit-picker span{width:100%;height:100%;display:grid;place-items:center;color:#777f81;font-size:8px;font-weight:bold}.unit-picker label:hover span{background:#191d1e;color:#ecebe5}.unit-picker input:checked+span{background:#2b2111;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.unit-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}
.template-field{display:grid;margin-top:10px}.template-field small{margin-top:5px;color:#697174;font-size:7px}.tier-preview{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0 12px}.tier-preview>div{padding:9px 11px;border:1px solid #343a3b;background:#101314}.tier-preview span,.effect-heading{color:#697174;font-size:7px;letter-spacing:1.1px}.tier-preview p{min-height:30px;margin:5px 0 0;color:#d9d9d3;font-size:10px;line-height:1.45}.shared-trigger-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));max-height:170px}.shared-effects{border:1px solid #303536}.shared-effect{display:grid;grid-template-columns:minmax(180px,1fr) 120px 120px 30px;gap:6px;align-items:center;padding:6px;border-top:1px solid #292f30}.shared-effect:first-child{border-top:0}.shared-effect select,.shared-effect input{min-width:0;margin:0;padding:7px;font-size:9px}.shared-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c}.effect-heading{min-height:28px;background:#0d1011}.effect-heading span:last-child{display:none} .template-field{display:grid;margin-top:10px}.template-field small{margin-top:5px;color:#697174;font-size:7px}.tier-preview{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:8px 0 12px}.tier-preview>div{padding:9px 11px;border:1px solid #343a3b;background:#101314}.tier-preview span,.effect-heading{color:#697174;font-size:7px;letter-spacing:1.1px}.tier-preview p{min-height:30px;margin:5px 0 0;color:#d9d9d3;font-size:10px;line-height:1.45}.shared-trigger-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));max-height:170px}.shared-effects{border:1px solid #303536}.shared-effect{display:grid;grid-template-columns:minmax(180px,1fr) 120px 120px 30px;gap:6px;align-items:center;padding:6px;border-top:1px solid #292f30}.shared-effect:first-child{border-top:0}.shared-effect select,.shared-effect input{min-width:0;margin:0;padding:7px;font-size:9px}.shared-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c}.effect-heading{min-height:28px;background:#0d1011}.effect-heading span:last-child{display:none}
.talent-row{grid-template-columns:minmax(150px,.8fr) minmax(105px,.45fr) minmax(260px,1.35fr) minmax(260px,1.35fr) 80px 45px;min-width:980px}.talent-row:not(.table-heading){align-items:start;padding-top:10px;padding-bottom:10px}.talent-description{margin:0;color:#b7bcba;font-size:9px;line-height:1.45;white-space:normal}.talent-description.unconfigured{color:#df8069;font-size:7px;letter-spacing:.8px}.talent-row .state-dot{margin-top:2px}.talent-row>button{margin-top:-3px}
.talent-kpis{grid-template-columns:repeat(6,minmax(105px,1fr))}
@media(max-width:1200px){.talent-kpis{grid-template-columns:repeat(3,minmax(105px,1fr))}}@media(max-width:650px){.talent-kpis{grid-template-columns:repeat(2,minmax(105px,1fr))}}
.section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1020px;grid-template-columns:1.1fr .75fr 1.1fr 1.1fr 1fr .3fr}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} .section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1020px;grid-template-columns:1.1fr .75fr 1.1fr 1.1fr 1fr .3fr}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4}
@media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}} @media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}}
@media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}}
.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px} .weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px}
.assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px} .assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px}
.registry-pagination{width:100%;height:auto;min-height:48px;margin:8px 0 0;padding:7px 9px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;border:1px solid #303536;border-bottom:0;background:#0f1213}.pagination-range{display:flex;align-items:center;gap:6px;color:#697174;font-size:7px;letter-spacing:.8px}.pagination-range b{color:#d7d9d5;font-size:9px}.pagination-pages{display:flex;align-items:center;justify-content:center;gap:4px}.pagination-pages a,.pagination-sizes a{min-width:30px;height:30px;display:grid;place-items:center;border:1px solid #343a3b;background:#111415;color:#8c9395;font-size:7px;font-weight:900;letter-spacing:.5px;cursor:pointer}.pagination-pages a:first-child,.pagination-pages a:last-child{min-width:58px;padding:0 8px}.pagination-pages a:hover,.pagination-pages a:focus-visible,.pagination-sizes a:hover,.pagination-sizes a:focus-visible{border-color:#8a672b;color:#f6a821}.pagination-pages a.active,.pagination-sizes a.active{border-color:#f6a821;background:#211a0e;color:#f6a821}.pagination-pages a:active,.pagination-sizes a:active{transform:translateY(1px);filter:brightness(.88)}.pagination-pages a.disabled{opacity:.35;pointer-events:none}.pagination-gap{color:#555d5f;font-size:8px}.pagination-sizes{display:flex;align-items:center;gap:4px}.pagination-sizes>span{margin-right:3px;color:#697174;font-size:7px;letter-spacing:1px}.pagination-sizes a{min-width:34px}.registry-pagination a:focus-visible{outline:2px solid #f6a821;outline-offset:2px}
.registry-pagination+.dashboard-table{margin-top:0}
@media(max-width:900px){.registry-pagination{grid-template-columns:1fr}.pagination-range,.pagination-pages,.pagination-sizes{justify-content:center}}@media(max-width:560px){.pagination-pages a:not(:first-child):not(:last-child):not(.active),.pagination-gap{display:none}.pagination-sizes>span{display:none}}
@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}} @media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}}
@@ -0,0 +1,29 @@
<script lang="ts">
import { page as currentPage } from '$app/state';
let { pagination }: { pagination: { page: number; perPage: number; total: number; pageCount: number } } = $props();
const href = (targetPage: number, perPage = pagination.perPage) => {
const params = new URLSearchParams(currentPage.url.searchParams);
params.set('page', String(targetPage));
params.set('perPage', String(perPage));
return `${currentPage.url.pathname}?${params}`;
};
const visiblePages = $derived(
Array.from({ length: pagination.pageCount }, (_, index) => index + 1)
.filter((page) => page === 1 || page === pagination.pageCount || Math.abs(page - pagination.page) <= 2)
);
const start = $derived(pagination.total ? (pagination.page - 1) * pagination.perPage + 1 : 0);
const end = $derived(Math.min(pagination.total, pagination.page * pagination.perPage));
</script>
<nav class="registry-pagination" aria-label="Registry pagination">
<div class="pagination-range"><span>DISPLAYING</span><b>{start}–{end}</b><span>OF {pagination.total}</span></div>
<div class="pagination-pages">
<a class:disabled={pagination.page === 1} aria-disabled={pagination.page === 1} href={href(Math.max(1, pagination.page - 1))}>← PREV</a>
{#each visiblePages as page, index}
{#if index > 0 && page - visiblePages[index - 1] > 1}<span class="pagination-gap">···</span>{/if}
<a class:active={page === pagination.page} aria-current={page === pagination.page ? 'page' : undefined} href={href(page)}>{page}</a>
{/each}
<a class:disabled={pagination.page === pagination.pageCount} aria-disabled={pagination.page === pagination.pageCount} href={href(Math.min(pagination.pageCount, pagination.page + 1))}>NEXT →</a>
</div>
<div class="pagination-sizes"><span>ROWS</span>{#each [10, 25, 50, 100] as size}<a class:active={size === pagination.perPage} href={href(1, size)}>{size}</a>{/each}</div>
</nav>
+18
View File
@@ -0,0 +1,18 @@
import type { URL } from 'node:url';
export const PAGE_SIZE_PRESETS = [10, 25, 50, 100] as const;
export function getPagination(url: URL, total: number) {
const requestedSize = Number(url.searchParams.get('perPage'));
const perPage = PAGE_SIZE_PRESETS.includes(requestedSize as (typeof PAGE_SIZE_PRESETS)[number]) ? requestedSize : 25;
const pageCount = Math.max(1, Math.ceil(total / perPage));
const requestedPage = Number(url.searchParams.get('page'));
const page = Math.min(pageCount, Math.max(1, Number.isInteger(requestedPage) ? requestedPage : 1));
return {
page,
perPage,
total,
pageCount,
offset: (page - 1) * perPage
};
}
+12 -5
View File
@@ -4,20 +4,27 @@ import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
const thresholds = [2, 3, 4] as const; const thresholds = [2, 3, 4] as const;
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase(); const database = await getDatabase();
const [brands, effects, stats] = await Promise.all([ const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
(await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE')).all(), SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active FROM brand_sets`)).get();
const pagination = getPagination(url, Number(summary?.total ?? 0));
const [brands, effects, stats, effectSummary] = await Promise.all([
(await database.prepare('SELECT * FROM brand_sets ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset),
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(), (await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all() (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get()
]); ]);
return { return {
brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })), brands: brands.map((brand) => ({ ...brand, effects: effects.filter((effect) => effect.brand_set_id === brand.id) })),
stats stats,
summary: { ...summary, effects: Number(effectSummary?.total ?? 0) },
pagination
}; };
}; };
+3 -1
View File
@@ -1,5 +1,6 @@
<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';
let { data, form } = $props(); let { data, form } = $props();
type Effect = { pieces_required: number; stat_attribute_id: string; value: number }; type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
type Editor = { id: string; name: string; effects: Effect[] }; type Editor = { id: string; name: string; effects: Effect[] };
@@ -20,7 +21,7 @@
<svelte:head><title>Brand sets — SHD.build admin</title></svelte:head> <svelte:head><title>Brand sets — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div> <div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT SYNERGY REGISTRY</p><h2>BRAND SETS</h2><p>Map equipment brands to the attributes unlocked at each set threshold.</p></div><button class="primary" onclick={() => editing = blank()}>ADD BRAND SET <span>+</span></button></div>
<div class="module-kpis"><div><span>TOTAL SETS</span><strong>{data.brands.length}</strong><small>All brand definitions</small></div><div><span>ACTIVE</span><strong>{data.brands.filter((brand) => Number(brand.active) === 1).length}</strong><small>Available to builder</small></div><div><span>SET EFFECTS</span><strong>{data.brands.reduce((total, brand) => total + brand.effects.length, 0)}</strong><small>Mapped thresholds</small></div><div><span>ATTRIBUTES</span><strong>{data.stats.length}</strong><small>Available effect targets</small></div></div> <div class="module-kpis"><div><span>TOTAL SETS</span><strong>{data.summary.total}</strong><small>All brand definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to builder</small></div><div><span>SET EFFECTS</span><strong>{data.summary.effects}</strong><small>Mapped thresholds</small></div><div><span>ATTRIBUTES</span><strong>{data.stats.length}</strong><small>Available effect targets</small></div></div>
{#if editing} {#if editing}
<div class="dashboard-editor"> <div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div> <div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY SET' : 'NEW SET'}</p><h3>{editing.id ? editing.name : 'BRAND SET DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
@@ -38,6 +39,7 @@
</PendingForm> </PendingForm>
</div> </div>
{/if} {/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div> <div class="dashboard-row brand-set-row table-heading"><span>BRAND SET</span><span>2 PIECES</span><span>3 PIECES</span><span>4+ PIECES</span><span>STATE</span><span></span></div>
{#each data.brands as brand} {#each data.brands as brand}
+10 -3
View File
@@ -4,15 +4,20 @@ import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase(); const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
COUNT(DISTINCT slot) AS slots FROM gear`)).get();
const pagination = getPagination(url, Number(summary?.total ?? 0));
const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([ const [gear, brandLinks, talentLinks, brands, talents] = await Promise.all([
(await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM gear ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset),
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(), (await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(), (await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
@@ -25,7 +30,9 @@ export const load: PageServerLoad = async ({ cookies }) => {
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)) talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
})), })),
brands, brands,
talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category })) talents: talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category })),
summary,
pagination
}; };
}; };
+3 -1
View File
@@ -1,5 +1,6 @@
<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';
let { data, form } = $props(); let { data, form } = $props();
type Editor = { id: string; name: string; slot: string; tier: number; level: 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: number; level: number; 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'];
@@ -21,7 +22,7 @@
<svelte:head><title>Gear — SHD.build admin</title></svelte:head> <svelte:head><title>Gear — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div> <div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div>
<div class="module-kpis"><div><span>TOTAL GEAR</span><strong>{data.gear.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.gear.filter((item) => Number(item.active) === 1).length}</strong><small>Available to builder</small></div><div><span>SLOTS</span><strong>{new Set(data.gear.map((item) => item.slot)).size}/6</strong><small>Slots represented</small></div><div><span>BRAND SETS</span><strong>{data.brands.length}</strong><small>Compatibility options</small></div></div> <div class="module-kpis"><div><span>TOTAL GEAR</span><strong>{data.summary.total}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to builder</small></div><div><span>SLOTS</span><strong>{data.summary.slots}/6</strong><small>Slots represented</small></div><div><span>BRAND SETS</span><strong>{data.brands.length}</strong><small>Compatibility options</small></div></div>
{#if editing} {#if editing}
<div class="dashboard-editor"> <div class="dashboard-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div> <div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY GEAR' : 'NEW GEAR'}</p><h3>{editing.id ? editing.name : 'GEAR DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
@@ -54,6 +55,7 @@
</PendingForm> </PendingForm>
</div> </div>
{/if} {/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>PROGRESSION</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>PROGRESSION</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}
+9 -3
View File
@@ -4,12 +4,18 @@ import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase(); const database = await getDatabase();
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all(); const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })) }; SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
const pagination = getPagination(url, Number(summary?.total ?? 0));
const stats = await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset);
return { stats: stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit })), summary, pagination };
}; };
export const actions: Actions = { export const actions: Actions = {
save: async ({ request, cookies }) => { save: async ({ request, cookies }) => {
+3 -1
View File
@@ -1,5 +1,6 @@
<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';
let { data, form } = $props(); let { data, form } = $props();
let editing = $state<any>(null); let editing = $state<any>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
@@ -14,6 +15,7 @@
</script> </script>
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head> <svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & 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>STATS & 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-kpis"><div><span>TOTAL RECORDS</span><strong>{data.stats.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.stats.filter((item)=>Number(item.active)===1).length}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Offensive').length}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Defensive').length}</strong><small>Survival-oriented</small></div></div> <div class="module-kpis"><div><span>TOTAL RECORDS</span><strong>{data.summary.total}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.summary.offensive}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.summary.defensive}</strong><small>Survival-oriented</small></div></div>
{#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><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}
<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>
+27 -3
View File
@@ -4,14 +4,17 @@ import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
type SharedEffect = { statId: string; valueI: number; valueII: number }; type SharedEffect = { statId: string; valueI: number; valueII: number };
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
const db = await getDatabase(); const db = await getDatabase();
const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get();
const pagination = getPagination(url, Number(totalRow?.total ?? 0));
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots] = await Promise.all([ const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots] = await Promise.all([
(await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(), (await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset),
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(), (await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(), (await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
(await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), (await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
@@ -21,6 +24,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(), (await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all() (await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all()
]); ]);
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
(await db.prepare(`SELECT
SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1,
SUM(CASE WHEN slot=2 THEN 1 ELSE 0 END) AS slot2 FROM weapon_talent_slots`)).get(),
(await db.prepare("SELECT COUNT(*) AS total FROM talent_weapon_scopes WHERE scope='Exotic'")).get(),
(await db.prepare(`SELECT
SUM(CASE WHEN category='Backpack' THEN 1 ELSE 0 END) AS backpack,
SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor
FROM talents`)).get()
]);
return { return {
talents: talents.map((talent) => ({ talents: talents.map((talent) => ({
...talent, ...talent,
@@ -43,7 +57,17 @@ export const load: PageServerLoad = async ({ cookies }) => {
})) }))
})), })),
triggers, triggers,
stats stats,
summary: {
total: Number(totalRow?.total ?? 0),
triggers: Number(triggerSummary?.total ?? 0),
slot1: Number(slotSummary?.slot1 ?? 0),
slot2: Number(slotSummary?.slot2 ?? 0),
exotic: Number(exoticSummary?.total ?? 0),
backpack: Number(gearSummary?.backpack ?? 0),
bodyArmor: Number(gearSummary?.body_armor ?? 0)
},
pagination
}; };
}; };
+8 -2
View File
@@ -1,5 +1,6 @@
<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';
let { data, form } = $props(); let { data, form } = $props();
type Effect = { statId: string; valueI: string; valueII: string }; type Effect = { statId: string; valueI: string; valueII: string };
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false); let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false);
@@ -28,6 +29,10 @@
const addEffect = () => talent.effects = [...talent.effects, { statId: '', valueI: '', valueII: '' }]; const addEffect = () => talent.effects = [...talent.effects, { statId: '', valueI: '', valueII: '' }];
const removeEffect = (index: number) => talent.effects = talent.effects.filter((_: Effect, row: number) => row !== index); const removeEffect = (index: number) => talent.effects = talent.effects.filter((_: Effect, row: number) => row !== index);
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name); const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description;
return typeof description === 'string' && description.trim() ? description : 'NOT CONFIGURED';
};
const displayValue = (effect: Effect, tier: 'I' | 'II') => { const displayValue = (effect: Effect, tier: 'I' | 'II') => {
const stat = data.stats.find((item) => item.id === effect.statId); const stat = data.stats.find((item) => item.id === effect.statId);
const value = tier === 'I' ? effect.valueI : effect.valueII; const value = tier === 'I' ? effect.valueI : effect.valueII;
@@ -51,7 +56,7 @@
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head> <svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div> <div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TOP / TALENT 1</span><strong>{data.talents.filter((item) => item.category === 'Weapon Talent 1').length}</strong><small>First-slot weapon talents</small></div><div><span>BOTTOM / TALENT 2</span><strong>{data.talents.filter((item) => item.category === 'Weapon Talent 2').length}</strong><small>Second-slot weapon talents</small></div><div><span>EXOTIC</span><strong>{data.talents.filter((item) => item.category === 'Exotic Weapon').length}</strong><small>Exotic-only talents</small></div></div> <div class="module-kpis talent-kpis"><div><span>TALENTS</span><strong>{data.summary.total}</strong><small>Registered definitions</small></div><div><span>TOP / TALENT 1</span><strong>{data.summary.slot1}</strong><small>First-slot weapon talents</small></div><div><span>BOTTOM / TALENT 2</span><strong>{data.summary.slot2}</strong><small>Second-slot weapon talents</small></div><div><span>EXOTIC</span><strong>{data.summary.exotic}</strong><small>Exotic-only talents</small></div><div><span>BACKPACK</span><strong>{data.summary.backpack}</strong><small>Backpack talents</small></div><div><span>BODY ARMOR</span><strong>{data.summary.bodyArmor}</strong><small>Body armor talents</small></div></div>
{#if trigger} {#if trigger}
<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div> <div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>
@@ -75,4 +80,5 @@
</div> </div>
{/if} {/if}
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers, 'I')}>{configured(item.tiers, 'I') ? 'CONFIGURED' : 'MISSING'}</span><span class:configured={configured(item.tiers, 'II')}>{configured(item.tiers, 'II') ? 'CONFIGURED' : 'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={() => editTalent(item)}>EDIT</button></div>{/each}</div> <RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I EFFECT</span><span>TIER II EFFECT</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class="state-dot">ACTIVE</b><button onclick={() => editTalent(item)}>EDIT</button></div>{/each}</div>
+31 -7
View File
@@ -2,12 +2,24 @@ import { requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session'; import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
export const load: PageServerLoad = async ({ cookies, url }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'users.view'); await requirePermission(await getSessionUser(cookies), 'users.view');
const query = url.searchParams.get('q')?.trim() ?? ''; const query = url.searchParams.get('q')?.trim() ?? '';
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`; const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
const database = await getDatabase(); const database = await getDatabase();
const summaryRows = await (await database.prepare(`
SELECT user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
deleted_accounts.deleted_at,
(SELECT MAX(created_at) FROM login_events WHERE user_id=users.id AND succeeded=1) AS last_login_at
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
`)).all(pattern, pattern);
const pagination = getPagination(url, summaryRows.length);
const users = await (await database.prepare(` const users = await (await database.prepare(`
SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name, SELECT users.id,users.username,COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role,users.discord_id,users.created_at, users.role,users.discord_id,users.created_at,
@@ -22,16 +34,28 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE WHERE users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC ORDER BY COALESCE(user_status.last_active_at,users.created_at) DESC
LIMIT 200 LIMIT ? OFFSET ?
`)).all(pattern, pattern); `)).all(pattern, pattern, pagination.perPage, pagination.offset);
const now = Date.now(); const now = Date.now();
const state = (user: Record<string, unknown>) => {
const activity = user.last_active_at ?? user.last_login_at;
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
return { inactive, accessState };
};
const summaryStates = summaryRows.map(state);
return { return {
users: users.map((user) => { users: users.map((user) => {
const activity = user.last_active_at ?? user.last_login_at; return { ...user, ...state(user) };
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
return { ...user, inactive, accessState: user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE' };
}), }),
query query,
summary: {
results: summaryRows.length,
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
inactive: summaryStates.filter((user) => user.inactive).length,
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE').length
},
pagination
}; };
}; };
+3 -1
View File
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
let { data } = $props(); let { data } = $props();
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER'; const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
</script> </script>
<svelte:head><title>User operations — ISAC</title></svelte:head> <svelte:head><title>User operations — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div> <div class="module-toolbar"><div><p class="eyebrow">USER OPERATIONS</p><h2>AGENT REGISTRY</h2><p>Identity, access state, recent activity, and community contribution overview.</p></div><form class="user-search"><input name="q" value={data.query} placeholder="SEARCH USERNAME"/><button class="secondary">SEARCH</button></form></div>
<div class="module-kpis"><div><span>RESULTS</span><strong>{data.users.length}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.users.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.users.filter((user) => user.inactive).length}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.users.filter((user) => user.accessState !== 'ACTIVE').length}</strong><small>Blocked or banned</small></div></div> <div class="module-kpis"><div><span>RESULTS</span><strong>{data.summary.results}</strong><small>Current query</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Active within 30 days</small></div><div><span>INACTIVE</span><strong>{data.summary.inactive}</strong><small>No activity for 30 days</small></div><div><span>RESTRICTED</span><strong>{data.summary.restricted}</strong><small>Blocked or banned</small></div></div>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div> <div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>LAST LOGIN</span><span>LAST ACTIVE</span><span>CONTENT</span><span></span></div>
{#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username} · {user.discord_id ? 'DISCORD LINKED' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div> {#each data.users as user}<div class="dashboard-row user-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username} · {user.discord_id ? 'DISCORD LINKED' : 'LOCAL IDENTITY'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><span>{date(user.last_login_at)}</span><span>{date(user.last_active_at)}</span><span>{user.build_count} BUILDS · {user.comment_count} COMMENTS</span><a class="row-action" href={`/admin/users/${user.id}`}>VIEW</a></div>
+10 -3
View File
@@ -4,16 +4,21 @@ import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding']; const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const; const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const;
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase(); const database = await getDatabase();
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
COUNT(DISTINCT category) AS categories FROM weapons`)).get();
const pagination = getPagination(url, Number(summary?.total ?? 0));
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, exoticTalents, stats] = await Promise.all([ const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, exoticTalents, stats] = await Promise.all([
(await database.prepare('SELECT * FROM weapons ORDER BY active DESC, category, name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM weapons ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?')).all(pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(),
(await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(), (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(),
@@ -46,7 +51,9 @@ export const load: PageServerLoad = async ({ cookies }) => {
talent1Options, talent1Options,
talent2Options, talent2Options,
exoticTalents, exoticTalents,
stats stats,
summary,
pagination
}; };
}; };
+3 -1
View File
@@ -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 SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.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 damageTypes = ['Blast', 'Pierce', 'Shredding']; const damageTypes = ['Blast', 'Pierce', 'Shredding'];
@@ -47,7 +48,7 @@
<svelte:head><title>Weapons — SHD.build admin</title></svelte:head> <svelte:head><title>Weapons — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => editing = blank()}>ADD WEAPON <span>+</span></button></div> <div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => editing = blank()}>ADD WEAPON <span>+</span></button></div>
<div class="module-kpis"><div><span>TOTAL WEAPONS</span><strong>{data.weapons.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.weapons.filter((weapon) => Number(weapon.active) === 1).length}</strong><small>Available to builder</small></div><div><span>CATEGORIES</span><strong>{new Set(data.weapons.map((weapon) => weapon.category)).size}/6</strong><small>Types represented</small></div><div><span>WEAPON TALENTS</span><strong>{data.talent1Options.length + data.talent2Options.length}</strong><small>Slotted options</small></div></div> <div class="module-kpis"><div><span>TOTAL WEAPONS</span><strong>{data.summary.total}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.summary.active}</strong><small>Available to builder</small></div><div><span>CATEGORIES</span><strong>{data.summary.categories}/6</strong><small>Types represented</small></div><div><span>WEAPON TALENTS</span><strong>{data.talent1Options.length + data.talent2Options.length}</strong><small>Slotted options</small></div></div>
{#if editing} {#if editing}
<div class="dashboard-editor weapon-editor"> <div class="dashboard-editor weapon-editor">
@@ -107,6 +108,7 @@
</div> </div>
{/if} {/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table weapon-dashboard-table"> <div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div> <div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
{#each data.weapons as weapon} {#each data.weapons as weapon}