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(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([]); const [loading, setLoading] = useState(true); 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. 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(null); const rowVirtualizer = useVirtualizer({ count: results.length, getScrollElement: () => parentRef.current, estimateSize: () => 46, overscan: 12, }); return ( {SYSTEM_OPTIONS.map((o) => ( ))} } /> {/* Category chips */}
{categories.map((c) => ( ))}
setQuery(e.target.value)} placeholder={`Search ${category.label.toLowerCase()}…`} aria-label="Search compendium" /> {category.filters.length > 0 && (
{category.filters.map((f) => { const opts = f.options(data); if (opts.length === 0) return null; return ( ); })} {Object.values(filters).some((v) => v) && ( )}
)}
{loading ? 'Loading…' : error ? {error} : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`}
{rowVirtualizer.getVirtualItems().map((vi) => { const entry = results[vi.index]!; return (
); })}
{selected ? (
{category.detail(selected)}
) : (

Select an entry to view details.

)}
); } function DetailActions({ category, entry, system }: { category: CategoryDef; entry: Entry; system: SystemId }) { const hasActions = category.toCombatant || category.linkAs; if (!hasActions) return null; return (
{category.toCombatant && } {category.linkAs && }
); } function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number } }) { const activeEncounterId = useUiStore((s) => s.activeEncounterId); const [msg, setMsg] = useState(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 ( <> {msg && {msg}} ); } function AddToCharacter({ kind, entry, system }: { kind: 'spell' | 'item'; entry: Entry; system: SystemId }) { const campaign = useActiveCampaign(); const characters = useCharacters(campaign?.id ?? ''); const [msg, setMsg] = useState(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 Select a campaign to add to a character.; if (characters.length === 0) return No characters in this campaign yet.; return ( <> {msg && {msg}} ); }