feat(frontend): inventory filter sidebar (characters, type, slots, cantrips, ratings, sets, state, reqs)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 06:22:10 +02:00
parent 9a9b33a528
commit 0533e2b9ee
4 changed files with 232 additions and 2 deletions

View file

@ -0,0 +1,46 @@
import { useState } from 'react';
import { CANTRIP_GROUPS } from './constants';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CantripFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [q, setQ] = useState('');
const toggle = (v: string) => update({
cantrips: f.cantrips.includes(v) ? f.cantrips.filter(x => x !== v) : [...f.cantrips, v],
});
const match = (label: string) => label.toLowerCase().includes(q.toLowerCase());
return (
<Group title="Cantrips" defaultOpen badge={f.cantrips.length || undefined}>
<input className="inv-minisearch" placeholder="find cantrip… e.g. invuln"
value={q} onChange={e => setQ(e.target.value)} />
{f.cantrips.length > 0 && (<>
<div className="inv-subhead">Selected</div>
{f.cantrips.map(v => (
<label className="inv-fitem inv-gold" key={v}>
<input type="checkbox" checked onChange={() => toggle(v)} />
{v.replace(/^Legendary /, '')}
</label>
))}
</>)}
{CANTRIP_GROUPS.map(g => {
const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label));
if (!items.length) return null;
return (
<div key={g.group}>
<div className="inv-subhead">{g.group}</div>
{items.map(i => (
<label className="inv-fitem" key={i.value}>
<input type="checkbox" checked={false} onChange={() => toggle(i.value)} /> {i.label}
</label>
))}
</div>
);
})}
<div className="inv-subhead">Any-tier spell search</div>
<input className="inv-minisearch" placeholder="spell name contains… e.g. Epic Invuln"
value={f.spellContains} onChange={e => update({ spellContains: e.target.value })} />
</Group>
);
}

View file

@ -0,0 +1,48 @@
import { useEffect, useMemo, useState } from 'react';
import { apiFetch } from '../../api/client';
import type { InventorySearch } from './useInventorySearch';
import { Group } from './FilterSidebar';
export function CharacterFilter({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [names, setNames] = useState<string[]>([]);
const [online, setOnline] = useState<Set<string>>(new Set());
const [q, setQ] = useState('');
useEffect(() => {
apiFetch<{ characters: Array<{ character_name: string }> }>('/inv/characters/list')
.then(r => setNames(r.characters.map(c => c.character_name).sort((a, b) => a.localeCompare(b))))
.catch(() => {});
apiFetch<{ players: Array<{ character_name: string }> }>('/live')
.then(r => setOnline(new Set(r.players.map(p => p.character_name))))
.catch(() => {});
}, []);
const checked = useMemo(() =>
f.characters === 'all' ? new Set(names) : new Set(f.characters), [f.characters, names]);
const toggle = (n: string) => {
const next = new Set(checked);
if (next.has(n)) next.delete(n); else next.add(n);
update({ characters: next.size === names.length ? 'all' : [...next] });
};
const shown = names.filter(n => n.toLowerCase().includes(q.toLowerCase()));
return (
<Group title="Characters" defaultOpen
badge={f.characters === 'all' ? 'All' : f.characters.length}>
<input className="inv-minisearch" placeholder="filter characters…"
value={q} onChange={e => setQ(e.target.value)} />
<div className="inv-links">
<span className="inv-linky" onClick={() => update({ characters: 'all' })}>All</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: [] })}>None</span>{' · '}
<span className="inv-linky" onClick={() => update({ characters: names.filter(n => online.has(n)) })}>Online</span>
</div>
{shown.map(n => (
<label className="inv-fitem" key={n}>
<input type="checkbox" checked={checked.has(n)} onChange={() => toggle(n)} />
{n} {online.has(n) && <span className="inv-online" />}
</label>
))}
</Group>
);
}

View file

