355 lines
8.6 KiB
TypeScript
355 lines
8.6 KiB
TypeScript
import type { CreatureId } from "@initiative/domain";
|
|
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { useEffect, useState } from "react";
|
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
|
import { cn } from "../lib/utils.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 {
|
|
panelRole: "browse" | "pinned";
|
|
side: "left" | "right";
|
|
}
|
|
|
|
function extractSourceCode(cId: CreatureId): string {
|
|
const colonIndex = cId.indexOf(":");
|
|
if (colonIndex === -1) return "";
|
|
return cId.slice(0, colonIndex).toUpperCase();
|
|
}
|
|
|
|
function CollapsedTab({
|
|
creatureName,
|
|
side,
|
|
onToggleCollapse,
|
|
}: Readonly<{
|
|
creatureName: string;
|
|
side: "left" | "right";
|
|
onToggleCollapse: () => void;
|
|
}>) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggleCollapse}
|
|
className={cn(
|
|
"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="Expand stat block panel"
|
|
>
|
|
<span className="writing-vertical-rl font-medium text-sm">
|
|
{creatureName}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PanelHeader({
|
|
panelRole,
|
|
showPinButton,
|
|
onToggleCollapse,
|
|
onPin,
|
|
onUnpin,
|
|
}: Readonly<{
|
|
panelRole: "browse" | "pinned";
|
|
showPinButton: boolean;
|
|
onToggleCollapse: () => void;
|
|
onPin: () => void;
|
|
onUnpin: () => void;
|
|
}>) {
|
|
return (
|
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
|
<div className="flex items-center gap-1">
|
|
{panelRole === "browse" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onToggleCollapse}
|
|
className="text-muted-foreground"
|
|
aria-label="Collapse 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({
|
|
isCollapsed,
|
|
side,
|
|
creatureName,
|
|
panelRole,
|
|
showPinButton,
|
|
onToggleCollapse,
|
|
onPin,
|
|
onUnpin,
|
|
children,
|
|
}: Readonly<{
|
|
isCollapsed: boolean;
|
|
side: "left" | "right";
|
|
creatureName: string;
|
|
panelRole: "browse" | "pinned";
|
|
showPinButton: boolean;
|
|
onToggleCollapse: () => void;
|
|
onPin: () => void;
|
|
onUnpin: () => void;
|
|
children: ReactNode;
|
|
}>) {
|
|
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
|
const collapsedTranslate =
|
|
side === "right"
|
|
? "translate-x-[calc(100%-40px)]"
|
|
: "translate-x-[calc(-100%+40px)]";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
|
|
sideClasses,
|
|
isCollapsed ? collapsedTranslate : "translate-x-0",
|
|
)}
|
|
>
|
|
{isCollapsed ? (
|
|
<CollapsedTab
|
|
creatureName={creatureName}
|
|
side={side}
|
|
onToggleCollapse={onToggleCollapse}
|
|
/>
|
|
) : (
|
|
<>
|
|
<PanelHeader
|
|
panelRole={panelRole}
|
|
showPinButton={showPinButton}
|
|
onToggleCollapse={onToggleCollapse}
|
|
onPin={onPin}
|
|
onUnpin={onUnpin}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto p-4">{children}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileDrawer({
|
|
onDismiss,
|
|
children,
|
|
}: Readonly<{
|
|
onDismiss: () => void;
|
|
children: ReactNode;
|
|
}>) {
|
|
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50">
|
|
<button
|
|
type="button"
|
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
|
onClick={onDismiss}
|
|
aria-label="Close stat block"
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
|
|
!isSwiping && "animate-slide-in-right",
|
|
)}
|
|
style={
|
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
|
}
|
|
{...handlers}
|
|
>
|
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onDismiss}
|
|
className="text-muted-foreground"
|
|
aria-label="Collapse 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>
|
|
);
|
|
}
|
|
|
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
|
const sidePanel = useSidePanelContext();
|
|
const { getCreature } = useBestiaryContext();
|
|
|
|
const creatureId =
|
|
panelRole === "browse"
|
|
? sidePanel.selectedCreatureId
|
|
: sidePanel.pinnedCreatureId;
|
|
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
|
|
|
const isBrowse = panelRole === "browse";
|
|
return {
|
|
creatureId,
|
|
creature,
|
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
|
onPin: isBrowse ? sidePanel.togglePin : () => {},
|
|
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
|
|
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
|
|
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
|
|
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
|
|
};
|
|
}
|
|
|
|
export function StatBlockPanel({
|
|
panelRole,
|
|
side,
|
|
}: Readonly<StatBlockPanelProps>) {
|
|
const { isSourceCached } = useBestiaryContext();
|
|
const {
|
|
creatureId,
|
|
creature,
|
|
isCollapsed,
|
|
onToggleCollapse,
|
|
onDismiss,
|
|
onPin,
|
|
onUnpin,
|
|
showPinButton,
|
|
bulkImportMode,
|
|
sourceManagerMode,
|
|
} = usePanelRole(panelRole);
|
|
|
|
const [isDesktop, setIsDesktop] = useState(
|
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
|
);
|
|
const [needsFetch, setNeedsFetch] = useState(false);
|
|
const [checkingCache, setCheckingCache] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const mq = globalThis.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);
|
|
void isSourceCached(sourceCode).then((cached) => {
|
|
setNeedsFetch(!cached);
|
|
setCheckingCache(false);
|
|
});
|
|
}, [creatureId, creature, isSourceCached]);
|
|
|
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
|
|
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
|
|
|
const handleSourceLoaded = () => {
|
|
setNeedsFetch(false);
|
|
};
|
|
|
|
const renderContent = () => {
|
|
if (sourceManagerMode) {
|
|
return <SourceManager />;
|
|
}
|
|
|
|
if (bulkImportMode) {
|
|
return <BulkImportPrompt />;
|
|
}
|
|
|
|
if (checkingCache) {
|
|
return (
|
|
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
|
|
);
|
|
}
|
|
|
|
if (creature) {
|
|
return <StatBlock creature={creature} />;
|
|
}
|
|
|
|
if (needsFetch && sourceCode) {
|
|
return (
|
|
<SourceFetchPrompt
|
|
sourceCode={sourceCode}
|
|
onSourceLoaded={handleSourceLoaded}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 text-muted-foreground text-sm">
|
|
No stat block available
|
|
</div>
|
|
);
|
|
};
|
|
|
|
let fallbackName = "Creature";
|
|
if (sourceManagerMode) fallbackName = "Sources";
|
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
|
const creatureName = creature?.name ?? fallbackName;
|
|
|
|
if (isDesktop) {
|
|
return (
|
|
<DesktopPanel
|
|
isCollapsed={isCollapsed}
|
|
side={side}
|
|
creatureName={creatureName}
|
|
panelRole={panelRole}
|
|
showPinButton={showPinButton}
|
|
onToggleCollapse={onToggleCollapse}
|
|
onPin={onPin}
|
|
onUnpin={onUnpin}
|
|
>
|
|
{renderContent()}
|
|
</DesktopPanel>
|
|
);
|
|
}
|
|
|
|
if (panelRole === "pinned" || isCollapsed) return null;
|
|
|
|
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
|
}
|