From 2c08a26f216c56d96a3009f44e58610d689a92d1 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 01:05:17 +0200 Subject: [PATCH] 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) --- bun.lock | 5 + package.json | 1 + src/features/compendium/CompendiumPage.tsx | 104 ++++++++++++++++----- src/features/compendium/registry.tsx | 5 + 4 files changed, 92 insertions(+), 23 deletions(-) diff --git a/bun.lock b/bun.lock index 0d34676..595300e 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index b9573d2..72abe3f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/compendium/CompendiumPage.tsx b/src/features/compendium/CompendiumPage.tsx index 8b22bb3..c9f6e05 100644 --- a/src/features/compendium/CompendiumPage.tsx +++ b/src/features/compendium/CompendiumPage.tsx @@ -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(activeCampaign?.system ?? '5e'); @@ -29,6 +32,7 @@ export function CompendiumPage() { const [error, setError] = useState(null); const [query, setQuery] = useState(''); const [filters, setFilters] = useState>({}); + const [sort, setSort] = useState('name-asc'); const [selected, setSelected] = useState(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(null); + const rowVirtualizer = useVirtualizer({ + count: results.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 46, + overscan: 12, + }); + return ( )} -

- {loading ? 'Loading…' : error ? {error} : `${results.length}${results.length === 300 ? '+' : ''} results`} -

+
+ + + {loading ? 'Loading…' : error ? {error} : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`} + +
-
    - {results.map((entry, i) => ( -
  • - -
  • - ))} -
+
+
+ {rowVirtualizer.getVirtualItems().map((vi) => { + const entry = results[vi.index]!; + return ( +
+ +
+ ); + })} +
+
diff --git a/src/features/compendium/registry.tsx b/src/features/compendium/registry.tsx index 00bcf37..439691c 100644 --- a/src/features/compendium/registry.tsx +++ b/src/features/compendium/registry.tsx @@ -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) => , 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) => , + 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) },