Implement the 030-bulk-import-sources feature that adds a one-click bulk import button to load all bestiary sources at once, with real-time progress feedback in the side panel and a toast notification when the panel is closed, plus completion/failure reporting with auto-dismiss on success and persistent display on partial failure, while also hardening the bestiary normalizer to handle variable stat blocks (spell summons with special AC/HP/CR) and skip malformed monster entries gracefully
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,10 @@ import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
import { SourceManager } from "./components/source-manager";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||
import { Toast } from "./components/toast";
|
||||
import { TurnNavigation } from "./components/turn-navigation";
|
||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
|
||||
function rollDice(): number {
|
||||
@@ -45,8 +47,11 @@ export function App() {
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
|
||||
const [selectedCreatureId, setSelectedCreatureId] =
|
||||
useState<CreatureId | null>(null);
|
||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
||||
|
||||
const selectedCreature: Creature | null = selectedCreatureId
|
||||
@@ -83,6 +88,28 @@ export function App() {
|
||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setBulkImportMode(true);
|
||||
setSelectedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
bulkImport.startImport(
|
||||
baseUrl,
|
||||
fetchAndCacheSource,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
},
|
||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
setBulkImportMode(false);
|
||||
bulkImport.reset();
|
||||
}, [bulkImport.reset]);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -169,6 +196,8 @@ export function App() {
|
||||
onAddFromBestiary={handleAddFromBestiary}
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,8 +210,42 @@ export function App() {
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
onClose={() => setSelectedCreatureId(null)}
|
||||
onClose={() => {
|
||||
setSelectedCreatureId(null);
|
||||
setBulkImportMode(false);
|
||||
}}
|
||||
bulkImportMode={bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
/>
|
||||
|
||||
{/* Toast for bulk import progress when panel is closed */}
|
||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
||||
progress={
|
||||
bulkImport.state.total > 0
|
||||
? (bulkImport.state.completed + bulkImport.state.failed) /
|
||||
bulkImport.state.total
|
||||
: 0
|
||||
}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={bulkImport.reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal file
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAllSourceCodes,
|
||||
getDefaultFetchUrl,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
describe("getAllSourceCodes", () => {
|
||||
it("returns all keys from the index sources object", () => {
|
||||
const codes = getAllSourceCodes();
|
||||
expect(codes.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(codes)).toBe(true);
|
||||
for (const code of codes) {
|
||||
expect(typeof code).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultFetchUrl", () => {
|
||||
it("returns the default URL when no baseUrl is provided", () => {
|
||||
const url = getDefaultFetchUrl("XMM");
|
||||
expect(url).toBe(
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("constructs URL from baseUrl with trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("normalizes baseUrl without trailing slash", () => {
|
||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||
});
|
||||
|
||||
it("lowercases the source code in the filename", () => {
|
||||
const url = getDefaultFetchUrl("MM", "https://example.com/");
|
||||
expect(url).toBe("https://example.com/bestiary-mm.json");
|
||||
});
|
||||
});
|
||||
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal file
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as indexAdapter from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
// We test the bulk import logic by extracting and exercising the async flow.
|
||||
// Since useBulkImport is a thin React wrapper around async logic,
|
||||
// we test the core behavior via a direct simulation.
|
||||
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../adapters/bestiary-index-adapter.js")
|
||||
>("../adapters/bestiary-index-adapter.js");
|
||||
return {
|
||||
...actual,
|
||||
getAllSourceCodes: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes);
|
||||
|
||||
interface BulkImportState {
|
||||
status: "idle" | "loading" | "complete" | "partial-failure";
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Simulate the core bulk import logic extracted from the hook */
|
||||
async function runBulkImport(
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
): Promise<BulkImportState> {
|
||||
const allCodes = indexAdapter.getAllSourceCodes();
|
||||
const total = allCodes.length;
|
||||
|
||||
const cacheChecks = await Promise.all(
|
||||
allCodes.map(async (code) => ({
|
||||
code,
|
||||
cached: await isSourceCached(code),
|
||||
})),
|
||||
);
|
||||
|
||||
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
|
||||
const uncached = cacheChecks.filter((c) => !c.cached);
|
||||
|
||||
if (uncached.length === 0) {
|
||||
return { status: "complete", total, completed: total, failed: 0 };
|
||||
}
|
||||
|
||||
let completed = alreadyCached;
|
||||
let failed = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
uncached.map(async ({ code }) => {
|
||||
const url = indexAdapter.getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
completed++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await refreshCache();
|
||||
|
||||
return {
|
||||
status: failed > 0 ? "partial-failure" : "complete",
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bulk import logic", () => {
|
||||
it("skips already-cached sources and counts them into completed", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]);
|
||||
|
||||
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||
const isSourceCached = vi
|
||||
.fn()
|
||||
.mockImplementation((code: string) =>
|
||||
Promise.resolve(code === "SRC1" || code === "SRC3"),
|
||||
);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(fetchAndCache).toHaveBeenCalledTimes(1);
|
||||
expect(fetchAndCache).toHaveBeenCalledWith(
|
||||
"SRC2",
|
||||
"https://example.com/bestiary-src2.json",
|
||||
);
|
||||
expect(result.completed).toBe(3);
|
||||
expect(result.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("increments completed on successful fetch", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
|
||||
|
||||
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(result.completed).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("increments failed on rejected fetch", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
|
||||
|
||||
const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.completed).toBe(0);
|
||||
expect(result.status).toBe("partial-failure");
|
||||
});
|
||||
|
||||
it("transitions to complete when all succeed", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||
|
||||
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.completed).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
|
||||
it("transitions to partial-failure when any fetch fails", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||
|
||||
const fetchAndCache = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("fail"));
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("partial-failure");
|
||||
expect(result.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("immediately transitions to complete when all sources are cached", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]);
|
||||
|
||||
const fetchAndCache = vi.fn();
|
||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("complete");
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.completed).toBe(3);
|
||||
expect(fetchAndCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls refreshCache exactly once when all settle", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||
|
||||
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(refreshCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call refreshCache when all sources are already cached", async () => {
|
||||
mockGetAllSourceCodes.mockReturnValue(["A"]);
|
||||
|
||||
const fetchAndCache = vi.fn();
|
||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await runBulkImport(
|
||||
"https://example.com/",
|
||||
fetchAndCache,
|
||||
isSourceCached,
|
||||
refreshCache,
|
||||
);
|
||||
|
||||
expect(refreshCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,8 @@ interface RawMonster {
|
||||
size: string[];
|
||||
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
||||
alignment?: string[];
|
||||
ac: (number | { ac: number; from?: string[] })[];
|
||||
hp: { average: number; formula: string };
|
||||
ac: (number | { ac: number; from?: string[] } | { special: string })[];
|
||||
hp: { average?: number; formula?: string; special?: string };
|
||||
speed: Record<
|
||||
string,
|
||||
number | { number: number; condition?: string } | boolean
|
||||
@@ -38,7 +38,7 @@ interface RawMonster {
|
||||
vulnerable?: (string | { special: string })[];
|
||||
conditionImmune?: string[];
|
||||
languages?: string[];
|
||||
cr: string | { cr: string };
|
||||
cr?: string | { cr: string };
|
||||
trait?: RawEntry[];
|
||||
action?: RawEntry[];
|
||||
bonus?: RawEntry[];
|
||||
@@ -140,7 +140,12 @@ function formatType(
|
||||
|
||||
let result = baseType;
|
||||
if (type.tags && type.tags.length > 0) {
|
||||
result += ` (${type.tags.map(capitalize).join(", ")})`;
|
||||
const tagStrs = type.tags
|
||||
.filter((t): t is string => typeof t === "string")
|
||||
.map(capitalize);
|
||||
if (tagStrs.length > 0) {
|
||||
result += ` (${tagStrs.join(", ")})`;
|
||||
}
|
||||
}
|
||||
if (type.swarmSize) {
|
||||
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
||||
@@ -161,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
|
||||
if (typeof first === "number") {
|
||||
return { value: first };
|
||||
}
|
||||
if ("special" in first) {
|
||||
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||
const match = first.special.match(/^(\d+)/);
|
||||
return {
|
||||
value: match ? Number(match[1]) : 0,
|
||||
source: first.special,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: first.ac,
|
||||
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
||||
@@ -339,7 +352,8 @@ function normalizeLegendary(
|
||||
};
|
||||
}
|
||||
|
||||
function extractCr(cr: string | { cr: string }): string {
|
||||
function extractCr(cr: string | { cr: string } | undefined): string {
|
||||
if (cr === undefined) return "—";
|
||||
return typeof cr === "string" ? cr : cr.cr;
|
||||
}
|
||||
|
||||
@@ -355,60 +369,81 @@ function makeCreatureId(source: string, name: string): CreatureId {
|
||||
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||
*/
|
||||
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||
// Filter out _copy entries — these reference another source's monster
|
||||
// and lack their own stats (ac, hp, cr, etc.)
|
||||
const monsters = raw.monster.filter(
|
||||
// Filter out _copy entries (reference another source's monster) and
|
||||
// monsters missing required fields (ac, hp, size, type)
|
||||
const monsters = raw.monster.filter((m) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
||||
(m) => !(m as any)._copy,
|
||||
);
|
||||
return monsters.map((m) => {
|
||||
const crStr = extractCr(m.cr);
|
||||
const ac = extractAc(m.ac);
|
||||
|
||||
return {
|
||||
id: makeCreatureId(m.source, m.name),
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
|
||||
size: formatSize(m.size),
|
||||
type: formatType(m.type),
|
||||
alignment: formatAlignment(m.alignment),
|
||||
ac: ac.value,
|
||||
acSource: ac.source,
|
||||
hp: { average: m.hp.average, formula: m.hp.formula },
|
||||
speed: formatSpeed(m.speed),
|
||||
abilities: {
|
||||
str: m.str,
|
||||
dex: m.dex,
|
||||
con: m.con,
|
||||
int: m.int,
|
||||
wis: m.wis,
|
||||
cha: m.cha,
|
||||
},
|
||||
cr: crStr,
|
||||
initiativeProficiency: m.initiative?.proficiency ?? 0,
|
||||
proficiencyBonus: proficiencyBonus(crStr),
|
||||
passive: m.passive,
|
||||
savingThrows: formatSaves(m.save),
|
||||
skills: formatSkills(m.skill),
|
||||
resist: formatDamageList(m.resist),
|
||||
immune: formatDamageList(m.immune),
|
||||
vulnerable: formatDamageList(m.vulnerable),
|
||||
conditionImmune: formatConditionImmunities(m.conditionImmune),
|
||||
senses:
|
||||
m.senses && m.senses.length > 0
|
||||
? m.senses.map((s) => stripTags(s)).join(", ")
|
||||
: undefined,
|
||||
languages:
|
||||
m.languages && m.languages.length > 0
|
||||
? m.languages.join(", ")
|
||||
: undefined,
|
||||
traits: normalizeTraits(m.trait),
|
||||
actions: normalizeTraits(m.action),
|
||||
bonusActions: normalizeTraits(m.bonus),
|
||||
reactions: normalizeTraits(m.reaction),
|
||||
legendaryActions: normalizeLegendary(m.legendary, m),
|
||||
spellcasting: normalizeSpellcasting(m.spellcasting),
|
||||
};
|
||||
if ((m as any)._copy) return false;
|
||||
return (
|
||||
Array.isArray(m.ac) &&
|
||||
m.ac.length > 0 &&
|
||||
m.hp !== undefined &&
|
||||
Array.isArray(m.size) &&
|
||||
m.size.length > 0 &&
|
||||
m.type !== undefined
|
||||
);
|
||||
});
|
||||
const creatures: Creature[] = [];
|
||||
for (const m of monsters) {
|
||||
try {
|
||||
creatures.push(normalizeMonster(m));
|
||||
} catch {
|
||||
// Skip monsters with unexpected data shapes
|
||||
}
|
||||
}
|
||||
return creatures;
|
||||
}
|
||||
|
||||
function normalizeMonster(m: RawMonster): Creature {
|
||||
const crStr = extractCr(m.cr);
|
||||
const ac = extractAc(m.ac);
|
||||
|
||||
return {
|
||||
id: makeCreatureId(m.source, m.name),
|
||||
name: m.name,
|
||||
source: m.source,
|
||||
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
|
||||
size: formatSize(m.size),
|
||||
type: formatType(m.type),
|
||||
alignment: formatAlignment(m.alignment),
|
||||
ac: ac.value,
|
||||
acSource: ac.source,
|
||||
hp: {
|
||||
average: m.hp.average ?? 0,
|
||||
formula: m.hp.formula ?? m.hp.special ?? "",
|
||||
},
|
||||
speed: formatSpeed(m.speed),
|
||||
abilities: {
|
||||
str: m.str,
|
||||
dex: m.dex,
|
||||
con: m.con,
|
||||
int: m.int,
|
||||
wis: m.wis,
|
||||
cha: m.cha,
|
||||
},
|
||||
cr: crStr,
|
||||
initiativeProficiency: m.initiative?.proficiency ?? 0,
|
||||
proficiencyBonus: proficiencyBonus(crStr),
|
||||
passive: m.passive,
|
||||
savingThrows: formatSaves(m.save),
|
||||
skills: formatSkills(m.skill),
|
||||
resist: formatDamageList(m.resist),
|
||||
immune: formatDamageList(m.immune),
|
||||
vulnerable: formatDamageList(m.vulnerable),
|
||||
conditionImmune: formatConditionImmunities(m.conditionImmune),
|
||||
senses:
|
||||
m.senses && m.senses.length > 0
|
||||
? m.senses.map((s) => stripTags(s)).join(", ")
|
||||
: undefined,
|
||||
languages:
|
||||
m.languages && m.languages.length > 0
|
||||
? m.languages.join(", ")
|
||||
: undefined,
|
||||
traits: normalizeTraits(m.trait),
|
||||
actions: normalizeTraits(m.action),
|
||||
bonusActions: normalizeTraits(m.bonus),
|
||||
reactions: normalizeTraits(m.reaction),
|
||||
legendaryActions: normalizeLegendary(m.legendary, m),
|
||||
spellcasting: normalizeSpellcasting(m.spellcasting),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,21 +33,61 @@ function mapCreature(c: CompactCreature): BestiaryIndexEntry {
|
||||
};
|
||||
}
|
||||
|
||||
// Source codes whose filename on the remote differs from a simple lowercase.
|
||||
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
|
||||
const FILENAME_OVERRIDES: Record<string, string> = {
|
||||
PSA: "ps-a",
|
||||
PSD: "ps-d",
|
||||
PSI: "ps-i",
|
||||
PSK: "ps-k",
|
||||
PSX: "ps-x",
|
||||
PSZ: "ps-z",
|
||||
};
|
||||
|
||||
// Source codes with no corresponding remote bestiary file.
|
||||
// Excluded from the index entirely so creatures aren't searchable
|
||||
// without a fetchable source.
|
||||
const EXCLUDED_SOURCES = new Set<string>([]);
|
||||
|
||||
let cachedIndex: BestiaryIndex | undefined;
|
||||
|
||||
export function loadBestiaryIndex(): BestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
const sources = Object.fromEntries(
|
||||
Object.entries(compact.sources).filter(
|
||||
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||
),
|
||||
);
|
||||
cachedIndex = {
|
||||
sources: compact.sources,
|
||||
creatures: compact.creatures.map(mapCreature),
|
||||
sources,
|
||||
creatures: compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getDefaultFetchUrl(sourceCode: string): string {
|
||||
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-${sourceCode.toLowerCase()}.json`;
|
||||
export function getAllSourceCodes(): string[] {
|
||||
const index = loadBestiaryIndex();
|
||||
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
||||
}
|
||||
|
||||
function sourceCodeToFilename(sourceCode: string): string {
|
||||
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
|
||||
}
|
||||
|
||||
export function getDefaultFetchUrl(
|
||||
sourceCode: string,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
|
||||
if (baseUrl !== undefined) {
|
||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return `${normalized}${filename}`;
|
||||
}
|
||||
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
|
||||
}
|
||||
|
||||
export function getSourceDisplayName(sourceCode: string): string {
|
||||
|
||||
@@ -20,6 +20,7 @@ const ATKR_MAP: Record<string, string> = {
|
||||
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
||||
*/
|
||||
export function stripTags(text: string): string {
|
||||
if (typeof text !== "string") return String(text);
|
||||
// Process special tags with specific output formats first
|
||||
let result = text;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { 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";
|
||||
@@ -10,6 +10,8 @@ interface ActionBarProps {
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
@@ -17,6 +19,8 @@ export function ActionBar({
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
@@ -124,14 +128,27 @@ export function ActionBar({
|
||||
Add
|
||||
</Button>
|
||||
{bestiaryLoaded && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
{onBulkImport && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onBulkImport}
|
||||
disabled={bulkImportDisabled}
|
||||
>
|
||||
<Import className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
115
apps/web/src/components/bulk-import-prompt.tsx
Normal file
115
apps/web/src/components/bulk-import-prompt.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
const DEFAULT_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
interface BulkImportPromptProps {
|
||||
importState: BulkImportState;
|
||||
onStartImport: (baseUrl: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function BulkImportPrompt({
|
||||
importState,
|
||||
onStartImport,
|
||||
onDone,
|
||||
}: BulkImportPromptProps) {
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
|
||||
if (importState.status === "complete") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
|
||||
All sources loaded
|
||||
</div>
|
||||
<Button size="sm" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (importState.status === "partial-failure") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button size="sm" onClick={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (importState.status === "loading") {
|
||||
const processed = importState.completed + importState.failed;
|
||||
const pct =
|
||||
importState.total > 0
|
||||
? Math.round((processed / importState.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading sources... {processed}/{importState.total}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// idle state
|
||||
const isDisabled = !baseUrl.trim() || importState.status !== "idle";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Bulk Import Sources
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Load stat block data for all {totalSources} sources at once. This will
|
||||
download approximately 12.5 MB of data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="bulk-base-url"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
id="bulk-base-url"
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onStartImport(baseUrl)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Load All
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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 type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
|
||||
@@ -16,6 +18,10 @@ interface StatBlockPanelProps {
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -32,6 +38,10 @@ export function StatBlockPanel({
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
onClose,
|
||||
bulkImportMode,
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
}: StatBlockPanelProps) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||
@@ -68,9 +78,9 @@ export function StatBlockPanel({
|
||||
});
|
||||
}, [creatureId, creature, isSourceCached]);
|
||||
|
||||
if (!creatureId) return null;
|
||||
if (!creatureId && !bulkImportMode) return null;
|
||||
|
||||
const sourceCode = extractSourceCode(creatureId);
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
const handleSourceLoaded = async () => {
|
||||
await refreshCache();
|
||||
@@ -78,6 +88,21 @@ export function StatBlockPanel({
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
onStartBulkImport &&
|
||||
onBulkImportDone
|
||||
) {
|
||||
return (
|
||||
<BulkImportPrompt
|
||||
importState={bulkImportState}
|
||||
onStartImport={onStartBulkImport}
|
||||
onDone={onBulkImportDone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (checkingCache) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||
@@ -107,12 +132,14 @@ export function StatBlockPanel({
|
||||
);
|
||||
};
|
||||
|
||||
const panelTitle = bulkImportMode ? "Bulk Import" : "Stat Block";
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
{panelTitle}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -141,7 +168,7 @@ export function StatBlockPanel({
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
Stat Block
|
||||
{panelTitle}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
47
apps/web/src/components/toast.tsx
Normal file
47
apps/web/src/components/toast.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
progress?: number;
|
||||
onDismiss: () => void;
|
||||
autoDismissMs?: number;
|
||||
}
|
||||
|
||||
export function Toast({
|
||||
message,
|
||||
progress,
|
||||
onDismiss,
|
||||
autoDismissMs,
|
||||
}: ToastProps) {
|
||||
useEffect(() => {
|
||||
if (autoDismissMs === undefined) return;
|
||||
const timer = setTimeout(onDismiss, autoDismissMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoDismissMs, onDismiss]);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<span className="text-sm text-foreground">{message}</span>
|
||||
{progress !== undefined && (
|
||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-hover-neutral"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
120
apps/web/src/hooks/use-bulk-import.ts
Normal file
120
apps/web/src/hooks/use-bulk-import.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
getAllSourceCodes,
|
||||
getDefaultFetchUrl,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
|
||||
const BATCH_SIZE = 6;
|
||||
|
||||
export interface BulkImportState {
|
||||
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
||||
readonly total: number;
|
||||
readonly completed: number;
|
||||
readonly failed: number;
|
||||
}
|
||||
|
||||
const IDLE_STATE: BulkImportState = {
|
||||
status: "idle",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
interface BulkImportHook {
|
||||
state: BulkImportState;
|
||||
startImport: (
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useBulkImport(): BulkImportHook {
|
||||
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
||||
const countersRef = useRef({ completed: 0, failed: 0 });
|
||||
|
||||
const startImport = useCallback(
|
||||
(
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => {
|
||||
const allCodes = getAllSourceCodes();
|
||||
const total = allCodes.length;
|
||||
|
||||
countersRef.current = { completed: 0, failed: 0 };
|
||||
setState({ status: "loading", total, completed: 0, failed: 0 });
|
||||
|
||||
(async () => {
|
||||
const cacheChecks = await Promise.all(
|
||||
allCodes.map(async (code) => ({
|
||||
code,
|
||||
cached: await isSourceCached(code),
|
||||
})),
|
||||
);
|
||||
|
||||
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
|
||||
const uncached = cacheChecks.filter((c) => !c.cached);
|
||||
|
||||
countersRef.current.completed = alreadyCached;
|
||||
|
||||
if (uncached.length === 0) {
|
||||
setState({
|
||||
status: "complete",
|
||||
total,
|
||||
completed: total,
|
||||
failed: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, completed: alreadyCached }));
|
||||
|
||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
||||
await Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
countersRef.current.completed++;
|
||||
} catch (err) {
|
||||
countersRef.current.failed++;
|
||||
console.warn(
|
||||
`[bulk-import] FAILED ${code} (${url}):`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
setState({
|
||||
status: "loading",
|
||||
total,
|
||||
completed: countersRef.current.completed,
|
||||
failed: countersRef.current.failed,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await refreshCache();
|
||||
|
||||
const { completed, failed } = countersRef.current;
|
||||
setState({
|
||||
status: failed > 0 ? "partial-failure" : "complete",
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
});
|
||||
})();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(IDLE_STATE);
|
||||
}, []);
|
||||
|
||||
return { state, startImport, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user