feat(frontend): multi-select weapon-type-by-skill filter + auto OD column

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 08:44:00 +02:00
parent b4bee24773
commit d6fa0fa9b0
7 changed files with 37 additions and 18 deletions

View file

@ -1,5 +1,5 @@
import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS } from './constants';
import { RATING_DEFS, WEAPON_TYPES } from './constants';
interface Chip { label: string; gold?: boolean; remove: () => void; }
@ -12,9 +12,13 @@ export function ActiveChips({ search }: { search: InventorySearch }) {
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: '' }),
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
remove: () => update({ itemType: 'all', weaponTypes: [] }),
});
for (const wt of f.weaponTypes) {
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
}
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,

View file

@ -66,14 +66,22 @@ export function FilterSidebar({ search }: { search: InventorySearch }) {
{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}
onChange={() => update({ itemType: v, weaponTypes: [] })} /> {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>
<div className="inv-subgroup">
{WEAPON_TYPES.map(w => (
<label className="inv-fitem" key={w.value}>
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
onChange={() => update({
weaponTypes: f.weaponTypes.includes(w.value)
? f.weaponTypes.filter(x => x !== w.value)
: [...f.weaponTypes, w.value],
})} /> {w.label}
</label>
))}
</div>
)}
</Group>
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>

View file

@ -42,7 +42,11 @@ export function ResultsTable({ search, selected, onSelect }: {
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 cols = useMemo(() => {
const eff = new Set(visible);
if (f.itemType === 'weapon') eff.add('od_rating');
return COLUMNS.filter(c => eff.has(c.key));
}, [visible, f.itemType]);
const items = result?.items ?? [];
useEffect(() => {

View file

@ -44,11 +44,13 @@ export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arm
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
{ value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
{ value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
{ value: 'caster', label: 'Wand/Staff/Orb' },
{ value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' },
{ value: 'finesse', label: 'Finesse' },
{ value: 'two_handed', label: 'Two-Handed' },
{ value: 'missile', label: 'Missile' },
{ value: 'war', label: 'War Magic' },
{ value: 'void', label: 'Void Magic' },
];
export interface RatingDef { param: string; label: string; common?: boolean; }

View file

@ -39,7 +39,7 @@ export interface SearchFilters {
/** 'all' → include_all_characters=true; otherwise explicit list. */
characters: 'all' | string[];
itemType: ItemType;
weaponType: string; // '' = all weapon types
weaponTypes: string[]; // [] = all weapon types
slots: string[];
cantrips: string[]; // full "Legendary X" value strings
spellContains: string;
@ -61,7 +61,7 @@ export interface SearchFilters {
}
export const DEFAULT_FILTERS: SearchFilters = {
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
text: '', characters: 'all', itemType: 'all', weaponTypes: [], slots: [],
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
equipStatus: '', bonded: false, attuned: false, rare: false,
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',

View file

@ -34,6 +34,7 @@ function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown
return undefined;
case 'slots':
case 'cantrips':
case 'weaponTypes':
return isStringArray(value) ? value : undefined;
case 'itemType':
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
@ -48,7 +49,6 @@ function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown
return out;
}
case 'text':
case 'weaponType':
case 'spellContains':
case 'itemSet':
case 'sortBy':
@ -104,7 +104,7 @@ export function buildParams(f: SearchFilters): URLSearchParams {
case 'pants': p.set('pants_only', 'true'); break;
case 'weapon':
p.set('weapon_only', 'true');
if (f.weaponType) p.set('weapon_type', f.weaponType);
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
break;
}
if (f.slots.length) p.set('slot_names', f.slots.join(','));

View file

@ -49,6 +49,7 @@
.inv-fitem { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: #bbb; cursor: pointer; }
.inv-fitem:hover { color: #fff; }
.inv-fitem input { accent-color: #88f; }
.inv-subgroup { margin: 2px 0 2px 10px; }
.inv-subhead { color: #666; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 2px; }
.inv-linky { color: #88f; font-size: 10px; cursor: pointer; }
.inv-linky:hover { text-decoration: underline; }