Weak/Normal/Elite toggle in PF2e stat block header applies standard adjustments (level, AC, HP, saves, Perception, attacks, damage) to individual combatants. Adjusted stats are highlighted blue (elite) or red (weak). Persisted via creatureAdjustment field on Combatant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
423 lines
10 KiB
TypeScript
423 lines
10 KiB
TypeScript
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 (
|
|
<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 { 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 (
|
|
<Pf2eStatBlock
|
|
creature={adjusted}
|
|
adjustment={combatant?.creatureAdjustment}
|
|
combatantId={combatant?.id}
|
|
baseCreature={baseCreature}
|
|
onSetAdjustment={setCreatureAdjustment}
|
|
/>
|
|
);
|
|
}
|
|
return <DndStatBlock creature={creature as Creature} />;
|
|
}
|
|
|
|
export function StatBlockPanel({
|
|
panelRole,
|
|
side,
|
|
}: Readonly<StatBlockPanelProps>) {
|
|
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<string | null>(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 <SourceManager />;
|
|
}
|
|
|
|
if (bulkImportMode) {
|
|
return <BulkImportPrompt />;
|
|
}
|
|
|
|
if (checkingCache) {
|
|
return (
|
|
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
|
|
);
|
|
}
|
|
|
|
if (creature) {
|
|
return renderStatBlock(creature, combatant, setCreatureAdjustment);
|
|
}
|
|
|
|
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;
|
|
|
|
const toast = skippedToast ? (
|
|
<Toast message={skippedToast} onDismiss={() => setSkippedToast(null)} />
|
|
) : null;
|
|
|
|
if (isDesktop) {
|
|
return (
|
|
<>
|
|
<DesktopPanel
|
|
isCollapsed={isCollapsed}
|
|
side={side}
|
|
creatureName={creatureName}
|
|
panelRole={panelRole}
|
|
showPinButton={showPinButton}
|
|
onToggleCollapse={onToggleCollapse}
|
|
onPin={onPin}
|
|
onUnpin={onUnpin}
|
|
>
|
|
{renderContent()}
|
|
</DesktopPanel>
|
|
{toast}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (panelRole === "pinned" || isCollapsed) return null;
|
|
|
|
return (
|
|
<>
|
|
<MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>
|
|
{toast}
|
|
</>
|
|
);
|
|
}
|