fix(frontend): harden inventory search — q= validation, error boundary, stable selection, URL throttle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ae24cad458
commit
edbf7086a6
6 changed files with 133 additions and 4948 deletions
|
|
@ -31,7 +31,7 @@ preserved on the `python-legacy` branch.
|
|||
| Telemetry DB | TimescaleDB | Docker `dereth-db`, 5432 | hypertables `telemetry_events`, `spawn_events` |
|
||||
| Inventory DB | postgres:14 | Docker `inventory-db`, 5433 | 7-table normalized item schema |
|
||||
| React frontend | `frontend/` → `static/` | served by `tracker-go` | unchanged by the migration — same paths, same API |
|
||||
| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` | `/classic`, `/suitbuilder.html`, `/inventory.html` |
|
||||
| Classic v1 / legacy pages | `static/classic/`, `static/*.html` | served by `tracker-go` | `/classic`, `/suitbuilder.html`; inventory search is now the React page at `/?view=inventory` |
|
||||
| Grafana | compose `dereth-grafana` | 127.0.0.1:3000 | anonymous Viewer auth, proxied at `/grafana/` |
|
||||
| Discord rare bot | `discord-rare-monitor/` (Python) | Docker, reads Go `/ws/live` | posts rares + relays allegiance chat |
|
||||
| Overlord Agent (assistant) | `agent/` | host-side systemd `overlord-agent`, 127.0.0.1:8767 | shells out to `claude -p`; outside Docker by design |
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ export function ActiveChips({ search }: { search: InventorySearch }) {
|
|||
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
|
||||
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
|
||||
const def = RATING_DEFS.find(r => r.param === param);
|
||||
chips.push({ label: `${def?.label ?? param} ≥ ${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
|
||||
chips.push({
|
||||
label: `${def?.label ?? param} ≥ ${v}`,
|
||||
remove: () => {
|
||||
const { [param]: _omit, ...rest } = f.ratings;
|
||||
update({ ratings: rest });
|
||||
},
|
||||
});
|
||||
}
|
||||
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
|
||||
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { useInventorySearch } from './useInventorySearch';
|
||||
import { FilterSidebar } from './FilterSidebar';
|
||||
import { ActiveChips } from './ActiveChips';
|
||||
|
|
@ -6,10 +6,54 @@ import { ResultsTable } from './ResultsTable';
|
|||
import { DetailPanel } from './DetailPanel';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
export function InventorySearchPage() {
|
||||
/** Composite key used to re-match a selected item across refetches (objects are recreated each fetch). */
|
||||
function itemKey(i: InvItem): string {
|
||||
return `${i.name} ${i.character_name} ${i.last_updated ?? ''}`;
|
||||
}
|
||||
|
||||
class InventorySearchErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('Inventory search crashed:', error, info);
|
||||
}
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="inv-page">
|
||||
<div className="inv-error" style={{ margin: 'auto', textAlign: 'center', padding: 24 }}>
|
||||
<p style={{ marginBottom: 12 }}>Something went wrong loading the inventory search page.</p>
|
||||
<button className="inv-btn" onClick={() => { window.location.href = '/?view=inventory'; }}>
|
||||
Reset filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function InventorySearchPageInner() {
|
||||
const search = useInventorySearch();
|
||||
const [selected, setSelected] = useState<InvItem | null>(null);
|
||||
|
||||
// Selection is by object identity; every refetch produces new item objects, so re-match the
|
||||
// previously selected item by composite key in the new result set (or clear it if it's gone).
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const items = search.result?.items ?? [];
|
||||
const key = itemKey(selected);
|
||||
const match = items.find(i => itemKey(i) === key);
|
||||
if (match && match !== selected) setSelected(match);
|
||||
else if (!match) setSelected(null);
|
||||
}, [search.result]);
|
||||
|
||||
return (
|
||||
<div className="inv-page">
|
||||
<div className="inv-topbar">
|
||||
|
|
@ -38,3 +82,11 @@ export function InventorySearchPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventorySearchPage() {
|
||||
return (
|
||||
<InventorySearchErrorBoundary>
|
||||
<InventorySearchPageInner />
|
||||
</InventorySearchErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ export function ResultsTable({ search, selected, onSelect }: {
|
|||
const items = result?.items ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
|
||||
try {
|
||||
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
|
||||
} catch {
|
||||
/* Safari private mode throws on quota — non-fatal, column prefs just won't persist. */
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
|
||||
|
|
|
|||
|
|
@ -12,14 +12,77 @@ function filtersToUrl(f: SearchFilters): void {
|
|||
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);
|
||||
try {
|
||||
window.history.replaceState(null, '', url);
|
||||
} catch {
|
||||
/* Safari throttles history.replaceState and throws when exceeded — non-fatal. */
|
||||
}
|
||||
}
|
||||
|
||||
const ITEM_TYPES = new Set(['all', 'armor', 'jewelry', 'weapon', 'clothing', 'shirt', 'pants']);
|
||||
const SORT_DIRS = new Set(['asc', 'desc']);
|
||||
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every(x => typeof x === 'string');
|
||||
}
|
||||
|
||||
/** Validate a single filter key's parsed value; return the valid value or undefined to drop it. */
|
||||
function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown {
|
||||
switch (key) {
|
||||
case 'characters':
|
||||
if (value === 'all' || isStringArray(value)) return value;
|
||||
return undefined;
|
||||
case 'slots':
|
||||
case 'cantrips':
|
||||
return isStringArray(value) ? value : undefined;
|
||||
case 'itemType':
|
||||
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
|
||||
case 'sortDir':
|
||||
return typeof value === 'string' && SORT_DIRS.has(value) ? value : undefined;
|
||||
case 'ratings': {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
||||
const out: Record<string, number | ''> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (v === '' || typeof v === 'number') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case 'text':
|
||||
case 'weaponType':
|
||||
case 'spellContains':
|
||||
case 'itemSet':
|
||||
case 'sortBy':
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
case 'equippedOnly':
|
||||
case 'bonded':
|
||||
case 'attuned':
|
||||
case 'rare':
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
case 'maxLevel':
|
||||
case 'minValue':
|
||||
case 'minWorkmanship':
|
||||
case 'maxBurden':
|
||||
return value === '' || typeof value === 'number' ? value : undefined;
|
||||
case 'page':
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function filtersFromUrl(): SearchFilters {
|
||||
try {
|
||||
const q = new URLSearchParams(window.location.search).get('q');
|
||||
if (!q) return DEFAULT_FILTERS;
|
||||
return { ...DEFAULT_FILTERS, ...JSON.parse(q) };
|
||||
const parsed = JSON.parse(q);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return DEFAULT_FILTERS;
|
||||
const merged: SearchFilters = { ...DEFAULT_FILTERS };
|
||||
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
|
||||
if (!(k in parsed)) continue;
|
||||
const validated = validateFilterValue(k, (parsed as any)[k]);
|
||||
if (validated !== undefined) (merged as any)[k] = validated;
|
||||
}
|
||||
return merged;
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
|
|
@ -88,8 +151,8 @@ export function useInventorySearch(): InventorySearch {
|
|||
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
|
||||
|
||||
useEffect(() => {
|
||||
filtersToUrl(filters);
|
||||
const timer = setTimeout(async () => {
|
||||
filtersToUrl(filters);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
|
|
|||
4940
static/script.js
4940
static/script.js
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue