Files
ttrpg_manager/src/features/compendium/CompendiumPage.tsx
T
NilsBriggen 2c08a26f21 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>
2026-06-08 01:05:17 +02:00

323 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas';
import { useUiStore } from '@/stores/uiStore';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/hooks';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
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');
const categories = categoriesForSystem(system);
const [categoryId, setCategoryId] = useState(categories[0]!.id);
const category = categories.find((c) => c.id === categoryId) ?? categories[0]!;
const [data, setData] = useState<Entry[]>([]);
const [loading, setLoading] = useState(true);
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.
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
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); } },
);
return () => { cancelled = true; };
}, [category]);
const filtered = useMemo(() => {
const active = Object.entries(filters).filter(([, v]) => v !== '');
if (active.length === 0) return data;
return data.filter((e) =>
active.every(([key, value]) => {
const def = category.filters.find((f) => f.key === key);
return def ? filterMatches(def, e, value) : true;
}),
);
}, [data, filters, category]);
const fuse = useMemo(
() => new Fuse(filtered, { keys: category.searchKeys, threshold: 0.34, ignoreLocation: true }),
[filtered, category],
);
const results = useMemo(() => {
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 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 (
<Page>
<PageHeader
title="Compendium"
subtitle="Searchable reference — bestiary, spells, items, feats, and more."
actions={
<div className="flex gap-1 rounded-md border border-line p-0.5">
{SYSTEM_OPTIONS.map((o) => (
<button
key={o.id}
onClick={() => changeSystem(o.id)}
className={cn(
'rounded px-3 py-1 text-sm transition-colors',
system === o.id ? 'bg-accent text-accent-ink' : 'text-muted hover:text-ink',
)}
>
{o.label}
</button>
))}
</div>
}
/>
{/* Category chips */}
<div className="mb-4 flex flex-wrap gap-1">
{categories.map((c) => (
<button
key={c.id}
onClick={() => setCategoryId(c.id)}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors',
c.id === category.id ? 'bg-elevated text-accent ring-1 ring-accent/40' : 'bg-panel text-muted hover:text-ink',
)}
>
{c.label}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
<div>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${category.label.toLowerCase()}…`}
aria-label="Search compendium"
/>
{category.filters.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
{category.filters.map((f) => {
const opts = f.options(data);
if (opts.length === 0) return null;
return (
<Select
key={f.key}
className="w-auto py-1 text-xs"
aria-label={f.label}
value={filters[f.key] ?? ''}
onChange={(e) => setFilters((prev) => ({ ...prev, [f.key]: e.target.value }))}
>
<option value="">{f.label}</option>
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
);
})}
{Object.values(filters).some((v) => v) && (
<Button size="sm" variant="ghost" onClick={() => setFilters({})}>Clear</Button>
)}
</div>
)}
<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>
<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">
{selected ? (
<div>
<DetailActions category={category} entry={selected} system={system} />
{category.detail(selected)}
</div>
) : (
<p className="text-sm text-muted">Select an entry to view details.</p>
)}
</div>
</div>
</Page>
);
}
function DetailActions({ category, entry, system }: { category: CategoryDef; entry: Entry; system: SystemId }) {
const hasActions = category.toCombatant || category.linkAs;
if (!hasActions) return null;
return (
<div className="mb-3 flex flex-wrap items-center gap-2 border-b border-line pb-3">
{category.toCombatant && <AddToCombat stats={category.toCombatant(entry)} />}
{category.linkAs && <AddToCharacter kind={category.linkAs} entry={entry} system={system} />}
</div>
);
}
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number } }) {
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const [msg, setMsg] = useState<string | null>(null);
const add = async () => {
if (!activeEncounterId) return;
const enc = await encountersRepo.get(activeEncounterId);
if (!enc) { setMsg('Open an encounter in Combat first.'); return; }
const initiative = rollDice('1d20', createRng()).total + stats.initBonus;
await encountersRepo.save(
addCombatant(enc, {
id: newId(), name: stats.name, kind: 'monster',
initiative, ac: stats.ac, hp: { current: stats.hp, max: stats.hp, temp: 0 },
conditions: [], notes: '',
}),
);
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
};
return (
<>
<Button size="sm" variant="primary" disabled={!activeEncounterId} onClick={add} title={activeEncounterId ? 'Add to the open encounter' : 'Open an encounter in Combat first'}>
+ Add to combat
</Button>
{msg && <span className="text-xs text-success" aria-live="polite">{msg}</span>}
</>
);
}
function AddToCharacter({ kind, entry, system }: { kind: 'spell' | 'item'; entry: Entry; system: SystemId }) {
const campaign = useActiveCampaign();
const characters = useCharacters(campaign?.id ?? '');
const [msg, setMsg] = useState<string | null>(null);
const add = async (characterId: string) => {
const c = await charactersRepo.get(characterId);
if (!c) return;
if (kind === 'spell') {
const level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
const spell: SpellEntry = {
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0,
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
};
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
} else {
const item: InventoryItem = {
id: newId(), name: entry.name, quantity: 1, weight: 0, equipped: false, attuned: false,
description: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
};
await charactersRepo.update(c.id, { inventory: [...c.inventory, item] });
}
setMsg(`Added to ${c.name}.`);
};
if (!campaign) return <span className="text-xs text-muted">Select a campaign to add to a character.</span>;
if (characters.length === 0) return <span className="text-xs text-muted">No characters in this campaign yet.</span>;
return (
<>
<Select
className="w-auto py-1 text-sm"
aria-label={`Add ${kind} to character`}
value=""
onChange={(e) => { if (e.target.value) { void add(e.target.value); e.target.value = ''; } }}
>
<option value="">+ Add {kind} to</option>
{characters.map((c: Character) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
{msg && <span className="text-xs text-success" aria-live="polite">{msg}</span>}
</>
);
}