From 91120d7c82cc250ea9883d7afbbd15606ab6f74d Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 10 Mar 2026 22:46:13 +0100 Subject: [PATCH] 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 --- apps/web/package.json | 1 + apps/web/src/App.tsx | 82 +- .../__tests__/bestiary-adapter.test.ts | 11 +- .../adapters/__tests__/bestiary-full.test.ts | 16 - apps/web/src/adapters/bestiary-adapter.ts | 18 +- apps/web/src/adapters/bestiary-cache.ts | 139 + .../src/adapters/bestiary-index-adapter.ts | 56 + apps/web/src/components/action-bar.tsx | 47 +- apps/web/src/components/bestiary-search.tsx | 16 +- .../src/components/source-fetch-prompt.tsx | 131 + apps/web/src/components/source-manager.tsx | 81 + apps/web/src/components/stat-block-panel.tsx | 97 +- apps/web/src/components/turn-navigation.tsx | 14 +- apps/web/src/hooks/use-bestiary.ts | 156 +- apps/web/src/hooks/use-encounter.ts | 22 +- data/bestiary/index.json | 36540 +++++++++ data/bestiary/xmm.json | 63266 ---------------- packages/application/src/index.ts | 2 +- packages/application/src/ports.ts | 7 +- packages/domain/src/creature-types.ts | 17 + packages/domain/src/index.ts | 2 + pnpm-lock.yaml | 8 + scripts/generate-bestiary-index.mjs | 167 + .../checklists/requirements.md | 36 + .../contracts/bestiary-port.md | 66 + specs/029-on-demand-bestiary/data-model.md | 133 + specs/029-on-demand-bestiary/plan.md | 94 + specs/029-on-demand-bestiary/quickstart.md | 76 + specs/029-on-demand-bestiary/research.md | 113 + specs/029-on-demand-bestiary/spec.md | 131 + specs/029-on-demand-bestiary/tasks.md | 198 + 31 files changed, 38321 insertions(+), 63422 deletions(-) delete mode 100644 apps/web/src/adapters/__tests__/bestiary-full.test.ts create mode 100644 apps/web/src/adapters/bestiary-cache.ts create mode 100644 apps/web/src/adapters/bestiary-index-adapter.ts create mode 100644 apps/web/src/components/source-fetch-prompt.tsx create mode 100644 apps/web/src/components/source-manager.tsx create mode 100644 data/bestiary/index.json delete mode 100644 data/bestiary/xmm.json create mode 100644 scripts/generate-bestiary-index.mjs create mode 100644 specs/029-on-demand-bestiary/checklists/requirements.md create mode 100644 specs/029-on-demand-bestiary/contracts/bestiary-port.md create mode 100644 specs/029-on-demand-bestiary/data-model.md create mode 100644 specs/029-on-demand-bestiary/plan.md create mode 100644 specs/029-on-demand-bestiary/quickstart.md create mode 100644 specs/029-on-demand-bestiary/research.md create mode 100644 specs/029-on-demand-bestiary/spec.md create mode 100644 specs/029-on-demand-bestiary/tasks.md diff --git a/apps/web/package.json b/apps/web/package.json index 4b217a1..2a57262 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "@initiative/domain": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "idb": "^8.0.3", "lucide-react": "^0.577.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ea0d7dd..251c397 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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( - null, - ); - const [suggestions, setSuggestions] = useState([]); + const [selectedCreatureId, setSelectedCreatureId] = + useState(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 (
@@ -117,9 +116,16 @@ export function App() { onRetreatTurn={retreatTurn} onClearEncounter={clearEncounter} onRollAllInitiative={handleRollAllInitiative} + onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} />
+ {sourceManagerOpen && ( +
+ +
+ )} + {/* Scrollable area — combatant list */}
@@ -163,17 +169,19 @@ export function App() { onAddFromBestiary={handleAddFromBestiary} bestiarySearch={search} bestiaryLoaded={isLoaded} - suggestions={suggestions} - onSearchChange={handleSearchChange} - onShowStatBlock={handleShowStatBlock} />
{/* Stat Block Panel */} setSelectedCreature(null)} + isSourceCached={isSourceCached} + fetchAndCacheSource={fetchAndCacheSource} + uploadAndCacheSource={uploadAndCacheSource} + refreshCache={refreshCache} + onClose={() => setSelectedCreatureId(null)} /> ); diff --git a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts index 9a0bec1..feaf276 100644 --- a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { normalizeBestiary } from "../bestiary-adapter.js"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + normalizeBestiary, + setSourceDisplayNames, +} from "../bestiary-adapter.js"; + +beforeAll(() => { + setSourceDisplayNames({ XMM: "MM 2024" }); +}); describe("normalizeBestiary", () => { it("normalizes a simple creature", () => { diff --git a/apps/web/src/adapters/__tests__/bestiary-full.test.ts b/apps/web/src/adapters/__tests__/bestiary-full.test.ts deleted file mode 100644 index 0fc2b25..0000000 --- a/apps/web/src/adapters/__tests__/bestiary-full.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, it } from "vitest"; -import rawData from "../../../../../data/bestiary/xmm.json"; -import { normalizeBestiary } from "../bestiary-adapter.js"; - -it("normalizes all 503 monsters without error", () => { - const creatures = normalizeBestiary( - rawData as unknown as Parameters[0], - ); - expect(creatures.length).toBe(503); - for (const c of creatures) { - expect(c.name).toBeTruthy(); - expect(c.id).toBeTruthy(); - expect(c.ac).toBeGreaterThanOrEqual(0); - expect(c.hp.average).toBeGreaterThan(0); - } -}); diff --git a/apps/web/src/adapters/bestiary-adapter.ts b/apps/web/src/adapters/bestiary-adapter.ts index a0519ed..e1bb7ce 100644 --- a/apps/web/src/adapters/bestiary-adapter.ts +++ b/apps/web/src/adapters/bestiary-adapter.ts @@ -81,9 +81,11 @@ interface RawSpellcasting { // --- Source mapping --- -const SOURCE_DISPLAY_NAMES: Record = { - XMM: "MM 2024", -}; +let sourceDisplayNames: Record = {}; + +export function setSourceDisplayNames(names: Record): void { + sourceDisplayNames = names; +} // --- Size mapping --- @@ -353,7 +355,13 @@ function makeCreatureId(source: string, name: string): CreatureId { * Normalizes raw 5etools bestiary JSON into domain Creature[]. */ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] { - return raw.monster.map((m) => { + // Filter out _copy entries — these reference another source's monster + // and lack their own stats (ac, hp, cr, etc.) + const monsters = raw.monster.filter( + // biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field + (m) => !(m as any)._copy, + ); + return monsters.map((m) => { const crStr = extractCr(m.cr); const ac = extractAc(m.ac); @@ -361,7 +369,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] { id: makeCreatureId(m.source, m.name), name: m.name, source: m.source, - sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source, + sourceDisplayName: sourceDisplayNames[m.source] ?? m.source, size: formatSize(m.size), type: formatType(m.type), alignment: formatAlignment(m.alignment), diff --git a/apps/web/src/adapters/bestiary-cache.ts b/apps/web/src/adapters/bestiary-cache.ts new file mode 100644 index 0000000..f450f55 --- /dev/null +++ b/apps/web/src/adapters/bestiary-cache.ts @@ -0,0 +1,139 @@ +import type { Creature, CreatureId } from "@initiative/domain"; +import { type IDBPDatabase, openDB } from "idb"; + +const DB_NAME = "initiative-bestiary"; +const STORE_NAME = "sources"; +const DB_VERSION = 1; + +export interface CachedSourceInfo { + readonly sourceCode: string; + readonly displayName: string; + readonly creatureCount: number; + readonly cachedAt: number; +} + +interface CachedSourceRecord { + sourceCode: string; + displayName: string; + creatures: Creature[]; + cachedAt: number; + creatureCount: number; +} + +let db: IDBPDatabase | null = null; +let dbFailed = false; + +// In-memory fallback when IndexedDB is unavailable +const memoryStore = new Map(); + +async function getDb(): Promise { + if (db) return db; + if (dbFailed) return null; + + try { + db = await openDB(DB_NAME, DB_VERSION, { + upgrade(database) { + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { + keyPath: "sourceCode", + }); + } + }, + }); + return db; + } catch { + dbFailed = true; + console.warn( + "IndexedDB unavailable — bestiary cache will not persist across sessions.", + ); + return null; + } +} + +export async function cacheSource( + sourceCode: string, + displayName: string, + creatures: Creature[], +): Promise { + const record: CachedSourceRecord = { + sourceCode, + displayName, + creatures, + cachedAt: Date.now(), + creatureCount: creatures.length, + }; + + const database = await getDb(); + if (database) { + await database.put(STORE_NAME, record); + } else { + memoryStore.set(sourceCode, record); + } +} + +export async function isSourceCached(sourceCode: string): Promise { + const database = await getDb(); + if (database) { + const record = await database.get(STORE_NAME, sourceCode); + return record !== undefined; + } + return memoryStore.has(sourceCode); +} + +export async function getCachedSources(): Promise { + const database = await getDb(); + if (database) { + const all: CachedSourceRecord[] = await database.getAll(STORE_NAME); + return all.map((r) => ({ + sourceCode: r.sourceCode, + displayName: r.displayName, + creatureCount: r.creatureCount, + cachedAt: r.cachedAt, + })); + } + return [...memoryStore.values()].map((r) => ({ + sourceCode: r.sourceCode, + displayName: r.displayName, + creatureCount: r.creatureCount, + cachedAt: r.cachedAt, + })); +} + +export async function clearSource(sourceCode: string): Promise { + const database = await getDb(); + if (database) { + await database.delete(STORE_NAME, sourceCode); + } else { + memoryStore.delete(sourceCode); + } +} + +export async function clearAll(): Promise { + const database = await getDb(); + if (database) { + await database.clear(STORE_NAME); + } else { + memoryStore.clear(); + } +} + +export async function loadAllCachedCreatures(): Promise< + Map +> { + const map = new Map(); + const database = await getDb(); + + let records: CachedSourceRecord[]; + if (database) { + records = await database.getAll(STORE_NAME); + } else { + records = [...memoryStore.values()]; + } + + for (const record of records) { + for (const creature of record.creatures) { + map.set(creature.id, creature); + } + } + return map; +} diff --git a/apps/web/src/adapters/bestiary-index-adapter.ts b/apps/web/src/adapters/bestiary-index-adapter.ts new file mode 100644 index 0000000..277b81c --- /dev/null +++ b/apps/web/src/adapters/bestiary-index-adapter.ts @@ -0,0 +1,56 @@ +import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain"; + +import rawIndex from "../../../../data/bestiary/index.json"; + +interface CompactCreature { + readonly n: string; + readonly s: string; + readonly ac: number; + readonly hp: number; + readonly dx: number; + readonly cr: string; + readonly ip: number; + readonly sz: string; + readonly tp: string; +} + +interface CompactIndex { + readonly sources: Record; + readonly creatures: readonly CompactCreature[]; +} + +function mapCreature(c: CompactCreature): BestiaryIndexEntry { + return { + name: c.n, + source: c.s, + ac: c.ac, + hp: c.hp, + dex: c.dx, + cr: c.cr, + initiativeProficiency: c.ip, + size: c.sz, + type: c.tp, + }; +} + +let cachedIndex: BestiaryIndex | undefined; + +export function loadBestiaryIndex(): BestiaryIndex { + if (cachedIndex) return cachedIndex; + + const compact = rawIndex as unknown as CompactIndex; + cachedIndex = { + sources: compact.sources, + creatures: compact.creatures.map(mapCreature), + }; + return cachedIndex; +} + +export function getDefaultFetchUrl(sourceCode: string): string { + return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-${sourceCode.toLowerCase()}.json`; +} + +export function getSourceDisplayName(sourceCode: string): string { + const index = loadBestiaryIndex(); + return index.sources[sourceCode] ?? sourceCode; +} diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index c1c6bad..2658f39 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -1,18 +1,15 @@ -import type { Creature } from "@initiative/domain"; import { Search } from "lucide-react"; import { type FormEvent, useState } from "react"; +import type { SearchResult } from "../hooks/use-bestiary.js"; import { BestiarySearch } from "./bestiary-search.js"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; interface ActionBarProps { onAddCombatant: (name: string) => void; - onAddFromBestiary: (creature: Creature) => void; - bestiarySearch: (query: string) => Creature[]; + onAddFromBestiary: (result: SearchResult) => void; + bestiarySearch: (query: string) => SearchResult[]; bestiaryLoaded: boolean; - suggestions: Creature[]; - onSearchChange: (query: string) => void; - onShowStatBlock?: (creature: Creature) => void; } export function ActionBar({ @@ -20,12 +17,10 @@ export function ActionBar({ onAddFromBestiary, bestiarySearch, bestiaryLoaded, - suggestions, - onSearchChange, - onShowStatBlock, }: ActionBarProps) { const [nameInput, setNameInput] = useState(""); const [searchOpen, setSearchOpen] = useState(false); + const [suggestions, setSuggestions] = useState([]); const [suggestionIndex, setSuggestionIndex] = useState(-1); const handleAdd = (e: FormEvent) => { @@ -33,28 +28,30 @@ export function ActionBar({ if (nameInput.trim() === "") return; onAddCombatant(nameInput); setNameInput(""); - onSearchChange(""); + setSuggestions([]); }; const handleNameChange = (value: string) => { setNameInput(value); setSuggestionIndex(-1); - onSearchChange(value); + if (value.length >= 2) { + setSuggestions(bestiarySearch(value)); + } else { + setSuggestions([]); + } }; - const handleSelectCreature = (creature: Creature) => { - onAddFromBestiary(creature); + const handleSelectCreature = (result: SearchResult) => { + onAddFromBestiary(result); setSearchOpen(false); setNameInput(""); - onSearchChange(""); - onShowStatBlock?.(creature); + setSuggestions([]); }; - const handleSelectSuggestion = (creature: Creature) => { - onAddFromBestiary(creature); + const handleSelectSuggestion = (result: SearchResult) => { + onAddFromBestiary(result); setNameInput(""); - onSearchChange(""); - onShowStatBlock?.(creature); + setSuggestions([]); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -71,7 +68,7 @@ export function ActionBar({ handleSelectSuggestion(suggestions[suggestionIndex]); } else if (e.key === "Escape") { setSuggestionIndex(-1); - onSearchChange(""); + setSuggestions([]); } }; @@ -100,8 +97,8 @@ export function ActionBar({ {suggestions.length > 0 && (
    - {suggestions.map((creature, i) => ( -
  • + {suggestions.map((result, i) => ( +
  • diff --git a/apps/web/src/components/bestiary-search.tsx b/apps/web/src/components/bestiary-search.tsx index 9ceb438..a0ecb42 100644 --- a/apps/web/src/components/bestiary-search.tsx +++ b/apps/web/src/components/bestiary-search.tsx @@ -1,4 +1,3 @@ -import type { Creature } from "@initiative/domain"; import { Search, X } from "lucide-react"; import { type KeyboardEvent, @@ -7,12 +6,13 @@ import { useRef, useState, } from "react"; +import type { SearchResult } from "../hooks/use-bestiary.js"; import { Input } from "./ui/input.js"; interface BestiarySearchProps { - onSelectCreature: (creature: Creature) => void; + onSelectCreature: (result: SearchResult) => void; onClose: () => void; - searchFn: (query: string) => Creature[]; + searchFn: (query: string) => SearchResult[]; } export function BestiarySearch({ @@ -101,8 +101,8 @@ export function BestiarySearch({
) : (
    - {results.map((creature, i) => ( -
  • + {results.map((result, i) => ( +
  • diff --git a/apps/web/src/components/source-fetch-prompt.tsx b/apps/web/src/components/source-fetch-prompt.tsx new file mode 100644 index 0000000..cbe9f76 --- /dev/null +++ b/apps/web/src/components/source-fetch-prompt.tsx @@ -0,0 +1,131 @@ +import { Download, Loader2, Upload } from "lucide-react"; +import { useRef, useState } from "react"; +import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js"; +import { Button } from "./ui/button.js"; +import { Input } from "./ui/input.js"; + +interface SourceFetchPromptProps { + sourceCode: string; + sourceDisplayName: string; + fetchAndCacheSource: (sourceCode: string, url: string) => Promise; + onSourceLoaded: () => void; + onUploadSource: (sourceCode: string, jsonData: unknown) => Promise; +} + +export function SourceFetchPrompt({ + sourceCode, + sourceDisplayName, + fetchAndCacheSource, + onSourceLoaded, + onUploadSource, +}: SourceFetchPromptProps) { + const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode)); + const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle"); + const [error, setError] = useState(""); + const fileInputRef = useRef(null); + + const handleFetch = async () => { + setStatus("fetching"); + setError(""); + try { + await fetchAndCacheSource(sourceCode, url); + onSourceLoaded(); + } catch (e) { + setStatus("error"); + setError(e instanceof Error ? e.message : "Failed to fetch source data"); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setStatus("fetching"); + setError(""); + + try { + const text = await file.text(); + const json = JSON.parse(text); + await onUploadSource(sourceCode, json); + onSourceLoaded(); + } catch (err) { + setStatus("error"); + setError( + err instanceof Error ? err.message : "Failed to process uploaded file", + ); + } + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
    +
    +

    + Load {sourceDisplayName} +

    +

    + Stat block data for this source needs to be loaded. Enter a URL or + upload a JSON file. +

    +
    + +
    + + setUrl(e.target.value)} + disabled={status === "fetching"} + className="text-xs" + /> +
    + +
    + + + or + + + +
    + + {status === "error" && ( +
    + {error} +
    + )} +
    + ); +} diff --git a/apps/web/src/components/source-manager.tsx b/apps/web/src/components/source-manager.tsx new file mode 100644 index 0000000..0e3f1f1 --- /dev/null +++ b/apps/web/src/components/source-manager.tsx @@ -0,0 +1,81 @@ +import { Database, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import type { CachedSourceInfo } from "../adapters/bestiary-cache.js"; +import * as bestiaryCache from "../adapters/bestiary-cache.js"; +import { Button } from "./ui/button.js"; + +interface SourceManagerProps { + onCacheCleared: () => void; +} + +export function SourceManager({ onCacheCleared }: SourceManagerProps) { + const [sources, setSources] = useState([]); + + const loadSources = useCallback(async () => { + const cached = await bestiaryCache.getCachedSources(); + setSources(cached); + }, []); + + useEffect(() => { + loadSources(); + }, [loadSources]); + + const handleClearSource = async (sourceCode: string) => { + await bestiaryCache.clearSource(sourceCode); + await loadSources(); + onCacheCleared(); + }; + + const handleClearAll = async () => { + await bestiaryCache.clearAll(); + await loadSources(); + onCacheCleared(); + }; + + if (sources.length === 0) { + return ( +
    + +

    No cached sources

    +
    + ); + } + + return ( +
    +
    + + Cached Sources + + +
    +
      + {sources.map((source) => ( +
    • +
      + + {source.displayName} + + + {source.creatureCount} creatures + +
      + +
    • + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/stat-block-panel.tsx b/apps/web/src/components/stat-block-panel.tsx index 049db4d..7f0ab99 100644 --- a/apps/web/src/components/stat-block-panel.tsx +++ b/apps/web/src/components/stat-block-panel.tsx @@ -1,17 +1,43 @@ -import type { Creature } from "@initiative/domain"; +import type { Creature, CreatureId } from "@initiative/domain"; import { X } from "lucide-react"; import { useEffect, useState } from "react"; +import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js"; +import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { StatBlock } from "./stat-block.js"; interface StatBlockPanelProps { + creatureId: CreatureId | null; creature: Creature | null; + isSourceCached: (sourceCode: string) => Promise; + fetchAndCacheSource: (sourceCode: string, url: string) => Promise; + uploadAndCacheSource: ( + sourceCode: string, + jsonData: unknown, + ) => Promise; + refreshCache: () => Promise; onClose: () => void; } -export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) { +function extractSourceCode(cId: CreatureId): string { + const colonIndex = cId.indexOf(":"); + if (colonIndex === -1) return ""; + return cId.slice(0, colonIndex).toUpperCase(); +} + +export function StatBlockPanel({ + creatureId, + creature, + isSourceCached, + fetchAndCacheSource, + uploadAndCacheSource, + refreshCache, + onClose, +}: StatBlockPanelProps) { const [isDesktop, setIsDesktop] = useState( () => window.matchMedia("(min-width: 1024px)").matches, ); + const [needsFetch, setNeedsFetch] = useState(false); + const [checkingCache, setCheckingCache] = useState(false); useEffect(() => { const mq = window.matchMedia("(min-width: 1024px)"); @@ -20,7 +46,66 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) { return () => mq.removeEventListener("change", handler); }, []); - if (!creature) return null; + // When creatureId changes, check if we need to show the fetch prompt + useEffect(() => { + if (!creatureId || creature) { + setNeedsFetch(false); + return; + } + + const sourceCode = extractSourceCode(creatureId); + if (!sourceCode) { + setNeedsFetch(false); + return; + } + + setCheckingCache(true); + isSourceCached(sourceCode).then((cached) => { + // If source is cached but creature not found, it's an edge case + // If source is not cached, show fetch prompt + setNeedsFetch(!cached); + setCheckingCache(false); + }); + }, [creatureId, creature, isSourceCached]); + + if (!creatureId) return null; + + const sourceCode = extractSourceCode(creatureId); + + const handleSourceLoaded = async () => { + await refreshCache(); + setNeedsFetch(false); + }; + + const renderContent = () => { + if (checkingCache) { + return ( +
    Loading...
    + ); + } + + if (creature) { + return ; + } + + if (needsFetch && sourceCode) { + return ( + + ); + } + + return ( +
    + No stat block available +
    + ); + }; if (isDesktop) { return ( @@ -37,9 +122,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) { -
    - -
    +
    {renderContent()}
    ); } @@ -69,7 +152,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
    - + {renderContent()}
    diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index 0db430f..e83f2c8 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -1,5 +1,5 @@ import type { Encounter } from "@initiative/domain"; -import { StepBack, StepForward, Trash2 } from "lucide-react"; +import { Settings, StepBack, StepForward, Trash2 } from "lucide-react"; import { D20Icon } from "./d20-icon"; import { Button } from "./ui/button"; @@ -9,6 +9,7 @@ interface TurnNavigationProps { onRetreatTurn: () => void; onClearEncounter: () => void; onRollAllInitiative: () => void; + onOpenSourceManager: () => void; } export function TurnNavigation({ @@ -17,6 +18,7 @@ export function TurnNavigation({ onRetreatTurn, onClearEncounter, onRollAllInitiative, + onOpenSourceManager, }: TurnNavigationProps) { const hasCombatants = encounter.combatants.length > 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; @@ -62,6 +64,16 @@ export function TurnNavigation({ > +