Files
initiative/apps/web/src/hooks/use-bestiary.ts

127 lines
3.5 KiB
TypeScript

import type {
BestiaryIndexEntry,
Creature,
CreatureId,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js";
import * as bestiaryCache from "../adapters/bestiary-cache.js";
import {
getSourceDisplayName,
loadBestiaryIndex,
} from "../adapters/bestiary-index-adapter.js";
export interface SearchResult extends BestiaryIndexEntry {
readonly sourceDisplayName: string;
}
interface BestiaryHook {
search: (query: string) => SearchResult[];
getCreature: (id: CreatureId) => Creature | undefined;
isLoaded: boolean;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
refreshCache: () => Promise<void>;
}
export function useBestiary(): BestiaryHook {
const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
const [, setTick] = useState(0);
useEffect(() => {
const index = loadBestiaryIndex();
setSourceDisplayNames(index.sources as Record<string, string>);
if (index.creatures.length > 0) {
setIsLoaded(true);
}
bestiaryCache.loadAllCachedCreatures().then((map) => {
creatureMapRef.current = map;
setTick((t) => t + 1);
});
}, []);
const search = useCallback((query: string): SearchResult[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
const index = loadBestiaryIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10)
.map((c) => ({
...c,
sourceDisplayName: getSourceDisplayName(c.source),
}));
}, []);
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
}, []);
const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => {
return bestiaryCache.isSourceCached(sourceCode);
},
[],
);
const fetchAndCacheSource = useCallback(
async (sourceCode: string, url: string): Promise<void> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
const json = await response.json();
const creatures = normalizeBestiary(json);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
},
[],
);
const uploadAndCacheSource = useCallback(
async (sourceCode: string, jsonData: unknown): Promise<void> => {
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
const creatures = normalizeBestiary(jsonData as any);
const displayName = getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
for (const c of creatures) {
creatureMapRef.current.set(c.id, c);
}
setTick((t) => t + 1);
},
[],
);
const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures();
creatureMapRef.current = map;
setTick((t) => t + 1);
}, []);
return {
search,
getCreature,
isLoaded,
isSourceCached: isSourceCachedFn,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
};
}