feat(database): add public game data registries
This commit is contained in:
@@ -1183,6 +1183,68 @@ back to a short orange text mark rather than removing the relationship.
|
||||
Use this pattern only for small bounded sets that remain scannable, such as the
|
||||
two-to-four compatible Brand Sets on Gear. Long or unbounded relationships
|
||||
continue to use counts, searchable inspection, or expanded detail views.
|
||||
Public Gear cards use the same rail for compatible Brand Sets and available
|
||||
Talents so players can identify actual options instead of reading a count.
|
||||
Each icon retains its accessible name and hover title, and missing artwork uses
|
||||
the shared placeholder without suppressing the relationship. Relationship
|
||||
icons are buttons: activating one opens a modal quick view using the same card
|
||||
facts as that record's public database category. The overlay closes through
|
||||
its explicit close action, backdrop, or Escape key. Do not render an empty
|
||||
Talent rail on item types that cannot equip Talents.
|
||||
|
||||
### Public game-database registries
|
||||
|
||||
The public Database is a player-facing reference surface, separate from the
|
||||
ISAC editing workflow. Its index exposes eight durable categories: Gear,
|
||||
Weapons, Weapon Mods, Gear Mods, OS Protocols, Talents, Brands, and Skill
|
||||
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,
|
||||
live name search, pagination, and a responsive card grid. Record cards use the
|
||||
same hierarchy:
|
||||
|
||||
- a fixed icon cell and compact identity header;
|
||||
- small textual badges for quality, tier, or exceptional state;
|
||||
- bordered fact cells for primary values;
|
||||
- relationship lines and tier descriptions only when applicable.
|
||||
|
||||
When a record represents an ordered progression rather than independent facts,
|
||||
use the stacked fact treatment. Brand Set bonuses are shown vertically in
|
||||
two-, three-, then four-piece order; do not add a redundant “3/3 bonuses”
|
||||
badge when all three rows are already visible.
|
||||
|
||||
Record cards may end with contextual actions that connect related public
|
||||
registries. Actions use the compact orange tactical-button treatment and
|
||||
encode their destination filter in the URL. Brand cards provide a “find Gear”
|
||||
action that opens the Gear registry with that Brand selected; the same action
|
||||
must remain available in Brand quick views.
|
||||
|
||||
Cards display canonical active data only. Attribute references must be
|
||||
resolved to readable names, values, and units instead of exposing IDs or raw
|
||||
JSON. Missing uploaded art uses the standard missing-item placeholder.
|
||||
Desktop layouts use three record columns, intermediate layouts use two, and
|
||||
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`);
|
||||
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
|
||||
within the same group.
|
||||
|
||||
Use segmented icon choices for bounded visual vocabularies of six or fewer,
|
||||
such as Gear slots. Use the matching searchable multi-select list for growing
|
||||
sets such as Brands and Talents. Always expose selected counts, a single clear
|
||||
action, `aria-pressed` state, keyboard focus, and explicit text explaining the
|
||||
combination rule. At intermediate width, the bounded facet spans the row above
|
||||
two searchable facets; on narrow screens all facet groups stack vertically.
|
||||
|
||||
### Build persistence and sharing
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { databaseCategories } from '$lib/database/categories';
|
||||
let { active = '' }: { active?: string } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="database-category-nav" aria-label="Game 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>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import GameIcon from './GameIcon.svelte';
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
|
||||
type Option = { value: string; label: string; iconUrl?: string; gameIcon?: GameIconName };
|
||||
type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
|
||||
let { groups }: { groups: Group[] } = $props();
|
||||
let queries = $state<Record<string, string>>({});
|
||||
|
||||
const change = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
const selected = params.getAll(key);
|
||||
params.delete(key);
|
||||
for (const entry of selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value]) params.append(key, entry);
|
||||
params.delete('page');
|
||||
void goto(`${page.url.pathname}?${params}`, { keepFocus: true, noScroll: true });
|
||||
};
|
||||
const clear = () => {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
for (const group of groups) params.delete(group.key);
|
||||
params.delete('page');
|
||||
void goto(`${page.url.pathname}?${params}`, { noScroll: true });
|
||||
};
|
||||
const visible = (group: Group) => {
|
||||
const query = (queries[group.key] ?? '').normalize('NFKC').trim().toLocaleLowerCase();
|
||||
return group.options.filter((option) => !query || option.label.toLocaleLowerCase().includes(query));
|
||||
};
|
||||
const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
|
||||
</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="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>
|
||||
{#if group.kind === 'searchable'}
|
||||
<label class="database-facet-search"><span class="sr-only">Search {group.label}</span><input type="search" value={queries[group.key] ?? ''} oninput={(event) => queries[group.key] = event.currentTarget.value} placeholder={`FILTER ${group.label}`}/></label>
|
||||
{/if}
|
||||
<div>
|
||||
{#each visible(group) as option}
|
||||
<button type="button" class:selected={group.selected.includes(option.value)} aria-pressed={group.selected.includes(option.value)} onclick={() => change(group.key, option.value)}>
|
||||
{#if option.gameIcon}<GameIcon name={option.gameIcon} decorative/>{:else if option.iconUrl !== undefined}<ItemIcon src={option.iconUrl} alt=""/>{:else}<i aria-hidden="true"></i>{/if}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<small>NO MATCHING OPTIONS</small>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import GameIcon from './GameIcon.svelte';
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import ItemIconRail from './ItemIconRail.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
item
|
||||
}: {
|
||||
item: DatabaseItem & { gameIcon?: GameIconName }
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<article class="database-item-card">
|
||||
<header>
|
||||
<div class="database-item-icon">
|
||||
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
|
||||
</div>
|
||||
<div><small>{item.kicker}</small><h2>{item.name}</h2></div>
|
||||
</header>
|
||||
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<div class:stacked={item.factLayout === 'stack'} class="database-item-facts">
|
||||
{#each item.facts as fact}<div><span>{fact.label}</span><b>{fact.value}</b></div>{/each}
|
||||
</div>
|
||||
{#if item.lines?.length}
|
||||
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||
{/if}
|
||||
{#if item.iconGroups?.length}
|
||||
<div class="database-item-icon-groups">
|
||||
{#each item.iconGroups as group}
|
||||
<div><span>{group.label}</span><ItemIconRail label={group.label} items={group.items} emptyLabel={group.emptyLabel}/></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.description}<p>{item.description}</p>{/if}
|
||||
{#if item.secondaryDescription}<p class="secondary-description">{item.secondaryDescription}</p>{/if}
|
||||
{#if item.actions?.length}
|
||||
<footer class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import ItemIcon from './ItemIcon.svelte';
|
||||
import type { DatabaseRelatedItem } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
label,
|
||||
items,
|
||||
emptyLabel = 'NONE ASSIGNED'
|
||||
}: {
|
||||
label: string;
|
||||
items: DatabaseRelatedItem[];
|
||||
emptyLabel?: string;
|
||||
} = $props();
|
||||
let selected = $state<DatabaseRelatedItem | null>(null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') selected = null; }}/>
|
||||
|
||||
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
|
||||
{#if items.length}
|
||||
{#each items as item}
|
||||
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} onclick={() => selected = item}>
|
||||
<ItemIcon src={item.iconUrl} alt=""/>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<small>{emptyLabel}</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selected?.preview}
|
||||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
|
||||
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
|
||||
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" aria-label="Close details" onclick={() => selected = null}>×</button></header>
|
||||
<article class="database-item-card">
|
||||
<header>
|
||||
<div class="database-item-icon"><ItemIcon src={selected.preview.iconUrl} alt=""/></div>
|
||||
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
|
||||
</header>
|
||||
{#if selected.preview.badges?.length}<div class="database-item-badges">{#each selected.preview.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
|
||||
<div class:stacked={selected.preview.factLayout === 'stack'} class="database-item-facts">{#each selected.preview.facts as fact}<div><span>{fact.label}</span><b>{fact.value}</b></div>{/each}</div>
|
||||
{#if selected.preview.lines?.length}<div class="database-item-lines">{#each selected.preview.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
|
||||
{#if selected.preview.description}<p>{selected.preview.description}</p>{/if}
|
||||
{#if selected.preview.secondaryDescription}<p class="secondary-description">{selected.preview.secondaryDescription}</p>{/if}
|
||||
{#if selected.preview.actions?.length}<footer class="database-item-actions">{#each selected.preview.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>{/if}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,14 @@
|
||||
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.' }
|
||||
] as const;
|
||||
|
||||
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
|
||||
|
||||
export const databaseCategory = (slug: string) => databaseCategories.find((category) => category.slug === slug);
|
||||
@@ -0,0 +1,31 @@
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
export type DatabaseFact = { label: string; value: string };
|
||||
|
||||
export type DatabaseItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kicker: string;
|
||||
iconUrl?: string;
|
||||
gameIcon?: string;
|
||||
badges?: DatabaseBadge[];
|
||||
facts: DatabaseFact[];
|
||||
factLayout?: 'grid' | 'stack';
|
||||
actions?: Array<{ label: string; href: string }>;
|
||||
lines?: DatabaseFact[];
|
||||
iconGroups?: DatabaseIconGroup[];
|
||||
description?: string;
|
||||
secondaryDescription?: string;
|
||||
};
|
||||
|
||||
export type DatabaseRelatedItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconUrl?: string;
|
||||
preview?: DatabaseItem;
|
||||
};
|
||||
|
||||
export type DatabaseIconGroup = {
|
||||
label: string;
|
||||
items: DatabaseRelatedItem[];
|
||||
emptyLabel?: string;
|
||||
};
|
||||
@@ -1,23 +1,23 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { databaseCategories } from '$lib/database/categories';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const registries = [
|
||||
{ key: 'attributes', label: 'Attributes', table: 'stat_attributes', detail: 'Stats, units, source ranges, caps, and calculation types.' },
|
||||
{ key: 'talents', label: 'Talents', table: 'talents', detail: 'Tiered effects, triggers, durations, cooldowns, and stacks.' },
|
||||
{ key: 'brands', label: 'Brand Sets', table: 'brand_sets', detail: 'Two-, three-, and four-piece equipment bonuses.' },
|
||||
{ key: 'gear', label: 'Gear', table: 'gear', detail: 'Equipment slots, base ranges, brands, talents, and tiers.' },
|
||||
{ key: 'weapons', label: 'Weapons', table: 'weapons', detail: 'Weapon families, damage ranges, talents, upgrades, and bonuses.' },
|
||||
{ key: 'weaponmods', label: 'Weapon Mods', table: 'weapon_mods', detail: 'Compatibility, quality rolls, secondary pools, and effects.' },
|
||||
{ key: 'gearmods', label: 'Gear Mods', table: 'gear_mods', detail: 'Primary scaling and secondary attribute pools by quality.' },
|
||||
{ key: 'protocols', label: 'OS Protocols', table: 'os_protocols', detail: 'Primary effects, shared profiles, and unique talents.' },
|
||||
{ key: 'skillchips', label: 'Skill Chips', table: 'skill_chip_sets', detail: 'Quality-scaled set bonuses, rolls, and chip talents.' }
|
||||
] as const;
|
||||
const tables = {
|
||||
gear: 'gear',
|
||||
weapons: 'weapons',
|
||||
'weapon-mods': 'weapon_mods',
|
||||
'gear-mods': 'gear_mods',
|
||||
'os-protocols': 'os_protocols',
|
||||
talents: 'talents',
|
||||
brands: 'brand_sets',
|
||||
'skill-chips': 'skill_chip_sets'
|
||||
} as const;
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const database = await getDatabase();
|
||||
const counts = await Promise.all(registries.map(async (registry) => {
|
||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${registry.table} WHERE active=1`)).get();
|
||||
return { ...registry, count: Number(row?.count ?? 0) };
|
||||
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) };
|
||||
}));
|
||||
return {
|
||||
registries: counts,
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
<div class="catalogue-count"><strong>{data.total}</strong><span>ACTIVE RECORDS</span><a class="primary" href="/isac">CONTRIBUTE DATA →</a></div>
|
||||
</header>
|
||||
<section class="community-database-intro">
|
||||
<div><span>DATABASE STATUS</span><strong>PUBLIC INDEX ONLINE</strong><small>Detailed public record pages will arrive incrementally. Current counts reflect active, approved data.</small></div>
|
||||
<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>
|
||||
<section class="database-registry-grid">
|
||||
{#each data.registries as registry, index}
|
||||
<article>
|
||||
<header><small>{String(index + 1).padStart(2, '0')} // {registry.key.toUpperCase()}</small><b>{registry.count}</b></header>
|
||||
<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>
|
||||
<p>{registry.detail}</p>
|
||||
<span>{registry.count ? 'ACTIVE DATA AVAILABLE' : 'AWAITING CONTRIBUTIONS'}</span>
|
||||
</article>
|
||||
<p>{registry.description}</p>
|
||||
<span>{registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { databaseCategory } from '$lib/database/categories';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const number = (value: unknown) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? (Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '')) : '—';
|
||||
};
|
||||
const valueWithUnit = (value: unknown, unit: unknown) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
|
||||
const range = (min: unknown, max: unknown, unit: unknown = '') => `${valueWithUnit(min, unit)}–${valueWithUnit(max, unit)}`;
|
||||
const list = (value: unknown, fallback = 'NONE') => typeof value === 'string' && value ? value : fallback;
|
||||
const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName;
|
||||
const weaponIcon = (category: unknown) => String(category).toLowerCase();
|
||||
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
|
||||
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
const category = databaseCategory(params.category);
|
||||
if (!category) error(404, 'Database category not found');
|
||||
const database = await getDatabase();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const searchArgs = search ? [pattern] : [];
|
||||
const searchClause = search ? " AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
|
||||
let queryArgs: unknown[] = searchArgs;
|
||||
let filters: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'segmented' | 'searchable';
|
||||
selected: string[];
|
||||
options: Array<{ value: string; label: string; iconUrl?: string; gameIcon?: GameIconName }>;
|
||||
}> = [];
|
||||
let countSql = '';
|
||||
let rowsSql = '';
|
||||
|
||||
switch (category.slug) {
|
||||
case 'gear': {
|
||||
const [brandRows, talentRows] = await Promise.all([
|
||||
(await database.prepare(`SELECT brands.id,brands.name,icons.updated_at FROM brand_sets brands
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=brands.id
|
||||
WHERE brands.active=1 ORDER BY brands.name COLLATE NOCASE`)).all(),
|
||||
(await database.prepare(`SELECT talents.id,talents.name,icons.updated_at FROM talents
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
|
||||
ORDER BY talents.name COLLATE NOCASE`)).all()
|
||||
]);
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const validBrands = new Set(brandRows.map((row) => String(row.id)));
|
||||
const validTalents = new Set(talentRows.map((row) => String(row.id)));
|
||||
const selectedSlots = url.searchParams.getAll('slot').filter((value) => slots.includes(value));
|
||||
const selectedBrands = url.searchParams.getAll('brand').filter((value) => validBrands.has(value));
|
||||
const selectedTalents = url.searchParams.getAll('talent').filter((value) => validTalents.has(value));
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
}
|
||||
if (selectedSlots.length) {
|
||||
conditions.push(`item.slot IN (${selectedSlots.map(() => '?').join(',')})`);
|
||||
queryArgs.push(...selectedSlots);
|
||||
}
|
||||
if (selectedBrands.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM gear_brand_sets filter_brands
|
||||
WHERE filter_brands.gear_id=item.id AND filter_brands.brand_set_id IN (${selectedBrands.map(() => '?').join(',')}))`);
|
||||
queryArgs.push(...selectedBrands);
|
||||
}
|
||||
if (selectedTalents.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM gear_talents filter_talents
|
||||
WHERE filter_talents.gear_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
||||
queryArgs.push(...selectedTalents);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.* FROM gear item WHERE ${where}
|
||||
ORDER BY item.slot,item.tier,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
filters = [
|
||||
{
|
||||
key: 'slot', label: 'GEAR SLOT', kind: 'segmented', selected: selectedSlots,
|
||||
options: slots.map((slot) => ({ value: slot, label: slot, gameIcon: gearIcon(slot) }))
|
||||
},
|
||||
{
|
||||
key: 'brand', label: 'BRAND SETS', kind: 'searchable', selected: selectedBrands,
|
||||
options: brandRows.map((row) => ({
|
||||
value: String(row.id), label: String(row.name),
|
||||
iconUrl: iconUrl('brand_set', row.id, row.updated_at)
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: 'talent', label: 'TALENTS', kind: 'searchable', selected: selectedTalents,
|
||||
options: talentRows.map((row) => ({
|
||||
value: String(row.id), label: String(row.name),
|
||||
iconUrl: iconUrl('talent', row.id, row.updated_at)
|
||||
}))
|
||||
}
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'weapons':
|
||||
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
native.damage_min,native.damage_max,native.reference_level,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
|
||||
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
|
||||
(SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
|
||||
FROM weapons item
|
||||
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.is_exotic DESC,item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'weapon-mods':
|
||||
countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
|
||||
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
|
||||
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
|
||||
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
|
||||
FROM weapon_mods item
|
||||
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
|
||||
LEFT JOIN weapon_mod_compatible_categories categories ON categories.weapon_mod_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
GROUP BY item.id ORDER BY item.slot_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'gear-mods':
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
|
||||
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
|
||||
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
|
||||
FROM gear_mods item WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'os-protocols':
|
||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
|
||||
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at
|
||||
FROM os_protocols item
|
||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=primary_effect.stat_attribute_id
|
||||
LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'talents':
|
||||
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii,
|
||||
(SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot,
|
||||
(SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope,
|
||||
icons.updated_at AS icon_updated_at
|
||||
FROM talents item
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'brands':
|
||||
countSql = `SELECT COUNT(*) AS total FROM brand_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,icons.updated_at AS icon_updated_at
|
||||
FROM brand_sets item
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'skill-chips':
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||
talents.name AS talent_name,
|
||||
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
|
||||
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
|
||||
icons.updated_at AS icon_updated_at
|
||||
FROM skill_chip_sets item
|
||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
}
|
||||
|
||||
const total = Number((await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const rows = await (await database.prepare(rowsSql)).all(...queryArgs, pagination.perPage, pagination.offset);
|
||||
const gearRelations = category.slug === 'gear' && rows.length
|
||||
? await (await database.prepare(`SELECT relation.*,icons.updated_at
|
||||
FROM (
|
||||
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
|
||||
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
|
||||
NULL AS tier_i,NULL AS tier_ii
|
||||
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
|
||||
WHERE brands.active=1
|
||||
UNION ALL
|
||||
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
|
||||
talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii
|
||||
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
|
||||
WHERE talents.active=1
|
||||
) relation
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_id=relation.id
|
||||
AND icons.entity_type=CASE relation.kind WHEN 'brand' THEN 'brand_set' ELSE 'talent' END
|
||||
WHERE relation.gear_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY relation.kind,relation.name COLLATE NOCASE`)).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const gearBrandEffects = category.slug === 'gear' && gearRelations.some((relation) => relation.kind === 'brand')
|
||||
? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
|
||||
attributes.name,attributes.unit FROM brand_set_effects effects
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE effects.brand_set_id IN (${gearRelations.filter((relation) => relation.kind === 'brand').map(() => '?').join(',')})
|
||||
ORDER BY effects.pieces_required`)).all(...gearRelations.filter((relation) => relation.kind === 'brand').map((relation) => relation.id))
|
||||
: [];
|
||||
const brandEffects = category.slug === 'brands' && rows.length
|
||||
? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
|
||||
attributes.name,attributes.unit FROM brand_set_effects effects
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
|
||||
const items = rows.map((row) => {
|
||||
switch (category.slug) {
|
||||
case 'gear':
|
||||
{
|
||||
const relations = gearRelations.filter((relation) => relation.gear_id === row.id);
|
||||
const relatedItems = (kind: 'brand' | 'talent', type: GameDataIconType) => relations
|
||||
.filter((relation) => relation.kind === kind)
|
||||
.map((relation) => {
|
||||
const relationIcon = iconUrl(type, relation.id, relation.updated_at);
|
||||
const effects = kind === 'brand' ? gearBrandEffects.filter((effect) => effect.brand_set_id === relation.id) : [];
|
||||
return {
|
||||
id: String(relation.id),
|
||||
name: String(relation.name),
|
||||
iconUrl: relationIcon,
|
||||
preview: kind === 'brand' ? {
|
||||
id: String(relation.id), name: String(relation.name), kicker: 'BRAND SET // EQUIPMENT',
|
||||
iconUrl: relationIcon,
|
||||
factLayout: 'stack' as const,
|
||||
actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(relation.id))}` }],
|
||||
facts: effects.map((effect) => ({
|
||||
label: `${effect.pieces_required} PIECES`,
|
||||
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
||||
}))
|
||||
} : {
|
||||
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
|
||||
iconUrl: relationIcon,
|
||||
badges: [
|
||||
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
|
||||
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
|
||||
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [
|
||||
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
|
||||
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
|
||||
],
|
||||
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
|
||||
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
|
||||
}
|
||||
};
|
||||
});
|
||||
const iconGroups = [
|
||||
{ label: 'BRAND OPTIONS', items: relatedItems('brand', 'brand_set'), emptyLabel: 'NO BRANDS ASSIGNED' },
|
||||
...(['Backpack', 'Body Armor'].includes(String(row.slot))
|
||||
? [{ label: 'AVAILABLE TALENTS', items: relatedItems('talent', 'talent'), emptyLabel: 'NO TALENTS ASSIGNED' }]
|
||||
: [])
|
||||
];
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.slot} // T${row.tier}`,
|
||||
gameIcon: gearIcon(row.slot),
|
||||
badges: [{ label: `T${row.tier}` }, { label: `${Number(row.tier) === 0 ? 1 : 2} MOD SLOT${Number(row.tier) === 0 ? '' : 'S'}` }],
|
||||
facts: [
|
||||
{ label: 'BASE HEALTH', value: range(row.base_health_min,row.base_health_max) },
|
||||
...(row.base_armor_min === null ? [] : [{ label: 'BASE ARMOR', value: range(row.base_armor_min,row.base_armor_max) }])
|
||||
],
|
||||
iconGroups
|
||||
};
|
||||
}
|
||||
case 'weapons':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.category} // ${row.damage_type}`,
|
||||
gameIcon: weaponIcon(row.category),
|
||||
badges: [{ label: `T${row.native_tier}` }, ...(Number(row.is_exotic) ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])],
|
||||
facts: [
|
||||
{ label: 'DAMAGE RANGE', value: row.damage_min === null ? 'NOT CONFIGURED' : `${range(row.damage_min,row.damage_max)} · L${row.reference_level}` },
|
||||
{ label: 'RATE / MAG', value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG` },
|
||||
{ label: 'RELOAD', value: valueWithUnit(row.reload_speed,'s') },
|
||||
{ label: 'ACCURACY / STABILITY', value: `${number(row.accuracy)} / ${number(row.stability)}` }
|
||||
],
|
||||
lines: [
|
||||
{ label: 'TALENT POOLS', value: `${row.talent_1_count} TOP · ${row.talent_2_count} BOTTOM` },
|
||||
{ label: 'BASE BONUSES', value: String(row.bonus_count) }
|
||||
]
|
||||
};
|
||||
case 'weapon-mods':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.slot_type} // WEAPON MOD`,
|
||||
badges: row.exotic_weapon_id ? [{ label: 'EXOTIC', tone: 'exotic' }] : [{ label: 'STANDARD → HIGH-END' }],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–20' },
|
||||
{ label: 'SECONDARY POOL', value: row.exotic_weapon_id ? 'FIXED ATTRIBUTES' : `${row.secondary_count} OPTIONS` },
|
||||
{ label: 'MOD EFFECTS', value: row.exotic_weapon_id ? 'FIXED' : String(row.effect_count) }
|
||||
],
|
||||
lines: [{ label: row.exotic_weapon_id ? 'ASSIGNED WEAPON' : 'WEAPON CATEGORIES', value: row.exotic_weapon_id ? list(row.exotic_weapon) : list(row.categories).toUpperCase() }]
|
||||
};
|
||||
case 'gear-mods':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: 'GEAR MOD // STANDARD QUALITIES',
|
||||
badges: [{ label: 'STANDARD → HIGH-END' }],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–20' },
|
||||
{ label: 'PRIMARY OPTIONS', value: '5' },
|
||||
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
|
||||
],
|
||||
lines: [{ label: 'OBSERVED PRIMARY ENVELOPE', value: `${number(row.level_1_floor)} → ${number(row.level_20_ceiling)}` }]
|
||||
};
|
||||
case 'os-protocols':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.primary_attribute_type} // OS PROTOCOL`,
|
||||
iconUrl: iconUrl('os_protocol',row.id,row.icon_updated_at),
|
||||
badges: [{ label: String(row.quality), tone: String(row.quality).toLowerCase().replace('-','') }],
|
||||
facts: [
|
||||
{ label: 'LEVELS', value: '01–50' },
|
||||
{ label: 'PRIMARY EFFECT', value: row.primary_stat ? `${row.primary_stat} · ${valueWithUnit(row.base_value,row.primary_unit)}` : 'NOT CONFIGURED' },
|
||||
{ label: 'TALENT UPGRADES', value: '0–5' }
|
||||
],
|
||||
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
|
||||
};
|
||||
case 'talents':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: `${row.category}${row.weapon_slot ? ` // SLOT ${row.weapon_slot}` : ''}`,
|
||||
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
|
||||
badges: [
|
||||
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []),
|
||||
...(row.cooldown_seconds ? [{ label: `${number(row.cooldown_seconds)}s COOLDOWN` }] : []),
|
||||
...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []),
|
||||
...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : [])
|
||||
],
|
||||
facts: [
|
||||
{ label: 'TIER I', value: row.tier_i ? 'CONFIGURED' : 'MISSING' },
|
||||
{ label: 'TIER II', value: row.tier_ii ? 'CONFIGURED' : 'MISSING' }
|
||||
],
|
||||
description: row.tier_i ? `TIER I · ${row.tier_i}` : 'Tier I description is not configured.',
|
||||
secondaryDescription: row.tier_ii ? `TIER II · ${row.tier_ii}` : 'Tier II description is not configured.'
|
||||
};
|
||||
case 'brands': {
|
||||
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: 'BRAND SET // EQUIPMENT',
|
||||
iconUrl: iconUrl('brand_set',row.id,row.icon_updated_at),
|
||||
factLayout: 'stack' as const,
|
||||
actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(row.id))}` }],
|
||||
facts: effects.map((effect) => ({
|
||||
label: `${effect.pieces_required} PIECES`,
|
||||
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
|
||||
}))
|
||||
};
|
||||
}
|
||||
case 'skill-chips':
|
||||
return {
|
||||
id: String(row.id), name: String(row.name), kicker: 'SKILL CHIP SET // 2–3 PIECES',
|
||||
iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at),
|
||||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||||
facts: [
|
||||
{ label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' },
|
||||
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
|
||||
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
|
||||
],
|
||||
lines: [{ label: '3-PIECE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return { category, items, pagination, search, filters };
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<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';
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.category.label} 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><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`}/>
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</main>
|
||||
Reference in New Issue
Block a user