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:
2026-06-08 01:05:17 +02:00
parent 379b79e6c4
commit 2c08a26f21
4 changed files with 92 additions and 23 deletions
+5
View File
@@ -6,6 +6,7 @@
"name": "ttrpg-manager", "name": "ttrpg-manager",
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.95.0", "@tanstack/react-router": "^1.95.0",
"@tanstack/react-virtual": "^3.14.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.10", "dexie": "^4.0.10",
"dexie-react-hooks": "^1.1.7", "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-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/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/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/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=="], "@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=="],
+1
View File
@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.95.0", "@tanstack/react-router": "^1.95.0",
"@tanstack/react-virtual": "^3.14.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.10", "dexie": "^4.0.10",
"dexie-react-hooks": "^1.1.7", "dexie-react-hooks": "^1.1.7",
+72 -14
View File
@@ -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 Fuse from 'fuse.js';
import type { SystemId } from '@/lib/rules'; import type { SystemId } from '@/lib/rules';
import { SYSTEM_OPTIONS } 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 { cn } from '@/lib/cn';
import { categoriesForSystem, filterMatches, type CategoryDef, type Entry } from './registry'; import { categoriesForSystem, filterMatches, type CategoryDef, type Entry } from './registry';
type SortKey = 'name-asc' | 'name-desc' | 'num-asc' | 'num-desc';
export function CompendiumPage() { export function CompendiumPage() {
const activeCampaign = useActiveCampaign(); const activeCampaign = useActiveCampaign();
const [system, setSystem] = useState<SystemId>(activeCampaign?.system ?? '5e'); const [system, setSystem] = useState<SystemId>(activeCampaign?.system ?? '5e');
@@ -29,6 +32,7 @@ export function CompendiumPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [sort, setSort] = useState<SortKey>('name-asc');
const [selected, setSelected] = useState<Entry | null>(null); const [selected, setSelected] = useState<Entry | null>(null);
// Load the active category's dataset. // Load the active category's dataset.
@@ -39,6 +43,7 @@ export function CompendiumPage() {
setSelected(null); setSelected(null);
setQuery(''); setQuery('');
setFilters({}); setFilters({});
setSort('name-asc');
category.load().then( category.load().then(
(d) => { if (!cancelled) { setData(d); setLoading(false); } }, (d) => { if (!cancelled) { setData(d); setLoading(false); } },
(e: unknown) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'Failed to load'); 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], [filtered, category],
); );
const results = useMemo(() => { const results = useMemo(() => {
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : filtered; const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : [...filtered];
return list.slice(0, 300); const num = category.numericSort;
}, [query, fuse, filtered]); 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) => { const changeSystem = (s: SystemId) => {
setSystem(s); setSystem(s);
setCategoryId(categoriesForSystem(s)[0]!.id); setCategoryId(categoriesForSystem(s)[0]!.id);
}; };
const sortOptions: { value: SortKey; label: string }[] = [
{ value: 'name-asc', label: 'Name AZ' },
{ value: 'name-desc', label: 'Name ZA' },
...(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 ( return (
<Page> <Page>
<PageHeader <PageHeader
@@ -142,26 +182,44 @@ export function CompendiumPage() {
</div> </div>
)} )}
<p className="mt-1 text-xs text-muted"> <div className="mt-2 flex items-center justify-between gap-2">
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length}${results.length === 300 ? '+' : ''} results`} <Select
</p> 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"> <div ref={parentRef} className="mt-2 max-h-[62vh] overflow-auto">
{results.map((entry, i) => ( <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
<li key={(entry.slug as string) ?? `${entry.name}-${i}`}> {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 <button
onClick={() => setSelected(entry)} onClick={() => setSelected(entry)}
className={cn( className={cn(
'flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm', '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', selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
)} )}
> >
<span className="truncate text-ink">{entry.name}</span> <span className="truncate text-ink">{entry.name}</span>
<span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span> <span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span>
</button> </button>
</li> </div>
))} );
</ul> })}
</div>
</div>
</div> </div>
<div className="rounded-lg border border-line bg-panel p-5"> <div className="rounded-lg border border-line bg-panel p-5">
+5
View File
@@ -47,6 +47,8 @@ export interface CategoryDef {
linkAs?: 'spell' | 'item'; linkAs?: 'spell' | 'item';
/** enables "add to combat" for monsters/creatures */ /** enables "add to combat" for monsters/creatures */
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number }; 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 { 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} />, detail: (e) => <Pf2eDetail entry={e} />,
filters, filters,
...(opts.linkAs ? { linkAs: opts.linkAs } : {}), ...(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; 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) }; 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: [ filters: [
{ key: 'type', label: 'Type', kind: 'eq', get: (e) => e.type as string, options: (d) => distinctStrings(d, (e) => e.type) }, { 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)}`) }, { 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'], searchKeys: ['name', 'school'],
meta: (e) => { const l = (e as unknown as Spell).level_int; return l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; }, 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} />, detail: (e) => <Spell5eDetail entry={e as unknown as Spell} />,
numericSort: { label: 'Level', get: (e) => Number(e.level_int) },
filters: [ 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: '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) }, { key: 'school', label: 'School', kind: 'eq', get: (e) => e.school as string, options: (d) => distinctStrings(d, (e) => e.school) },