2c08a26f21
- 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>
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
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 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
|
||
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>}
|
||
</>
|
||
);
|
||
}
|