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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user