diff --git a/frontend/src/components/inventory/CantripFilter.tsx b/frontend/src/components/inventory/CantripFilter.tsx new file mode 100644 index 00000000..be02e415 --- /dev/null +++ b/frontend/src/components/inventory/CantripFilter.tsx @@ -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 ( + + setQ(e.target.value)} /> + {f.cantrips.length > 0 && (<> +
Selected
+ {f.cantrips.map(v => ( + + ))} + )} + {CANTRIP_GROUPS.map(g => { + const items = g.items.filter(i => !f.cantrips.includes(i.value) && match(i.label)); + if (!items.length) return null; + return ( +
+
{g.group}
+ {items.map(i => ( + + ))} +
+ ); + })} +
Any-tier spell search
+ update({ spellContains: e.target.value })} /> +
+ ); +} diff --git a/frontend/src/components/inventory/CharacterFilter.tsx b/frontend/src/components/inventory/CharacterFilter.tsx new file mode 100644 index 00000000..f0f1fb68 --- /dev/null +++ b/frontend/src/components/inventory/CharacterFilter.tsx @@ -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([]); + const [online, setOnline] = useState>(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 ( + + setQ(e.target.value)} /> +
+ update({ characters: 'all' })}>All{' · '} + update({ characters: [] })}>None{' · '} + update({ characters: names.filter(n => online.has(n)) })}>Online +
+ {shown.map(n => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/inventory/FilterSidebar.tsx b/frontend/src/components/inventory/FilterSidebar.tsx index 006eed02..caeeba78 100644 --- a/frontend/src/components/inventory/FilterSidebar.tsx +++ b/frontend/src/components/inventory/FilterSidebar.tsx @@ -1,3 +1,133 @@ -// Stub — replaced in Task 4 +import React, { useEffect, useState } from 'react'; import type { InventorySearch } from './useInventorySearch'; -export function FilterSidebar(_: { search: InventorySearch }) { return
; } +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 ( +
+
setOpen(o => !o)}> + {props.title} + {props.badge ? {props.badge} : null} +
+ {open &&
{props.children}
} +
+ ); +} + +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>([]); + 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 ( +
+ + + {types.map(([v, label]) => ( + + ))} + {f.itemType === 'weapon' && ( + + )} + + + {JEWELRY_SLOTS.map(s => ( + + ))} + setShowArmorSlots(v => !v)}> + {showArmorSlots ? 'hide' : 'show'} armor slots {showArmorSlots ? '▴' : '▾'} + + {showArmorSlots && ARMOR_SLOTS.map(s => ( + + ))} + + + + {RATING_DEFS.filter(r => allRatings || r.common).map(r => ( +
+ + setRating(r.param, e.target.value)} /> +
+ ))} + setAllRatings(v => !v)}> + {allRatings ? 'common ratings ▴' : `all ${RATING_DEFS.length} ratings ▾`} + +
+ + setSetQuery(e.target.value)} /> + + {sets.filter(s => s.id.toLowerCase().includes(setQuery.toLowerCase())).map(s => ( + + ))} + + + + + + + + +
+ update({ maxLevel: e.target.value === '' ? '' : Number(e.target.value) })} />
+
+ update({ minValue: e.target.value === '' ? '' : Number(e.target.value) })} />
+
+ update({ minWorkmanship: e.target.value === '' ? '' : Number(e.target.value) })} />
+
+ update({ maxBurden: e.target.value === '' ? '' : Number(e.target.value) })} />
+
+
+ ); +} diff --git a/frontend/src/styles/inventory.css b/frontend/src/styles/inventory.css index 5df15221..22689c07 100644 --- a/frontend/src/styles/inventory.css +++ b/frontend/src/styles/inventory.css @@ -86,3 +86,9 @@ .inv-hint { color: #555; font-size: 10px; margin-top: 12px; } .inv-closex { float: right; color: #666; cursor: pointer; font-size: 14px; } .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; }