362 lines
8.4 KiB
TypeScript
362 lines
8.4 KiB
TypeScript
import type { Creature, CreatureId } from "@initiative/domain";
|
|
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { useEffect, useState } from "react";
|
|
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
|
import { SourceManager } from "./source-manager.js";
|
|
import { StatBlock } from "./stat-block.js";
|
|
import { Button } from "./ui/button.js";
|
|
|
|
interface StatBlockPanelProps {
|
|
creatureId: CreatureId | null;
|
|
creature: Creature | null;
|
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
|
uploadAndCacheSource: (
|
|
sourceCode: string,
|
|
jsonData: unknown,
|
|
) => Promise<void>;
|
|
refreshCache: () => Promise<void>;
|
|
panelRole: "browse" | "pinned";
|
|
isFolded: boolean;
|
|
onToggleFold: () => void;
|
|
onPin: () => void;
|
|
onUnpin: () => void;
|
|
showPinButton: boolean;
|
|
side: "left" | "right";
|
|
onDismiss: () => void;
|
|
bulkImportMode?: boolean;
|
|
bulkImportState?: BulkImportState;
|
|
onStartBulkImport?: (baseUrl: string) => void;
|
|
onBulkImportDone?: () => void;
|
|
sourceManagerMode?: boolean;
|
|
}
|
|
|
|
function extractSourceCode(cId: CreatureId): string {
|
|
const colonIndex = cId.indexOf(":");
|
|
if (colonIndex === -1) return "";
|
|
return cId.slice(0, colonIndex).toUpperCase();
|
|
}
|
|
|
|
function FoldedTab({
|
|
creatureName,
|
|
side,
|
|
onToggleFold,
|
|
}: {
|
|
creatureName: string;
|
|
side: "left" | "right";
|
|
onToggleFold: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleFold}
|
|
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
|
side === "right" ? "self-start" : "self-end"
|
|
}`}
|
|
aria-label="Unfold stat block panel"
|
|
>
|
|
<span className="writing-vertical-rl text-sm font-medium">
|
|
{creatureName}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PanelHeader({
|
|
panelRole,
|
|
showPinButton,
|
|
onToggleFold,
|
|
onPin,
|
|
onUnpin,
|
|
}: {
|
|
panelRole: "browse" | "pinned";
|
|
showPinButton: boolean;
|
|
onToggleFold: () => void;
|
|
onPin: () => void;
|
|
onUnpin: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
|
<div className="flex items-center gap-1">
|
|
{panelRole === "browse" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onToggleFold}
|
|
className="text-muted-foreground"
|
|
aria-label="Fold stat block panel"
|
|
>
|
|
<PanelRightClose className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{panelRole === "browse" && showPinButton && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onPin}
|
|
className="text-muted-foreground"
|
|
aria-label="Pin creature"
|
|
>
|
|
<Pin className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{panelRole === "pinned" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onUnpin}
|
|
className="text-muted-foreground"
|
|
aria-label="Unpin creature"
|
|
>
|
|
<PinOff className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DesktopPanel({
|
|
isFolded,
|
|
side,
|
|
creatureName,
|
|
panelRole,
|
|
showPinButton,
|
|
onToggleFold,
|
|
onPin,
|
|
onUnpin,
|
|
children,
|
|
}: {
|
|
isFolded: boolean;
|
|
side: "left" | "right";
|
|
creatureName: string;
|
|
panelRole: "browse" | "pinned";
|
|
showPinButton: boolean;
|
|
onToggleFold: () => void;
|
|
onPin: () => void;
|
|
onUnpin: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
|
const foldedTranslate =
|
|
side === "right"
|
|
? "translate-x-[calc(100%-40px)]"
|
|
: "translate-x-[calc(-100%+40px)]";
|
|
|
|
return (
|
|
<div
|
|
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
|
|
>
|
|
{isFolded ? (
|
|
<FoldedTab
|
|
creatureName={creatureName}
|
|
side={side}
|
|
onToggleFold={onToggleFold}
|
|
/>
|
|
) : (
|
|
<>
|
|
<PanelHeader
|
|
panelRole={panelRole}
|
|
showPinButton={showPinButton}
|
|
onToggleFold={onToggleFold}
|
|
onPin={onPin}
|
|
onUnpin={onUnpin}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto p-4">{children}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileDrawer({
|
|
onDismiss,
|
|
children,
|
|
}: {
|
|
onDismiss: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50">
|
|
<button
|
|
type="button"
|
|
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
|
onClick={onDismiss}
|
|
aria-label="Close stat block"
|
|
/>
|
|
<div
|
|
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
|
style={
|
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
|
}
|
|
{...handlers}
|
|
>
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onDismiss}
|
|
className="text-muted-foreground"
|
|
aria-label="Fold stat block panel"
|
|
>
|
|
<PanelRightClose className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StatBlockPanel({
|
|
creatureId,
|
|
creature,
|
|
isSourceCached,
|
|
fetchAndCacheSource,
|
|
uploadAndCacheSource,
|
|
refreshCache,
|
|
panelRole,
|
|
isFolded,
|
|
onToggleFold,
|
|
onPin,
|
|
onUnpin,
|
|
showPinButton,
|
|
side,
|
|
onDismiss,
|
|
bulkImportMode,
|
|
bulkImportState,
|
|
onStartBulkImport,
|
|
onBulkImportDone,
|
|
sourceManagerMode,
|
|
}: StatBlockPanelProps) {
|
|
const [isDesktop, setIsDesktop] = useState(
|
|
() => window.matchMedia("(min-width: 1024px)").matches,
|
|
);
|
|
const [needsFetch, setNeedsFetch] = useState(false);
|
|
const [checkingCache, setCheckingCache] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const mq = window.matchMedia("(min-width: 1024px)");
|
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
|
mq.addEventListener("change", handler);
|
|
return () => mq.removeEventListener("change", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!creatureId || creature) {
|
|
setNeedsFetch(false);
|
|
return;
|
|
}
|
|
|
|
const sourceCode = extractSourceCode(creatureId);
|
|
if (!sourceCode) {
|
|
setNeedsFetch(false);
|
|
return;
|
|
}
|
|
|
|
setCheckingCache(true);
|
|
isSourceCached(sourceCode).then((cached) => {
|
|
setNeedsFetch(!cached);
|
|
setCheckingCache(false);
|
|
});
|
|
}, [creatureId, creature, isSourceCached]);
|
|
|
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
|
|
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
|
|
|
const handleSourceLoaded = async () => {
|
|
await refreshCache();
|
|
setNeedsFetch(false);
|
|
};
|
|
|
|
const renderContent = () => {
|
|
if (sourceManagerMode) {
|
|
return <SourceManager onCacheCleared={refreshCache} />;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (creature) {
|
|
return <StatBlock creature={creature} />;
|
|
}
|
|
|
|
if (needsFetch && sourceCode) {
|
|
return (
|
|
<SourceFetchPrompt
|
|
sourceCode={sourceCode}
|
|
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
|
fetchAndCacheSource={fetchAndCacheSource}
|
|
onSourceLoaded={handleSourceLoaded}
|
|
onUploadSource={uploadAndCacheSource}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 text-sm text-muted-foreground">
|
|
No stat block available
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const creatureName =
|
|
creature?.name ??
|
|
(sourceManagerMode
|
|
? "Sources"
|
|
: bulkImportMode
|
|
? "Import All Sources"
|
|
: "Creature");
|
|
|
|
if (isDesktop) {
|
|
return (
|
|
<DesktopPanel
|
|
isFolded={isFolded}
|
|
side={side}
|
|
creatureName={creatureName}
|
|
panelRole={panelRole}
|
|
showPinButton={showPinButton}
|
|
onToggleFold={onToggleFold}
|
|
onPin={onPin}
|
|
onUnpin={onUnpin}
|
|
>
|
|
{renderContent()}
|
|
</DesktopPanel>
|
|
);
|
|
}
|
|
|
|
if (panelRole === "pinned") return null;
|
|
|
|
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
|
}
|