@ -1,3 +1,133 @@
// Stub — replaced in Task 4 import React, { useEffect, useState } from 'react';
import type { InventorySearch } from './useInventorySearch'; import type { InventorySearch } from './useInventorySearch';
export function FilterSidebar(_: { search: InventorySearch }) { return <div className="inv-sidebar" />; } import { CharacterFilter } from './CharacterFilter';
import { CantripFilter } from './CantripFilter';
import { ARMOR_SLOTS, JEWELRY_SLOTS, RATING_DEFS, WEAPON_TYPES } from './constants';
import type { ItemType } from './types';
import { apiFetch } from '../../api/client';
export function Group(props: {
title: string; badge?: string | number; defaultOpen?: boolean; children: React.ReactNode;
}) {
const [open, setOpen] = useState(props.defaultOpen ?? false);
return (
<div className={`inv-grp${open ? ' inv-open' : ''}`}>
<div className="inv-grp-head" onClick={() => setOpen(o => !o)}>
<span className="inv-arrow"></span> {props.title}
{props.badge ? <span className="inv-badge">{props.badge}</span> : null}
</div>
{open && <div className="inv-grp-body">{props.children}</div>}
</div>
);
}
export function FilterSidebar({ search }: { search: InventorySearch }) {
const { filters: f, update } = search;
const [showArmorSlots, setShowArmorSlots] = useState(false);
const [allRatings, setAllRatings] = useState(false);
const [sets, setSets] = useState<Array<{ id: string; item_count: number }>>([]);
const [setQuery, setSetQuery] = useState('');
useEffect(() => {
apiFetch<{ sets: Array<{ id: string; item_count: number }> }>('/inv/sets/list')
.then(r => setSets(r.sets)).catch(() => {});
}, []);
const toggleSlot = (s: string) => update({
slots: f.slots.includes(s) ? f.slots.filter(x => x !== s) : [...f.slots, s],
});
const setRating = (param: string, v: string) => update({
ratings: { ...f.ratings, [param]: v === '' ? '' : Number(v) },
});
const ratingCount = Object.values(f.ratings).filter(v => v !== '').length;
const stateCount = [f.equippedOnly, f.bonded, f.attuned, f.rare].filter(Boolean).length;
const reqCount = [f.maxLevel, f.minValue, f.minWorkmanship, f.maxBurden].filter(v => v !== '').length;
const types: Array<[ItemType, string]> = [['all', 'All items'], ['armor', 'Armor'],
['jewelry', 'Jewelry'], ['weapon', 'Weapons'], ['clothing', 'Clothing'],
['shirt', 'Shirts'], ['pants', 'Pants']];
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
<input type="radio" name="inv-t" checked={f.itemType === v}
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
</label>
))}
{f.itemType === 'weapon' && (
<select className="inv-minisearch" value={f.weaponType}
onChange={e => update({ weaponType: e.target.value })}>
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
{JEWELRY_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
<span className="inv-linky" onClick={() => setShowArmorSlots(v => !v)}>
{showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'}
</span>
{showArmorSlots && ARMOR_SLOTS.map(s => (
<label className="inv-fitem" key={s}>
<input type="checkbox" checked={f.slots.includes(s)} onChange={() => toggleSlot(s)} /> {s}
</label>
))}
</Group>
<CantripFilter search={search} />
<Group title="Ratings" badge={ratingCount || undefined}>
{RATING_DEFS.filter(r => allRatings || r.common).map(r => (
<div className="inv-range" key={r.param}>
<label>{r.label} </label>
<input type="number" value={f.ratings[r.param] ?? ''} placeholder="min"
onChange={e => setRating(r.param, e.target.value)} />
</div>
))}
<span className="inv-linky" onClick={() => setAllRatings(v => !v)}>
{allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`}
</span>
</Group>
<Group title="Equipment sets" badge={f.itemSet ? 1 : undefined}>
<input className="inv-minisearch" placeholder="find set…" value={setQuery}
onChange={e => setSetQuery(e.target.value)} />
<label className="inv-fitem">
<input type="radio" name="inv-set" checked={f.itemSet === ''}
onChange={() => update({ itemSet: '' })} /> Any set
</label>
{sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => (
<label className="inv-fitem" key={s.id}>
<input type="radio" name="inv-set" checked={f.itemSet === s.id}
onChange={() => update({ itemSet: s.id })} /> {s.id} <span className="inv-dim">({s.item_count})</span>
</label>
))}
</Group>
<Group title="Item state" badge={stateCount || undefined}>
<label className="inv-fitem"><input type="checkbox" checked={f.equippedOnly}
onChange={e => update({ equippedOnly: e.target.checked })} /> Equipped only</label>
<label className="inv-fitem"><input type="checkbox" checked={f.bonded}
onChange={e => update({ bonded: e.target.checked })} /> Bonded</label>
<label className="inv-fitem"><input type="checkbox" checked={f.attuned}
onChange={e => update({ attuned: e.target.checked })} /> Attuned</label>
<label className="inv-fitem"><input type="checkbox" checked={f.rare}
onChange={e => update({ rare: e.target.checked })} /> Rare</label>
</Group>
<Group title="Reqs & value" badge={reqCount || undefined}>
<div className="inv-range"><label>Wield lvl </label>
<input type="number" value={f.maxLevel} placeholder="max"
onChange={e => update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Value </label>
<input type="number" value={f.minValue} placeholder="min"
onChange={e => update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Workmanship </label>
<input type="number" value={f.minWorkmanship} placeholder="min"
onChange={e => update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
<div className="inv-range"><label>Burden </label>
<input type="number" value={f.maxBurden} placeholder="max"
onChange={e => update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} /></div>
</Group>
</div>
);
}

View file

@ -86,3 +86,9 @@
.inv-hint { color: #555; font-size: 10px; margin-top: 12px; } .inv-hint { color: #555; font-size: 10px; margin-top: 12px; }
.inv-closex { float: right; color: #666; cursor: pointer; font-size: 14px; } .inv-closex { float: right; color: #666; cursor: pointer; font-size: 14px; }
.inv-closex:hover { color: #f88; } .inv-closex:hover { color: #f88; }
/* ── FilterSidebar additions (Task 4) ── */
.inv-dim { color: #666; font-size: 10px; }
.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; }