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
|
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
|
two-to-four compatible Brand Sets on Gear. Long or unbounded relationships
|
||||||
continue to use counts, searchable inspection, or expanded detail views.
|
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
|
### 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 { getDatabase } from '$lib/server/database';
|
||||||
|
import { databaseCategories } from '$lib/database/categories';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
const registries = [
|
const tables = {
|
||||||
{ key: 'attributes', label: 'Attributes', table: 'stat_attributes', detail: 'Stats, units, source ranges, caps, and calculation types.' },
|
gear: 'gear',
|
||||||
{ key: 'talents', label: 'Talents', table: 'talents', detail: 'Tiered effects, triggers, durations, cooldowns, and stacks.' },
|
weapons: 'weapons',
|
||||||
{ key: 'brands', label: 'Brand Sets', table: 'brand_sets', detail: 'Two-, three-, and four-piece equipment bonuses.' },
|
'weapon-mods': 'weapon_mods',
|
||||||
{ key: 'gear', label: 'Gear', table: 'gear', detail: 'Equipment slots, base ranges, brands, talents, and tiers.' },
|
'gear-mods': 'gear_mods',
|
||||||
{ key: 'weapons', label: 'Weapons', table: 'weapons', detail: 'Weapon families, damage ranges, talents, upgrades, and bonuses.' },
|
'os-protocols': 'os_protocols',
|
||||||
{ key: 'weaponmods', label: 'Weapon Mods', table: 'weapon_mods', detail: 'Compatibility, quality rolls, secondary pools, and effects.' },
|
talents: 'talents',
|
||||||
{ key: 'gearmods', label: 'Gear Mods', table: 'gear_mods', detail: 'Primary scaling and secondary attribute pools by quality.' },
|
brands: 'brand_sets',
|
||||||
{ key: 'protocols', label: 'OS Protocols', table: 'os_protocols', detail: 'Primary effects, shared profiles, and unique talents.' },
|
'skill-chips': 'skill_chip_sets'
|
||||||
{ key: 'skillchips', label: 'Skill Chips', table: 'skill_chip_sets', detail: 'Quality-scaled set bonuses, rolls, and chip talents.' }
|
} as const;
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const counts = await Promise.all(registries.map(async (registry) => {
|
const counts = await Promise.all(databaseCategories.map(async (category) => {
|
||||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${registry.table} WHERE active=1`)).get();
|
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
|
||||||
return { ...registry, count: Number(row?.count ?? 0) };
|
return { ...category, count: Number(row?.count ?? 0) };
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
registries: counts,
|
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>
|
<div class="catalogue-count"><strong>{data.total}</strong><span>ACTIVE RECORDS</span><a class="primary" href="/isac">CONTRIBUTE DATA →</a></div>
|
||||||
</header>
|
</header>
|
||||||
<section class="community-database-intro">
|
<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>
|
<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>
|
||||||
<section class="database-registry-grid">
|
<section class="database-registry-grid">
|
||||||
{#each data.registries as registry, index}
|
{#each data.registries as registry, index}
|
||||||
<article>
|
<a href={`/database/${registry.slug}`}>
|
||||||
<header><small>{String(index + 1).padStart(2, '0')} // {registry.key.toUpperCase()}</small><b>{registry.count}</b></header>
|
<header><small>{String(index + 1).padStart(2, '0')} // {registry.code}</small><b>{registry.count}</b></header>
|
||||||
<h2>{registry.label}</h2>
|
<h2>{registry.label}</h2>
|
||||||
<p>{registry.detail}</p>
|
<p>{registry.description}</p>
|
||||||
<span>{registry.count ? 'ACTIVE DATA AVAILABLE' : 'AWAITING CONTRIBUTIONS'}</span>
|
<span>{registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'}</span>
|
||||||
</article>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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