From 4735aa72e9d61d88c9d093be28800d451f1276c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 06:26:38 +0200 Subject: [PATCH] feat(frontend): inventory chips row + results table (sort, columns, paging, keyboard) Co-Authored-By: Claude Sonnet 5 --- .../src/components/inventory/ActiveChips.tsx | 50 +++++++- .../src/components/inventory/ResultsTable.tsx | 116 +++++++++++++++++- frontend/src/styles/inventory.css | 7 ++ 3 files changed, 168 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/inventory/ActiveChips.tsx b/frontend/src/components/inventory/ActiveChips.tsx index 0c7080e8..fffb135c 100644 --- a/frontend/src/components/inventory/ActiveChips.tsx +++ b/frontend/src/components/inventory/ActiveChips.tsx @@ -1,3 +1,49 @@ -// Stub — replaced in Task 5 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 ( +
+ ACTIVE + {chips.map((c, i) => ( + + {c.label} × + + ))} +
+ ); +} diff --git a/frontend/src/components/inventory/ResultsTable.tsx b/frontend/src/components/inventory/ResultsTable.tsx index ba6c35e1..59adf9a5 100644 --- a/frontend/src/components/inventory/ResultsTable.tsx +++ b/frontend/src/components/inventory/ResultsTable.tsx @@ -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 { InvItem } from './types'; -export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) { - return
; + +function loadVisible(): Set { + 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 && }; + case 'spell_names': + return {(item.spell_names ?? []).map((s, i) => ( + {i > 0 && ', '}{s} + ))}; + 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>(loadVisible); + const [pickerOpen, setPickerOpen] = useState(false); + const scrollRef = useRef(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 ( +
+
+ + {pickerOpen && ( +
+ {COLUMNS.map(c => ( + + ))} +
+ )} +
+ + + {cols.map(c => ( + + ))} + + + {items.map((it, i) => ( + onSelect(it === selected ? null : it)}> + {cols.map(c => )} + + ))} + {!items.length && } + +
sortOn(c.sortKey)}>{c.label}
{cell(it, c.key)}
No items match.
+
+ goto(f.page - 1)}>◀ + page {result?.page ?? 1} / {totalPages} + goto(f.page + 1)}>▶ + {result?.limit ?? 200} / page +
+
+ ); } diff --git a/frontend/src/styles/inventory.css b/frontend/src/styles/inventory.css index 22689c07..3dffbc70 100644 --- a/frontend/src/styles/inventory.css +++ b/frontend/src/styles/inventory.css @@ -92,3 +92,10 @@ .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-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; }