feat(frontend): replace inventory pagination with one-shot load + incremental scroll render
Sort/filter always operate on the full result set server-side; the whole set (up to 10k) loads in one request and rows render progressively on scroll. Over-cap searches show a "narrow your filters" notice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e4960eae5e
commit
936628065c
16 changed files with 87 additions and 63 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { COLUMNS, COLUMNS_LS_KEY } from './constants';
|
||||
import { COLUMNS, COLUMNS_LS_KEY, RENDER_CHUNK } from './constants';
|
||||
import type { InventorySearch } from './useInventorySearch';
|
||||
import type { InvItem } from './types';
|
||||
|
||||
|
|
@ -40,6 +40,9 @@ export function ResultsTable({ search, selected, onSelect }: {
|
|||
const { filters: f, update, result } = search;
|
||||
const [visible, setVisible] = useState<Set<string>>(loadVisible);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
// Incremental rendering: the full result set is loaded in one request, but
|
||||
// only renderCount rows are in the DOM; scrolling near the bottom grows it.
|
||||
const [renderCount, setRenderCount] = useState(RENDER_CHUNK);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const cols = useMemo(() => {
|
||||
const eff = new Set(visible);
|
||||
|
|
@ -47,6 +50,21 @@ export function ResultsTable({ search, selected, onSelect }: {
|
|||
return COLUMNS.filter(c => eff.has(c.key));
|
||||
}, [visible, f.itemType]);
|
||||
const items = result?.items ?? [];
|
||||
const shown = items.slice(0, renderCount);
|
||||
|
||||
// New result set (search/sort/filter change) → jump back to the top.
|
||||
useEffect(() => {
|
||||
setRenderCount(RENDER_CHUNK);
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
}, [result]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || renderCount >= items.length) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 600) {
|
||||
setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -65,25 +83,24 @@ export function ResultsTable({ search, selected, onSelect }: {
|
|||
e.preventDefault();
|
||||
const idx = selected ? items.indexOf(selected) : -1;
|
||||
const next = e.key === 'ArrowDown' ? Math.min(idx + 1, items.length - 1) : Math.max(idx - 1, 0);
|
||||
if (items[next]) onSelect(items[next]);
|
||||
if (items[next]) {
|
||||
onSelect(items[next]);
|
||||
// Keep keyboard navigation working past the rendered window.
|
||||
if (next >= renderCount) setRenderCount(c => Math.min(c + RENDER_CHUNK, items.length));
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [items, selected, onSelect]);
|
||||
}, [items, selected, onSelect, renderCount]);
|
||||
|
||||
const sortOn = (sortKey?: string) => {
|
||||
if (!sortKey) return;
|
||||
if (f.sortBy === sortKey) update({ sortDir: f.sortDir === 'asc' ? 'desc' : 'asc' });
|
||||
else update({ sortBy: sortKey, sortDir: 'asc' });
|
||||
};
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.total_count / result.limit)) : 1;
|
||||
const goto = (p: number) => {
|
||||
update({ page: Math.min(Math.max(1, p), totalPages) });
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inv-results" ref={scrollRef}>
|
||||
<div className="inv-results" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="inv-colpicker-anchor">
|
||||
<button className="inv-btn inv-colpicker-btn" onClick={() => setPickerOpen(o => !o)}>⚙ Columns</button>
|
||||
{pickerOpen && (
|
||||
|
|
@ -108,7 +125,7 @@ export function ResultsTable({ search, selected, onSelect }: {
|
|||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map((it, i) => (
|
||||
{shown.map((it, i) => (
|
||||
<tr key={i} className={it === selected ? 'inv-sel' : ''}
|
||||
onClick={() => onSelect(it === selected ? null : it)}>
|
||||
{cols.map(c => <td key={c.key}>{cell(it, c.key)}</td>)}
|
||||
|
|
@ -117,12 +134,14 @@ export function ResultsTable({ search, selected, onSelect }: {
|
|||
{!items.length && <tr><td colSpan={cols.length} className="inv-dim">No items match.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="inv-pager">
|
||||
<span className="inv-pg" onClick={() => goto(f.page - 1)}>◀</span>
|
||||
<span>page {result?.page ?? 1} / {totalPages}</span>
|
||||
<span className="inv-pg" onClick={() => goto(f.page + 1)}>▶</span>
|
||||
<span className="inv-pager-right">{result?.limit ?? 200} / page</span>
|
||||
</div>
|
||||
{renderCount < items.length && (
|
||||
<div className="inv-pager"><span className="inv-dim">Scroll for more… ({shown.length.toLocaleString()} of {items.length.toLocaleString()} shown)</span></div>
|
||||
)}
|
||||
{result?.has_next && renderCount >= items.length && (
|
||||
<div className="inv-pager"><span className="inv-dim">
|
||||
Showing the first {items.length.toLocaleString()} of {result.total_count.toLocaleString()} matches — narrow your filters to see the rest.
|
||||
</span></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,5 +101,10 @@ export const COLUMNS: ColumnDef[] = [
|
|||
{ key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false },
|
||||
];
|
||||
|
||||
export const PAGE_SIZE = 200;
|
||||
// One-shot fetch: every search loads the full result set in a single request
|
||||
// (no pagination); rows are rendered incrementally on scroll. Capped at 10k —
|
||||
// a filterless all-characters browse is ~38k items / 65 MB JSON, which no
|
||||
// browser should be asked to swallow; real searches are a few thousand rows.
|
||||
export const PAGE_SIZE = 10000;
|
||||
export const RENDER_CHUNK = 300;
|
||||
export const COLUMNS_LS_KEY = 'inv.visibleColumns';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue