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:
parent
9a9b33a528
commit
0533e2b9ee
4 changed files with 232 additions and 2 deletions
46
frontend/src/components/inventory/CantripFilter.tsx
Normal file
46
frontend/src/components/inventory/CantripFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/inventory/CharacterFilter.tsx
Normal file
48
frontend/src/components/inventory/CharacterFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue