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` |
|
| 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 |
|
| 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 |
|
| 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/` |
|
| 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 |
|
| 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 |
|
| 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: '' }) });
|
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
|
||||||
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
|
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
|
||||||
const def = RATING_DEFS.find(r => r.param === param);
|
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.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
|
||||||
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });
|
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 { useInventorySearch } from './useInventorySearch';
|
||||||
import { FilterSidebar } from './FilterSidebar';
|
import { FilterSidebar } from './FilterSidebar';
|
||||||
import { ActiveChips } from './ActiveChips';
|
import { ActiveChips } from './ActiveChips';
|
||||||
|
|
@ -6,10 +6,54 @@ import { ResultsTable } from './ResultsTable';
|
||||||
import { DetailPanel } from './DetailPanel';
|
import { DetailPanel } from './DetailPanel';
|
||||||
import type { InvItem } from './types';
|
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 search = useInventorySearch();
|
||||||
const [selected, setSelected] = useState<InvItem | null>(null);
|
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 (
|
return (
|
||||||
<div className="inv-page">
|
<div className="inv-page">
|
||||||
<div className="inv-topbar">
|
<div className="inv-topbar">
|
||||||
|
|
@ -38,3 +82,11 @@ export function InventorySearchPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InventorySearchPage() {
|
||||||
|
return (
|
||||||
|
<InventorySearchErrorBoundary>
|
||||||
|
<InventorySearchPageInner />
|
||||||
|
</InventorySearchErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,11 @@ export function ResultsTable({ search, selected, onSelect }: {
|
||||||
const items = result?.items ?? [];
|
const items = result?.items ?? [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
try {
|
||||||
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
|
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
|
||||||
|
} catch {
|
||||||
|
/* Safari private mode throws on quota — non-fatal, column prefs just won't persist. */
|
||||||
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
|
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,77 @@ function filtersToUrl(f: SearchFilters): void {
|
||||||
url.searchParams.set('view', 'inventory');
|
url.searchParams.set('view', 'inventory');
|
||||||
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
|
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
|
||||||
else url.searchParams.delete('q');
|
else url.searchParams.delete('q');
|
||||||
|
try {
|
||||||
window.history.replaceState(null, '', url);
|
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 {
|
function filtersFromUrl(): SearchFilters {
|
||||||
try {
|
try {
|
||||||
const q = new URLSearchParams(window.location.search).get('q');
|
const q = new URLSearchParams(window.location.search).get('q');
|
||||||
if (!q) return DEFAULT_FILTERS;
|
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 {
|
} catch {
|
||||||
return DEFAULT_FILTERS;
|
return DEFAULT_FILTERS;
|
||||||
}
|
}
|
||||||
|
|
@ -88,8 +151,8 @@ export function useInventorySearch(): InventorySearch {
|
||||||
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
|
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filtersToUrl(filters);
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
|
filtersToUrl(filters);
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
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