Replace prop drilling with React context providers. App.tsx shrinks from 427 lines to ~80 lines of pure layout. Components consume shared state directly via 7 context providers instead of threading 50+ props. Key changes: - 7 context providers wrapping existing hooks (encounter, bestiary, player characters, side panel, theme, bulk import, initiative rolls) - 2 coordinating hooks extracted from App.tsx (useInitiativeRolls, useAutoStatBlock) - All 9 affected components refactored from prop-based to context-based - 6 test files updated to use providers or context mocks - Prop count enforcement script (max 8 per component interface) - Constitution principle II-A added (context-based state flow) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
import { Database, Search, Trash2 } from "lucide-react";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useOptimistic,
|
|
useState,
|
|
} from "react";
|
|
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
|
import { Button } from "./ui/button.js";
|
|
import { Input } from "./ui/input.js";
|
|
|
|
export function SourceManager() {
|
|
const { refreshCache } = useBestiaryContext();
|
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
|
const [filter, setFilter] = useState("");
|
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
|
sources,
|
|
(
|
|
state,
|
|
action: { type: "remove"; sourceCode: string } | { type: "clear" },
|
|
) =>
|
|
action.type === "clear"
|
|
? []
|
|
: state.filter((s) => s.sourceCode !== action.sourceCode),
|
|
);
|
|
|
|
const loadSources = useCallback(async () => {
|
|
const cached = await bestiaryCache.getCachedSources();
|
|
setSources(cached);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadSources();
|
|
}, [loadSources]);
|
|
|
|
const handleClearSource = async (sourceCode: string) => {
|
|
applyOptimistic({ type: "remove", sourceCode });
|
|
await bestiaryCache.clearSource(sourceCode);
|
|
await loadSources();
|
|
void refreshCache();
|
|
};
|
|
|
|
const handleClearAll = async () => {
|
|
applyOptimistic({ type: "clear" });
|
|
await bestiaryCache.clearAll();
|
|
await loadSources();
|
|
void refreshCache();
|
|
};
|
|
|
|
const filteredSources = useMemo(() => {
|
|
const term = filter.toLowerCase();
|
|
return term
|
|
? optimisticSources.filter((s) =>
|
|
s.displayName.toLowerCase().includes(term),
|
|
)
|
|
: optimisticSources;
|
|
}, [optimisticSources, filter]);
|
|
|
|
if (optimisticSources.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
|
<Database className="h-8 w-8 text-muted-foreground" />
|
|
<p className="text-muted-foreground text-sm">No cached sources</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-semibold text-foreground text-sm">
|
|
Cached Sources
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
className="hover:border-hover-destructive hover:text-hover-destructive"
|
|
onClick={handleClearAll}
|
|
>
|
|
<Trash2 className="mr-1 h-3 w-3" />
|
|
Clear All
|
|
</Button>
|
|
</div>
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Filter sources…"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<ul className="flex flex-col gap-1">
|
|
{filteredSources.map((source) => (
|
|
<li
|
|
key={source.sourceCode}
|
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
|
>
|
|
<div>
|
|
<span className="text-foreground text-sm">
|
|
{source.displayName}
|
|
</span>
|
|
<span className="ml-2 text-muted-foreground text-xs">
|
|
{source.creatureCount} creatures
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleClearSource(source.sourceCode)}
|
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
|
aria-label={`Remove ${source.displayName}`}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|