Replace the static "No combatants yet" text with a centered, breathing "+" icon that focuses the action bar input on click, guiding users to add their first combatant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import {
|
|
rollAllInitiativeUseCase,
|
|
rollInitiativeUseCase,
|
|
} from "@initiative/application";
|
|
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
|
import { Plus } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { ActionBar } from "./components/action-bar";
|
|
import { CombatantRow } from "./components/combatant-row";
|
|
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";
|
|
|
|
function rollDice(): number {
|
|
return Math.floor(Math.random() * 20) + 1;
|
|
}
|
|
|
|
export function App() {
|
|
const {
|
|
encounter,
|
|
advanceTurn,
|
|
retreatTurn,
|
|
addCombatant,
|
|
clearEncounter,
|
|
removeCombatant,
|
|
editCombatant,
|
|
setInitiative,
|
|
setHp,
|
|
adjustHp,
|
|
setAc,
|
|
toggleCondition,
|
|
toggleConcentration,
|
|
addFromBestiary,
|
|
makeStore,
|
|
} = useEncounter();
|
|
|
|
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);
|
|
// Derive the creature ID so stat block panel can try to show it
|
|
const slug = result.name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/(^-|-$)/g, "");
|
|
setSelectedCreatureId(
|
|
`${result.source.toLowerCase()}:${slug}` as CreatureId,
|
|
);
|
|
},
|
|
[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);
|
|
|
|
// 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]);
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col">
|
|
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
|
{/* Turn Navigation — fixed at top */}
|
|
<div className="shrink-0 pt-8">
|
|
<TurnNavigation
|
|
encounter={encounter}
|
|
onAdvanceTurn={advanceTurn}
|
|
onRetreatTurn={retreatTurn}
|
|
onClearEncounter={clearEncounter}
|
|
onRollAllInitiative={handleRollAllInitiative}
|
|
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
/>
|
|
</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.length === 0 ? " h-full items-center justify-center" : ""}`}
|
|
>
|
|
{encounter.combatants.length === 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => actionBarInputRef.current?.focus()}
|
|
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary"
|
|
>
|
|
<Plus className="size-14" />
|
|
</button>
|
|
) : (
|
|
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">
|
|
<ActionBar
|
|
onAddCombatant={addCombatant}
|
|
onAddFromBestiary={handleAddFromBestiary}
|
|
bestiarySearch={search}
|
|
bestiaryLoaded={isLoaded}
|
|
onViewStatBlock={handleViewStatBlock}
|
|
onBulkImport={handleBulkImport}
|
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
inputRef={actionBarInputRef}
|
|
/>
|
|
</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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|