feat(frontend): useInventorySearch hook (debounced abortable search, URL state)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5ddcecaece
commit
6fd87a3751
1 changed files with 118 additions and 0 deletions
118
frontend/src/components/inventory/useInventorySearch.ts
Normal file
118
frontend/src/components/inventory/useInventorySearch.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue