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:
parent
0533e2b9ee
commit
4735aa72e9
3 changed files with 168 additions and 5 deletions
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue