Files
initiative/apps/web/src/components/stat-block-panel.tsx
Lukas 09a801487d
All checks were successful
CI / check (push) Successful in 2m32s
CI / build-image (push) Successful in 19s
Add PF2e weak/elite creature adjustments with stat block toggle
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>
2026-04-11 02:24:30 +02:00

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}
</>
);
}