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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user