92 lines
3.5 KiB
TypeScript
92 lines
3.5 KiB
TypeScript
import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react';
|
|
import { useInventorySearch } from './useInventorySearch';
|
|
import { FilterSidebar } from './FilterSidebar';
|
|
import { ActiveChips } from './ActiveChips';
|
|
import { ResultsTable } from './ResultsTable';
|
|
import { DetailPanel } from './DetailPanel';
|
|
import type { InvItem } from './types';
|
|
|
|
/** 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">
|
|
<span className="inv-title">⚔ Inventory Search</span>
|
|
<input
|
|
className="inv-searchbox"
|
|
placeholder="Search name or material…"
|
|
value={search.filters.text}
|
|
onChange={e => search.update({ text: e.target.value })}
|
|
/>
|
|
<button className="inv-btn" onClick={() => { search.reset(); setSelected(null); }}>Reset</button>
|
|
<span className="inv-count">
|
|
{search.error ? <span className="inv-error">{search.error}</span>
|
|
: search.result ? <><b>{search.result.total_count.toLocaleString()}</b> items
|
|
{search.queryMs != null && <> · {search.queryMs} ms</>}
|
|
{search.loading && ' · …'}</>
|
|
: 'loading…'}
|
|
</span>
|
|
</div>
|
|
<ActiveChips search={search} />
|
|
<div className="inv-main">
|
|
<FilterSidebar search={search} />
|
|
<ResultsTable search={search} selected={selected} onSelect={setSelected} />
|
|
{selected && <DetailPanel item={selected} onClose={() => setSelected(null)} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function InventorySearchPage() {
|
|
return (
|
|
<InventorySearchErrorBoundary>
|
|
<InventorySearchPageInner />
|
|
</InventorySearchErrorBoundary>
|
|
);
|
|
}
|