Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 22:46:13 +01:00
parent 99d1ba1bcd
commit 91120d7c82
31 changed files with 38321 additions and 63422 deletions

View File

@@ -6,9 +6,10 @@ import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
import { SourceManager } from "./components/source-manager";
import { StatBlockPanel } from "./components/stat-block-panel";
import { TurnNavigation } from "./components/turn-navigation";
import { useBestiary } from "./hooks/use-bestiary";
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useEncounter } from "./hooks/use-encounter";
function rollDice(): number {
@@ -34,44 +35,43 @@ export function App() {
makeStore,
} = useEncounter();
const { search, getCreature, isLoaded } = useBestiary();
const {
search,
getCreature,
isLoaded,
isSourceCached,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
} = useBestiary();
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
null,
);
const [suggestions, setSuggestions] = useState<Creature[]>([]);
const [selectedCreatureId, setSelectedCreatureId] =
useState<CreatureId | null>(null);
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
const selectedCreature: Creature | null = selectedCreatureId
? (getCreature(selectedCreatureId) ?? null)
: null;
const handleAddFromBestiary = useCallback(
(creature: Creature) => {
addFromBestiary(creature);
setSelectedCreature(creature);
(result: SearchResult) => {
addFromBestiary(result);
// Derive the creature ID so stat block panel can try to show it
const slug = result.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
setSelectedCreatureId(
`${result.source.toLowerCase()}:${slug}` as CreatureId,
);
},
[addFromBestiary],
);
const handleShowStatBlock = useCallback((creature: Creature) => {
setSelectedCreature(creature);
const handleCombatantStatBlock = useCallback((creatureId: string) => {
setSelectedCreatureId(creatureId as CreatureId);
}, []);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
const creature = getCreature(creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
},
[getCreature],
);
const handleSearchChange = useCallback(
(query: string) => {
if (!isLoaded || query.length < 2) {
setSuggestions([]);
return;
}
setSuggestions(search(query));
},
[isLoaded, search],
);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
@@ -102,9 +102,8 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
const creature = getCreature(active.creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
setSelectedCreatureId(active.creatureId as CreatureId);
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
return (
<div className="flex h-screen flex-col">
@@ -117,9 +116,16 @@ export function App() {
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
/>
</div>
{sourceManagerOpen && (
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
<SourceManager onCacheCleared={refreshCache} />
</div>
)}
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col pb-2">
@@ -163,17 +169,19 @@ export function App() {
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
/>
</div>
</div>
{/* Stat Block Panel */}
<StatBlockPanel
creatureId={selectedCreatureId}
creature={selectedCreature}
onClose={() => setSelectedCreature(null)}
isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache}
onClose={() => setSelectedCreatureId(null)}
/>
</div>
);