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,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<SearchResult[]>([]);
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 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((creature, i) => (
<li key={creature.id}>
{suggestions.map((result, i) => (
<li key={`${result.source}:${result.name}`}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
@@ -109,12 +106,12 @@ export function ActionBar({
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-hover-neutral-bg"
}`}
onClick={() => handleSelectSuggestion(creature)}
onClick={() => handleSelectSuggestion(result)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{creature.name}</span>
<span>{result.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
{result.sourceDisplayName}
</span>
</button>
</li>