diff --git a/frontend/src/components/inventory/useInventorySearch.ts b/frontend/src/components/inventory/useInventorySearch.ts new file mode 100644 index 00000000..a96a0c30 --- /dev/null +++ b/frontend/src/components/inventory/useInventorySearch.ts @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types'; +import { PAGE_SIZE } from './constants'; + +/** Serialize only the keys that differ from defaults, as a compact q= JSON param. */ +function filtersToUrl(f: SearchFilters): void { + const diff: Partial = {}; + for (const k of Object.keys(DEFAULT_FILTERS) as Array) { + if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k]; + } + const url = new URL(window.location.href); + url.searchParams.set('view', 'inventory'); + if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff)); + else url.searchParams.delete('q'); + window.history.replaceState(null, '', url); +} + +function filtersFromUrl(): SearchFilters { + try { + const q = new URLSearchParams(window.location.search).get('q'); + if (!q) return DEFAULT_FILTERS; + return { ...DEFAULT_FILTERS, ...JSON.parse(q) }; + } catch { + return DEFAULT_FILTERS; + } +} + +export function buildParams(f: SearchFilters): URLSearchParams { + const p = new URLSearchParams(); + if (f.characters === 'all') p.set('include_all_characters', 'true'); + else if (f.characters.length === 1) p.set('character', f.characters[0]); + else p.set('characters', f.characters.join(',')); + + if (f.text) p.set('text', f.text); + switch (f.itemType) { + case 'armor': p.set('armor_only', 'true'); break; + case 'jewelry': p.set('jewelry_only', 'true'); break; + case 'clothing': p.set('clothing_only', 'true'); break; + case 'shirt': p.set('shirt_only', 'true'); break; + case 'pants': p.set('pants_only', 'true'); break; + case 'weapon': + p.set('weapon_only', 'true'); + if (f.weaponType) p.set('weapon_type', f.weaponType); + break; + } + if (f.slots.length) p.set('slot_names', f.slots.join(',')); + if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(',')); + 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.bonded) p.set('bonded', 'true'); + if (f.attuned) p.set('attuned', 'true'); + if (f.rare) p.set('is_rare', 'true'); + if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel)); + if (f.minValue !== '') p.set('min_value', String(f.minValue)); + if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship)); + if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden)); + p.set('sort_by', f.sortBy); + p.set('sort_dir', f.sortDir); + p.set('page', String(f.page)); + p.set('limit', String(PAGE_SIZE)); + return p; +} + +export interface InventorySearch { + filters: SearchFilters; + /** Patch filters; resets page to 1 unless the patch itself sets page. */ + update: (patch: Partial) => void; + reset: () => void; + result: SearchResponse | null; + loading: boolean; + error: string | null; + queryMs: number | null; +} + +export function useInventorySearch(): InventorySearch { + const [filters, setFilters] = useState(filtersFromUrl); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [queryMs, setQueryMs] = useState(null); + const abortRef = useRef(null); + + const update = useCallback((patch: Partial) => { + setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch })); + }, []); + const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []); + + useEffect(() => { + filtersToUrl(filters); + const timer = setTimeout(async () => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + const t0 = performance.now(); + try { + const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, { + credentials: 'include', signal: ctrl.signal, + }); + if (!res.ok) throw new Error(`search: HTTP ${res.status}`); + const data: SearchResponse = await res.json(); + if (data.error) throw new Error(data.error); + setResult(data); + setError(null); + setQueryMs(Math.round(performance.now() - t0)); + } catch (e: any) { + if (e?.name !== 'AbortError') setError(String(e?.message ?? e)); + } finally { + if (abortRef.current === ctrl) setLoading(false); + } + }, 400); + return () => clearTimeout(timer); + }, [filters]); + + return { filters, update, reset, result, loading, error, queryMs }; +}