feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 06:26:38 +02:00
parent 0533e2b9ee
commit 4735aa72e9
3 changed files with 168 additions and 5 deletions

View file

@ -1,3 +1,49 @@
// Stub — replaced in Task 5
import type { InventorySearch } from './useInventorySearch'; import type { InventorySearch } from './useInventorySearch';
export function ActiveChips(_: { search: InventorySearch }) { return null; } import { RATING_DEFS } from './constants';
interface Chip { label: string; gold?: boolean; remove: () => void; }
export function ActiveChips({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const chips: Chip[] = [];
if (f.text) chips.push({ label: `"${f.text}"`, remove: () => update({ text: '' }) });
if (f.characters !== 'all') chips.push({
label: `${f.characters.length} character${f.characters.length === 1 ? '' : 's'}`,
remove: () => update({ characters: 'all' }),
});
if (f.itemType !== 'all') chips.push({
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponType: '' }),
});
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
for (const c of f.cantrips) chips.push({
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
remove: () => update({ cantrips: f.cantrips.filter(x => x !== c) }),
});
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({ label: `${def?.label ?? param}${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
if (f.bonded) chips.push({ label: 'Bonded', remove: () => update({ bonded: false }) });
if (f.attuned) chips.push({ label: 'Attuned', remove: () => update({ attuned: false }) });
if (f.rare) chips.push({ label: 'Rare', remove: () => update({ rare: false }) });
if (f.maxLevel !== '') chips.push({ label: `Wield ≤ ${f.maxLevel}`, remove: () => update({ maxLevel: '' }) });
if (f.minValue !== '') chips.push({ label: `Value ≥ ${f.minValue}`, remove: () => update({ minValue: '' }) });
if (f.minWorkmanship !== '') chips.push({ label: `Work ≥ ${f.minWorkmanship}`, remove: () => update({ minWorkmanship: '' }) });
if (f.maxBurden !== '') chips.push({ label: `Burden ≤ ${f.maxBurden}`, remove: () => update({ maxBurden: '' }) });
if (!chips.length) return null;
return (
<div className="inv-chipsrow">
<span className="inv-chips-lbl">ACTIVE</span>
{chips.map((c, i) => (
<span className={`inv-chip${c.gold ? ' inv-chip-gold' : ''}`} key={i}>
{c.label} <span className="inv-chip-x" onClick={c.remove}>×</span>
</span>
))}
</div>
);
}

View file

@ -1,6 +1,116 @@
// Stub — replaced in Task 5 import React, { useEffect, useMemo, useRef, useState } from 'react';
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
import type { InventorySearch } from './useInventorySearch'; import type { InventorySearch } from './useInventorySearch';
import type { InvItem } from './types'; import type { InvItem } from './types';
export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) {
return <div className="inv-results" />; function loadVisible(): Set<string> {
try {
const raw = localStorage.getItem(COLUMNS_LS_KEY);
if (raw) return new Set(JSON.parse(raw));
} catch { /* fall through */ }
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
}
function cell(item: InvItem, key: string): React.ReactNode {
switch (key) {
case 'name':
return <>{item.name}{item.is_equipped && <span className="inv-equipped"> </span>}</>;
case 'spell_names':
return <span className="inv-spells">{(item.spell_names ?? []).map((s, i) => (
<span key={i}>{i > 0 && ', '}<span className={/legendary/i.test(s) ? 'inv-leg' : ''}>{s}</span></span>
))}</span>;
case 'value':
return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
default: {
const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v);
}
}
}
export function ResultsTable({ search, selected, onSelect }: {
search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void;
}) {
const { filters: f, update, result } = search;
const [visible, setVisible] = useState<Set<string>>(loadVisible);
const [pickerOpen, setPickerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
const items = result?.items ?? [];
useEffect(() => {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'SELECT') return;
if (e.key === 'Escape') { onSelect(null); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const idx = selected ? items.indexOf(selected) : -1;
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
if (items[next]) onSelect(items[next]);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [items, selected, onSelect]);
const sortOn = (sortKey?: string) => {
if (!sortKey) return;
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
else update({ sortBy: sortKey, sortDir: 'asc' });
};
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
const goto = (p: number) => {
update({ page: Math.min(Math.max(1, p), totalPages) });
scrollRef.current?.scrollTo(0, 0);
};
return (
<div className="inv-results" ref={scrollRef}>
<div className="inv-colpicker-anchor">
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}> Columns</button>
{pickerOpen && (
<div className="inv-colpicker">
{COLUMNS.map(c => (
<label className="inv-fitem" key={c.key}>
<input type="checkbox" checked={visible.has(c.key)} onChange={() => {
const next = new Set(visible);
if (next.has(c.key)) next.delete(c.key); else next.add(c.key);
setVisible(next);
}} /> {c.label}
</label>
))}
</div>
)}
</div>
<table>
<thead><tr>
{cols.map(c => (
<th key={c.key} className={f.sortBy === c.sortKey ? `inv-sorted-${f.sortDir}` : ''}
onClick={() => sortOn(c.sortKey)}>{c.label}</th>
))}
</tr></thead>
<tbody>
{items.map((it, i) => (
<tr key={i} className={it === selected ? 'inv-sel' : ''}
onClick={() => onSelect(it === selected ? null : it)}>
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
</tr>
))}
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
</tbody>
</table>
<div className="inv-pager">
<span className="inv-pg" onClick={() => goto(f.page - 1)}></span>
<span>page {result?.page ?? 1} / {totalPages}</span>
<span className="inv-pg" onClick={() => goto(f.page + 1)}></span>
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
</div>
</div>
);
} }

View file

@ -92,3 +92,10 @@
.inv-online { width: 6px; height: 6px; border-radius: 50%; background: #4c4; display: inline-block; margin-left: 4px; } .inv-online { width: 6px; height: 6px; border-radius: 50%; background: #4c4; display: inline-block; margin-left: 4px; }
.inv-links { margin-bottom: 4px; font-size: 10px; } .inv-links { margin-bottom: 4px; font-size: 10px; }
.inv-gold { color: #fc6; } .inv-gold { color: #fc6; }
/* ── ResultsTable additions (Task 5) ── */
.inv-colpicker-anchor { position: relative; align-self: flex-end; padding: 4px 10px; }
.inv-colpicker { position: absolute; right: 10px; top: 30px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; padding: 8px; z-index: 5; max-height: 300px; overflow-y: auto; }
.inv-sorted-asc::after { content: " ▲"; font-size: 9px; }
.inv-sorted-desc::after { content: " ▼"; font-size: 9px; }
.inv-pager-right { margin-left: auto; }