feat: refine database discovery and ISAC interactions
This commit is contained in:
+81
-15
@@ -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
|
||||
@@ -1538,4 +1580,28 @@ Pair the short state label with a complete consequence sentence. Contributors
|
||||
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.
|
||||
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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 ?? '')
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user