Refactor App.tsx from god component to context-based architecture
Replace prop drilling with React context providers. App.tsx shrinks from 427 lines to ~80 lines of pure layout. Components consume shared state directly via 7 context providers instead of threading 50+ props. Key changes: - 7 context providers wrapping existing hooks (encounter, bestiary, player characters, side panel, theme, bulk import, initiative rolls) - 2 coordinating hooks extracted from App.tsx (useInitiativeRolls, useAutoStatBlock) - All 9 affected components refactored from prop-based to context-based - 6 test files updated to use providers or context mocks - Prop count enforcement script (max 8 per component interface) - Constitution principle II-A added (context-based state flow) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,42 +1,19 @@
|
||||
import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import {
|
||||
type CombatantId,
|
||||
type Creature,
|
||||
type CreatureId,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
} 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 { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar.js";
|
||||
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||
import { CombatantRow } from "./components/combatant-row.js";
|
||||
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";
|
||||
import { useTheme } from "./hooks/use-theme";
|
||||
import { cn } from "./lib/utils";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
} from "./components/player-character-section.js";
|
||||
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||
import { Toast } from "./components/toast.js";
|
||||
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
function useActionBarAnimation(combatantCount: number) {
|
||||
const wasEmptyRef = useRef(combatantCount === 0);
|
||||
@@ -76,164 +53,27 @@ function useActionBarAnimation(combatantCount: number) {
|
||||
}
|
||||
|
||||
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 { encounter, isEmpty } = useEncounterContext();
|
||||
const sidePanel = useSidePanelContext();
|
||||
const rolls = useInitiativeRollsContext();
|
||||
|
||||
const {
|
||||
characters: playerCharacters,
|
||||
createCharacter: createPlayerCharacter,
|
||||
editCharacter: editPlayerCharacter,
|
||||
deleteCharacter: deletePlayerCharacter,
|
||||
} = usePlayerCharacters();
|
||||
useAutoStatBlock();
|
||||
|
||||
const {
|
||||
search,
|
||||
getCreature,
|
||||
isLoaded,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
const { preference: themePreference, cycleTheme } = useTheme();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
||||
|
||||
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) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
if (creatureId && sidePanel.panelView.mode === "closed") {
|
||||
sidePanel.showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, sidePanel.panelView.mode, sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
sidePanel.showCreature(creatureId as CreatureId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId, mode: RollMode = "normal") => {
|
||||
const diceRolls: [number, ...number[]] =
|
||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
diceRolls,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
setRollSingleSkipped(true);
|
||||
const combatant = encounter.combatants.find((c) => c.id === id);
|
||||
if (combatant?.creatureId) {
|
||||
sidePanel.showCreature(combatant.creatureId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature, encounter.combatants, sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(
|
||||
(mode: RollMode = "normal") => {
|
||||
const result = rollAllInitiativeUseCase(
|
||||
makeStore(),
|
||||
rollDice,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
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 actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Auto-update stat block panel when the active combatant changes
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
|
||||
sidePanel.updateCreature(activeCreatureId);
|
||||
if (activeIndex >= 0) {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
activeRowRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
@@ -243,49 +83,27 @@ export function App() {
|
||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||
>
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
/>
|
||||
<TurnNavigation />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
/* Empty state — ActionBar centered */
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||
<div
|
||||
className={cn("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}
|
||||
themePreference={themePreference}
|
||||
onCycleTheme={cycleTheme}
|
||||
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) => (
|
||||
@@ -294,133 +112,51 @@ export function App() {
|
||||
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
|
||||
}
|
||||
isStatBlockOpen={
|
||||
c.creatureId === sidePanel.selectedCreatureId
|
||||
}
|
||||
onRollInitiative={
|
||||
c.creatureId ? handleRollInitiative : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Bar — fixed at bottom */}
|
||||
<div
|
||||
className={cn("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}
|
||||
themePreference={themePreference}
|
||||
onCycleTheme={cycleTheme}
|
||||
/>
|
||||
</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={() => {}}
|
||||
/>
|
||||
<StatBlockPanel panelRole="pinned" side="left" />
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<StatBlockPanel panelRole="browse" side="right" />
|
||||
|
||||
<BulkImportToasts
|
||||
state={bulkImport.state}
|
||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
||||
onReset={bulkImport.reset}
|
||||
/>
|
||||
<BulkImportToasts />
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
{rolls.rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={() => setRollSkippedCount(0)}
|
||||
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||
onDismiss={rolls.dismissRollSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!rollSingleSkipped && (
|
||||
{!!rolls.rollSingleSkipped && (
|
||||
<Toast
|
||||
message="Can't roll — bestiary source not loaded"
|
||||
onDismiss={() => setRollSingleSkipped(false)}
|
||||
onDismiss={rolls.dismissRollSingleSkipped}
|
||||
autoDismissMs={4000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayerCharacterSection
|
||||
ref={playerCharacterRef}
|
||||
characters={playerCharacters}
|
||||
onCreateCharacter={createPlayerCharacter}
|
||||
onEditCharacter={editPlayerCharacter}
|
||||
onDeleteCharacter={deletePlayerCharacter}
|
||||
/>
|
||||
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../App";
|
||||
import { App } from "../App.js";
|
||||
import { AllProviders } from "./test-providers.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||
@@ -76,7 +77,7 @@ async function addCombatant(
|
||||
describe("App integration", () => {
|
||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
// Empty state: centered input visible, no TurnNavigation
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
@@ -109,7 +110,7 @@ describe("App integration", () => {
|
||||
|
||||
it("advances and retreats turns across two combatants", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Fighter");
|
||||
await addCombatant(user, "Wizard");
|
||||
@@ -137,7 +138,7 @@ describe("App integration", () => {
|
||||
|
||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
render(<App />, { wrapper: AllProviders });
|
||||
|
||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||
|
||||
|
||||
@@ -4,11 +4,33 @@ import "@testing-library/jest-dom/vitest";
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
||||
|
||||
// Mock the context modules
|
||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||
useSidePanelContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adapters to avoid IndexedDB
|
||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
const CLOSE_REGEX = /close/i;
|
||||
const COLLAPSE_REGEX = /collapse/i;
|
||||
|
||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||
const CREATURE: Creature = {
|
||||
id: CREATURE_ID,
|
||||
@@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
interface PanelOverrides {
|
||||
creatureId?: CreatureId | null;
|
||||
creature?: Creature | null;
|
||||
panelRole?: "browse" | "pinned";
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onPin?: () => void;
|
||||
onUnpin?: () => void;
|
||||
showPinButton?: boolean;
|
||||
side?: "left" | "right";
|
||||
onDismiss?: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
}
|
||||
|
||||
function renderPanel(overrides: PanelProps = {}) {
|
||||
const props = {
|
||||
creatureId: CREATURE_ID,
|
||||
creature: CREATURE,
|
||||
function setupMocks(overrides: PanelOverrides = {}) {
|
||||
const panelRole = overrides.panelRole ?? "browse";
|
||||
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||
const creature = overrides.creature ?? CREATURE;
|
||||
const isCollapsed = overrides.isCollapsed ?? false;
|
||||
|
||||
const onToggleCollapse = vi.fn();
|
||||
const onPin = vi.fn();
|
||||
const onUnpin = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||
isWideDesktop: false,
|
||||
bulkImportMode: overrides.bulkImportMode ?? false,
|
||||
sourceManagerMode: false,
|
||||
panelView: creatureId
|
||||
? { mode: "creature" as const, creatureId }
|
||||
: { mode: "closed" as const },
|
||||
showCreature: vi.fn(),
|
||||
updateCreature: vi.fn(),
|
||||
showBulkImport: vi.fn(),
|
||||
showSourceManager: vi.fn(),
|
||||
dismissPanel: onDismiss,
|
||||
toggleCollapse: onToggleCollapse,
|
||||
togglePin: onPin,
|
||||
unpin: onUnpin,
|
||||
} as ReturnType<typeof useSidePanelContext>);
|
||||
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
|
||||
isSourceCached: vi.fn().mockResolvedValue(true),
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
isLoaded: true,
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
refreshCache: vi.fn(),
|
||||
panelRole: "browse" as const,
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onUnpin: vi.fn(),
|
||||
showPinButton: false,
|
||||
side: "right" as const,
|
||||
onDismiss: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
|
||||
render(<StatBlockPanel {...props} />);
|
||||
return props;
|
||||
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||
}
|
||||
|
||||
function renderPanel(overrides: PanelOverrides = {}) {
|
||||
const callbacks = setupMocks(overrides);
|
||||
const panelRole = overrides.panelRole ?? "browse";
|
||||
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
|
||||
render(<StatBlockPanel panelRole={panelRole} side={side} />);
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
@@ -113,19 +159,19 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||
);
|
||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||
const props = renderPanel({ isCollapsed: true });
|
||||
const callbacks = renderPanel({ isCollapsed: true });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||
);
|
||||
expect(props.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies translate-x class when collapsed (right side)", () => {
|
||||
@@ -163,53 +209,58 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||
const props = renderPanel();
|
||||
const callbacks = renderPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render pinned panel on mobile", () => {
|
||||
const { container } = render(
|
||||
<StatBlockPanel
|
||||
creatureId={CREATURE_ID}
|
||||
creature={CREATURE}
|
||||
isSourceCached={vi.fn().mockResolvedValue(true)}
|
||||
fetchAndCacheSource={vi.fn()}
|
||||
uploadAndCacheSource={vi.fn()}
|
||||
refreshCache={vi.fn()}
|
||||
panelRole="pinned"
|
||||
isCollapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
onPin={vi.fn()}
|
||||
onUnpin={vi.fn()}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
(() => {
|
||||
setupMocks({ panelRole: "pinned" });
|
||||
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||
})(),
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("US2: Pin and Unpin", () => {
|
||||
it("shows pin button when showPinButton is true on desktop", () => {
|
||||
renderPanel({ showPinButton: true });
|
||||
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||
setupMocks();
|
||||
// Override to set isWideDesktop
|
||||
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||
typeof useSidePanelContext
|
||||
>;
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
...ctx,
|
||||
isWideDesktop: true,
|
||||
});
|
||||
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pin creature" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides pin button when showPinButton is false", () => {
|
||||
renderPanel({ showPinButton: false });
|
||||
it("hides pin button when isWideDesktop is false", () => {
|
||||
renderPanel();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Pin creature" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onPin when pin button is clicked", () => {
|
||||
const props = renderPanel({ showPinButton: true });
|
||||
setupMocks();
|
||||
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||
typeof useSidePanelContext
|
||||
>;
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
...ctx,
|
||||
isWideDesktop: true,
|
||||
});
|
||||
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
||||
expect(props.onPin).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows unpin button for pinned role", () => {
|
||||
@@ -220,9 +271,9 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("calls onUnpin when unpin button is clicked", () => {
|
||||
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
||||
expect(props.onUnpin).toHaveBeenCalledTimes(1);
|
||||
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("positions pinned panel on the left side", () => {
|
||||
@@ -255,7 +306,7 @@ describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||
});
|
||||
|
||||
it("pinned panel is always expanded (no translate offset)", () => {
|
||||
renderPanel({ panelRole: "pinned", side: "left", isCollapsed: false });
|
||||
renderPanel({ panelRole: "pinned", side: "left" });
|
||||
const unpinBtn = screen.getByRole("button", {
|
||||
name: "Unpin creature",
|
||||
});
|
||||
|
||||
28
apps/web/src/__tests__/test-providers.tsx
Normal file
28
apps/web/src/__tests__/test-providers.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "../contexts/index.js";
|
||||
|
||||
export function AllProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,21 +3,59 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ActionBar } from "../action-bar";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { ActionBar } from "../action-bar.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs — jsdom doesn't implement these
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
onAddCombatant: vi.fn(),
|
||||
onAddFromBestiary: vi.fn(),
|
||||
bestiarySearch: () => [],
|
||||
bestiaryLoaded: false,
|
||||
};
|
||||
|
||||
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
const props = { ...defaultProps, ...overrides };
|
||||
return render(<ActionBar {...props} />);
|
||||
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||
}
|
||||
|
||||
describe("ActionBar", () => {
|
||||
@@ -26,26 +64,26 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submitting with a name calls onAddCombatant", async () => {
|
||||
it("submitting with a name adds a combatant", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "Goblin");
|
||||
// The Add button appears when name >= 2 chars and no suggestions
|
||||
const addButton = screen.getByRole("button", { name: "Add" });
|
||||
await user.click(addButton);
|
||||
expect(onAddCombatant).toHaveBeenCalledWith("Goblin", undefined);
|
||||
// Input is cleared after adding (context handles the state)
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("submitting with empty name does nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddCombatant = vi.fn();
|
||||
renderBar({ onAddCombatant });
|
||||
renderBar();
|
||||
// Submit the form directly (Enter on empty input)
|
||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||
await user.type(input, "{Enter}");
|
||||
expect(onAddCombatant).not.toHaveBeenCalled();
|
||||
// Input stays empty, no error
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||
@@ -66,23 +104,18 @@ describe("ActionBar", () => {
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows roll all initiative button when showRollAllInitiative is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
|
||||
it("does not show roll all initiative button when no creature combatants", () => {
|
||||
renderBar();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
||||
const onRollAllInitiative = vi.fn();
|
||||
renderBar({
|
||||
showRollAllInitiative: true,
|
||||
onRollAllInitiative,
|
||||
rollAllInitiativeDisabled: true,
|
||||
});
|
||||
it("shows overflow menu items", () => {
|
||||
renderBar({ onManagePlayers: vi.fn() });
|
||||
// The overflow menu should be present (it contains Player Characters etc.)
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
||||
).toBeDisabled();
|
||||
screen.getByRole("button", { name: "More actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CombatantRow } from "../combatant-row";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { CombatantRow } from "../combatant-row.js";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
saveEncounter: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||
loadPlayerCharacters: () => [],
|
||||
savePlayerCharacters: () => {},
|
||||
}));
|
||||
|
||||
// Mock bestiary — no IndexedDB or JSON index
|
||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
||||
isSourceCached: () => Promise.resolve(false),
|
||||
cacheSource: () => Promise.resolve(),
|
||||
getCachedSources: () => Promise.resolve([]),
|
||||
clearSource: () => Promise.resolve(),
|
||||
clearAll: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: () => "",
|
||||
getSourceDisplayName: (code: string) => code,
|
||||
}));
|
||||
|
||||
// DOM API stubs
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
onRename: vi.fn(),
|
||||
onSetInitiative: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onSetHp: vi.fn(),
|
||||
onAdjustHp: vi.fn(),
|
||||
onSetAc: vi.fn(),
|
||||
onToggleCondition: vi.fn(),
|
||||
onToggleConcentration: vi.fn(),
|
||||
};
|
||||
|
||||
function renderRow(
|
||||
overrides: Partial<{
|
||||
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||
isActive: boolean;
|
||||
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
||||
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
||||
onShowStatBlock: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const combatant = overrides.combatant ?? {
|
||||
@@ -38,15 +70,13 @@ function renderRow(
|
||||
currentHp: 10,
|
||||
ac: 13,
|
||||
};
|
||||
const props = {
|
||||
...defaultProps,
|
||||
combatant,
|
||||
isActive: overrides.isActive ?? false,
|
||||
onRollInitiative: overrides.onRollInitiative,
|
||||
onShowStatBlock: overrides.onShowStatBlock,
|
||||
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
||||
};
|
||||
return render(<CombatantRow {...props} />);
|
||||
return render(
|
||||
<CombatantRow
|
||||
combatant={combatant}
|
||||
isActive={overrides.isActive ?? false}
|
||||
/>,
|
||||
{ wrapper: AllProviders },
|
||||
);
|
||||
}
|
||||
|
||||
describe("CombatantRow", () => {
|
||||
@@ -132,10 +162,9 @@ describe("CombatantRow", () => {
|
||||
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||
});
|
||||
|
||||
it("remove button calls onRemove after confirmation", async () => {
|
||||
it("remove button removes after confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
renderRow({ onRemove });
|
||||
renderRow();
|
||||
const removeBtn = screen.getByRole("button", {
|
||||
name: "Remove combatant",
|
||||
});
|
||||
@@ -146,16 +175,19 @@ describe("CombatantRow", () => {
|
||||
name: "Confirm remove combatant",
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
expect(onRemove).toHaveBeenCalledWith(combatantId("1"));
|
||||
// After confirming, the button returns to its initial state
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Confirm remove combatant" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows d20 roll button when initiative is undefined and onRollInitiative is provided", () => {
|
||||
it("shows d20 roll button when initiative is undefined and combatant has creatureId", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
creatureId: "srd:goblin" as CreatureId,
|
||||
},
|
||||
onRollInitiative: vi.fn(),
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Roll initiative" }),
|
||||
|
||||
@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||
clearAll: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||
useBestiaryContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
||||
import { SourceManager } from "../source-manager";
|
||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||
import { SourceManager } from "../source-manager.js";
|
||||
|
||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function setupMockContext() {
|
||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockUseBestiaryContext.mockReturnValue({
|
||||
refreshCache,
|
||||
search: vi.fn().mockReturnValue([]),
|
||||
getCreature: vi.fn(),
|
||||
isLoaded: true,
|
||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||
fetchAndCacheSource: vi.fn(),
|
||||
uploadAndCacheSource: vi.fn(),
|
||||
} as ReturnType<typeof useBestiaryContext>);
|
||||
return { refreshCache };
|
||||
}
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
setupMockContext();
|
||||
mockGetCachedSources.mockResolvedValue([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
|
||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Clear All button calls cache clear and onCacheCleared", async () => {
|
||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
mockClearAll.mockResolvedValue(undefined);
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearAll).toHaveBeenCalled();
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("individual source delete button calls clear for that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCacheCleared = vi.fn();
|
||||
const { refreshCache } = setupMockContext();
|
||||
mockGetCachedSources
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
|
||||
]);
|
||||
mockClearSource.mockResolvedValue(undefined);
|
||||
|
||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
||||
render(<SourceManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||
});
|
||||
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
||||
});
|
||||
expect(onCacheCleared).toHaveBeenCalled();
|
||||
expect(refreshCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,23 @@ import type { Encounter } from "@initiative/domain";
|
||||
import { combatantId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TurnNavigation } from "../turn-navigation";
|
||||
|
||||
afterEach(cleanup);
|
||||
// Mock the context module
|
||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||
import { TurnNavigation } from "../turn-navigation.js";
|
||||
|
||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
@@ -20,14 +32,38 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<TurnNavigation
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
const value = {
|
||||
encounter,
|
||||
advanceTurn: vi.fn(),
|
||||
retreatTurn: vi.fn(),
|
||||
clearEncounter: vi.fn(),
|
||||
isEmpty: encounter.combatants.length === 0,
|
||||
hasCreatureCombatants: false,
|
||||
canRollAllInitiative: false,
|
||||
addCombatant: vi.fn(),
|
||||
removeCombatant: vi.fn(),
|
||||
editCombatant: vi.fn(),
|
||||
setInitiative: vi.fn(),
|
||||
setHp: vi.fn(),
|
||||
adjustHp: vi.fn(),
|
||||
setAc: vi.fn(),
|
||||
toggleCondition: vi.fn(),
|
||||
toggleConcentration: vi.fn(),
|
||||
addFromBestiary: vi.fn(),
|
||||
addFromPlayerCharacter: vi.fn(),
|
||||
makeStore: vi.fn(),
|
||||
events: [],
|
||||
};
|
||||
|
||||
mockUseEncounterContext.mockReturnValue(
|
||||
value as ReturnType<typeof useEncounterContext>,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
||||
mockContext(overrides);
|
||||
return render(<TurnNavigation />);
|
||||
}
|
||||
|
||||
describe("TurnNavigation", () => {
|
||||
@@ -49,7 +85,7 @@ describe("TurnNavigation", () => {
|
||||
|
||||
it("does not render an em dash between round and name", () => {
|
||||
const { container } = renderNav();
|
||||
expect(container.textContent).not.toContain("—");
|
||||
expect(container.textContent).not.toContain("\u2014");
|
||||
});
|
||||
|
||||
it("round badge and combatant name are siblings in the center area", () => {
|
||||
@@ -60,69 +96,27 @@ describe("TurnNavigation", () => {
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 2 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 3,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ roundNumber: 3 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the next combatant name when turn advances", () => {
|
||||
const { rerender } = render(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const combatants = [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
];
|
||||
mockContext({ combatants, activeIndex: 0 });
|
||||
const { rerender } = render(<TurnNavigation />);
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TurnNavigation
|
||||
encounter={{
|
||||
combatants: [
|
||||
{ id: combatantId("1"), name: "Goblin" },
|
||||
{ id: combatantId("2"), name: "Conjurer" },
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 1,
|
||||
}}
|
||||
onAdvanceTurn={vi.fn()}
|
||||
onRetreatTurn={vi.fn()}
|
||||
onClearEncounter={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
mockContext({ combatants, activeIndex: 1 });
|
||||
rerender(<TurnNavigation />);
|
||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlayerCharacter, RollMode } from "@initiative/domain";
|
||||
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
@@ -18,11 +18,18 @@ import React, {
|
||||
useDeferredValue,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
@@ -34,27 +41,9 @@ interface QueuedCreature {
|
||||
}
|
||||
|
||||
interface ActionBarProps {
|
||||
onAddCombatant: (
|
||||
name: string,
|
||||
opts?: { initiative?: number; ac?: number; maxHp?: number },
|
||||
) => void;
|
||||
onAddFromBestiary: (result: SearchResult) => void;
|
||||
bestiarySearch: (query: string) => SearchResult[];
|
||||
bestiaryLoaded: boolean;
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
playerCharacters?: readonly PlayerCharacter[];
|
||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||
onManagePlayers?: () => void;
|
||||
onRollAllInitiative?: (mode?: RollMode) => void;
|
||||
showRollAllInitiative?: boolean;
|
||||
rollAllInitiativeDisabled?: boolean;
|
||||
onOpenSourceManager?: () => void;
|
||||
autoFocus?: boolean;
|
||||
themePreference?: "system" | "light" | "dark";
|
||||
onCycleTheme?: () => void;
|
||||
onManagePlayers?: () => void;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
@@ -285,25 +274,48 @@ function buildOverflowItems(opts: {
|
||||
}
|
||||
|
||||
export function ActionBar({
|
||||
onAddCombatant,
|
||||
onAddFromBestiary,
|
||||
bestiarySearch,
|
||||
bestiaryLoaded,
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
playerCharacters,
|
||||
onAddFromPlayerCharacter,
|
||||
onManagePlayers,
|
||||
onRollAllInitiative,
|
||||
showRollAllInitiative,
|
||||
rollAllInitiativeDisabled,
|
||||
onOpenSourceManager,
|
||||
autoFocus,
|
||||
themePreference,
|
||||
onCycleTheme,
|
||||
onManagePlayers,
|
||||
}: Readonly<ActionBarProps>) {
|
||||
const {
|
||||
addCombatant,
|
||||
addFromBestiary,
|
||||
addFromPlayerCharacter,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
} = useEncounterContext();
|
||||
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||
useBestiaryContext();
|
||||
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||
useSidePanelContext();
|
||||
const { preference: themePreference, cycleTheme } = useThemeContext();
|
||||
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const creatureId = addFromBestiary(result);
|
||||
if (creatureId && panelView.mode === "closed") {
|
||||
showCreature(creatureId);
|
||||
}
|
||||
},
|
||||
[addFromBestiary, panelView.mode, showCreature],
|
||||
);
|
||||
|
||||
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;
|
||||
showCreature(cId);
|
||||
},
|
||||
[showCreature],
|
||||
);
|
||||
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||
@@ -340,7 +352,7 @@ export function ActionBar({
|
||||
const confirmQueued = () => {
|
||||
if (!queued) return;
|
||||
for (let i = 0; i < queued.count; i++) {
|
||||
onAddFromBestiary(queued.result);
|
||||
handleAddFromBestiary(queued.result);
|
||||
}
|
||||
clearInput();
|
||||
};
|
||||
@@ -366,7 +378,7 @@ export function ActionBar({
|
||||
if (init !== undefined) opts.initiative = init;
|
||||
if (ac !== undefined) opts.ac = ac;
|
||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||
setNameInput("");
|
||||
setSuggestions([]);
|
||||
setPcMatches([]);
|
||||
@@ -468,14 +480,14 @@ export function ActionBar({
|
||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
||||
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseSelect = (result: SearchResult) => {
|
||||
onViewStatBlock?.(result);
|
||||
handleViewStatBlock(result);
|
||||
setBrowseMode(false);
|
||||
clearInput();
|
||||
};
|
||||
@@ -507,12 +519,12 @@ export function ActionBar({
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
onOpenSourceManager,
|
||||
onOpenSourceManager: showSourceManager,
|
||||
bestiaryLoaded,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
themePreference,
|
||||
onCycleTheme,
|
||||
onCycleTheme: cycleTheme,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -535,7 +547,7 @@ export function ActionBar({
|
||||
className="pr-8"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{bestiaryLoaded && !!onViewStatBlock && (
|
||||
{!!bestiaryLoaded && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
@@ -596,7 +608,7 @@ export function ActionBar({
|
||||
onSetSuggestionIndex={setSuggestionIndex}
|
||||
onSetQueued={setQueued}
|
||||
onConfirmQueued={confirmQueued}
|
||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -632,20 +644,20 @@ export function ActionBar({
|
||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||
<Button type="submit">Add</Button>
|
||||
)}
|
||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
||||
{!!hasCreatureCombatants && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-hover-action"
|
||||
onClick={() => onRollAllInitiative()}
|
||||
onClick={() => handleRollAllInitiative()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openRollAllMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
{...rollAllLongPress}
|
||||
disabled={rollAllInitiativeDisabled}
|
||||
disabled={!canRollAllInitiative}
|
||||
title="Roll all initiative"
|
||||
aria-label="Roll all initiative"
|
||||
>
|
||||
@@ -654,7 +666,7 @@ export function ActionBar({
|
||||
{!!rollAllMenuPos && (
|
||||
<RollModeMenu
|
||||
position={rollAllMenuPos}
|
||||
onSelect={(mode) => onRollAllInitiative(mode)}
|
||||
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||
onClose={() => setRollAllMenuPos(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
const DEFAULT_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
interface BulkImportPromptProps {
|
||||
importState: BulkImportState;
|
||||
onStartImport: (baseUrl: string) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
export function BulkImportPrompt() {
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||
const { dismissPanel } = useSidePanelContext();
|
||||
|
||||
export function BulkImportPrompt({
|
||||
importState,
|
||||
onStartImport,
|
||||
onDone,
|
||||
}: Readonly<BulkImportPromptProps>) {
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
dismissPanel();
|
||||
reset();
|
||||
};
|
||||
|
||||
if (importState.status === "complete") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-green-400 text-sm">
|
||||
All sources loaded
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
|
||||
Loaded {importState.completed}/{importState.total} sources (
|
||||
{importState.failed} failed)
|
||||
</div>
|
||||
<Button onClick={onDone}>Done</Button>
|
||||
<Button onClick={handleDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
||||
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||
Load All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Toast } from "./toast.js";
|
||||
|
||||
interface BulkImportToastsProps {
|
||||
state: BulkImportState;
|
||||
visible: boolean;
|
||||
onReset: () => void;
|
||||
}
|
||||
export function BulkImportToasts() {
|
||||
const { state, reset } = useBulkImportContext();
|
||||
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||
|
||||
export function BulkImportToasts({
|
||||
state,
|
||||
visible,
|
||||
onReset,
|
||||
}: Readonly<BulkImportToastsProps>) {
|
||||
if (!visible) return null;
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -30,7 +25,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
);
|
||||
@@ -40,7 +35,7 @@ export function BulkImportToasts({
|
||||
return (
|
||||
<Toast
|
||||
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||
onDismiss={onReset}
|
||||
onDismiss={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
import { Book, BookOpen, Brain, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLongPress } from "../hooks/use-long-press";
|
||||
import { cn } from "../lib/utils";
|
||||
import { AcShield } from "./ac-shield";
|
||||
import { ConditionPicker } from "./condition-picker";
|
||||
import { ConditionTags } from "./condition-tags";
|
||||
import { D20Icon } from "./d20-icon";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { RollModeMenu } from "./roll-mode-menu";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { AcShield } from "./ac-shield.js";
|
||||
import { ConditionPicker } from "./condition-picker.js";
|
||||
import { ConditionTags } from "./condition-tags.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface Combatant {
|
||||
readonly id: CombatantId;
|
||||
@@ -30,22 +34,12 @@ interface Combatant {
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly creatureId?: CreatureId;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
combatant: Combatant;
|
||||
isActive: boolean;
|
||||
onRename: (id: CombatantId, newName: string) => void;
|
||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||
onRemove: (id: CombatantId) => void;
|
||||
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
||||
onAdjustHp: (id: CombatantId, delta: number) => void;
|
||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||
onToggleConcentration: (id: CombatantId) => void;
|
||||
onShowStatBlock?: () => void;
|
||||
isStatBlockOpen?: boolean;
|
||||
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
@@ -346,7 +340,7 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Empty + bestiary creature → d20 roll button
|
||||
// Empty + bestiary creature -> d20 roll button
|
||||
if (initiative === undefined && onRollInitiative) {
|
||||
return (
|
||||
<>
|
||||
@@ -378,8 +372,8 @@ function InitiativeDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Has value → bold number, click to edit
|
||||
// Empty + manual → "--" placeholder, click to edit
|
||||
// Has value -> bold number, click to edit
|
||||
// Empty + manual -> "--" placeholder, click to edit
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -423,18 +417,30 @@ export function CombatantRow({
|
||||
ref,
|
||||
combatant,
|
||||
isActive,
|
||||
onRename,
|
||||
onSetInitiative,
|
||||
onRemove,
|
||||
onSetHp,
|
||||
onAdjustHp,
|
||||
onSetAc,
|
||||
onToggleCondition,
|
||||
onToggleConcentration,
|
||||
onShowStatBlock,
|
||||
isStatBlockOpen,
|
||||
onRollInitiative,
|
||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
||||
const {
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
removeCombatant,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature } = useSidePanelContext();
|
||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||
|
||||
// Derive what was previously conditional props
|
||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||
const { creatureId } = combatant;
|
||||
const onShowStatBlock = creatureId
|
||||
? () => showCreature(creatureId)
|
||||
: undefined;
|
||||
const onRollInitiative = combatant.creatureId
|
||||
? handleRollInitiative
|
||||
: undefined;
|
||||
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
const dimmed = status === "unconscious";
|
||||
@@ -484,7 +490,7 @@ export function CombatantRow({
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleConcentration(id)}
|
||||
onClick={() => toggleConcentration(id)}
|
||||
title="Concentrating"
|
||||
aria-label="Toggle concentration"
|
||||
className={cn(
|
||||
@@ -500,7 +506,7 @@ export function CombatantRow({
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={onSetInitiative}
|
||||
onSetInitiative={setInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
|
||||
@@ -541,18 +547,18 @@ export function CombatantRow({
|
||||
<EditableName
|
||||
name={name}
|
||||
combatantId={id}
|
||||
onRename={onRename}
|
||||
onRename={editCombatant}
|
||||
color={pcColor}
|
||||
/>
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -560,7 +566,7 @@ export function CombatantRow({
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
@@ -568,7 +574,7 @@ export function CombatantRow({
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
@@ -582,7 +588,7 @@ export function CombatantRow({
|
||||
</span>
|
||||
)}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -590,7 +596,7 @@ export function CombatantRow({
|
||||
<ConfirmButton
|
||||
icon={<X size={16} />}
|
||||
label="Remove combatant"
|
||||
onConfirm={() => onRemove(id)}
|
||||
onConfirm={() => removeCombatant(id)}
|
||||
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||
import { PlayerManagement } from "./player-management.js";
|
||||
|
||||
@@ -7,37 +8,14 @@ export interface PlayerCharacterSectionHandle {
|
||||
openManagement: () => void;
|
||||
}
|
||||
|
||||
interface PlayerCharacterSectionProps {
|
||||
characters: readonly PlayerCharacter[];
|
||||
onCreateCharacter: (
|
||||
name: string,
|
||||
ac: number,
|
||||
maxHp: number,
|
||||
color: string | undefined,
|
||||
icon: string | undefined,
|
||||
) => void;
|
||||
onEditCharacter: (
|
||||
id: PlayerCharacterId,
|
||||
fields: {
|
||||
name?: string;
|
||||
ac?: number;
|
||||
maxHp?: number;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
},
|
||||
) => void;
|
||||
onDeleteCharacter: (id: PlayerCharacterId) => void;
|
||||
}
|
||||
|
||||
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
characters,
|
||||
onCreateCharacter,
|
||||
onEditCharacter,
|
||||
onDeleteCharacter,
|
||||
ref,
|
||||
}: PlayerCharacterSectionProps & {
|
||||
}: {
|
||||
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||
}) {
|
||||
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<
|
||||
@@ -59,7 +37,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
}}
|
||||
onSave={(name, ac, maxHp, color, icon) => {
|
||||
if (editingPlayer) {
|
||||
onEditCharacter(editingPlayer.id, {
|
||||
editCharacter(editingPlayer.id, {
|
||||
name,
|
||||
ac,
|
||||
maxHp,
|
||||
@@ -67,7 +45,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
icon: icon ?? null,
|
||||
});
|
||||
} else {
|
||||
onCreateCharacter(name, ac, maxHp, color, icon);
|
||||
createCharacter(name, ac, maxHp, color, icon);
|
||||
}
|
||||
}}
|
||||
playerCharacter={editingPlayer}
|
||||
@@ -81,7 +59,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setCreateOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
onDelete={(id) => onDeleteCharacter(id)}
|
||||
onDelete={(id) => deleteCharacter(id)}
|
||||
onCreate={() => {
|
||||
setEditingPlayer(undefined);
|
||||
setCreateOpen(true);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||
import {
|
||||
getDefaultFetchUrl,
|
||||
getSourceDisplayName,
|
||||
} from "../adapters/bestiary-index-adapter.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
sourceDisplayName: string;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
onSourceLoaded: () => void;
|
||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
sourceDisplayName,
|
||||
fetchAndCacheSource,
|
||||
onSourceLoaded,
|
||||
onUploadSource,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
await onUploadSource(sourceCode, json);
|
||||
await uploadAndCacheSource(sourceCode, json);
|
||||
onSourceLoaded();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
|
||||
@@ -8,16 +8,12 @@ import {
|
||||
} from "react";
|
||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceManagerProps {
|
||||
onCacheCleared: () => void;
|
||||
}
|
||||
|
||||
export function SourceManager({
|
||||
onCacheCleared,
|
||||
}: Readonly<SourceManagerProps>) {
|
||||
export function SourceManager() {
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||
@@ -44,14 +40,14 @@ export function SourceManager({
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
applyOptimistic({ type: "clear" });
|
||||
await bestiaryCache.clearAll();
|
||||
await loadSources();
|
||||
onCacheCleared();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
const filteredSources = useMemo(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-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";
|
||||
@@ -13,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
creatureId: CreatureId | null;
|
||||
creature: Creature | null;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
) => Promise<void>;
|
||||
refreshCache: () => Promise<void>;
|
||||
panelRole: "browse" | "pinned";
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onPin: () => void;
|
||||
onUnpin: () => void;
|
||||
showPinButton: boolean;
|
||||
side: "left" | "right";
|
||||
onDismiss: () => void;
|
||||
bulkImportMode?: boolean;
|
||||
bulkImportState?: BulkImportState;
|
||||
onStartBulkImport?: (baseUrl: string) => void;
|
||||
onBulkImportDone?: () => void;
|
||||
sourceManagerMode?: boolean;
|
||||
}
|
||||
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
@@ -228,27 +208,49 @@ function MobileDrawer({
|
||||
);
|
||||
}
|
||||
|
||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
const sidePanel = useSidePanelContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
const creatureId =
|
||||
panelRole === "browse"
|
||||
? sidePanel.selectedCreatureId
|
||||
: sidePanel.pinnedCreatureId;
|
||||
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||
|
||||
const isBrowse = panelRole === "browse";
|
||||
return {
|
||||
creatureId,
|
||||
creature,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
creatureId,
|
||||
creature,
|
||||
isSourceCached,
|
||||
fetchAndCacheSource,
|
||||
uploadAndCacheSource,
|
||||
refreshCache,
|
||||
panelRole,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
side,
|
||||
onDismiss,
|
||||
bulkImportMode,
|
||||
bulkImportState,
|
||||
onStartBulkImport,
|
||||
onBulkImportDone,
|
||||
sourceManagerMode,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const { isSourceCached } = useBestiaryContext();
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
onPin,
|
||||
onUnpin,
|
||||
showPinButton,
|
||||
bulkImportMode,
|
||||
sourceManagerMode,
|
||||
} = usePanelRole(panelRole);
|
||||
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
@@ -285,29 +287,17 @@ export function StatBlockPanel({
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
const handleSourceLoaded = async () => {
|
||||
await refreshCache();
|
||||
const handleSourceLoaded = () => {
|
||||
setNeedsFetch(false);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (sourceManagerMode) {
|
||||
return <SourceManager onCacheCleared={refreshCache} />;
|
||||
return <SourceManager />;
|
||||
}
|
||||
|
||||
if (
|
||||
bulkImportMode &&
|
||||
bulkImportState &&
|
||||
onStartBulkImport &&
|
||||
onBulkImportDone
|
||||
) {
|
||||
return (
|
||||
<BulkImportPrompt
|
||||
importState={bulkImportState}
|
||||
onStartImport={onStartBulkImport}
|
||||
onDone={onBulkImportDone}
|
||||
/>
|
||||
);
|
||||
if (bulkImportMode) {
|
||||
return <BulkImportPrompt />;
|
||||
}
|
||||
|
||||
if (checkingCache) {
|
||||
@@ -324,10 +314,7 @@ export function StatBlockPanel({
|
||||
return (
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
onSourceLoaded={handleSourceLoaded}
|
||||
onUploadSource={uploadAndCacheSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
}
|
||||
export function TurnNavigation() {
|
||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||
useEncounterContext();
|
||||
|
||||
export function TurnNavigation({
|
||||
encounter,
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
}: Readonly<TurnNavigationProps>) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
@@ -25,7 +16,7 @@ export function TurnNavigation({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRetreatTurn}
|
||||
onClick={retreatTurn}
|
||||
disabled={!hasCombatants || isAtStart}
|
||||
title="Previous turn"
|
||||
aria-label="Previous turn"
|
||||
@@ -48,14 +39,14 @@ export function TurnNavigation({
|
||||
<ConfirmButton
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Clear encounter"
|
||||
onConfirm={onClearEncounter}
|
||||
onConfirm={clearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAdvanceTurn}
|
||||
onClick={advanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
title="Next turn"
|
||||
aria-label="Next turn"
|
||||
|
||||
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
23
apps/web/src/contexts/bestiary-context.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useBestiary } from "../hooks/use-bestiary.js";
|
||||
|
||||
export type { SearchResult } from "../hooks/use-bestiary.js";
|
||||
|
||||
type BestiaryContextValue = ReturnType<typeof useBestiary>;
|
||||
|
||||
const BestiaryContext = createContext<BestiaryContextValue | null>(null);
|
||||
|
||||
export function BestiaryProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBestiary();
|
||||
return (
|
||||
<BestiaryContext.Provider value={value}>
|
||||
{children}
|
||||
</BestiaryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBestiaryContext(): BestiaryContextValue {
|
||||
const ctx = useContext(BestiaryContext);
|
||||
if (!ctx) throw new Error("useBestiaryContext requires BestiaryProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
21
apps/web/src/contexts/bulk-import-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useBulkImport } from "../hooks/use-bulk-import.js";
|
||||
|
||||
type BulkImportContextValue = ReturnType<typeof useBulkImport>;
|
||||
|
||||
const BulkImportContext = createContext<BulkImportContextValue | null>(null);
|
||||
|
||||
export function BulkImportProvider({ children }: { children: ReactNode }) {
|
||||
const value = useBulkImport();
|
||||
return (
|
||||
<BulkImportContext.Provider value={value}>
|
||||
{children}
|
||||
</BulkImportContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBulkImportContext(): BulkImportContextValue {
|
||||
const ctx = useContext(BulkImportContext);
|
||||
if (!ctx) throw new Error("useBulkImportContext requires BulkImportProvider");
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/encounter-context.tsx
Normal file
21
apps/web/src/contexts/encounter-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useEncounter } from "../hooks/use-encounter.js";
|
||||
|
||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||
|
||||
const EncounterContext = createContext<EncounterContextValue | null>(null);
|
||||
|
||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||
const value = useEncounter();
|
||||
return (
|
||||
<EncounterContext.Provider value={value}>
|
||||
{children}
|
||||
</EncounterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEncounterContext(): EncounterContextValue {
|
||||
const ctx = useContext(EncounterContext);
|
||||
if (!ctx) throw new Error("useEncounterContext requires EncounterProvider");
|
||||
return ctx;
|
||||
}
|
||||
7
apps/web/src/contexts/index.ts
Normal file
7
apps/web/src/contexts/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { BestiaryProvider } from "./bestiary-context.js";
|
||||
export { BulkImportProvider } from "./bulk-import-context.js";
|
||||
export { EncounterProvider } from "./encounter-context.js";
|
||||
export { InitiativeRollsProvider } from "./initiative-rolls-context.js";
|
||||
export { PlayerCharactersProvider } from "./player-characters-context.js";
|
||||
export { SidePanelProvider } from "./side-panel-context.js";
|
||||
export { ThemeProvider } from "./theme-context.js";
|
||||
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
25
apps/web/src/contexts/initiative-rolls-context.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useInitiativeRolls } from "../hooks/use-initiative-rolls.js";
|
||||
|
||||
type InitiativeRollsContextValue = ReturnType<typeof useInitiativeRolls>;
|
||||
|
||||
const InitiativeRollsContext =
|
||||
createContext<InitiativeRollsContextValue | null>(null);
|
||||
|
||||
export function InitiativeRollsProvider({ children }: { children: ReactNode }) {
|
||||
const value = useInitiativeRolls();
|
||||
return (
|
||||
<InitiativeRollsContext.Provider value={value}>
|
||||
{children}
|
||||
</InitiativeRollsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInitiativeRollsContext(): InitiativeRollsContextValue {
|
||||
const ctx = useContext(InitiativeRollsContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useInitiativeRollsContext requires InitiativeRollsProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
29
apps/web/src/contexts/player-characters-context.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { usePlayerCharacters } from "../hooks/use-player-characters.js";
|
||||
|
||||
type PlayerCharactersContextValue = ReturnType<typeof usePlayerCharacters>;
|
||||
|
||||
const PlayerCharactersContext =
|
||||
createContext<PlayerCharactersContextValue | null>(null);
|
||||
|
||||
export function PlayerCharactersProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = usePlayerCharacters();
|
||||
return (
|
||||
<PlayerCharactersContext.Provider value={value}>
|
||||
{children}
|
||||
</PlayerCharactersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlayerCharactersContext(): PlayerCharactersContextValue {
|
||||
const ctx = useContext(PlayerCharactersContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"usePlayerCharactersContext requires PlayerCharactersProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
21
apps/web/src/contexts/side-panel-context.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useSidePanelState } from "../hooks/use-side-panel-state.js";
|
||||
|
||||
type SidePanelContextValue = ReturnType<typeof useSidePanelState>;
|
||||
|
||||
const SidePanelContext = createContext<SidePanelContextValue | null>(null);
|
||||
|
||||
export function SidePanelProvider({ children }: { children: ReactNode }) {
|
||||
const value = useSidePanelState();
|
||||
return (
|
||||
<SidePanelContext.Provider value={value}>
|
||||
{children}
|
||||
</SidePanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidePanelContext(): SidePanelContextValue {
|
||||
const ctx = useContext(SidePanelContext);
|
||||
if (!ctx) throw new Error("useSidePanelContext requires SidePanelProvider");
|
||||
return ctx;
|
||||
}
|
||||
19
apps/web/src/contexts/theme-context.tsx
Normal file
19
apps/web/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useTheme } from "../hooks/use-theme.js";
|
||||
|
||||
type ThemeContextValue = ReturnType<typeof useTheme>;
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const value = useTheme();
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThemeContext(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useThemeContext requires ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
17
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
17
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
export function useAutoStatBlock(): void {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { panelView, updateCreature } = useSidePanelContext();
|
||||
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCreatureId && panelView.mode === "creature") {
|
||||
updateCreature(activeCreatureId);
|
||||
}
|
||||
}, [activeCreatureId, panelView.mode, updateCreature]);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
const BATCH_SIZE = 6;
|
||||
|
||||
export interface BulkImportState {
|
||||
interface BulkImportState {
|
||||
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
||||
readonly total: number;
|
||||
readonly completed: number;
|
||||
|
||||
75
apps/web/src/hooks/use-initiative-rolls.ts
Normal file
75
apps/web/src/hooks/use-initiative-rolls.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
rollAllInitiativeUseCase,
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import {
|
||||
type CombatantId,
|
||||
isDomainError,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, 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";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
}
|
||||
|
||||
export function useInitiativeRolls() {
|
||||
const { encounter, makeStore } = useEncounterContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { showCreature } = useSidePanelContext();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
const [rollSingleSkipped, setRollSingleSkipped] = useState(false);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId, mode: RollMode = "normal") => {
|
||||
const diceRolls: [number, ...number[]] =
|
||||
mode === "normal" ? [rollDice()] : [rollDice(), rollDice()];
|
||||
const result = rollInitiativeUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
diceRolls,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
if (isDomainError(result)) {
|
||||
setRollSingleSkipped(true);
|
||||
const combatant = encounter.combatants.find((c) => c.id === id);
|
||||
if (combatant?.creatureId) {
|
||||
showCreature(combatant.creatureId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature, encounter.combatants, showCreature],
|
||||
);
|
||||
|
||||
const handleRollAllInitiative = useCallback(
|
||||
(mode: RollMode = "normal") => {
|
||||
const result = rollAllInitiativeUseCase(
|
||||
makeStore(),
|
||||
rollDice,
|
||||
getCreature,
|
||||
mode,
|
||||
);
|
||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||
setRollSkippedCount(result.skippedNoSource);
|
||||
}
|
||||
},
|
||||
[makeStore, getCreature],
|
||||
);
|
||||
|
||||
return {
|
||||
rollSkippedCount,
|
||||
rollSingleSkipped,
|
||||
dismissRollSkipped: useCallback(() => setRollSkippedCount(0), []),
|
||||
dismissRollSingleSkipped: useCallback(
|
||||
() => setRollSingleSkipped(false),
|
||||
[],
|
||||
),
|
||||
handleRollInitiative,
|
||||
handleRollAllInitiative,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { App } from "./App.js";
|
||||
import {
|
||||
BestiaryProvider,
|
||||
BulkImportProvider,
|
||||
EncounterProvider,
|
||||
InitiativeRollsProvider,
|
||||
PlayerCharactersProvider,
|
||||
SidePanelProvider,
|
||||
ThemeProvider,
|
||||
} from "./contexts/index.js";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<EncounterProvider>
|
||||
<BestiaryProvider>
|
||||
<PlayerCharactersProvider>
|
||||
<BulkImportProvider>
|
||||
<SidePanelProvider>
|
||||
<InitiativeRollsProvider>
|
||||
<App />
|
||||
</InitiativeRollsProvider>
|
||||
</SidePanelProvider>
|
||||
</BulkImportProvider>
|
||||
</PlayerCharactersProvider>
|
||||
</BestiaryProvider>
|
||||
</EncounterProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user