feat(database): redesign talent registry

This commit is contained in:
JB
2026-07-26 01:19:58 -04:00
parent 143a30a23a
commit 0e93aa25bd
7 changed files with 90 additions and 18 deletions
+21
View File
@@ -1371,3 +1371,24 @@ a dedicated column to the right of the graphic. Clamp display values to
`0–100`, use orange only for the filled measure, and keep the unfilled track
neutral. Stack meter labels and tracks on narrow cards rather than shrinking
them below a reliably readable width.
Talent registry cards treat the two tier descriptions as their primary
content; never replace them with configured/missing summary facts. Render Tier
I and Tier II as distinct structured data blocks, then place optional cooldown
metadata below both descriptions. Do not use the serif, quote-like prose
treatment for tier effects. Use compact fixed tier labels, sans-serif effect
text, and engineered borders. Tier I is the neutral baseline; Tier II uses the
orange rail, label, and subtly elevated surface to communicate that it is the
improved version. Duration and stack limits may remain compact badges. The
top-right header status uses the applicable Backpack or Body Armor icon for
gear talents and the SMG icon as the generic Weapon Talent symbol. Weapon
Talent headers pair that icon with an explicit `1 · TOP` or `2 · BOTTOM`
status badge; never rely on the slot number alone.
When a public registry needs only one small bounded filter vocabulary, use the
compact facet-panel form: one full-width group with all options visible in a
single row on wide screens. Talent slot filtering uses Backpack, Body Armor,
Weapon 1 · Top, and Weapon 2 · Bottom. Always include the Top/Bottom context
when presenting numbered Weapon Talent slots to users. The filter remains
additive within the group and wraps to two columns on narrow screens; do not
introduce search or dropdown controls for this four-option set.
+4 -1
View File
File diff suppressed because one or more lines are too long
@@ -36,7 +36,7 @@
<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">
<div class:compact={groups.length === 1} class="database-facet-groups">
{#each groups as group}
<fieldset class:searchable={group.kind === 'searchable'} class="database-facet-group">
<legend>{group.label}<span>{group.selected.length ? `${group.selected.length} ACTIVE` : 'ANY'}</span></legend>
+5 -2
View File
@@ -41,8 +41,11 @@
{/each}
</div>
{/if}
{#if item.description}<p>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description">{item.secondaryDescription}</p>{/if}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{#if item.afterLines?.length}
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/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}
+3 -2
View File
@@ -41,8 +41,9 @@
{#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}
<DatabaseFacts facts={selected.preview.facts} layout={selected.preview.factLayout}/>
{#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.description}<p class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.description}</p>{/if}
{#if selected.preview.secondaryDescription}<p class="secondary-description" class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.secondaryDescription}</p>{/if}
{#if selected.preview.afterLines?.length}<div class="database-item-lines after-description">{#each selected.preview.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/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>
+2
View File
@@ -24,9 +24,11 @@ export type DatabaseItem = {
factLayout?: 'grid' | 'stack';
actions?: Array<{ label: string; href: string }>;
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
};
export type DatabaseRelatedItem = {
+54 -12
View File
@@ -207,8 +207,35 @@ export const load: PageServerLoad = async ({ params, url }) => {
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}`;
case 'talents': {
const talentSlots = [
{ value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const },
{ value: 'Body Armor', label: 'Body Armor', gameIcon: 'body_armor' as const },
{ value: 'Weapon 1', label: 'Weapon 1 · Top', gameIcon: 'smg' as const },
{ value: 'Weapon 2', label: 'Weapon 2 · Bottom', gameIcon: 'smg' as const }
];
const selectedTalentSlots = url.searchParams.getAll('talentSlot')
.filter((value) => talentSlots.some((slot) => slot.value === value));
const conditions = ['item.active=1'];
queryArgs = [];
if (search) {
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
queryArgs.push(pattern);
}
if (selectedTalentSlots.length) {
const slotConditions = selectedTalentSlots.map((slot) => {
if (slot === 'Backpack') return "item.category='Backpack'";
if (slot === 'Body Armor') return "item.category IN ('Body Armor','Vest')";
queryArgs.push(slot === 'Weapon 1' ? 1 : 2);
return `item.category='Weapon' AND EXISTS (
SELECT 1 FROM weapon_talent_slots filter_slots
WHERE filter_slots.talent_id=item.id AND filter_slots.slot=?
)`;
});
conditions.push(`(${slotConditions.map((condition) => `(${condition})`).join(' OR ')})`);
}
const where = conditions.join(' AND ');
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`;
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,
@@ -217,9 +244,17 @@ export const load: PageServerLoad = async ({ params, url }) => {
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}
WHERE ${where}
ORDER BY item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
filters = [{
key: 'talentSlot',
label: 'TALENT SLOT',
kind: 'segmented',
selected: selectedTalentSlots,
options: talentSlots
}];
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
@@ -449,23 +484,30 @@ export const load: PageServerLoad = async ({ params, url }) => {
],
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
};
case 'talents':
case 'talents': {
const talentCategory = String(row.category);
const isWeaponTalent = talentCategory === 'Weapon';
const normalizedCategory = talentCategory === 'Vest' ? 'Body Armor' : talentCategory;
const slotLabel = isWeaponTalent && row.weapon_slot ? `WEAPON ${row.weapon_slot}` : normalizedCategory.toUpperCase();
return {
id: String(row.id), name: String(row.name), kicker: `${row.category}${row.weapon_slot ? ` // SLOT ${row.weapon_slot}` : ''}`,
id: String(row.id), name: String(row.name), kicker: `${slotLabel} TALENT`,
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
headerMetaIcon: isWeaponTalent ? 'smg' : gearIcon(normalizedCategory),
headerBadge: isWeaponTalent && row.weapon_slot
? `${row.weapon_slot} · ${Number(row.weapon_slot) === 1 ? 'TOP' : 'BOTTOM'}`
: undefined,
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.'
facts: [],
description: row.tier_i ? String(row.tier_i) : 'Description is not available.',
secondaryDescription: row.tier_ii ? String(row.tier_ii) : 'Description is not available.',
descriptionStyle: 'data' as const,
afterLines: row.cooldown_seconds ? [{ label: 'TALENT COOLDOWN', value: `${number(row.cooldown_seconds)} SECONDS` }] : []
};
}
case 'brands': {
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
return {