import type { AnyCreature, Combatant, CombatantId, Creature, CreatureId, Pf2eCreature, } from "@initiative/domain"; import { applyPf2eAdjustment } 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 { useEncounterContext } from "../contexts/encounter-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 { DndStatBlock } from "./dnd-stat-block.js"; import { Pf2eStatBlock } from "./pf2e-stat-block.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceManager } from "./source-manager.js"; import { Toast } from "./toast.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 ""; const prefix = cId.slice(0, colonIndex); // D&D source codes are short uppercase (e.g. "mm" from "MM"). // PF2e source codes use hyphens (e.g. "pathfinder-monster-core"). return prefix.includes("-") ? prefix : prefix.toUpperCase(); } function CollapsedTab({ creatureName, side, onToggleCollapse, }: Readonly<{ creatureName: string; side: "left" | "right"; onToggleCollapse: () => void; }>) { return ( ); } function PanelHeader({ panelRole, showPinButton, onToggleCollapse, onPin, onUnpin, }: Readonly<{ panelRole: "browse" | "pinned"; showPinButton: boolean; onToggleCollapse: () => void; onPin: () => void; onUnpin: () => void; }>) { return (
{panelRole === "browse" && ( )}
{panelRole === "browse" && showPinButton && ( )} {panelRole === "pinned" && ( )}
); } 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 (
{isCollapsed ? ( ) : ( <>
{children}
)}
); } function MobileDrawer({ onDismiss, children, }: Readonly<{ onDismiss: () => void; children: ReactNode; }>) { const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss); return (
{children}
); } function usePanelRole(panelRole: "browse" | "pinned") { const sidePanel = useSidePanelContext(); const { getCreature } = useBestiaryContext(); const { encounter, setCreatureAdjustment } = useEncounterContext(); const creatureId = panelRole === "browse" ? sidePanel.selectedCreatureId : sidePanel.pinnedCreatureId; const creature = creatureId ? (getCreature(creatureId) ?? null) : null; const combatantId = panelRole === "browse" ? sidePanel.selectedCombatantId : null; const combatant = combatantId ? (encounter.combatants.find((c) => c.id === combatantId) ?? null) : null; const isBrowse = panelRole === "browse"; return { creatureId, creature, combatant, setCreatureAdjustment, 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, }; } function renderStatBlock( creature: AnyCreature, combatant: Combatant | null, setCreatureAdjustment: ( id: CombatantId, adj: "weak" | "elite" | undefined, base: Pf2eCreature, ) => void, ) { if ("system" in creature && creature.system === "pf2e") { const baseCreature = creature; const adjusted = combatant?.creatureAdjustment ? applyPf2eAdjustment(baseCreature, combatant.creatureAdjustment) : baseCreature; return ( ); } return ; } export function StatBlockPanel({ panelRole, side, }: Readonly) { const { creatureId, creature, combatant, setCreatureAdjustment, 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); const [skippedToast, setSkippedToast] = useState(null); 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; } // Show fetch prompt both when source is uncached AND when the source is // cached but this specific creature is missing (e.g. skipped by ad blocker). setNeedsFetch(true); setCheckingCache(false); }, [creatureId, creature]); if (!creatureId && !bulkImportMode && !sourceManagerMode) return null; const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const handleSourceLoaded = (skippedNames: string[]) => { if (skippedNames.length > 0) { const names = skippedNames.join(", "); setSkippedToast( `${skippedNames.length} creature(s) skipped (ad blocker?): ${names}`, ); } }; const renderContent = () => { if (sourceManagerMode) { return ; } if (bulkImportMode) { return ; } if (checkingCache) { return (
Loading...
); } if (creature) { return renderStatBlock(creature, combatant, setCreatureAdjustment); } if (needsFetch && sourceCode) { return ( ); } return (
No stat block available
); }; let fallbackName = "Creature"; if (sourceManagerMode) fallbackName = "Sources"; else if (bulkImportMode) fallbackName = "Import All Sources"; const creatureName = creature?.name ?? fallbackName; const toast = skippedToast ? ( setSkippedToast(null)} /> ) : null; if (isDesktop) { return ( <> {renderContent()} {toast} ); } if (panelRole === "pinned" || isCollapsed) return null; return ( <> {renderContent()} {toast} ); }