Add ISAC registry search controls
This commit is contained in:
@@ -633,6 +633,20 @@ never silently describe only the visible page. On narrow screens, retain
|
||||
previous, next, current page, and page-size controls before optional page
|
||||
numbers.
|
||||
|
||||
Registries with a meaningful name, title, or agent-identity column place the
|
||||
shared server-side search inside this pagination rail. Submission resets the
|
||||
page number while preserving the selected KPI filter and row-count preset;
|
||||
page navigation preserves the query. Provide an explicit clear action whenever
|
||||
a query is active. Search applies to the complete filtered registry before
|
||||
pagination, including visible pending proposals, and never only to the current
|
||||
page. Specialized oversight and history searches may remain in their richer
|
||||
filter consoles when they search evidence beyond a single identity column.
|
||||
|
||||
At intermediate widths the search spans the pagination rail above its range
|
||||
and navigation controls. At mobile widths all groups stack, the input retains a
|
||||
usable width, and search, clear, previous, and next remain explicit keyboard
|
||||
and touch targets.
|
||||
|
||||
### Registry metric filters
|
||||
|
||||
The KPI row above an ISAC registry is an operational filter surface, not passive
|
||||
|
||||
+2
-2
@@ -123,9 +123,9 @@
|
||||
.game-icon{display:block;width:24px;height:24px;object-fit:contain}.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}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:8px;font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:5px;letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.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:65px 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;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:9px}.weapon-range-row>strong .game-icon{width:20px;height:20px}.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-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.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.compact{position:relative;z-index:1;display:grid;grid-template-columns:minmax(120px,.85fr) minmax(135px,1fr);min-width:0}.assignment-picker.compact:focus-within,.assignment-picker.compact.open{z-index:20;border-color:#8a672b}.assignment-picker.compact .assignment-selected{grid-template-columns:42px minmax(0,1fr);min-width:0;min-height:40px;border-right:1px solid var(--shd-border);border-bottom:0}.assignment-picker.compact .assignment-summary span{font-size:5px}.assignment-picker.compact .assignment-summary b{font-size:10px}.assignment-picker.compact .assignment-chips{min-width:0;padding:4px}.assignment-picker.compact .assignment-chips>button{max-width:100%;padding:5px 6px}.assignment-picker.compact .assignment-chips>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.assignment-picker.compact .assignment-search{display:grid;align-content:center;padding:4px}.assignment-picker.compact .assignment-search>span{display:none}.assignment-picker.compact .assignment-search input{height:32px;margin:0}.assignment-picker.compact .assignment-results-head,.assignment-picker.compact .assignment-results{display:none;position:absolute;left:-1px;right:-1px;background:#0d1011}.assignment-picker.compact.open .assignment-results-head{top:100%;display:flex;border:1px solid #8a672b;border-bottom-color:var(--shd-border)}.assignment-picker.compact.open .assignment-results{top:calc(100% + 27px);display:block;border:1px solid #8a672b;border-top:0;box-shadow:0 12px 24px #000b}.assignment-picker.compact .assignment-results>button{min-height:38px}@media(max-width:600px){.assignment-picker.compact{grid-template-columns:1fr}.assignment-picker.compact .assignment-selected{border-right:0;border-bottom:1px solid var(--shd-border)}}
|
||||
.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{width:100%;height:auto;min-height:48px;margin:8px 0 0;padding:7px 9px;display:grid;grid-template-columns:minmax(190px,260px) auto 1fr auto;align-items:center;gap:12px;border:1px solid #303536;border-bottom:0;background:#0f1213}.registry-pagination:not(:has(.pagination-search)){grid-template-columns:auto 1fr auto}.pagination-search{min-width:0;display:grid;grid-template-columns:minmax(100px,1fr) auto auto;align-items:center;gap:4px}.pagination-search input[type=search]{min-width:0;height:30px;margin:0;padding:0 8px;font-size:7px}.pagination-search button,.pagination-search>a{height:30px;display:grid;place-items:center;margin:0;padding:0 9px;border:1px solid #3b4142;background:#111415;color:#8c9395;font-size:6px;font-weight:900;letter-spacing:.6px;cursor:pointer}.pagination-search button:hover,.pagination-search button:focus-visible,.pagination-search>a:hover,.pagination-search>a:focus-visible{border-color:#8a672b;color:var(--shd-orange)}.pagination-search button:active,.pagination-search>a:active{transform:translateY(1px);filter:brightness(.88)}.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:1100px){.registry-pagination,.registry-pagination:not(:has(.pagination-search)){grid-template-columns:1fr 1fr}.pagination-search{grid-column:1/3}.pagination-pages{justify-content:flex-end}}@media(max-width:650px){.registry-pagination,.registry-pagination:not(:has(.pagination-search)){grid-template-columns:1fr}.pagination-search{grid-column:auto}.pagination-range,.pagination-pages,.pagination-sizes{justify-content:center}}@media(max-width:560px){.pagination-search{grid-template-columns:minmax(0,1fr) auto}.pagination-search>a{grid-column:1/3}.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}}
|
||||
.weapon-tier-card.unavailable{position:relative;opacity:.42;border-color:#292d2e}.weapon-tier-card.unavailable .weapon-range-row{filter:grayscale(1)}.tier-unavailable{margin:0;padding:7px;border-bottom:1px solid #282e2f;background:#0d1011;color:#7b8284;font-size:6px;font-weight:900;letter-spacing:.8px;text-align:center}.core-range-row>strong{gap:7px}.core-icon-stack{display:flex;align-items:center}.core-icon-stack .game-icon{width:17px;height:17px;margin-left:-7px}.core-icon-stack .game-icon:first-child{margin-left:0}.weapon-archive-action{min-height:40px;padding:0 13px}.weapon-archive-action>b{font-size:9px;transform:rotate(90deg)}
|
||||
.legal-table-wrap{overflow-x:auto;margin:18px 0}.legal-table{width:100%;min-width:720px;border-collapse:collapse;border:1px solid var(--shd-border);background:#0b0e0f}.legal-table th,.legal-table td{padding:12px 14px;border:1px solid var(--shd-border);text-align:left;vertical-align:top}.legal-table th{color:var(--shd-orange);background:#111516;font-size:7px;letter-spacing:1px}.legal-table td{color:#a1a7a8;font:12px/1.55 Georgia,serif}.legal-table code,.legal-document code{color:#d9dedf;font:11px Consolas,monospace}.cookie-notice{position:fixed;z-index:1000;right:24px;bottom:24px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:22px;width:min(620px,calc(100vw - 48px));padding:18px 20px;border:1px solid var(--shd-orange);background:rgba(10,13,14,.98);box-shadow:0 14px 48px #000c,4px 0 0 var(--shd-orange)}.cookie-notice .eyebrow{margin:0 0 8px}.cookie-notice div>p:not(.eyebrow){margin:0;color:var(--shd-text-muted);font:12px/1.5 Georgia,serif}.cookie-notice a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.cookie-notice button{min-width:84px}@media(max-width:600px){.cookie-notice{right:12px;bottom:12px;grid-template-columns:1fr;width:calc(100vw - 24px)}.cookie-notice button{width:100%}}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { page as currentPage } from '$app/state';
|
||||
let { pagination, namespace = '' }: { pagination: { page: number; perPage: number; total: number; pageCount: number }; namespace?: string } = $props();
|
||||
let {
|
||||
pagination,
|
||||
namespace = '',
|
||||
search,
|
||||
searchPlaceholder = 'SEARCH BY NAME'
|
||||
}: {
|
||||
pagination: { page: number; perPage: number; total: number; pageCount: number };
|
||||
namespace?: string;
|
||||
search?: string;
|
||||
searchPlaceholder?: string;
|
||||
} = $props();
|
||||
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
|
||||
const perPageKey = $derived(namespace ? `${namespace}PerPage` : 'perPage');
|
||||
const searchKey = $derived(namespace ? `${namespace}Q` : 'q');
|
||||
const href = (targetPage: number, perPage = pagination.perPage) => {
|
||||
const params = new URLSearchParams(currentPage.url.searchParams);
|
||||
params.set(namespace ? `${namespace}Page` : 'page', String(targetPage));
|
||||
params.set(namespace ? `${namespace}PerPage` : 'perPage', String(perPage));
|
||||
params.set(pageKey, String(targetPage));
|
||||
params.set(perPageKey, String(perPage));
|
||||
return `${currentPage.url.pathname}?${params}`;
|
||||
};
|
||||
const clearSearchHref = $derived.by(() => {
|
||||
const params = new URLSearchParams(currentPage.url.searchParams);
|
||||
params.delete(searchKey);
|
||||
params.delete(pageKey);
|
||||
const query = params.toString();
|
||||
return `${currentPage.url.pathname}${query ? `?${query}` : ''}`;
|
||||
});
|
||||
const retainedParams = $derived([...currentPage.url.searchParams].filter(([key]) => key !== searchKey && key !== pageKey));
|
||||
const visiblePages = $derived(
|
||||
Array.from({ length: pagination.pageCount }, (_, index) => index + 1)
|
||||
.filter((page) => page === 1 || page === pagination.pageCount || Math.abs(page - pagination.page) <= 2)
|
||||
@@ -16,6 +37,14 @@
|
||||
</script>
|
||||
|
||||
<nav class="registry-pagination" aria-label="Registry pagination">
|
||||
{#if search !== undefined}
|
||||
<form class="pagination-search">
|
||||
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
|
||||
<input type="search" name={searchKey} value={search} maxlength="80" placeholder={searchPlaceholder} aria-label={searchPlaceholder}/>
|
||||
<button class="secondary">SEARCH</button>
|
||||
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
|
||||
</form>
|
||||
{/if}
|
||||
<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>
|
||||
|
||||
@@ -2,6 +2,15 @@ import type { URL } from 'node:url';
|
||||
|
||||
export const PAGE_SIZE_PRESETS = [10, 25, 50, 100] as const;
|
||||
|
||||
export function getRegistrySearch(url: URL, namespace = '') {
|
||||
const key = namespace ? `${namespace}Q` : 'q';
|
||||
const search = String(url.searchParams.get(key) ?? '').normalize('NFKC').trim().slice(0, 80);
|
||||
return {
|
||||
search,
|
||||
pattern: `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
|
||||
};
|
||||
}
|
||||
|
||||
export function getPagination(url: URL, total: number, namespace = '') {
|
||||
const pageKey = namespace ? `${namespace}Page` : 'page';
|
||||
const perPageKey = namespace ? `${namespace}PerPage` : 'perPage';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
@@ -32,21 +32,31 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
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 filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const where = filter === 'active' ? 'WHERE active=1' : filter === 'offensive' ? "WHERE type='Offensive'" : filter === 'defensive' ? "WHERE type='Defensive'" : '';
|
||||
const visiblePending = pending.filter((stat) => filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true);
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const visiblePending = pending.filter((stat) =>
|
||||
(filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true)
|
||||
&& (!search || String(stat.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
);
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(...args);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const stats = databaseLimit
|
||||
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset)
|
||||
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset)
|
||||
: [];
|
||||
return {
|
||||
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
|
||||
pagination,
|
||||
filter
|
||||
filter,
|
||||
search
|
||||
};
|
||||
};
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
<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'}]}/>
|
||||
{#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} search={data.search}/>
|
||||
<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 visibleStats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1} class:pending-record={stat.pending}><div><strong>{stat.name}</strong><small>{stat.pending ? `PROPOSED BY ${stat.proposerName}` : 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:pending={stat.pending} class="state-dot">{stat.pending?'PENDING':Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b>{#if stat.pending}<span></span>{:else}<button onclick={() => editing={...stat}}>EDIT</button>{/if}</div>{/each}</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
@@ -32,16 +32,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)).get();
|
||||
const filter = ['active', 'archived', 'complete'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const where = filter === 'active' ? 'WHERE active=1' : filter === 'archived' ? 'WHERE active=0'
|
||||
: filter === 'complete' ? 'WHERE (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3' : '';
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get();
|
||||
const visiblePending = mappedPendingBrands.filter(() => filter !== 'archived');
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'complete' ? ['(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(...args);
|
||||
const visiblePending = mappedPendingBrands.filter((brand) => filter !== 'archived' && (!search || String(brand.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())));
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) : [],
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY active DESC, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(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 COUNT(*) AS total FROM brand_set_effects')).get(),
|
||||
@@ -55,7 +60,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) },
|
||||
pagination,
|
||||
filter
|
||||
filter,
|
||||
search
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<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>
|
||||
{#each data.brands as brand}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
@@ -33,10 +33,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN slot IN ('Gloves','Knee Pads','Body Armor') THEN 1 ELSE 0 END) AS armored,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)).get();
|
||||
const filter = ['active', 'armored', 'talented'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const where = filter === 'active' ? 'WHERE active=1' : filter === 'armored' ? "WHERE slot IN ('Gloves','Knee Pads','Body Armor')"
|
||||
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'armored' ? ["slot IN ('Gloves','Knee Pads','Body Armor')"] : filter === 'talented' ? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(...args);
|
||||
const pendingDefinitions = await getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
|
||||
@@ -44,14 +48,15 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
{ module: 'gear', action: 'save', kind: 'gear', fields: ['slot', 'tier', 'healthMin', 'healthMax', 'armorMin', 'armorMax', 'brandIds', 'talentIds'] }
|
||||
]);
|
||||
const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) =>
|
||||
filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true
|
||||
(filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true)
|
||||
&& (!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
);
|
||||
const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
|
||||
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
|
||||
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
|
||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(databaseLimit, databaseOffset) : [],
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(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 id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
@@ -95,7 +100,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
|
||||
pagination: adjustedPagination,
|
||||
filter
|
||||
filter,
|
||||
search
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<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>
|
||||
{#each data.gear as item}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -56,6 +56,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
? requestedFilter : '';
|
||||
const conditions: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`);
|
||||
else if (qualities.some((quality) => quality.toLowerCase() === filter)) {
|
||||
conditions.push('quality=?');
|
||||
@@ -64,6 +65,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
conditions.push('primary_attribute_type=?');
|
||||
args.push(attributeTypes.find((type) => type.toLowerCase() === filter));
|
||||
}
|
||||
if (search) {
|
||||
conditions.push("name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
args.push(pattern);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [summary, filtered] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
@@ -98,6 +103,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
filter === 'archived' ? false
|
||||
: qualities.some((quality) => quality.toLowerCase() === filter) ? String(item.quality).toLowerCase() === filter
|
||||
: attributeTypes.some((type) => type.toLowerCase() === filter) ? String(item.attributeType).toLowerCase() === filter : true
|
||||
).filter((item) =>
|
||||
!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
).map((protocol) => ({
|
||||
...protocol,
|
||||
iconUrl: '',
|
||||
@@ -131,7 +138,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
|
||||
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
|
||||
triggers: [...pending.filter((item) => item.kind === 'trigger'), ...triggers],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'protocol').length, pending: pending.filter((item) => item.kind === 'protocol').length }, filter, pagination, search
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table protocol-dashboard-table">
|
||||
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
|
||||
{#each data.protocols as protocol}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -15,12 +15,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const database = await getDatabase();
|
||||
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : '';
|
||||
const where = permissionPrefix ? `WHERE EXISTS (
|
||||
const conditions = [
|
||||
...(permissionPrefix ? [`EXISTS (
|
||||
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||
WHERE rp.role_id=roles.id AND p.name LIKE ?
|
||||
)` : '';
|
||||
const filterArgs = permissionPrefix ? [permissionPrefix] : [];
|
||||
)`] : []),
|
||||
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const filterArgs = [...(permissionPrefix ? [permissionPrefix] : []), ...(search ? [pattern] : [])];
|
||||
const [summary, filtered, permissions] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
|
||||
@@ -49,6 +54,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
permissions,
|
||||
summary,
|
||||
filter,
|
||||
search,
|
||||
pagination,
|
||||
canManageSystemAdmin
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row role-row table-heading"><span>ROLE</span><span>ASSIGNED USERS</span><span>DISCRETE PERMISSIONS</span><span>STATE</span><span></span></div>
|
||||
{#each data.roles as role}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -46,24 +46,28 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'archived', 'configured'].includes(requestedFilter) ? requestedFilter : '';
|
||||
const where = filter === 'active' ? 'WHERE sets.active=1'
|
||||
: filter === 'archived' ? 'WHERE sets.active=0'
|
||||
: filter === 'configured' ? `WHERE EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)
|
||||
AND EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)` : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
|
||||
: filter === 'configured' ? ['EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)', 'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)'] : []),
|
||||
...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const [summary, filtered, pendingStats, pendingTriggers, pendingSets] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
||||
FROM skill_chip_sets`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args),
|
||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit', 'chipMin', 'chipMax'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'skillchips', action: 'save', kind: 'skillchip', fields: ['twoPieceStatId', 'twoPieceMin', 'twoPieceMax', 'primaryStatIds', 'secondaryStatIds', 'talentName', 'descriptionTemplate', 'talentEffects', 'triggerIds'] }])
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [sets, twoPiece, primaryOptions, secondaryOptions, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
|
||||
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||
(await database.prepare(`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY sets.active DESC,sets.name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
|
||||
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
|
||||
@@ -80,7 +84,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
skill_chip_min: item.chipMin === '' ? null : Number(item.chipMin),
|
||||
skill_chip_max: item.chipMax === '' ? null : Number(item.chipMax)
|
||||
})), ...stats];
|
||||
const pendingSetRows = filter === 'archived' ? [] : pendingSets.map((set) => ({
|
||||
const pendingSetRows = filter === 'archived' ? [] : pendingSets.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((set) => ({
|
||||
...set,
|
||||
iconUrl: '',
|
||||
twoPiece: {
|
||||
@@ -117,7 +121,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
stats: availableStats,
|
||||
rollStats: availableStats.filter((stat) => stat.skill_chip_min !== null && stat.skill_chip_max !== null),
|
||||
triggers: [...pendingTriggers, ...triggers],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingSets.length, pending: pendingSets.length }, filter, pagination, search
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
|
||||
{#each data.sets as item}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
@@ -16,15 +16,20 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const db = await getDatabase();
|
||||
const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get();
|
||||
const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const where = filter === 'slot1' ? 'WHERE EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)'
|
||||
: filter === 'slot2' ? 'WHERE EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=2)'
|
||||
: filter === 'exotic' ? "WHERE EXISTS (SELECT 1 FROM talent_weapon_scopes WHERE talent_id=talents.id AND scope='Exotic')"
|
||||
: filter === 'backpack' ? "WHERE category='Backpack'"
|
||||
: filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : '';
|
||||
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(filter === 'slot1' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)']
|
||||
: filter === 'slot2' ? ['EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=2)']
|
||||
: filter === 'exotic' ? ["EXISTS (SELECT 1 FROM talent_weapon_scopes WHERE talent_id=talents.id AND scope='Exotic')"]
|
||||
: filter === 'backpack' ? ["category='Backpack'"] : filter === 'body-armor' ? ["category IN ('Vest','Body Armor')"] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get(...args);
|
||||
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
|
||||
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
|
||||
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
|
||||
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
|
||||
(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 talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
|
||||
@@ -60,6 +65,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
: filter === 'backpack' ? item.category === 'Backpack'
|
||||
: filter === 'body-armor' ? item.category === 'Body Armor'
|
||||
: filter === 'exotic' ? false : true
|
||||
).filter((item) =>
|
||||
!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
).map((item) => ({
|
||||
...item,
|
||||
iconUrl: '',
|
||||
@@ -107,7 +114,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
bodyArmor: Number(gearSummary?.body_armor ?? 0)
|
||||
},
|
||||
pagination,
|
||||
filter
|
||||
filter,
|
||||
search
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -97,5 +97,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<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" class:pending-record={item.pending}><div class="registry-icon-identity">{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<span>◇</span>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></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:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : 'ACTIVE'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{/each}</div>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
const date = (value: unknown) => value ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value))) : 'NEVER';
|
||||
</script>
|
||||
<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, authentication methods, 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, authentication methods, access state, recent activity, and community contribution overview.</p></div></div>
|
||||
<KpiFilters active={data.filter} items={[{value:'',label:'RESULTS',count:data.summary.results,description:'Show current query'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Active within 30 days'},{value:'inactive',label:'INACTIVE',count:data.summary.inactive,description:'No activity for 30 days'},{value:'restricted',label:'RESTRICTED',count:data.summary.restricted,description:'Blocked, banned, or deleted'}]}/>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.query} searchPlaceholder="SEARCH DISPLAY OR LOGIN NAME"/>
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row user-row table-heading"><span>USER</span><span>ACCESS</span><span>ROLES</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 mobile-detail-row"><div><strong>{user.display_name}</strong><small>LOGIN: {user.username ?? 'NOT CONFIGURED'}</small></div><span class:restricted={user.accessState !== 'ACTIVE'}>{user.accessState}{user.inactive && user.accessState === 'ACTIVE' ? ' · INACTIVE' : ''}</span><div class="role-summary">{#each String(user.assigned_roles ?? user.role ?? 'User').split(' · ') as role}<b>{role.toUpperCase()}</b>{/each}</div><span><small>{user.last_login_provider?.toUpperCase() ?? 'NONE'}</small>{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={`/isac/users/${user.id}`}>VIEW</a><a class="mobile-row-open" href={`/isac/users/${user.id}`} aria-label={`View agent ${user.display_name}`}></a></div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
@@ -35,8 +35,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN is_exotic=1 THEN 1 ELSE 0 END) AS exotic FROM weapons`)).get();
|
||||
const filter = ['active', 'standard', 'exotic'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const requestedWeaponId = url.searchParams.get('weapon')?.trim() ?? '';
|
||||
const where = requestedWeaponId ? 'WHERE id=?' : filter === 'active' ? 'WHERE active=1' : filter === 'standard' ? 'WHERE is_exotic=0' : filter === 'exotic' ? 'WHERE is_exotic=1' : '';
|
||||
const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
...(requestedWeaponId ? ['id=?'] : filter === 'active' ? ['active=1'] : filter === 'standard' ? ['is_exotic=0'] : filter === 'exotic' ? ['is_exotic=1'] : []),
|
||||
...(!requestedWeaponId && search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects, talentIcons, pending] = await Promise.all([
|
||||
@@ -77,6 +82,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
requestedWeaponId ? item.id === requestedWeaponId
|
||||
: filter === 'standard' ? !item.isExotic
|
||||
: filter === 'exotic' ? Boolean(item.isExotic) : true
|
||||
).filter((item) =>
|
||||
requestedWeaponId || !search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
).map((weapon) => ({
|
||||
...weapon,
|
||||
native_tier: Number(weapon.nativeTier),
|
||||
@@ -133,6 +140,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
requestedWeaponId
|
||||
};
|
||||
};
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||||
<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>
|
||||
{#each data.weapons as weapon}
|
||||
|
||||
Reference in New Issue
Block a user