feat(frontend): useInventorySearch hook (debounced abortable search, URL state)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 06:09:47 +02:00
parent 5ddcecaece
commit 6fd87a3751

View file

@ -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<SearchFilters> = {};
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
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<SearchFilters>) => void;
reset: () => void;
result: SearchResponse | null;
loading: boolean;
error: string | null;
queryMs: number | null;
}
export function useInventorySearch(): InventorySearch {
const [filters, setFilters] = useState<SearchFilters>(filtersFromUrl);
const [result, setResult] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queryMs, setQueryMs] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const update = useCallback((patch: Partial<SearchFilters>) => {
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 };
}