Use h-dvh (100dvh) instead of h-screen (100vh) so the layout accounts for browser chrome (address bar, bottom toolbar). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import {
|
|
rollAllInitiativeUseCase,
|
|
rollInitiativeUseCase,
|
|
} from "@initiative/application";
|
|
import {
|
|
type CombatantId,
|
|
type Creature,
|
|
type CreatureId,
|
|
isDomainError,
|
|
} from "@initiative/domain";
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { ActionBar } from "./components/action-bar";
|
|
import { BulkImportToasts } from "./components/bulk-import-toasts";
|
|
import { CombatantRow } from "./components/combatant-row";
|
|
import {
|
|
PlayerCharacterSection,
|
|
type PlayerCharacterSectionHandle,
|
|
} from "./components/player-character-section";
|
|
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";
|
|
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
|
|
|
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 exitingClass = topBarExiting
|
|
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
|
: "";
|
|
const topBarClass = settling ? " animate-slide-down-in" : exitingClass;
|
|
const showTopBar = !empty || topBarExiting;
|
|
|
|
return {
|
|
risingClass,
|
|
settlingClass,
|
|
topBarClass,
|
|
showTopBar,
|
|
onSettleEnd: () => setSettling(false),
|
|
onRiseEnd: () => setRising(false),
|
|
onTopBarExitEnd: () => setTopBarExiting(false),
|
|
};
|
|
}
|
|
|
|
export function App() {
|
|
const {
|
|
encounter,
|
|
isEmpty,
|
|
hasCreatureCombatants,
|
|
canRollAllInitiative,
|
|
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 {
|
|
search,
|
|
getCreature,
|
|
isLoaded,
|
|
isSourceCached,
|
|
fetchAndCacheSource,
|
|
uploadAndCacheSource,
|
|
refreshCache,
|
|
} = useBestiary();
|
|
|
|
const bulkImport = useBulkImport();
|
|
const sidePanel = useSidePanelState();
|
|
|
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
|
|
|
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
|
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
|
: null;
|
|
|
|
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
|
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
|
: null;
|
|
|
|
const handleAddFromBestiary = useCallback(
|
|
(result: SearchResult) => {
|
|
addFromBestiary(result);
|
|
},
|
|
[addFromBestiary],
|
|
);
|
|
|
|
const handleCombatantStatBlock = useCallback(
|
|
(creatureId: string) => {
|
|
sidePanel.showCreature(creatureId as CreatureId);
|
|
},
|
|
[sidePanel.showCreature],
|
|
);
|
|
|
|
const handleRollInitiative = useCallback(
|
|
(id: CombatantId) => {
|
|
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
|
},
|
|
[makeStore, getCreature],
|
|
);
|
|
|
|
const handleRollAllInitiative = useCallback(() => {
|
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
|
setRollSkippedCount(result.skippedNoSource);
|
|
}
|
|
}, [makeStore, getCreature]);
|
|
|
|
const handleViewStatBlock = useCallback(
|
|
(result: SearchResult) => {
|
|
const slug = result.name
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
.replaceAll(/(^-|-$)/g, "");
|
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
|
sidePanel.showCreature(cId);
|
|
},
|
|
[sidePanel.showCreature],
|
|
);
|
|
|
|
const handleStartBulkImport = useCallback(
|
|
(baseUrl: string) => {
|
|
bulkImport.startImport(
|
|
baseUrl,
|
|
fetchAndCacheSource,
|
|
isSourceCached,
|
|
refreshCache,
|
|
);
|
|
},
|
|
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
|
);
|
|
|
|
const handleBulkImportDone = useCallback(() => {
|
|
sidePanel.dismissPanel();
|
|
bulkImport.reset();
|
|
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
|
|
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(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",
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex h-dvh flex-col">
|
|
<div className="relative mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col gap-3 px-4">
|
|
{!!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 min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
|
<div
|
|
className={`w-full${actionBarAnim.risingClass}`}
|
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
|
>
|
|
<ActionBar
|
|
onAddCombatant={addCombatant}
|
|
onAddFromBestiary={handleAddFromBestiary}
|
|
bestiarySearch={search}
|
|
bestiaryLoaded={isLoaded}
|
|
onViewStatBlock={handleViewStatBlock}
|
|
onBulkImport={sidePanel.showBulkImport}
|
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
inputRef={actionBarInputRef}
|
|
playerCharacters={playerCharacters}
|
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
onManagePlayers={() =>
|
|
playerCharacterRef.current?.openManagement()
|
|
}
|
|
onRollAllInitiative={handleRollAllInitiative}
|
|
showRollAllInitiative={hasCreatureCombatants}
|
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
|
onOpenSourceManager={sidePanel.showSourceManager}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Scrollable area — combatant list */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
<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={sidePanel.showBulkImport}
|
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
inputRef={actionBarInputRef}
|
|
playerCharacters={playerCharacters}
|
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
onManagePlayers={() =>
|
|
playerCharacterRef.current?.openManagement()
|
|
}
|
|
onRollAllInitiative={handleRollAllInitiative}
|
|
showRollAllInitiative={hasCreatureCombatants}
|
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
|
onOpenSourceManager={sidePanel.showSourceManager}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pinned Stat Block Panel (left) */}
|
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
|
<StatBlockPanel
|
|
creatureId={sidePanel.pinnedCreatureId}
|
|
creature={pinnedCreature}
|
|
isSourceCached={isSourceCached}
|
|
fetchAndCacheSource={fetchAndCacheSource}
|
|
uploadAndCacheSource={uploadAndCacheSource}
|
|
refreshCache={refreshCache}
|
|
panelRole="pinned"
|
|
isCollapsed={false}
|
|
onToggleCollapse={() => {}}
|
|
onPin={() => {}}
|
|
onUnpin={sidePanel.unpin}
|
|
showPinButton={false}
|
|
side="left"
|
|
onDismiss={() => {}}
|
|
/>
|
|
)}
|
|
|
|
{/* Browse Stat Block Panel (right) */}
|
|
<StatBlockPanel
|
|
creatureId={sidePanel.selectedCreatureId}
|
|
creature={selectedCreature}
|
|
isSourceCached={isSourceCached}
|
|
fetchAndCacheSource={fetchAndCacheSource}
|
|
uploadAndCacheSource={uploadAndCacheSource}
|
|
refreshCache={refreshCache}
|
|
panelRole="browse"
|
|
isCollapsed={sidePanel.isRightPanelCollapsed}
|
|
onToggleCollapse={sidePanel.toggleCollapse}
|
|
onPin={sidePanel.togglePin}
|
|
onUnpin={() => {}}
|
|
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
|
side="right"
|
|
onDismiss={sidePanel.dismissPanel}
|
|
bulkImportMode={sidePanel.bulkImportMode}
|
|
bulkImportState={bulkImport.state}
|
|
onStartBulkImport={handleStartBulkImport}
|
|
onBulkImportDone={handleBulkImportDone}
|
|
sourceManagerMode={sidePanel.sourceManagerMode}
|
|
/>
|
|
|
|
<BulkImportToasts
|
|
state={bulkImport.state}
|
|
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
|
onReset={bulkImport.reset}
|
|
/>
|
|
|
|
{rollSkippedCount > 0 && (
|
|
<Toast
|
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
|
onDismiss={() => setRollSkippedCount(0)}
|
|
autoDismissMs={4000}
|
|
/>
|
|
)}
|
|
|
|
<PlayerCharacterSection
|
|
ref={playerCharacterRef}
|
|
characters={playerCharacters}
|
|
onCreateCharacter={createPlayerCharacter}
|
|
onEditCharacter={editPlayerCharacter}
|
|
onDeleteCharacter={deletePlayerCharacter}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|