feat: refine database discovery and ISAC interactions

This commit is contained in:
JB
2026-07-26 13:24:30 -04:00
parent 2e868d86ff
commit e8ed35e35f
11 changed files with 636 additions and 50 deletions
+80 -14
View File
@@ -198,7 +198,9 @@ The canonical tokens live in `src/app.css` under `:root`.
| Page background | `--shd-bg` | `#0a0c0d` |
| Base surface | `--shd-surface` | `#111415` |
| Raised surface | `--shd-surface-raised` | `#141718` |
| Subordinate border | `--shd-border-soft` | `#272c2d` |
| Primary border | `--shd-border` | `#303536` |
| Interactive boundary | `--shd-border-strong` | `#424849` |
| Main text | `--shd-text` | `#ecebe5` |
| Muted text | `--shd-text-muted` | `#858b8d` |
| SHD action orange | `--shd-orange` | `#f6a821` |
@@ -745,14 +747,21 @@ never silently describe only the visible page. On narrow screens, retain
previous, next, current page, and page-size controls before optional page
numbers.
When only one page exists, omit page navigation and row-count presets; retain
only the useful result range and any registry search. Do not render disabled
pagination controls simply to preserve the rail's shape.
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.
shared server-side search inside this pagination rail. Results update after a
short typing debounce, so do not place a redundant Search button beside the
field. Label the behavior as `LIVE SEARCH`, replace it with `SEARCHING…` while
navigation is pending, and provide an explicit clear action whenever a query is
active. Search resets the page number while preserving the selected KPI filter
and row-count preset; page navigation preserves the query. 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
@@ -771,10 +780,25 @@ Filtering is server-backed and occurs before pagination. Filter links preserve
compatible search and page-size parameters, reset the page number, remain
bookmarkable, and expose the selected state with both orange emphasis and
`aria-current`. Cards require pointer, hover, focus-visible, pressed, and
textually identifiable active states. Counts describe the full search scope,
textually identifiable active states. Every card explicitly says `FILTER →`
or `SELECTED`; do not rely on KPI-card styling alone to communicate that the
metrics are controls. Counts describe the full search scope,
not merely the visible page. Do not use aggregate facts that cannot filter the
affected registry—replace them with actionable subsets.
### Registry row interaction
Desktop registry rows are passive data containers unless the entire row
actually opens a detail view. Do not apply a whole-row hover surface when only
the trailing Edit or View action is interactive. Render that action as a
compact bordered control with hover, focus-visible, and pressed states so its
interaction boundary is unmistakable.
On mobile, a row may become a full-width detail target when the route provides
an intentional mobile detail interaction. In that case retain the explicit
`TAP TO VIEW` or `TAP TO REVIEW` text, full-row focus treatment, and minimum
touch target; do not infer full-row behavior from desktop hover styling.
### Image uploads
All image uploads use the shared SHD drop-zone treatment and expose three equivalent
@@ -1278,9 +1302,14 @@ Chips. Attributes are implementation vocabulary resolved into the facts and
effects shown on those records; do not promote Attributes to a public
top-level category.
Each index category is a fully interactive square registry card with a live
approved-record count, concise scope statement, visible hover/focus state, and
pressed feedback. A category page uses the shared compact category rail,
Each index category is a fully interactive square registry card with its full
player-facing category name as the first and strongest element. Record count
and availability are supporting metadata in the footer; operational states
such as “active registry” or “intake” must never compete with the category
identity. Use the larger display-weight category name with a short orange
identity rail; render any adjacent count in muted telemetry styling. Cards
retain a concise scope statement, visible hover/focus state, and pressed
feedback. A category page uses the shared compact category rail,
live name search, pagination, and a responsive card grid. Record cards use the
same hierarchy:
@@ -1315,9 +1344,22 @@ narrow layouts use one. The eight-category rail reduces from eight to four to
two columns. Preserve keyboard focus, pressed feedback, reduced-motion
behavior, and readable textual state in every layout.
Category-specific discovery uses the shared tactical filter matrix. Filters
apply immediately, remain encoded in the URL, reset pagination, and compose
with name search. Multiple selections inside one facet are inclusive (`OR`);
Category-specific discovery uses the shared `DatabaseDiscoveryConsole`. It
joins the tactical filter matrix, live registry search, result range,
pagination, and page-size controls inside one connected instrument; do not
stack separate bordered filter and pagination bars. Categories without facets
retain the same console with search and pagination alone.
The matrix is a disclosure inside that console. It starts expanded on desktop
and collapsed on viewports at or below 700px when no filters are active. Active
filters make the initial state expanded. Its header always exposes the active
filter count and an explicit `SHOW FILTERS` or `HIDE FILTERS` action, while
search and pagination remain visible when the matrix is closed. Preserve the
user's manual disclosure choice in session storage rather than carrying it
between unrelated browser sessions.
Filters apply immediately, remain encoded in the URL, reset pagination, and
compose with name search. Multiple selections inside one facet are inclusive (`OR`);
active facet groups combine restrictively (`AND`). For example, selecting two
Brands and four Talents returns Gear matching any selected Brand and any
selected Talent. Never implement multi-select facets as an implicit match-all
@@ -1539,3 +1581,27 @@ must not have to infer this behavior from their role name, data-mode color, or a
success message shown after saving. Read-only operators do not see a mutation
statement because their mutation controls are absent. At narrow widths the
message becomes its own compact row above the module toolbar.
### Public database index
The public Database is a lookup surface first and a contribution gateway
second. Use a compact angular command band rather than a cinematic display
hero: keep the archive identity, state how many registries are online, state the
record total, and present contribution as a bordered secondary action.
Place one discovery bar directly below the command band. Its label presents
browsing and global search as equal paths—select a registry or search all
data—and its search field searches across all public registry names and useful
descriptive data such as Talent text, Damage Types, item slots, qualities, and
Brand bonus Attributes. Do not stack a separate registry-heading bar above or
below it. Results name both the matching record and its registry, then link into
that registry with the record name applied as its local search. Keep the search
and results dense, keyboard accessible, responsive, and URL-addressable.
Registry selection begins immediately after the shared discovery bar. Do not
place stewardship, status, promotional, or redundant instructional panels
between it and the registry grid. At wide desktop widths, eight registries form a
four-by-two matrix; the grid reduces to two columns and then one as available
space narrows. Registry cards lead with complete category names. Counts and
availability remain quiet footer metadata, and internal abbreviations or
workflow-centric states are not exposed as primary public labels.
+361
View File
@@ -5,6 +5,7 @@
--shd-surface-active:#15191a;
--shd-border:#303536;
--shd-border-soft:#272c2d;
--shd-border-strong:#424849;
--shd-text:#ecebe5;
--shd-text-muted:#858b8d;
--shd-text-dim:#5d6567;
@@ -431,3 +432,363 @@
.mutation-consequence{align-items:flex-start;flex-direction:column;gap:5px}
}
/* Public database prioritizes registry lookup over gateway spectacle. */
.community-index-page{padding-top:20px}
.community-index-hero{
min-height:138px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:28px;
align-items:center;
margin-bottom:8px;
padding:18px 24px;
}
.community-index-hero h1{
margin:7px 0 5px;
font-size:clamp(34px,4vw,56px);
line-height:.9;
letter-spacing:-.025em;
}
.community-index-hero>div>p:last-child{
max-width:720px;
margin:0;
font-size:var(--shd-type-supporting);
line-height:1.5;
}
.community-index-hero .catalogue-count{
min-width:230px;
justify-items:end;
text-align:right;
}
.community-index-hero .catalogue-count strong{
color:var(--shd-text);
font-size:var(--shd-type-supporting);
letter-spacing:.5px;
}
.community-index-hero .catalogue-count span{margin:4px 0 10px}
.community-index-hero .catalogue-count .secondary{
min-height:36px;
display:grid;
place-items:center;
padding:0 12px;
border:1px solid var(--shd-border);
color:var(--shd-text-muted);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.7px;
}
.community-index-hero .catalogue-count .secondary:hover,
.community-index-hero .catalogue-count .secondary:focus-visible{
border-color:var(--shd-orange);
color:var(--shd-orange);
}
.community-index-page .database-registry-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
.community-index-page .database-registry-grid>a{min-height:165px}
.community-index-page .database-registry-grid>a>header{
align-items:center;
gap:12px;
min-height:52px;
padding:6px 10px 10px;
border-bottom:1px solid var(--shd-border);
border-left:2px solid var(--shd-orange);
}
.community-index-page .database-registry-grid h2{
margin:0;
color:var(--shd-text);
font-size:clamp(20px,1.35vw,24px);
line-height:1;
letter-spacing:.3px;
}
.community-index-page .database-registry-grid header b{
flex:none;
color:var(--shd-text-muted);
font-size:var(--shd-type-telemetry);
line-height:1;
}
.community-index-page .database-registry-grid p{margin:12px 0}
.community-index-page .database-registry-grid>a>footer{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
margin-top:auto;
padding-top:12px;
}
.community-index-page .database-registry-grid footer small{
color:var(--shd-text-muted);
font-size:var(--shd-type-telemetry);
letter-spacing:.7px;
}
.community-index-page .database-registry-grid footer span{
color:var(--shd-orange);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.7px;
}
.database-global-search{
display:grid;
grid-template-columns:minmax(220px,.45fr) minmax(0,1fr);
align-items:center;
gap:18px;
margin-bottom:8px;
padding:12px;
border:1px solid var(--shd-border);
border-left:2px solid var(--shd-orange);
background:var(--shd-terminal);
}
.database-global-search>div{display:grid;gap:3px}
.database-global-search>div span,
.database-global-search>div strong{
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.8px;
}
.database-global-search>div span{color:var(--shd-orange)}
.database-global-search>div strong{color:var(--shd-text)}
.database-global-search form{
display:grid;
grid-template-columns:minmax(0,1fr) auto auto;
gap:6px;
}
.database-global-search input{
min-width:0;
min-height:40px;
padding:0 12px;
border:1px solid var(--shd-border-strong);
background:#090b0c;
color:var(--shd-text);
font:700 var(--shd-type-supporting)/1 var(--shd-font-ui);
letter-spacing:.35px;
}
.database-global-search input:focus-visible{
outline:1px solid var(--shd-orange);
outline-offset:1px;
border-color:var(--shd-orange);
}
.database-global-search :is(button,a){
min-height:40px;
display:grid;
place-items:center;
padding:0 14px;
border:1px solid var(--shd-orange);
background:var(--shd-orange);
color:#101010;
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.7px;
}
.database-global-search a{
border-color:var(--shd-border-strong);
background:transparent;
color:var(--shd-text-muted);
}
.database-global-search :is(button,a):hover,
.database-global-search :is(button,a):focus-visible{filter:brightness(1.12)}
.database-global-search :is(button,a):active{transform:translateY(1px);filter:brightness(.86)}
.database-global-results{
margin-bottom:8px;
border:1px solid var(--shd-border);
background:#0e1112;
}
.database-global-results>header{
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
min-height:42px;
padding:7px 12px;
border-bottom:1px solid var(--shd-border);
}
.database-global-results>header>div{display:flex;align-items:center;gap:10px}
.database-global-results>header span,
.database-global-results>header strong,
.database-global-results>header b{
font-size:var(--shd-type-telemetry);
letter-spacing:.7px;
}
.database-global-results>header span{color:var(--shd-orange);font-weight:900}
.database-global-results>header strong{color:var(--shd-text)}
.database-global-results>header b{color:var(--shd-text-muted)}
.database-global-results>div{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.database-global-results>div>a{
display:grid;
grid-template-columns:110px minmax(0,1fr) minmax(100px,.7fr) auto;
align-items:center;
gap:10px;
min-height:48px;
padding:7px 12px;
border-top:1px solid var(--shd-border);
color:var(--shd-text);
}
.database-global-results>div>a:nth-child(-n+2){border-top:0}
.database-global-results>div>a:nth-child(even){border-left:1px solid var(--shd-border)}
.database-global-results>div>a:hover,
.database-global-results>div>a:focus-visible{background:#171b1c;box-shadow:inset 2px 0 var(--shd-orange)}
.database-global-results>div>a:active{filter:brightness(.86)}
.database-global-results>div small,
.database-global-results>div span,
.database-global-results>div b{font-size:var(--shd-type-telemetry);letter-spacing:.5px}
.database-global-results>div small{color:var(--shd-orange);font-weight:900}
.database-global-results>div strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--shd-type-supporting)}
.database-global-results>div span{overflow:hidden;color:var(--shd-text-muted);text-overflow:ellipsis;white-space:nowrap}
.database-global-results>div b{color:var(--shd-orange)}
.database-global-results>p{margin:0;padding:16px;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.5 var(--shd-font-editorial)}
.database-category-nav>a{grid-template-columns:minmax(0,1fr) auto}
@media(max-width:1100px){
.community-index-page .database-registry-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.database-global-results>div{grid-template-columns:1fr}
.database-global-results>div>a:nth-child(n){border-top:1px solid var(--shd-border);border-left:0}
.database-global-results>div>a:first-child{border-top:0}
}
@media(max-width:750px){
.community-index-hero{grid-template-columns:1fr;gap:14px;padding:18px}
.community-index-hero .catalogue-count{min-width:0;justify-items:start;text-align:left}
.database-global-search{grid-template-columns:1fr;gap:9px}
.database-global-search form{grid-template-columns:1fr auto}
.database-global-search form input{grid-column:1/-1}
.database-global-results>header{align-items:flex-start}
.database-global-results>div>a{grid-template-columns:minmax(0,1fr) auto}
.database-global-results>div :is(small,span){grid-column:1/-1}
.community-index-page .database-registry-grid{grid-template-columns:1fr}
}
.database-discovery-console{
margin:10px 0;
border:1px solid var(--shd-border);
border-left:2px solid var(--shd-orange);
background:#0d1011;
}
.database-discovery-console>.database-facet-panel{
margin:0;
border:0;
border-left:0;
}
.database-discovery-console>.registry-pagination{
margin:0;
border:0;
border-top:1px solid var(--shd-border);
background:#101314;
}
.database-discovery-console>.registry-pagination:first-child{border-top:0}
.database-facet-actions{display:flex;align-items:center;gap:5px}
.database-facet-panel>header .database-facet-toggle{
display:flex;
align-items:center;
gap:8px;
border-color:var(--shd-border-strong);
background:#101314;
color:var(--shd-text);
}
.database-facet-panel>header .database-facet-toggle b{
min-width:12px;
color:var(--shd-orange);
font-size:15px;
line-height:1;
}
.database-facet-panel>header .database-facet-toggle:hover,
.database-facet-panel>header .database-facet-toggle:focus-visible{
border-color:var(--shd-orange);
color:var(--shd-orange);
}
.database-facet-panel.collapsed>header{border-bottom:0}
.database-facet-groups[hidden]{display:none}
@media(max-width:700px){
.database-facet-panel>header{align-items:stretch}
.database-facet-actions{width:100%}
.database-facet-actions>button{flex:1}
}
/* ISAC interaction polish: distinguish controls from telemetry and preserve readable supporting data. */
.kpi-filters>a{
align-content:center;
}
.kpi-filters>a>i{
position:absolute;
top:50%;
right:9px;
padding:3px 5px;
border:1px solid var(--shd-border-strong);
color:var(--shd-text-muted);
font-size:var(--shd-type-telemetry);
font-style:normal;
font-weight:900;
letter-spacing:.55px;
transform:translateY(-50%);
}
.kpi-filters>a>strong{padding-right:76px}
.kpi-filters>a>small{color:var(--shd-text-muted)}
.kpi-filters>a.active>i{
border-color:var(--shd-orange);
background:#211a0e;
color:var(--shd-orange);
}
.kpi-filters>a:hover>i,
.kpi-filters>a:focus-visible>i{
border-color:var(--shd-orange);
color:var(--shd-orange);
}
.pagination-search-state{
min-width:82px;
height:30px;
display:grid;
place-items:center;
color:var(--shd-text-muted);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.55px;
white-space:nowrap;
}
.pagination-search-state.pending{color:var(--shd-orange)}
.dashboard-row:not(.table-heading):not(.mobile-detail-row):not(.pending-record):hover{background:transparent}
.dashboard-row>button:not(.mobile-row-open),
.dashboard-row>.row-action,
.dashboard-row>.table-action{
min-height:30px;
display:inline-grid;
place-items:center;
justify-self:end;
padding:0 9px;
border:1px solid #4c3d26;
background:#17140f;
color:var(--shd-orange);
font-size:var(--shd-type-telemetry);
font-weight:900;
letter-spacing:.55px;
cursor:pointer;
}
.dashboard-row>button:not(.mobile-row-open):hover,
.dashboard-row>button:not(.mobile-row-open):focus-visible,
.dashboard-row>.row-action:hover,
.dashboard-row>.row-action:focus-visible,
.dashboard-row>.table-action:hover,
.dashboard-row>.table-action:focus-visible{
border-color:var(--shd-orange);
background:#211a0e;
}
.dashboard-row>button:not(.mobile-row-open):active,
.dashboard-row>.row-action:active,
.dashboard-row>.table-action:active{
transform:translateY(1px);
filter:brightness(.86);
}
.module-toolbar>div>p:last-child,
.module-kpis small,
.dashboard-row>div small,
.dashboard-row>span small,
.table-heading,
.pagination-range,
.pagination-sizes>span,
.database-category-command>div:last-child span,
.database-item-card>header small,
.database-item-facts span,
.database-item-lines span,
.database-item-icon-groups>div>span{color:var(--shd-text-muted)}
@media(prefers-reduced-motion:reduce){
.dashboard-row>button:not(.mobile-row-open):active,
.dashboard-row>.row-action:active,
.dashboard-row>.table-action:active{transform:none}
}
@@ -3,10 +3,10 @@
let { active = '' }: { active?: string } = $props();
</script>
<nav class="database-category-nav" aria-label="Game database categories">
<nav class="database-category-nav" aria-label="The Division Resurgence database categories">
{#each databaseCategories as category}
<a class:active={active === category.slug} aria-current={active === category.slug ? 'page' : undefined} href={`/database/${category.slug}`}>
<small>{category.code}</small><span>{category.label}</span><b>›</b>
<span>{category.label}</span><b>›</b>
</a>
{/each}
</nav>
@@ -0,0 +1,21 @@
<script lang="ts">
import DatabaseFacetFilters from './DatabaseFacetFilters.svelte';
import RegistryPagination from './RegistryPagination.svelte';
let {
filters = [],
pagination,
search,
searchPlaceholder = 'SEARCH BY NAME'
}: {
filters?: any[];
pagination: { page: number; perPage: number; total: number; pageCount: number };
search: string;
searchPlaceholder?: string;
} = $props();
</script>
<section class="database-discovery-console" aria-label="Database search, filters, and pagination">
{#if filters.length}<DatabaseFacetFilters groups={filters}/>{/if}
<RegistryPagination {pagination} {search} {searchPlaceholder}/>
</section>
+29 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import GameIcon from './GameIcon.svelte';
import ItemIcon from './ItemIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
@@ -9,6 +10,9 @@
type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
let { groups }: { groups: Group[] } = $props();
let queries = $state<Record<string, string>>({});
let expanded = $state(true);
let observedSelectedCount = 0;
const disclosureKey = 'shd.database.filters.expanded';
const change = (key: string, value: string) => {
const params = new URLSearchParams(page.url.searchParams);
@@ -32,11 +36,33 @@
|| option.searchText?.toLocaleLowerCase().includes(query));
};
const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
$effect(() => {
if (selectedCount > observedSelectedCount) expanded = true;
observedSelectedCount = selectedCount;
});
const toggleExpanded = () => {
expanded = !expanded;
sessionStorage.setItem(disclosureKey, expanded ? 'true' : 'false');
};
onMount(() => {
if (selectedCount) {
expanded = true;
return;
}
const stored = sessionStorage.getItem(disclosureKey);
expanded = stored === null ? !window.matchMedia('(max-width: 700px)').matches : stored === 'true';
});
</script>
<section class="database-facet-panel" aria-label="Database filters">
<header><div><small>TACTICAL FILTER MATRIX</small><strong>MATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP</strong></div>{#if selectedCount}<button type="button" onclick={clear}>CLEAR {selectedCount} FILTER{selectedCount === 1 ? '' : 'S'}</button>{/if}</header>
<div class:compact={groups.length === 1} class="database-facet-groups">
<section class:collapsed={!expanded} class="database-facet-panel" aria-label="Database filters">
<header>
<div><small>TACTICAL FILTER MATRIX</small><strong>{selectedCount ? `${selectedCount} ACTIVE · MATCH EVERY ACTIVE GROUP` : 'MATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP'}</strong></div>
<div class="database-facet-actions">
{#if selectedCount}<button type="button" onclick={clear}>CLEAR {selectedCount}</button>{/if}
<button type="button" class="database-facet-toggle" aria-expanded={expanded} aria-controls="database-facet-groups" onclick={toggleExpanded}>{expanded ? 'HIDE FILTERS' : `SHOW FILTERS${selectedCount ? ` · ${selectedCount} ACTIVE` : ''}`} <b aria-hidden="true">{expanded ? '−' : '+'}</b></button>
</div>
</header>
<div id="database-facet-groups" hidden={!expanded} class:compact={groups.length === 1} class="database-facet-groups">
{#each groups as group}
<fieldset class:searchable={group.kind === 'searchable'} class="database-facet-group">
<legend>{group.label}<span>{group.selected.length ? `${group.selected.length} ACTIVE` : 'ANY'}</span></legend>
+3 -2
View File
@@ -16,8 +16,9 @@
<nav class={`module-kpis kpi-filters ${className}`} aria-label="Registry filters">
{#each items as item}
<a href={href(item.value)} class:active={active === item.value} aria-current={active === item.value ? 'true' : undefined}>
<span>{item.label}</span><strong>{item.count}</strong><small>{item.description}</small>
{@const selected = active === item.value}
<a href={href(item.value)} class:active={selected} aria-current={selected ? 'page' : undefined} aria-label={`${item.label}: ${item.count}. ${item.description}. ${selected ? 'Current filter' : 'Apply filter'}`}>
<span>{item.label}</span><i aria-hidden="true">{selected ? 'SELECTED' : 'FILTER →'}</i><strong>{item.count}</strong><small>{item.description}</small>
</a>
{/each}
</nav>
+1 -1
View File
@@ -73,7 +73,7 @@
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
<input type="search" name={searchKey} value={searchValue} maxlength="80" placeholder={searchPlaceholder} aria-label={`${searchPlaceholder} — results update as you type`} oninput={(event) => scheduleSearch(event.currentTarget.value)}/>
<button class="secondary" aria-busy={searchPending} disabled={searchPending}>{searchPending ? 'SEARCHING…' : 'SEARCH'}</button>
<span class="pagination-search-state" class:pending={searchPending} aria-live="polite">{searchPending ? 'SEARCHING…' : 'LIVE SEARCH'}</span>
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
</form>
{/if}
+8 -8
View File
@@ -1,12 +1,12 @@
export const databaseCategories = [
{ slug: 'gear', label: 'Gear', code: 'GEAR', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
{ slug: 'weapons', label: 'Weapons', code: 'WPN', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
{ slug: 'weapon-mods', label: 'Weapon Mods', code: 'WMOD', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
{ slug: 'gear-mods', label: 'Gear Mods', code: 'GMOD', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
{ slug: 'os-protocols', label: 'OS Protocols', code: 'OSPR', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
{ slug: 'talents', label: 'Talents', code: 'TLNT', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
{ slug: 'brands', label: 'Brands', code: 'BRND', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
{ slug: 'skill-chips', label: 'Skill Chips', code: 'CHIP', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' }
{ slug: 'gear', label: 'Gear', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
{ slug: 'weapons', label: 'Weapons', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
{ slug: 'weapon-mods', label: 'Weapon Mods', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
{ slug: 'gear-mods', label: 'Gear Mods', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
{ slug: 'os-protocols', label: 'OS Protocols', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
{ slug: 'talents', label: 'Talents', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
{ slug: 'brands', label: 'Brands', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
{ slug: 'skill-chips', label: 'Skill Chips', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' }
] as const;
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
+88 -2
View File
@@ -1,4 +1,5 @@
import { getDatabase } from '$lib/server/database';
import { getRegistrySearch } from '$lib/server/pagination';
import { databaseCategories } from '$lib/database/categories';
import type { PageServerLoad } from './$types';
@@ -13,14 +14,99 @@ const tables = {
'skill-chips': 'skill_chip_sets'
} as const;
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ url }) => {
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const counts = await Promise.all(databaseCategories.map(async (category) => {
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
return { ...category, count: Number(row?.count ?? 0) };
}));
const searchResults = search
? await (await database.prepare(`
SELECT * FROM (
SELECT 'gear' AS slug,'Gear' AS category,item.name,
item.slot || ' · T' || item.tier AS detail
FROM gear item
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE)
UNION ALL
SELECT 'weapons','Weapons',item.name,
item.category || ' · ' || item.damage_type || ' damage'
FROM weapons item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'weapon-mods','Weapon Mods',item.name,item.slot_type
FROM weapon_mods item
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE)
UNION ALL
SELECT 'gear-mods','Gear Mods',item.name,'Gear modification'
FROM gear_mods item
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
UNION ALL
SELECT 'os-protocols','OS Protocols',item.name,
item.quality || ' · ' || item.primary_attribute_type
FROM os_protocols item
LEFT JOIN os_protocol_talents talent ON talent.protocol_id=item.id
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
)
UNION ALL
SELECT 'talents','Talents',item.name,item.category
FROM talents item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM talent_tiers tier
WHERE tier.talent_id=item.id AND (
tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
)
UNION ALL
SELECT 'brands','Brands',item.name,
COALESCE((
SELECT GROUP_CONCAT(attribute.name, ', ')
FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id
),'Brand Set bonuses')
FROM brand_sets item
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR EXISTS (
SELECT 1 FROM brand_set_effects effect
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
WHERE effect.brand_set_id=item.id AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE
)
)
UNION ALL
SELECT 'skill-chips','Skill Chips',item.name,COALESCE(talent.name,'Skill Chip set')
FROM skill_chip_sets item
LEFT JOIN skill_chip_talents talent ON talent.skill_chip_set_id=item.id
WHERE item.active=1 AND (
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
)
) matches
ORDER BY name COLLATE NOCASE
LIMIT 40
`)).all(...Array(23).fill(pattern))
: [];
return {
registries: counts,
total: counts.reduce((total, registry) => total + registry.count, 0)
total: counts.reduce((total, registry) => total + registry.count, 0),
search,
searchResults: searchResults.map((result) => ({
slug: String(result.slug),
category: String(result.category),
name: String(result.name),
detail: String(result.detail ?? '')
}))
};
};
+37 -10
View File
@@ -1,28 +1,55 @@
<script lang="ts">
let { data } = $props();
const onlineRegistries = $derived(data.registries.filter((registry) => registry.count > 0).length);
</script>
<svelte:head>
<title>Game database — SHD.build</title>
<title>The Division Resurgence database — SHD.build</title>
<meta name="description" content="Community-maintained The Division Resurgence attributes, talents, brands, gear, weapons, mods, protocols, and Skill Chip data."/>
</svelte:head>
<main class="page community-index-page">
<header class="catalogue-hero community-index-hero">
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE</p><h1>GAME<br/><em>DATABASE.</em></h1><p>Community-maintained Resurgence data powering SHD.build tools, loadouts, and reference material.</p></div>
<div class="catalogue-count"><strong>{data.total}</strong><span>ACTIVE RECORDS</span><a class="primary" href="/isac">CONTRIBUTE DATA →</a></div>
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE</p><h1>RESURGENCE <em>DATABASE.</em></h1><p>Explore equipment, talents, modifications, and gameplay effects from The Division Resurgence.</p></div>
<div class="catalogue-count"><strong>{onlineRegistries} REGISTRIES ONLINE</strong><span>{data.total} ACTIVE RECORDS</span><a class="secondary" href="/isac">CONTRIBUTE THROUGH ISAC →</a></div>
</header>
<section class="community-database-intro">
<div><span>DATABASE STATUS</span><strong>PUBLIC INDEX ONLINE</strong><small>Select a registry to inspect active, approved records and their resolved gameplay attributes.</small></div>
<div><span>DATA STEWARDSHIP</span><strong>COMMUNITY + OVERSIGHT</strong><small>Registered agents can inspect source data and propose verified corrections through ISAC.</small></div>
<section class="database-global-search" aria-labelledby="database-search-heading">
<div>
<span>DATABASE INDEX</span>
<strong id="database-search-heading">SELECT A REGISTRY OR SEARCH ALL DATA</strong>
</div>
<form method="GET" action="/database" role="search">
<label class="sr-only" for="database-global-query">Search all database categories</label>
<input id="database-global-query" name="q" type="search" value={data.search} placeholder="SEARCH ITEMS, TALENTS, ATTRIBUTES, OR EFFECTS" maxlength="80"/>
<button type="submit">SEARCH ALL DATA</button>
{#if data.search}<a href="/database" aria-label="Clear database search">CLEAR</a>{/if}
</form>
</section>
{#if data.search}
<section class="database-global-results" aria-live="polite">
<header><div><span>SEARCH RESULTS</span><strong>“{data.search}”</strong></div><b>{data.searchResults.length}{data.searchResults.length === 40 ? '+' : ''} MATCHES</b></header>
{#if data.searchResults.length}
<div>
{#each data.searchResults as result}
<a href={`/database/${result.slug}?q=${encodeURIComponent(result.name)}`}>
<small>{result.category}</small>
<strong>{result.name}</strong>
<span>{result.detail}</span>
<b>VIEW →</b>
</a>
{/each}
</div>
{:else}
<p>No database entries match this search. Try an item name, category, attribute, talent effect, or damage type.</p>
{/if}
</section>
{/if}
<section class="database-registry-grid">
{#each data.registries as registry, index}
{#each data.registries as registry}
<a href={`/database/${registry.slug}`}>
<header><small>{String(index + 1).padStart(2, '0')} // {registry.code}</small><b>{registry.count}</b></header>
<h2>{registry.label}</h2>
<header><h2>{registry.label}</h2><b>{registry.count}</b></header>
<p>{registry.description}</p>
<span>{registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'}</span>
<footer><small>{registry.count ? `${registry.count} ENTRIES` : 'NO ENTRIES YET'}</small><span>OPEN REGISTRY →</span></footer>
</a>
{/each}
</section>
+5 -7
View File
@@ -1,31 +1,29 @@
<script lang="ts">
import DatabaseCategoryNav from '$lib/components/DatabaseCategoryNav.svelte';
import DatabaseItemCard from '$lib/components/DatabaseItemCard.svelte';
import DatabaseFacetFilters from '$lib/components/DatabaseFacetFilters.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import DatabaseDiscoveryConsole from '$lib/components/DatabaseDiscoveryConsole.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.category.label} database — SHD.build</title>
<title>{data.category.label} — The Division Resurgence database — SHD.build</title>
<meta name="description" content={`${data.category.description} Community-maintained data for The Division Resurgence.`}/>
</svelte:head>
<main class="page database-category-page">
<header class="database-category-command">
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE / {data.category.code}</p><h1>{data.category.label}</h1><p>{data.category.description}</p></div>
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE / DATABASE REGISTRY</p><h1>{data.category.label}</h1><p>{data.category.description}</p></div>
<div><strong>{data.pagination.total}</strong><span>ACTIVE RECORDS</span><a href="/database">DATABASE INDEX →</a></div>
</header>
<DatabaseCategoryNav active={data.category.slug}/>
{#if data.filters?.length}<DatabaseFacetFilters groups={data.filters}/>{/if}
<RegistryPagination pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
<DatabaseDiscoveryConsole filters={data.filters} pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
<section class="database-item-grid">
{#each data.items as item}
<DatabaseItemCard item={item as any}/>
{:else}
<div class="database-empty">
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
<p>{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `This registry is awaiting approved ${data.category.label.toLocaleLowerCase()} data.`}</p>
<p>{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `No ${data.category.label.toLocaleLowerCase()} have been added yet.`}</p>
</div>
{/each}
</section>