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

@@ -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<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
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 (
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
);
}
if (creature) {
return <StatBlock creature={creature} />;
}
if (needsFetch && sourceCode) {
return (
<SourceFetchPrompt
sourceCode={sourceCode}
sourceDisplayName={getSourceDisplayName(sourceCode)}
fetchAndCacheSource={fetchAndCacheSource}
onSourceLoaded={handleSourceLoaded}
onUploadSource={uploadAndCacheSource}
/>
);
}
return (
<div className="p-4 text-sm text-muted-foreground">
No stat block available
</div>
);
};
if (isDesktop) {
return (
@@ -37,9 +122,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
<div className="flex-1 overflow-y-auto p-4">{renderContent()}</div>
</div>
);
}
@@ -69,7 +152,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
<StatBlock creature={creature} />
{renderContent()}
</div>
</div>
</div>