Compendium: exact result count, sorting, virtualized list
- Drop the 300-row cap; show the true filtered count (e.g. '8,402 results') - Virtualize the results list (@tanstack/react-virtual) so thousands of rows render smoothly - Sort dropdown: Name A-Z/Z-A plus a numeric axis per category (CR for monsters, Level for spells/feats/PF2e categories) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"name": "ttrpg-manager",
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.10",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
@@ -443,10 +444,14 @@
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.2", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.10",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import Fuse from 'fuse.js';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
||||
@@ -17,6 +18,8 @@ import { Input, Select } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { categoriesForSystem, filterMatches, type CategoryDef, type Entry } from './registry';
|
||||
|
||||
type SortKey = 'name-asc' | 'name-desc' | 'num-asc' | 'num-desc';
|
||||
|
||||
export function CompendiumPage() {
|
||||
const activeCampaign = useActiveCampaign();
|
||||
const [system, setSystem] = useState<SystemId>(activeCampaign?.system ?? '5e');
|
||||
@@ -29,6 +32,7 @@ export function CompendiumPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [sort, setSort] = useState<SortKey>('name-asc');
|
||||
const [selected, setSelected] = useState<Entry | null>(null);
|
||||
|
||||
// Load the active category's dataset.
|
||||
@@ -39,6 +43,7 @@ export function CompendiumPage() {
|
||||
setSelected(null);
|
||||
setQuery('');
|
||||
setFilters({});
|
||||
setSort('name-asc');
|
||||
category.load().then(
|
||||
(d) => { if (!cancelled) { setData(d); setLoading(false); } },
|
||||
(e: unknown) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'Failed to load'); setLoading(false); } },
|
||||
@@ -62,15 +67,50 @@ export function CompendiumPage() {
|
||||
[filtered, category],
|
||||
);
|
||||
const results = useMemo(() => {
|
||||
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : filtered;
|
||||
return list.slice(0, 300);
|
||||
}, [query, fuse, filtered]);
|
||||
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : [...filtered];
|
||||
const num = category.numericSort;
|
||||
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
|
||||
const byNum = (a: Entry, b: Entry) => {
|
||||
const av = num ? num.get(a) : 0;
|
||||
const bv = num ? num.get(b) : 0;
|
||||
// entries without a numeric value sort to the end
|
||||
const an = Number.isFinite(av) ? av : Infinity;
|
||||
const bn = Number.isFinite(bv) ? bv : Infinity;
|
||||
return an - bn || byName(a, b);
|
||||
};
|
||||
switch (sort) {
|
||||
case 'name-asc': return list.sort(byName);
|
||||
case 'name-desc': return list.sort((a, b) => byName(b, a));
|
||||
case 'num-asc': return list.sort(byNum);
|
||||
case 'num-desc': return list.sort((a, b) => byNum(b, a));
|
||||
}
|
||||
}, [query, fuse, filtered, sort, category]);
|
||||
|
||||
const changeSystem = (s: SystemId) => {
|
||||
setSystem(s);
|
||||
setCategoryId(categoriesForSystem(s)[0]!.id);
|
||||
};
|
||||
|
||||
const sortOptions: { value: SortKey; label: string }[] = [
|
||||
{ value: 'name-asc', label: 'Name A–Z' },
|
||||
{ value: 'name-desc', label: 'Name Z–A' },
|
||||
...(category.numericSort
|
||||
? ([
|
||||
{ value: 'num-asc', label: `${category.numericSort.label} ↑` },
|
||||
{ value: 'num-desc', label: `${category.numericSort.label} ↓` },
|
||||
] as const)
|
||||
: []),
|
||||
];
|
||||
|
||||
// Virtualize the results list so thousands of rows render smoothly.
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: results.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 46,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
@@ -142,26 +182,44 @@ export function CompendiumPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length}${results.length === 300 ? '+' : ''} results`}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<Select
|
||||
className="w-auto py-1 text-xs"
|
||||
aria-label="Sort"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortKey)}
|
||||
>
|
||||
{sortOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</Select>
|
||||
<span className="text-xs text-muted">
|
||||
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-2 max-h-[62vh] space-y-1 overflow-auto">
|
||||
{results.map((entry, i) => (
|
||||
<li key={(entry.slug as string) ?? `${entry.name}-${i}`}>
|
||||
<button
|
||||
onClick={() => setSelected(entry)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm',
|
||||
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-ink">{entry.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div ref={parentRef} className="mt-2 max-h-[62vh] overflow-auto">
|
||||
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((vi) => {
|
||||
const entry = results[vi.index]!;
|
||||
return (
|
||||
<div
|
||||
key={(entry.slug as string) ?? `${entry.name}-${vi.index}`}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${vi.size}px`, transform: `translateY(${vi.start}px)`, paddingBottom: 4 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelected(entry)}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm',
|
||||
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-ink">{entry.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line bg-panel p-5">
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface CategoryDef {
|
||||
linkAs?: 'spell' | 'item';
|
||||
/** enables "add to combat" for monsters/creatures */
|
||||
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number };
|
||||
/** optional numeric sort axis (e.g. CR, spell level) in addition to name */
|
||||
numericSort?: { label: string; get: (e: Entry) => number };
|
||||
}
|
||||
|
||||
export function filterMatches(def: FilterDef, entry: Entry, value: string): boolean {
|
||||
@@ -125,6 +127,7 @@ function pf2eCategory(id: string, label: string, file: string, opts: { level?: b
|
||||
detail: (e) => <Pf2eDetail entry={e} />,
|
||||
filters,
|
||||
...(opts.linkAs ? { linkAs: opts.linkAs } : {}),
|
||||
...(opts.level !== false ? { numericSort: { label: 'Level', get: (e: Entry) => Number(e.level) } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +143,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
const m = e as unknown as Monster;
|
||||
return { name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1, initBonus: abilityModifier(m.dexterity ?? 10) };
|
||||
},
|
||||
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
|
||||
filters: [
|
||||
{ key: 'type', label: 'Type', kind: 'eq', get: (e) => e.type as string, options: (d) => distinctStrings(d, (e) => e.type) },
|
||||
{ key: 'crMin', label: 'Min CR', kind: 'gte', get: (e) => e.cr as number, options: (d) => numericOptions(d, (e) => e.cr, (n) => `CR ≥ ${crNum(n)}`) },
|
||||
@@ -152,6 +156,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
searchKeys: ['name', 'school'],
|
||||
meta: (e) => { const l = (e as unknown as Spell).level_int; return l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; },
|
||||
detail: (e) => <Spell5eDetail entry={e as unknown as Spell} />,
|
||||
numericSort: { label: 'Level', get: (e) => Number(e.level_int) },
|
||||
filters: [
|
||||
{ key: 'level', label: 'Level', kind: 'eq', get: (e) => e.level_int as number, options: (d) => numericOptions(d, (e) => e.level_int, (n) => (n === 0 ? 'Cantrip' : `Level ${n}`)) },
|
||||
{ key: 'school', label: 'School', kind: 'eq', get: (e) => e.school as string, options: (d) => distinctStrings(d, (e) => e.school) },
|
||||
|
||||
Reference in New Issue
Block a user