Show inline on-hit effects on attack lines (e.g., "plus Grab"), frequency limits on abilities (e.g., "(1/day)"), and perception details text alongside senses. Strip redundant frequency lines from Foundry descriptions. Also add resilient PF2e source fetching: batched requests with retry, graceful handling of ad-blocker-blocked creature files (partial success with toast warning and re-fetch prompt for missing creatures). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
132 lines
3.7 KiB
TypeScript
132 lines
3.7 KiB
TypeScript
import { Download, Loader2, Upload } from "lucide-react";
|
|
import { useId, useRef, useState } from "react";
|
|
import { useAdapters } from "../contexts/adapter-context.js";
|
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
|
import { Button } from "./ui/button.js";
|
|
import { Input } from "./ui/input.js";
|
|
|
|
interface SourceFetchPromptProps {
|
|
sourceCode: string;
|
|
onSourceLoaded: (skippedNames: string[]) => void;
|
|
}
|
|
|
|
export function SourceFetchPrompt({
|
|
sourceCode,
|
|
onSourceLoaded,
|
|
}: Readonly<SourceFetchPromptProps>) {
|
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
|
const { edition } = useRulesEditionContext();
|
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
|
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
|
|
const [url, setUrl] = useState(() =>
|
|
indexPort.getDefaultFetchUrl(sourceCode),
|
|
);
|
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
|
const [error, setError] = useState<string>("");
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const sourceUrlId = useId();
|
|
|
|
const handleFetch = async () => {
|
|
setStatus("fetching");
|
|
setError("");
|
|
try {
|
|
const { skippedNames } = await fetchAndCacheSource(sourceCode, url);
|
|
setStatus("idle");
|
|
onSourceLoaded(skippedNames);
|
|
} 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 uploadAndCacheSource(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="font-semibold text-foreground text-sm">
|
|
Load {sourceDisplayName}
|
|
</h3>
|
|
<p className="mt-1 text-muted-foreground text-xs">
|
|
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={sourceUrlId} className="text-muted-foreground text-xs">
|
|
Source URL
|
|
</label>
|
|
<Input
|
|
id={sourceUrlId}
|
|
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 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-muted-foreground text-xs">or</span>
|
|
|
|
<Button
|
|
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-destructive text-xs">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|