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",
|
"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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 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 (
|
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) => {
|
||||||
<button
|
const entry = results[vi.index]!;
|
||||||
onClick={() => setSelected(entry)}
|
return (
|
||||||
className={cn(
|
<div
|
||||||
'flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm',
|
key={(entry.slug as string) ?? `${entry.name}-${vi.index}`}
|
||||||
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${vi.size}px`, transform: `translateY(${vi.start}px)`, paddingBottom: 4 }}
|
||||||
)}
|
>
|
||||||
>
|
<button
|
||||||
<span className="truncate text-ink">{entry.name}</span>
|
onClick={() => setSelected(entry)}
|
||||||
<span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span>
|
className={cn(
|
||||||
</button>
|
'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm',
|
||||||
</li>
|
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
||||||
))}
|
)}
|
||||||
</ul>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-5">
|
<div className="rounded-lg border border-line bg-panel p-5">
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
Reference in New Issue
Block a user