feat(frontend): inventory item-state filter — Any/Equipped/Not equipped radios, moved under Characters

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 06:59:22 +02:00
parent 4e1b9f4a1f
commit 71f235c8b1
17 changed files with 44 additions and 35 deletions

View file

@ -32,7 +32,10 @@ export function ActiveChips({ search }: { search: InventorySearch }) {
});
}
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.equipStatus) chips.push({
label: f.equipStatus === 'equipped' ? 'Equipped' : 'Not equipped',
remove: () => update({ equipStatus: '' }),
});
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 }) });

View file

@ -39,7 +39,7 @@ export function FilterSidebar({ search }: { search: InventorySearch }) {
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 stateCount = [f.equipStatus !== '', 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'],
@ -48,6 +48,20 @@ export function FilterSidebar({ search }: { search: InventorySearch }) {
return (
<div className="inv-sidebar">
<CharacterFilter search={search} />
<Group title="Item state" defaultOpen badge={stateCount || undefined}>
{([['', 'Any'], ['equipped', 'Equipped'], ['unequipped', 'Not equipped']] as const).map(([v, label]) => (
<label className="inv-fitem" key={label}>
<input type="radio" name="inv-eq" checked={f.equipStatus === v}
onChange={() => update({ equipStatus: v })} /> {label}
</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="Item type" defaultOpen badge={f.itemType !== 'all' ? 1 : undefined}>
{types.map(([v, label]) => (
<label className="inv-fitem" key={v}>
@ -104,16 +118,6 @@ export function FilterSidebar({ search }: { search: InventorySearch }) {
</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"

View file

@ -45,7 +45,8 @@ export interface SearchFilters {
/** min-rating param name -> value ('' = unset). */
ratings: Record<string, number | ''>;
itemSet: string; // set id, '' = none
equippedOnly: boolean;
/** '' = any, or the backend's equipment_status values. */
equipStatus: '' | 'equipped' | 'unequipped';
bonded: boolean;
attuned: boolean;
rare: boolean;
@ -61,7 +62,7 @@ export interface SearchFilters {
export const DEFAULT_FILTERS: SearchFilters = {
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
equippedOnly: false, bonded: false, attuned: false, rare: false,
equipStatus: '', bonded: false, attuned: false, rare: false,
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
sortBy: 'name', sortDir: 'asc', page: 1,
};

View file

@ -53,7 +53,8 @@ function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown
case 'itemSet':
case 'sortBy':
return typeof value === 'string' ? value : undefined;
case 'equippedOnly':
case 'equipStatus':
return value === '' || value === 'equipped' || value === 'unequipped' ? value : undefined;
case 'bonded':
case 'attuned':
case 'rare':
@ -111,7 +112,7 @@ export function buildParams(f: SearchFilters): URLSearchParams {
if (f.spellContains) p.set('spell_contains', f.spellContains);
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v));
if (f.itemSet) p.set('item_set', f.itemSet);
if (f.equippedOnly) p.set('equipment_status', 'equipped');
if (f.equipStatus) p.set('equipment_status', f.equipStatus);
if (f.bonded) p.set('bonded', 'true');
if (f.attuned) p.set('attuned', 'true');
if (f.rare) p.set('is_rare', 'true');