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:
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
@@ -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<void>;
|
||||
onSourceLoaded: () => void;
|
||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
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<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Load {sourceDisplayName}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Stat block data for this source needs to be loaded. Enter a URL or
|
||||
upload a JSON file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="source-url" className="text-xs text-muted-foreground">
|
||||
Source URL
|
||||
</label>
|
||||
<Input
|
||||
id="source-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={status === "fetching"}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleFetch}
|
||||
disabled={status === "fetching" || !url}
|
||||
>
|
||||
{status === "fetching" ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{status === "fetching" ? "Loading..." : "Load"}
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground">or</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={status === "fetching"}
|
||||
>
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
Upload file
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "error" && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user