Top bar stripped to turn navigation only (Prev, round badge, Clear, Next). Roll All Initiative, Manage Sources, and Bulk Import moved to a new overflow menu in the bottom bar. Player Characters also moved there. Browse stat blocks is now an Eye/EyeOff toggle inside the search input that switches between add mode and browse mode. Add button only appears when entering a custom creature name. Roll All Initiative button shows conditionally — only when bestiary creatures lack initiative values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
463 lines
14 KiB
TypeScript
463 lines
14 KiB
TypeScript
import {
|
|
rollAllInitiativeUseCase,
|
|
rollInitiativeUseCase,
|
|
} from "@initiative/application";
|
|
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { ActionBar } from "./components/action-bar";
|
|
import { CombatantRow } from "./components/combatant-row";
|
|
import { CreatePlayerModal } from "./components/create-player-modal";
|
|
import { PlayerManagement } from "./components/player-management";
|
|
import { SourceManager } from "./components/source-manager";
|
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
|
import { Toast } from "./components/toast";
|
|
import { TurnNavigation } from "./components/turn-navigation";
|
|
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
|
import { useBulkImport } from "./hooks/use-bulk-import";
|
|
import { useEncounter } from "./hooks/use-encounter";
|
|
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
|
|
|
function rollDice(): number {
|
|
return Math.floor(Math.random() * 20) + 1;
|
|
}
|
|
|
|
function useActionBarAnimation(combatantCount: number) {
|
|
const wasEmptyRef = useRef(combatantCount === 0);
|
|
const [settling, setSettling] = useState(false);
|
|
const [rising, setRising] = useState(false);
|
|
const [topBarExiting, setTopBarExiting] = useState(false);
|
|
|
|
useLayoutEffect(() => {
|
|
const nowEmpty = combatantCount === 0;
|
|
if (wasEmptyRef.current && !nowEmpty) {
|
|
setSettling(true);
|
|
} else if (!wasEmptyRef.current && nowEmpty) {
|
|
setRising(true);
|
|
setTopBarExiting(true);
|
|
}
|
|
wasEmptyRef.current = nowEmpty;
|
|
}, [combatantCount]);
|
|
|
|
const empty = combatantCount === 0;
|
|
const risingClass = rising ? " animate-rise-to-center" : "";
|
|
const settlingClass = settling ? " animate-settle-to-bottom" : "";
|
|
const topBarClass = settling
|
|
? " animate-slide-down-in"
|
|
: topBarExiting
|
|
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
|
: "";
|
|
const showTopBar = !empty || topBarExiting;
|
|
|
|
return {
|
|
risingClass,
|
|
settlingClass,
|
|
topBarClass,
|
|
showTopBar,
|
|
onSettleEnd: () => setSettling(false),
|
|
onRiseEnd: () => setRising(false),
|
|
onTopBarExitEnd: () => setTopBarExiting(false),
|
|
};
|
|
}
|
|
|
|
export function App() {
|
|
const {
|
|
encounter,
|
|
advanceTurn,
|
|
retreatTurn,
|
|
addCombatant,
|
|
clearEncounter,
|
|
removeCombatant,
|
|
editCombatant,
|
|
setInitiative,
|
|
setHp,
|
|
adjustHp,
|
|
setAc,
|
|
toggleCondition,
|
|
toggleConcentration,
|
|
addFromBestiary,
|
|
addFromPlayerCharacter,
|
|
makeStore,
|
|
} = useEncounter();
|
|
|
|
const {
|
|
characters: playerCharacters,
|
|
createCharacter: createPlayerCharacter,
|
|
editCharacter: editPlayerCharacter,
|
|
deleteCharacter: deletePlayerCharacter,
|
|
} = usePlayerCharacters();
|
|
|
|
const [createPlayerOpen, setCreatePlayerOpen] = useState(false);
|
|
const [managementOpen, setManagementOpen] = useState(false);
|
|
const [editingPlayer, setEditingPlayer] = useState<
|
|
(typeof playerCharacters)[number] | undefined
|
|
>(undefined);
|
|
|
|
const {
|
|
search,
|
|
getCreature,
|
|
isLoaded,
|
|
isSourceCached,
|
|
fetchAndCacheSource,
|
|
uploadAndCacheSource,
|
|
refreshCache,
|
|
} = useBestiary();
|
|
|
|
const bulkImport = useBulkImport();
|
|
|
|
const [selectedCreatureId, setSelectedCreatureId] =
|
|
useState<CreatureId | null>(null);
|
|
const [bulkImportMode, setBulkImportMode] = useState(false);
|
|
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
|
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
|
null,
|
|
);
|
|
const [isWideDesktop, setIsWideDesktop] = useState(
|
|
() => window.matchMedia("(min-width: 1280px)").matches,
|
|
);
|
|
|
|
useEffect(() => {
|
|
const mq = window.matchMedia("(min-width: 1280px)");
|
|
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
|
mq.addEventListener("change", handler);
|
|
return () => mq.removeEventListener("change", handler);
|
|
}, []);
|
|
|
|
const selectedCreature: Creature | null = selectedCreatureId
|
|
? (getCreature(selectedCreatureId) ?? null)
|
|
: null;
|
|
|
|
const pinnedCreature: Creature | null = pinnedCreatureId
|
|
? (getCreature(pinnedCreatureId) ?? null)
|
|
: null;
|
|
|
|
const handleAddFromBestiary = useCallback(
|
|
(result: SearchResult) => {
|
|
addFromBestiary(result);
|
|
},
|
|
[addFromBestiary],
|
|
);
|
|
|
|
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
|
setSelectedCreatureId(creatureId as CreatureId);
|
|
setIsRightPanelFolded(false);
|
|
}, []);
|
|
|
|
const handleRollInitiative = useCallback(
|
|
(id: CombatantId) => {
|
|
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
|
},
|
|
[makeStore, getCreature],
|
|
);
|
|
|
|
const handleRollAllInitiative = useCallback(() => {
|
|
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
|
}, [makeStore, getCreature]);
|
|
|
|
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
|
const slug = result.name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/(^-|-$)/g, "");
|
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
|
setSelectedCreatureId(cId);
|
|
setIsRightPanelFolded(false);
|
|
}, []);
|
|
|
|
const handleBulkImport = useCallback(() => {
|
|
setBulkImportMode(true);
|
|
setSelectedCreatureId(null);
|
|
}, []);
|
|
|
|
const handleStartBulkImport = useCallback(
|
|
(baseUrl: string) => {
|
|
bulkImport.startImport(
|
|
baseUrl,
|
|
fetchAndCacheSource,
|
|
isSourceCached,
|
|
refreshCache,
|
|
);
|
|
},
|
|
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
|
);
|
|
|
|
const handleBulkImportDone = useCallback(() => {
|
|
setBulkImportMode(false);
|
|
bulkImport.reset();
|
|
}, [bulkImport.reset]);
|
|
|
|
const handleDismissBrowsePanel = useCallback(() => {
|
|
setSelectedCreatureId(null);
|
|
setBulkImportMode(false);
|
|
}, []);
|
|
|
|
const handleToggleFold = useCallback(() => {
|
|
setIsRightPanelFolded((f) => !f);
|
|
}, []);
|
|
|
|
const handlePin = useCallback(() => {
|
|
if (selectedCreatureId) {
|
|
setPinnedCreatureId((prev) =>
|
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
|
);
|
|
}
|
|
}, [selectedCreatureId]);
|
|
|
|
const handleUnpin = useCallback(() => {
|
|
setPinnedCreatureId(null);
|
|
}, []);
|
|
|
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
|
|
|
// Auto-scroll to the active combatant when the turn changes
|
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
activeRowRef.current?.scrollIntoView({
|
|
block: "nearest",
|
|
behavior: "smooth",
|
|
});
|
|
}, [encounter.activeIndex]);
|
|
|
|
// Auto-show stat block for the active combatant when turn changes,
|
|
// but only when the viewport is wide enough to show it alongside the tracker.
|
|
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
|
|
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
|
useEffect(() => {
|
|
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
|
prevActiveIndexRef.current = encounter.activeIndex;
|
|
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
|
const active = encounter.combatants[encounter.activeIndex];
|
|
if (!active?.creatureId || !isLoaded) return;
|
|
setSelectedCreatureId(active.creatureId as CreatureId);
|
|
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
|
|
|
const isEmpty = encounter.combatants.length === 0;
|
|
const showRollAllInitiative = encounter.combatants.some(
|
|
(c) => c.creatureId != null && c.initiative == null,
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col">
|
|
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
|
{actionBarAnim.showTopBar && (
|
|
<div
|
|
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
|
>
|
|
<TurnNavigation
|
|
encounter={encounter}
|
|
onAdvanceTurn={advanceTurn}
|
|
onRetreatTurn={retreatTurn}
|
|
onClearEncounter={clearEncounter}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isEmpty ? (
|
|
/* Empty state — ActionBar centered */
|
|
<div className="flex flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
|
|
<div
|
|
className={`w-full${actionBarAnim.risingClass}`}
|
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
|
>
|
|
<ActionBar
|
|
onAddCombatant={addCombatant}
|
|
onAddFromBestiary={handleAddFromBestiary}
|
|
bestiarySearch={search}
|
|
bestiaryLoaded={isLoaded}
|
|
onViewStatBlock={handleViewStatBlock}
|
|
onBulkImport={handleBulkImport}
|
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
inputRef={actionBarInputRef}
|
|
playerCharacters={playerCharacters}
|
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
onManagePlayers={() => setManagementOpen(true)}
|
|
onRollAllInitiative={handleRollAllInitiative}
|
|
showRollAllInitiative={showRollAllInitiative}
|
|
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{sourceManagerOpen && (
|
|
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
|
<SourceManager onCacheCleared={refreshCache} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Scrollable area — combatant list */}
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
<div className="flex flex-col px-2 py-2">
|
|
{encounter.combatants.map((c, i) => (
|
|
<CombatantRow
|
|
key={c.id}
|
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
|
combatant={c}
|
|
isActive={i === encounter.activeIndex}
|
|
onRename={editCombatant}
|
|
onSetInitiative={setInitiative}
|
|
onRemove={removeCombatant}
|
|
onSetHp={setHp}
|
|
onAdjustHp={adjustHp}
|
|
onSetAc={setAc}
|
|
onToggleCondition={toggleCondition}
|
|
onToggleConcentration={toggleConcentration}
|
|
onShowStatBlock={
|
|
c.creatureId
|
|
? () => handleCombatantStatBlock(c.creatureId as string)
|
|
: undefined
|
|
}
|
|
onRollInitiative={
|
|
c.creatureId ? handleRollInitiative : undefined
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Bar — fixed at bottom */}
|
|
<div
|
|
className={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
|
>
|
|
<ActionBar
|
|
onAddCombatant={addCombatant}
|
|
onAddFromBestiary={handleAddFromBestiary}
|
|
bestiarySearch={search}
|
|
bestiaryLoaded={isLoaded}
|
|
onViewStatBlock={handleViewStatBlock}
|
|
onBulkImport={handleBulkImport}
|
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
inputRef={actionBarInputRef}
|
|
playerCharacters={playerCharacters}
|
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
onManagePlayers={() => setManagementOpen(true)}
|
|
onRollAllInitiative={handleRollAllInitiative}
|
|
showRollAllInitiative={showRollAllInitiative}
|
|
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pinned Stat Block Panel (left) */}
|
|
{pinnedCreatureId && isWideDesktop && (
|
|
<StatBlockPanel
|
|
creatureId={pinnedCreatureId}
|
|
creature={pinnedCreature}
|
|
isSourceCached={isSourceCached}
|
|
fetchAndCacheSource={fetchAndCacheSource}
|
|
uploadAndCacheSource={uploadAndCacheSource}
|
|
refreshCache={refreshCache}
|
|
panelRole="pinned"
|
|
isFolded={false}
|
|
onToggleFold={() => {}}
|
|
onPin={() => {}}
|
|
onUnpin={handleUnpin}
|
|
showPinButton={false}
|
|
side="left"
|
|
onDismiss={() => {}}
|
|
/>
|
|
)}
|
|
|
|
{/* Browse Stat Block Panel (right) */}
|
|
<StatBlockPanel
|
|
creatureId={selectedCreatureId}
|
|
creature={selectedCreature}
|
|
isSourceCached={isSourceCached}
|
|
fetchAndCacheSource={fetchAndCacheSource}
|
|
uploadAndCacheSource={uploadAndCacheSource}
|
|
refreshCache={refreshCache}
|
|
panelRole="browse"
|
|
isFolded={isRightPanelFolded}
|
|
onToggleFold={handleToggleFold}
|
|
onPin={handlePin}
|
|
onUnpin={() => {}}
|
|
showPinButton={isWideDesktop && !!selectedCreature}
|
|
side="right"
|
|
onDismiss={handleDismissBrowsePanel}
|
|
bulkImportMode={bulkImportMode}
|
|
bulkImportState={bulkImport.state}
|
|
onStartBulkImport={handleStartBulkImport}
|
|
onBulkImportDone={handleBulkImportDone}
|
|
/>
|
|
|
|
{/* Toast for bulk import progress when panel is closed */}
|
|
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
|
<Toast
|
|
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
|
progress={
|
|
bulkImport.state.total > 0
|
|
? (bulkImport.state.completed + bulkImport.state.failed) /
|
|
bulkImport.state.total
|
|
: 0
|
|
}
|
|
onDismiss={() => {}}
|
|
/>
|
|
)}
|
|
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
|
<Toast
|
|
message="All sources loaded"
|
|
onDismiss={bulkImport.reset}
|
|
autoDismissMs={3000}
|
|
/>
|
|
)}
|
|
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
|
<Toast
|
|
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
|
onDismiss={bulkImport.reset}
|
|
/>
|
|
)}
|
|
|
|
<CreatePlayerModal
|
|
open={createPlayerOpen}
|
|
onClose={() => {
|
|
setCreatePlayerOpen(false);
|
|
setEditingPlayer(undefined);
|
|
}}
|
|
onSave={(name, ac, maxHp, color, icon) => {
|
|
if (editingPlayer) {
|
|
editPlayerCharacter?.(editingPlayer.id, {
|
|
name,
|
|
ac,
|
|
maxHp,
|
|
color,
|
|
icon,
|
|
});
|
|
} else {
|
|
createPlayerCharacter(name, ac, maxHp, color, icon);
|
|
}
|
|
}}
|
|
playerCharacter={editingPlayer}
|
|
/>
|
|
|
|
<PlayerManagement
|
|
open={managementOpen}
|
|
onClose={() => setManagementOpen(false)}
|
|
characters={playerCharacters}
|
|
onEdit={(pc) => {
|
|
setEditingPlayer(pc);
|
|
setCreatePlayerOpen(true);
|
|
setManagementOpen(false);
|
|
}}
|
|
onDelete={(id) => deletePlayerCharacter?.(id)}
|
|
onCreate={() => {
|
|
setEditingPlayer(undefined);
|
|
setCreatePlayerOpen(true);
|
|
setManagementOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|