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,9 +1,9 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
───────────────────
|
───────────────────
|
||||||
Version change: 2.2.1 → 3.0.0 (MAJOR — specs describe features not changes, proportional workflow)
|
Version change: 3.0.0 → 3.1.0 (MINOR — new principle II-A: context-based state flow)
|
||||||
Modified sections:
|
Modified sections:
|
||||||
- Development Workflow: specs are living feature documents; full pipeline for new features only
|
- Core Principles: added II-A. Context-Based State Flow (max 8 props, context over prop drilling)
|
||||||
Templates requiring updates: none
|
Templates requiring updates: none
|
||||||
-->
|
-->
|
||||||
# Encounter Console Constitution
|
# Encounter Console Constitution
|
||||||
@@ -38,6 +38,22 @@ dependency direction:
|
|||||||
|
|
||||||
A module in an inner layer MUST NOT import from an outer layer.
|
A module in an inner layer MUST NOT import from an outer layer.
|
||||||
|
|
||||||
|
### II-A. Context-Based State Flow
|
||||||
|
|
||||||
|
UI components MUST consume shared application state via React context
|
||||||
|
providers, not prop drilling. Props are reserved for per-instance
|
||||||
|
configuration (e.g., a specific data item, a layout variant, a ref).
|
||||||
|
|
||||||
|
- Components MUST NOT declare more than 8 explicit props in their
|
||||||
|
own interface. This is enforced by `scripts/check-component-props.mjs`
|
||||||
|
at pre-commit.
|
||||||
|
- Generic UI primitives (`components/ui/`) that extend HTML element
|
||||||
|
attributes are exempt — only explicitly declared props count, not
|
||||||
|
inherited HTML attributes.
|
||||||
|
- Coordinating hooks that consume multiple contexts (e.g.,
|
||||||
|
`useInitiativeRolls`) are preferred over wiring callbacks through
|
||||||
|
a parent component.
|
||||||
|
|
||||||
### III. Clarification-First
|
### III. Clarification-First
|
||||||
|
|
||||||
Before making any non-trivial assumption during specification,
|
Before making any non-trivial assumption during specification,
|
||||||
@@ -140,4 +156,4 @@ MUST comply with its principles.
|
|||||||
**Compliance review**: Every spec and plan MUST include a
|
**Compliance review**: Every spec and plan MUST include a
|
||||||
Constitution Check section validating adherence to all principles.
|
Constitution Check section validating adherence to all principles.
|
||||||
|
|
||||||
**Version**: 3.0.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-11
|
**Version**: 3.1.0 | **Ratified**: 2026-03-03 | **Last Amended**: 2026-03-19
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pnpm test:watch # Tests in watch mode
|
|||||||
pnpm typecheck # tsc --build (project references)
|
pnpm typecheck # tsc --build (project references)
|
||||||
pnpm lint # Biome lint
|
pnpm lint # Biome lint
|
||||||
pnpm format # Biome format (writes)
|
pnpm format # Biome format (writes)
|
||||||
|
pnpm check:props # Component prop count enforcement (max 8)
|
||||||
pnpm --filter web dev # Vite dev server (localhost:5173)
|
pnpm --filter web dev # Vite dev server (localhost:5173)
|
||||||
pnpm --filter web build # Production build
|
pnpm --filter web build # Production build
|
||||||
```
|
```
|
||||||
@@ -71,6 +72,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||||
|
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|||||||
@@ -1,42 +1,19 @@
|
|||||||
import {
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
rollAllInitiativeUseCase,
|
import { ActionBar } from "./components/action-bar.js";
|
||||||
rollInitiativeUseCase,
|
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||||
} from "@initiative/application";
|
import { CombatantRow } from "./components/combatant-row.js";
|
||||||
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 {
|
import {
|
||||||
PlayerCharacterSection,
|
PlayerCharacterSection,
|
||||||
type PlayerCharacterSectionHandle,
|
type PlayerCharacterSectionHandle,
|
||||||
} from "./components/player-character-section";
|
} from "./components/player-character-section.js";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||||
import { Toast } from "./components/toast";
|
import { Toast } from "./components/toast.js";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
import { cn } from "./lib/utils.js";
|
||||||
import { useTheme } from "./hooks/use-theme";
|
|
||||||
import { cn } from "./lib/utils";
|
|
||||||
|
|
||||||
function rollDice(): number {
|
|
||||||
return Math.floor(Math.random() * 20) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useActionBarAnimation(combatantCount: number) {
|
function useActionBarAnimation(combatantCount: number) {
|
||||||
const wasEmptyRef = useRef(combatantCount === 0);
|
const wasEmptyRef = useRef(combatantCount === 0);
|
||||||
@@ -76,164 +53,27 @@ function useActionBarAnimation(combatantCount: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const { encounter, isEmpty } = useEncounterContext();
|
||||||
encounter,
|
const sidePanel = useSidePanelContext();
|
||||||
isEmpty,
|
const rolls = useInitiativeRollsContext();
|
||||||
hasCreatureCombatants,
|
|
||||||
canRollAllInitiative,
|
|
||||||
advanceTurn,
|
|
||||||
retreatTurn,
|
|
||||||
addCombatant,
|
|
||||||
clearEncounter,
|
|
||||||
removeCombatant,
|
|
||||||
editCombatant,
|
|
||||||
setInitiative,
|
|
||||||
setHp,
|
|
||||||
adjustHp,
|
|
||||||
setAc,
|
|
||||||
toggleCondition,
|
|
||||||
toggleConcentration,
|
|
||||||
addFromBestiary,
|
|
||||||
addFromPlayerCharacter,
|
|
||||||
makeStore,
|
|
||||||
} = useEncounter();
|
|
||||||
|
|
||||||
const {
|
useAutoStatBlock();
|
||||||
characters: playerCharacters,
|
|
||||||
createCharacter: createPlayerCharacter,
|
|
||||||
editCharacter: editPlayerCharacter,
|
|
||||||
deleteCharacter: deletePlayerCharacter,
|
|
||||||
} = usePlayerCharacters();
|
|
||||||
|
|
||||||
const {
|
|
||||||
search,
|
|
||||||
getCreature,
|
|
||||||
isLoaded,
|
|
||||||
isSourceCached,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
uploadAndCacheSource,
|
|
||||||
refreshCache,
|
|
||||||
} = useBestiary();
|
|
||||||
|
|
||||||
const bulkImport = useBulkImport();
|
|
||||||
const sidePanel = useSidePanelState();
|
|
||||||
const { 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 playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
|
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||||
|
|
||||||
// Auto-update stat block panel when the active combatant changes
|
// Auto-scroll to active combatant when turn changes
|
||||||
const activeCreatureId =
|
const activeIndex = encounter.activeIndex;
|
||||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeCreatureId && sidePanel.panelView.mode === "creature") {
|
|
||||||
sidePanel.updateCreature(activeCreatureId);
|
|
||||||
}
|
|
||||||
}, [activeCreatureId, sidePanel.panelView.mode, sidePanel.updateCreature]);
|
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeIndex >= 0) {
|
||||||
activeRowRef.current?.scrollIntoView({
|
activeRowRef.current?.scrollIntoView({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
@@ -243,49 +83,27 @@ export function App() {
|
|||||||
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
className={cn("shrink-0 pt-8", actionBarAnim.topBarClass)}
|
||||||
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
onAnimationEnd={actionBarAnim.onTopBarExitEnd}
|
||||||
>
|
>
|
||||||
<TurnNavigation
|
<TurnNavigation />
|
||||||
encounter={encounter}
|
|
||||||
onAdvanceTurn={advanceTurn}
|
|
||||||
onRetreatTurn={retreatTurn}
|
|
||||||
onClearEncounter={clearEncounter}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
/* Empty state — ActionBar centered */
|
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
<div className="flex min-h-0 flex-1 items-center justify-center pt-8 pb-[15%]">
|
||||||
<div
|
<div
|
||||||
className={cn("w-full", actionBarAnim.risingClass)}
|
className={cn("w-full", actionBarAnim.risingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onRiseEnd}
|
onAnimationEnd={actionBarAnim.onRiseEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
onAddCombatant={addCombatant}
|
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
|
||||||
bestiarySearch={search}
|
|
||||||
bestiaryLoaded={isLoaded}
|
|
||||||
onViewStatBlock={handleViewStatBlock}
|
|
||||||
onBulkImport={sidePanel.showBulkImport}
|
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
|
||||||
showRollAllInitiative={hasCreatureCombatants}
|
|
||||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
|
||||||
onOpenSourceManager={sidePanel.showSourceManager}
|
|
||||||
themePreference={themePreference}
|
|
||||||
onCycleTheme={cycleTheme}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Scrollable area — combatant list */}
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex flex-col px-2 py-2">
|
||||||
{encounter.combatants.map((c, i) => (
|
{encounter.combatants.map((c, i) => (
|
||||||
@@ -294,133 +112,51 @@ export function App() {
|
|||||||
ref={i === encounter.activeIndex ? activeRowRef : null}
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
||||||
combatant={c}
|
combatant={c}
|
||||||
isActive={i === encounter.activeIndex}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Bar — fixed at bottom */}
|
|
||||||
<div
|
<div
|
||||||
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
className={cn("shrink-0 pb-8", actionBarAnim.settlingClass)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
onAddCombatant={addCombatant}
|
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
|
||||||
bestiarySearch={search}
|
|
||||||
bestiaryLoaded={isLoaded}
|
|
||||||
onViewStatBlock={handleViewStatBlock}
|
|
||||||
onBulkImport={sidePanel.showBulkImport}
|
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
|
||||||
onManagePlayers={() =>
|
onManagePlayers={() =>
|
||||||
playerCharacterRef.current?.openManagement()
|
playerCharacterRef.current?.openManagement()
|
||||||
}
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
|
||||||
showRollAllInitiative={hasCreatureCombatants}
|
|
||||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
|
||||||
onOpenSourceManager={sidePanel.showSourceManager}
|
|
||||||
themePreference={themePreference}
|
|
||||||
onCycleTheme={cycleTheme}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
|
||||||
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
<StatBlockPanel
|
<StatBlockPanel panelRole="pinned" side="left" />
|
||||||
creatureId={sidePanel.pinnedCreatureId}
|
|
||||||
creature={pinnedCreature}
|
|
||||||
isSourceCached={isSourceCached}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
|
||||||
refreshCache={refreshCache}
|
|
||||||
panelRole="pinned"
|
|
||||||
isCollapsed={false}
|
|
||||||
onToggleCollapse={() => {}}
|
|
||||||
onPin={() => {}}
|
|
||||||
onUnpin={sidePanel.unpin}
|
|
||||||
showPinButton={false}
|
|
||||||
side="left"
|
|
||||||
onDismiss={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Browse Stat Block Panel (right) */}
|
<StatBlockPanel panelRole="browse" side="right" />
|
||||||
<StatBlockPanel
|
|
||||||
creatureId={sidePanel.selectedCreatureId}
|
|
||||||
creature={selectedCreature}
|
|
||||||
isSourceCached={isSourceCached}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
|
||||||
refreshCache={refreshCache}
|
|
||||||
panelRole="browse"
|
|
||||||
isCollapsed={sidePanel.isRightPanelCollapsed}
|
|
||||||
onToggleCollapse={sidePanel.toggleCollapse}
|
|
||||||
onPin={sidePanel.togglePin}
|
|
||||||
onUnpin={() => {}}
|
|
||||||
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
|
||||||
side="right"
|
|
||||||
onDismiss={sidePanel.dismissPanel}
|
|
||||||
bulkImportMode={sidePanel.bulkImportMode}
|
|
||||||
bulkImportState={bulkImport.state}
|
|
||||||
onStartBulkImport={handleStartBulkImport}
|
|
||||||
onBulkImportDone={handleBulkImportDone}
|
|
||||||
sourceManagerMode={sidePanel.sourceManagerMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BulkImportToasts
|
<BulkImportToasts />
|
||||||
state={bulkImport.state}
|
|
||||||
visible={!sidePanel.bulkImportMode || sidePanel.isRightPanelCollapsed}
|
|
||||||
onReset={bulkImport.reset}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{rollSkippedCount > 0 && (
|
{rolls.rollSkippedCount > 0 && (
|
||||||
<Toast
|
<Toast
|
||||||
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
onDismiss={() => setRollSkippedCount(0)}
|
onDismiss={rolls.dismissRollSkipped}
|
||||||
autoDismissMs={4000}
|
autoDismissMs={4000}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!rollSingleSkipped && (
|
{!!rolls.rollSingleSkipped && (
|
||||||
<Toast
|
<Toast
|
||||||
message="Can't roll — bestiary source not loaded"
|
message="Can't roll — bestiary source not loaded"
|
||||||
onDismiss={() => setRollSingleSkipped(false)}
|
onDismiss={rolls.dismissRollSingleSkipped}
|
||||||
autoDismissMs={4000}
|
autoDismissMs={4000}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayerCharacterSection
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
ref={playerCharacterRef}
|
|
||||||
characters={playerCharacters}
|
|
||||||
onCreateCharacter={createPlayerCharacter}
|
|
||||||
onEditCharacter={editPlayerCharacter}
|
|
||||||
onDeleteCharacter={deletePlayerCharacter}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
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
|
// Mock persistence — no localStorage interaction
|
||||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
vi.mock("../persistence/encounter-storage.js", () => ({
|
||||||
@@ -76,7 +77,7 @@ async function addCombatant(
|
|||||||
describe("App integration", () => {
|
describe("App integration", () => {
|
||||||
it("adds a combatant and removes it, returning to empty state", async () => {
|
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
// Empty state: centered input visible, no TurnNavigation
|
// Empty state: centered input visible, no TurnNavigation
|
||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
@@ -109,7 +110,7 @@ describe("App integration", () => {
|
|||||||
|
|
||||||
it("advances and retreats turns across two combatants", async () => {
|
it("advances and retreats turns across two combatants", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
await addCombatant(user, "Fighter");
|
await addCombatant(user, "Fighter");
|
||||||
await addCombatant(user, "Wizard");
|
await addCombatant(user, "Wizard");
|
||||||
@@ -137,7 +138,7 @@ describe("App integration", () => {
|
|||||||
|
|
||||||
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,33 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type { Creature, CreatureId } from "@initiative/domain";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 CLOSE_REGEX = /close/i;
|
||||||
const COLLAPSE_REGEX = /collapse/i;
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
|
|
||||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||||
const CREATURE: Creature = {
|
const CREATURE: Creature = {
|
||||||
id: CREATURE_ID,
|
id: CREATURE_ID,
|
||||||
@@ -44,41 +66,65 @@ function mockMatchMedia(matches: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelOverrides {
|
||||||
creatureId?: CreatureId | null;
|
creatureId?: CreatureId | null;
|
||||||
creature?: Creature | null;
|
creature?: Creature | null;
|
||||||
panelRole?: "browse" | "pinned";
|
panelRole?: "browse" | "pinned";
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
|
||||||
onPin?: () => void;
|
|
||||||
onUnpin?: () => void;
|
|
||||||
showPinButton?: boolean;
|
|
||||||
side?: "left" | "right";
|
side?: "left" | "right";
|
||||||
onDismiss?: () => void;
|
|
||||||
bulkImportMode?: boolean;
|
bulkImportMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPanel(overrides: PanelProps = {}) {
|
function setupMocks(overrides: PanelOverrides = {}) {
|
||||||
const props = {
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
creatureId: CREATURE_ID,
|
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||||
creature: CREATURE,
|
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),
|
isSourceCached: vi.fn().mockResolvedValue(true),
|
||||||
|
search: vi.fn().mockReturnValue([]),
|
||||||
|
isLoaded: true,
|
||||||
fetchAndCacheSource: vi.fn(),
|
fetchAndCacheSource: vi.fn(),
|
||||||
uploadAndCacheSource: vi.fn(),
|
uploadAndCacheSource: vi.fn(),
|
||||||
refreshCache: vi.fn(),
|
refreshCache: vi.fn(),
|
||||||
panelRole: "browse" as const,
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
isCollapsed: false,
|
|
||||||
onToggleCollapse: vi.fn(),
|
|
||||||
onPin: vi.fn(),
|
|
||||||
onUnpin: vi.fn(),
|
|
||||||
showPinButton: false,
|
|
||||||
side: "right" as const,
|
|
||||||
onDismiss: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<StatBlockPanel {...props} />);
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||||
return props;
|
}
|
||||||
|
|
||||||
|
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", () => {
|
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", () => {
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
const props = renderPanel();
|
const callbacks = renderPanel();
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
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", () => {
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
const props = renderPanel({ isCollapsed: true });
|
const callbacks = renderPanel({ isCollapsed: true });
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Expand stat block panel" }),
|
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)", () => {
|
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", () => {
|
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||||
const props = renderPanel();
|
const callbacks = renderPanel();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
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", () => {
|
it("does not render pinned panel on mobile", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<StatBlockPanel
|
(() => {
|
||||||
creatureId={CREATURE_ID}
|
setupMocks({ panelRole: "pinned" });
|
||||||
creature={CREATURE}
|
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||||
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()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(container.innerHTML).toBe("");
|
expect(container.innerHTML).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US2: Pin and Unpin", () => {
|
describe("US2: Pin and Unpin", () => {
|
||||||
it("shows pin button when showPinButton is true on desktop", () => {
|
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||||
renderPanel({ showPinButton: true });
|
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(
|
expect(
|
||||||
screen.getByRole("button", { name: "Pin creature" }),
|
screen.getByRole("button", { name: "Pin creature" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides pin button when showPinButton is false", () => {
|
it("hides pin button when isWideDesktop is false", () => {
|
||||||
renderPanel({ showPinButton: false });
|
renderPanel();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: "Pin creature" }),
|
screen.queryByRole("button", { name: "Pin creature" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onPin when pin button is clicked", () => {
|
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" }));
|
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", () => {
|
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", () => {
|
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" }));
|
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", () => {
|
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)", () => {
|
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", {
|
const unpinBtn = screen.getByRole("button", {
|
||||||
name: "Unpin creature",
|
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 { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { ActionBar } from "../action-bar";
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const defaultProps = {
|
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||||
onAddCombatant: vi.fn(),
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||||
onAddFromBestiary: vi.fn(),
|
|
||||||
bestiarySearch: () => [],
|
|
||||||
bestiaryLoaded: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderBar(overrides: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
|
||||||
const props = { ...defaultProps, ...overrides };
|
|
||||||
return render(<ActionBar {...props} />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ActionBar", () => {
|
describe("ActionBar", () => {
|
||||||
@@ -26,26 +64,26 @@ describe("ActionBar", () => {
|
|||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
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 user = userEvent.setup();
|
||||||
const onAddCombatant = vi.fn();
|
renderBar();
|
||||||
renderBar({ onAddCombatant });
|
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "Goblin");
|
await user.type(input, "Goblin");
|
||||||
// The Add button appears when name >= 2 chars and no suggestions
|
// The Add button appears when name >= 2 chars and no suggestions
|
||||||
const addButton = screen.getByRole("button", { name: "Add" });
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
await user.click(addButton);
|
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 () => {
|
it("submitting with empty name does nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onAddCombatant = vi.fn();
|
renderBar();
|
||||||
renderBar({ onAddCombatant });
|
|
||||||
// Submit the form directly (Enter on empty input)
|
// Submit the form directly (Enter on empty input)
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "{Enter}");
|
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 () => {
|
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();
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows roll all initiative button when showRollAllInitiative is true", () => {
|
it("does not show roll all initiative button when no creature combatants", () => {
|
||||||
const onRollAllInitiative = vi.fn();
|
renderBar();
|
||||||
renderBar({ showRollAllInitiative: true, onRollAllInitiative });
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("roll all initiative button is disabled when rollAllInitiativeDisabled is true", () => {
|
it("shows overflow menu items", () => {
|
||||||
const onRollAllInitiative = vi.fn();
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
renderBar({
|
// The overflow menu should be present (it contains Player Characters etc.)
|
||||||
showRollAllInitiative: true,
|
|
||||||
onRollAllInitiative,
|
|
||||||
rollAllInitiativeDisabled: true,
|
|
||||||
});
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Roll all initiative" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeDisabled();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,65 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
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 { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { CombatantRow } from "../combatant-row";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { PLAYER_COLOR_HEX } from "../player-icon-map";
|
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);
|
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(
|
function renderRow(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onRollInitiative: (id: ReturnType<typeof combatantId>) => void;
|
|
||||||
onRemove: (id: ReturnType<typeof combatantId>) => void;
|
|
||||||
onShowStatBlock: () => void;
|
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const combatant = overrides.combatant ?? {
|
const combatant = overrides.combatant ?? {
|
||||||
@@ -38,15 +70,13 @@ function renderRow(
|
|||||||
currentHp: 10,
|
currentHp: 10,
|
||||||
ac: 13,
|
ac: 13,
|
||||||
};
|
};
|
||||||
const props = {
|
return render(
|
||||||
...defaultProps,
|
<CombatantRow
|
||||||
combatant,
|
combatant={combatant}
|
||||||
isActive: overrides.isActive ?? false,
|
isActive={overrides.isActive ?? false}
|
||||||
onRollInitiative: overrides.onRollInitiative,
|
/>,
|
||||||
onShowStatBlock: overrides.onShowStatBlock,
|
{ wrapper: AllProviders },
|
||||||
onRemove: overrides.onRemove ?? defaultProps.onRemove,
|
);
|
||||||
};
|
|
||||||
return render(<CombatantRow {...props} />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CombatantRow", () => {
|
describe("CombatantRow", () => {
|
||||||
@@ -132,10 +162,9 @@ describe("CombatantRow", () => {
|
|||||||
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
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 user = userEvent.setup();
|
||||||
const onRemove = vi.fn();
|
renderRow();
|
||||||
renderRow({ onRemove });
|
|
||||||
const removeBtn = screen.getByRole("button", {
|
const removeBtn = screen.getByRole("button", {
|
||||||
name: "Remove combatant",
|
name: "Remove combatant",
|
||||||
});
|
});
|
||||||
@@ -146,16 +175,19 @@ describe("CombatantRow", () => {
|
|||||||
name: "Confirm remove combatant",
|
name: "Confirm remove combatant",
|
||||||
});
|
});
|
||||||
await user.click(confirmBtn);
|
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({
|
renderRow({
|
||||||
combatant: {
|
combatant: {
|
||||||
id: combatantId("1"),
|
id: combatantId("1"),
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
|
creatureId: "srd:goblin" as CreatureId,
|
||||||
},
|
},
|
||||||
onRollInitiative: vi.fn(),
|
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Roll initiative" }),
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
|
|||||||
@@ -11,28 +11,51 @@ vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|||||||
clearAll: vi.fn(),
|
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 * 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 mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||||
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
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", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
|
setupMockContext();
|
||||||
mockGetCachedSources.mockResolvedValue([]);
|
mockGetCachedSources.mockResolvedValue([]);
|
||||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
render(<SourceManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
|
setupMockContext();
|
||||||
mockGetCachedSources.mockResolvedValue([
|
mockGetCachedSources.mockResolvedValue([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
@@ -47,7 +70,7 @@ describe("SourceManager", () => {
|
|||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<SourceManager onCacheCleared={vi.fn()} />);
|
render(<SourceManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -56,9 +79,9 @@ describe("SourceManager", () => {
|
|||||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
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 user = userEvent.setup();
|
||||||
const onCacheCleared = vi.fn();
|
const { refreshCache } = setupMockContext();
|
||||||
mockGetCachedSources
|
mockGetCachedSources
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -70,7 +93,7 @@ describe("SourceManager", () => {
|
|||||||
])
|
])
|
||||||
.mockResolvedValue([]);
|
.mockResolvedValue([]);
|
||||||
mockClearAll.mockResolvedValue(undefined);
|
mockClearAll.mockResolvedValue(undefined);
|
||||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
render(<SourceManager />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
@@ -80,12 +103,12 @@ describe("SourceManager", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearAll).toHaveBeenCalled();
|
expect(mockClearAll).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
expect(onCacheCleared).toHaveBeenCalled();
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("individual source delete button calls clear for that source", async () => {
|
it("individual source delete button calls clear for that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onCacheCleared = vi.fn();
|
const { refreshCache } = setupMockContext();
|
||||||
mockGetCachedSources
|
mockGetCachedSources
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -111,7 +134,7 @@ describe("SourceManager", () => {
|
|||||||
]);
|
]);
|
||||||
mockClearSource.mockResolvedValue(undefined);
|
mockClearSource.mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<SourceManager onCacheCleared={onCacheCleared} />);
|
render(<SourceManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -122,6 +145,6 @@ describe("SourceManager", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
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 { combatantId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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 = {
|
const encounter: Encounter = {
|
||||||
combatants: [
|
combatants: [
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
{ id: combatantId("1"), name: "Goblin" },
|
||||||
@@ -20,14 +32,38 @@ function renderNav(overrides: Partial<Encounter> = {}) {
|
|||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
return render(
|
const value = {
|
||||||
<TurnNavigation
|
encounter,
|
||||||
encounter={encounter}
|
advanceTurn: vi.fn(),
|
||||||
onAdvanceTurn={vi.fn()}
|
retreatTurn: vi.fn(),
|
||||||
onRetreatTurn={vi.fn()}
|
clearEncounter: vi.fn(),
|
||||||
onClearEncounter={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", () => {
|
describe("TurnNavigation", () => {
|
||||||
@@ -49,7 +85,7 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("does not render an em dash between round and name", () => {
|
it("does not render an em dash between round and name", () => {
|
||||||
const { container } = renderNav();
|
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", () => {
|
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", () => {
|
it("updates the round badge when round changes", () => {
|
||||||
const { rerender } = render(
|
mockContext({ roundNumber: 2 });
|
||||||
<TurnNavigation
|
const { rerender } = render(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 2,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
mockContext({ roundNumber: 3 });
|
||||||
<TurnNavigation
|
rerender(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 3,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the next combatant name when turn advances", () => {
|
it("renders the next combatant name when turn advances", () => {
|
||||||
const { rerender } = render(
|
const combatants = [
|
||||||
<TurnNavigation
|
|
||||||
encounter={{
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
{ id: combatantId("1"), name: "Goblin" },
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
{ id: combatantId("2"), name: "Conjurer" },
|
||||||
],
|
];
|
||||||
activeIndex: 0,
|
mockContext({ combatants, activeIndex: 0 });
|
||||||
roundNumber: 1,
|
const { rerender } = render(<TurnNavigation />);
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
mockContext({ combatants, activeIndex: 1 });
|
||||||
<TurnNavigation
|
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()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlayerCharacter, RollMode } from "@initiative/domain";
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -18,11 +18,18 @@ import React, {
|
|||||||
useDeferredValue,
|
useDeferredValue,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon.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 { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
@@ -34,27 +41,9 @@ interface QueuedCreature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ActionBarProps {
|
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>;
|
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;
|
autoFocus?: boolean;
|
||||||
themePreference?: "system" | "light" | "dark";
|
onManagePlayers?: () => void;
|
||||||
onCycleTheme?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
@@ -285,25 +274,48 @@ function buildOverflowItems(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
onAddCombatant,
|
|
||||||
onAddFromBestiary,
|
|
||||||
bestiarySearch,
|
|
||||||
bestiaryLoaded,
|
|
||||||
onViewStatBlock,
|
|
||||||
onBulkImport,
|
|
||||||
bulkImportDisabled,
|
|
||||||
inputRef,
|
inputRef,
|
||||||
playerCharacters,
|
|
||||||
onAddFromPlayerCharacter,
|
|
||||||
onManagePlayers,
|
|
||||||
onRollAllInitiative,
|
|
||||||
showRollAllInitiative,
|
|
||||||
rollAllInitiativeDisabled,
|
|
||||||
onOpenSourceManager,
|
|
||||||
autoFocus,
|
autoFocus,
|
||||||
themePreference,
|
onManagePlayers,
|
||||||
onCycleTheme,
|
|
||||||
}: Readonly<ActionBarProps>) {
|
}: 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 [nameInput, setNameInput] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
@@ -340,7 +352,7 @@ export function ActionBar({
|
|||||||
const confirmQueued = () => {
|
const confirmQueued = () => {
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
for (let i = 0; i < queued.count; i++) {
|
for (let i = 0; i < queued.count; i++) {
|
||||||
onAddFromBestiary(queued.result);
|
handleAddFromBestiary(queued.result);
|
||||||
}
|
}
|
||||||
clearInput();
|
clearInput();
|
||||||
};
|
};
|
||||||
@@ -366,7 +378,7 @@ export function ActionBar({
|
|||||||
if (init !== undefined) opts.initiative = init;
|
if (init !== undefined) opts.initiative = init;
|
||||||
if (ac !== undefined) opts.ac = ac;
|
if (ac !== undefined) opts.ac = ac;
|
||||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
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("");
|
setNameInput("");
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setPcMatches([]);
|
setPcMatches([]);
|
||||||
@@ -468,14 +480,14 @@ export function ActionBar({
|
|||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||||
setBrowseMode(false);
|
setBrowseMode(false);
|
||||||
clearInput();
|
clearInput();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrowseSelect = (result: SearchResult) => {
|
const handleBrowseSelect = (result: SearchResult) => {
|
||||||
onViewStatBlock?.(result);
|
handleViewStatBlock(result);
|
||||||
setBrowseMode(false);
|
setBrowseMode(false);
|
||||||
clearInput();
|
clearInput();
|
||||||
};
|
};
|
||||||
@@ -507,12 +519,12 @@ export function ActionBar({
|
|||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager: showSourceManager,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
themePreference,
|
themePreference,
|
||||||
onCycleTheme,
|
onCycleTheme: cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -535,7 +547,7 @@ export function ActionBar({
|
|||||||
className="pr-8"
|
className="pr-8"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{bestiaryLoaded && !!onViewStatBlock && (
|
{!!bestiaryLoaded && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -596,7 +608,7 @@ export function ActionBar({
|
|||||||
onSetSuggestionIndex={setSuggestionIndex}
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
onSetQueued={setQueued}
|
onSetQueued={setQueued}
|
||||||
onConfirmQueued={confirmQueued}
|
onConfirmQueued={confirmQueued}
|
||||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -632,20 +644,20 @@ export function ActionBar({
|
|||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit">Add</Button>
|
<Button type="submit">Add</Button>
|
||||||
)}
|
)}
|
||||||
{showRollAllInitiative && !!onRollAllInitiative && (
|
{!!hasCreatureCombatants && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={() => onRollAllInitiative()}
|
onClick={() => handleRollAllInitiative()}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openRollAllMenu(e.clientX, e.clientY);
|
openRollAllMenu(e.clientX, e.clientY);
|
||||||
}}
|
}}
|
||||||
{...rollAllLongPress}
|
{...rollAllLongPress}
|
||||||
disabled={rollAllInitiativeDisabled}
|
disabled={!canRollAllInitiative}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
@@ -654,7 +666,7 @@ export function ActionBar({
|
|||||||
{!!rollAllMenuPos && (
|
{!!rollAllMenuPos && (
|
||||||
<RollModeMenu
|
<RollModeMenu
|
||||||
position={rollAllMenuPos}
|
position={rollAllMenuPos}
|
||||||
onSelect={(mode) => onRollAllInitiative(mode)}
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||||
onClose={() => setRollAllMenuPos(null)}
|
onClose={() => setRollAllMenuPos(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,35 +1,41 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
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 { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL =
|
const DEFAULT_BASE_URL =
|
||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
interface BulkImportPromptProps {
|
export function BulkImportPrompt() {
|
||||||
importState: BulkImportState;
|
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||||
onStartImport: (baseUrl: string) => void;
|
useBestiaryContext();
|
||||||
onDone: () => void;
|
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 [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
const baseUrlId = useId();
|
const baseUrlId = useId();
|
||||||
const totalSources = getAllSourceCodes().length;
|
const totalSources = getAllSourceCodes().length;
|
||||||
|
|
||||||
|
const handleStart = (url: string) => {
|
||||||
|
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
dismissPanel();
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
if (importState.status === "complete") {
|
if (importState.status === "complete") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<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
|
All sources loaded
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onDone}>Done</Button>
|
<Button onClick={handleDone}>Done</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,7 +47,7 @@ export function BulkImportPrompt({
|
|||||||
Loaded {importState.completed}/{importState.total} sources (
|
Loaded {importState.completed}/{importState.total} sources (
|
||||||
{importState.failed} failed)
|
{importState.failed} failed)
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onDone}>Done</Button>
|
<Button onClick={handleDone}>Done</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +102,7 @@ export function BulkImportPrompt({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => onStartImport(baseUrl)} disabled={isDisabled}>
|
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||||
Load All
|
Load All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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";
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
interface BulkImportToastsProps {
|
export function BulkImportToasts() {
|
||||||
state: BulkImportState;
|
const { state, reset } = useBulkImportContext();
|
||||||
visible: boolean;
|
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||||
onReset: () => void;
|
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||||
}
|
|
||||||
|
|
||||||
export function BulkImportToasts({
|
|
||||||
state,
|
|
||||||
visible,
|
|
||||||
onReset,
|
|
||||||
}: Readonly<BulkImportToastsProps>) {
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
@@ -30,7 +25,7 @@ export function BulkImportToasts({
|
|||||||
return (
|
return (
|
||||||
<Toast
|
<Toast
|
||||||
message="All sources loaded"
|
message="All sources loaded"
|
||||||
onDismiss={onReset}
|
onDismiss={reset}
|
||||||
autoDismissMs={3000}
|
autoDismissMs={3000}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -40,7 +35,7 @@ export function BulkImportToasts({
|
|||||||
return (
|
return (
|
||||||
<Toast
|
<Toast
|
||||||
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
onDismiss={onReset}
|
onDismiss={reset}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
type RollMode,
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Book, BookOpen, Brain, X } from "lucide-react";
|
import { Book, BookOpen, Brain, X } from "lucide-react";
|
||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLongPress } from "../hooks/use-long-press";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { cn } from "../lib/utils";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { AcShield } from "./ac-shield";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { ConditionPicker } from "./condition-picker";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { ConditionTags } from "./condition-tags";
|
import { cn } from "../lib/utils.js";
|
||||||
import { D20Icon } from "./d20-icon";
|
import { AcShield } from "./ac-shield.js";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
import { ConditionPicker } from "./condition-picker.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||||
import { Input } from "./ui/input";
|
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 {
|
interface Combatant {
|
||||||
readonly id: CombatantId;
|
readonly id: CombatantId;
|
||||||
@@ -30,22 +34,12 @@ interface Combatant {
|
|||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
|
readonly creatureId?: CreatureId;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantRowProps {
|
interface CombatantRowProps {
|
||||||
combatant: Combatant;
|
combatant: Combatant;
|
||||||
isActive: boolean;
|
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({
|
function EditableName({
|
||||||
@@ -346,7 +340,7 @@ function InitiativeDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty + bestiary creature → d20 roll button
|
// Empty + bestiary creature -> d20 roll button
|
||||||
if (initiative === undefined && onRollInitiative) {
|
if (initiative === undefined && onRollInitiative) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -378,8 +372,8 @@ function InitiativeDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has value → bold number, click to edit
|
// Has value -> bold number, click to edit
|
||||||
// Empty + manual → "--" placeholder, click to edit
|
// Empty + manual -> "--" placeholder, click to edit
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -423,18 +417,30 @@ export function CombatantRow({
|
|||||||
ref,
|
ref,
|
||||||
combatant,
|
combatant,
|
||||||
isActive,
|
isActive,
|
||||||
onRename,
|
|
||||||
onSetInitiative,
|
|
||||||
onRemove,
|
|
||||||
onSetHp,
|
|
||||||
onAdjustHp,
|
|
||||||
onSetAc,
|
|
||||||
onToggleCondition,
|
|
||||||
onToggleConcentration,
|
|
||||||
onShowStatBlock,
|
|
||||||
isStatBlockOpen,
|
|
||||||
onRollInitiative,
|
|
||||||
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
}: 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 { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
const dimmed = status === "unconscious";
|
const dimmed = status === "unconscious";
|
||||||
@@ -484,7 +490,7 @@ export function CombatantRow({
|
|||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleConcentration(id)}
|
onClick={() => toggleConcentration(id)}
|
||||||
title="Concentrating"
|
title="Concentrating"
|
||||||
aria-label="Toggle concentration"
|
aria-label="Toggle concentration"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -500,7 +506,7 @@ export function CombatantRow({
|
|||||||
initiative={initiative}
|
initiative={initiative}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
onSetInitiative={onSetInitiative}
|
onSetInitiative={setInitiative}
|
||||||
onRollInitiative={onRollInitiative}
|
onRollInitiative={onRollInitiative}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -541,18 +547,18 @@ export function CombatantRow({
|
|||||||
<EditableName
|
<EditableName
|
||||||
name={name}
|
name={name}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
onRename={onRename}
|
onRename={editCombatant}
|
||||||
color={pcColor}
|
color={pcColor}
|
||||||
/>
|
/>
|
||||||
<ConditionTags
|
<ConditionTags
|
||||||
conditions={combatant.conditions}
|
conditions={combatant.conditions}
|
||||||
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{!!pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -560,7 +566,7 @@ export function CombatantRow({
|
|||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
@@ -568,7 +574,7 @@ export function CombatantRow({
|
|||||||
<ClickableHp
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
onAdjust={(delta) => adjustHp(id, delta)}
|
||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
/>
|
/>
|
||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
@@ -582,7 +588,7 @@ export function CombatantRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -590,7 +596,7 @@ export function CombatantRow({
|
|||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<X size={16} />}
|
icon={<X size={16} />}
|
||||||
label="Remove combatant"
|
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"
|
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>
|
</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 { type RefObject, useImperativeHandle, useState } from "react";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
import { PlayerManagement } from "./player-management.js";
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
@@ -7,37 +8,14 @@ export interface PlayerCharacterSectionHandle {
|
|||||||
openManagement: () => void;
|
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({
|
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||||
characters,
|
|
||||||
onCreateCharacter,
|
|
||||||
onEditCharacter,
|
|
||||||
onDeleteCharacter,
|
|
||||||
ref,
|
ref,
|
||||||
}: PlayerCharacterSectionProps & {
|
}: {
|
||||||
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||||
}) {
|
}) {
|
||||||
|
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
const [managementOpen, setManagementOpen] = useState(false);
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingPlayer, setEditingPlayer] = useState<
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
@@ -59,7 +37,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
}}
|
}}
|
||||||
onSave={(name, ac, maxHp, color, icon) => {
|
onSave={(name, ac, maxHp, color, icon) => {
|
||||||
if (editingPlayer) {
|
if (editingPlayer) {
|
||||||
onEditCharacter(editingPlayer.id, {
|
editCharacter(editingPlayer.id, {
|
||||||
name,
|
name,
|
||||||
ac,
|
ac,
|
||||||
maxHp,
|
maxHp,
|
||||||
@@ -67,7 +45,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
onCreateCharacter(name, ac, maxHp, color, icon);
|
createCharacter(name, ac, maxHp, color, icon);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
playerCharacter={editingPlayer}
|
playerCharacter={editingPlayer}
|
||||||
@@ -81,7 +59,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
setManagementOpen(false);
|
setManagementOpen(false);
|
||||||
}}
|
}}
|
||||||
onDelete={(id) => onDeleteCharacter(id)}
|
onDelete={(id) => deleteCharacter(id)}
|
||||||
onCreate={() => {
|
onCreate={() => {
|
||||||
setEditingPlayer(undefined);
|
setEditingPlayer(undefined);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "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 { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceFetchPromptProps {
|
interface SourceFetchPromptProps {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
sourceDisplayName: string;
|
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
|
||||||
onSourceLoaded: () => void;
|
onSourceLoaded: () => void;
|
||||||
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceFetchPrompt({
|
export function SourceFetchPrompt({
|
||||||
sourceCode,
|
sourceCode,
|
||||||
sourceDisplayName,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
onUploadSource,
|
|
||||||
}: Readonly<SourceFetchPromptProps>) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||||
|
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
||||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
@@ -47,7 +47,7 @@ export function SourceFetchPrompt({
|
|||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
await onUploadSource(sourceCode, json);
|
await uploadAndCacheSource(sourceCode, json);
|
||||||
onSourceLoaded();
|
onSourceLoaded();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||||
import * as bestiaryCache 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 { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
export function SourceManager() {
|
||||||
onCacheCleared: () => void;
|
const { refreshCache } = useBestiaryContext();
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceManager({
|
|
||||||
onCacheCleared,
|
|
||||||
}: Readonly<SourceManagerProps>) {
|
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
@@ -44,14 +40,14 @@ export function SourceManager({
|
|||||||
applyOptimistic({ type: "remove", sourceCode });
|
applyOptimistic({ type: "remove", sourceCode });
|
||||||
await bestiaryCache.clearSource(sourceCode);
|
await bestiaryCache.clearSource(sourceCode);
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
const handleClearAll = async () => {
|
||||||
applyOptimistic({ type: "clear" });
|
applyOptimistic({ type: "clear" });
|
||||||
await bestiaryCache.clearAll();
|
await bestiaryCache.clearAll();
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredSources = useMemo(() => {
|
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 { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
@@ -13,28 +13,8 @@ import { StatBlock } from "./stat-block.js";
|
|||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
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";
|
panelRole: "browse" | "pinned";
|
||||||
isCollapsed: boolean;
|
|
||||||
onToggleCollapse: () => void;
|
|
||||||
onPin: () => void;
|
|
||||||
onUnpin: () => void;
|
|
||||||
showPinButton: boolean;
|
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
onDismiss: () => void;
|
|
||||||
bulkImportMode?: boolean;
|
|
||||||
bulkImportState?: BulkImportState;
|
|
||||||
onStartBulkImport?: (baseUrl: string) => void;
|
|
||||||
onBulkImportDone?: () => void;
|
|
||||||
sourceManagerMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -228,27 +208,49 @@ function MobileDrawer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({
|
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,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
isSourceCached,
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
fetchAndCacheSource,
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
uploadAndCacheSource,
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
refreshCache,
|
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({
|
||||||
panelRole,
|
panelRole,
|
||||||
|
side,
|
||||||
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
|
const { isSourceCached } = useBestiaryContext();
|
||||||
|
const {
|
||||||
|
creatureId,
|
||||||
|
creature,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
|
onDismiss,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
side,
|
|
||||||
onDismiss,
|
|
||||||
bulkImportMode,
|
bulkImportMode,
|
||||||
bulkImportState,
|
|
||||||
onStartBulkImport,
|
|
||||||
onBulkImportDone,
|
|
||||||
sourceManagerMode,
|
sourceManagerMode,
|
||||||
}: Readonly<StatBlockPanelProps>) {
|
} = usePanelRole(panelRole);
|
||||||
|
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
);
|
);
|
||||||
@@ -285,29 +287,17 @@ export function StatBlockPanel({
|
|||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
const handleSourceLoaded = async () => {
|
const handleSourceLoaded = () => {
|
||||||
await refreshCache();
|
|
||||||
setNeedsFetch(false);
|
setNeedsFetch(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (sourceManagerMode) {
|
if (sourceManagerMode) {
|
||||||
return <SourceManager onCacheCleared={refreshCache} />;
|
return <SourceManager />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (bulkImportMode) {
|
||||||
bulkImportMode &&
|
return <BulkImportPrompt />;
|
||||||
bulkImportState &&
|
|
||||||
onStartBulkImport &&
|
|
||||||
onBulkImportDone
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<BulkImportPrompt
|
|
||||||
importState={bulkImportState}
|
|
||||||
onStartImport={onStartBulkImport}
|
|
||||||
onDone={onBulkImportDone}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkingCache) {
|
if (checkingCache) {
|
||||||
@@ -324,10 +314,7 @@ export function StatBlockPanel({
|
|||||||
return (
|
return (
|
||||||
<SourceFetchPrompt
|
<SourceFetchPrompt
|
||||||
sourceCode={sourceCode}
|
sourceCode={sourceCode}
|
||||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
onSourceLoaded={handleSourceLoaded}
|
onSourceLoaded={handleSourceLoaded}
|
||||||
onUploadSource={uploadAndCacheSource}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,12 @@
|
|||||||
import type { Encounter } from "@initiative/domain";
|
|
||||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
|
||||||
interface TurnNavigationProps {
|
export function TurnNavigation() {
|
||||||
encounter: Encounter;
|
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
||||||
onAdvanceTurn: () => void;
|
useEncounterContext();
|
||||||
onRetreatTurn: () => void;
|
|
||||||
onClearEncounter: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TurnNavigation({
|
|
||||||
encounter,
|
|
||||||
onAdvanceTurn,
|
|
||||||
onRetreatTurn,
|
|
||||||
onClearEncounter,
|
|
||||||
}: Readonly<TurnNavigationProps>) {
|
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
@@ -25,7 +16,7 @@ export function TurnNavigation({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onRetreatTurn}
|
onClick={retreatTurn}
|
||||||
disabled={!hasCombatants || isAtStart}
|
disabled={!hasCombatants || isAtStart}
|
||||||
title="Previous turn"
|
title="Previous turn"
|
||||||
aria-label="Previous turn"
|
aria-label="Previous turn"
|
||||||
@@ -48,14 +39,14 @@ export function TurnNavigation({
|
|||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 className="h-5 w-5" />}
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
label="Clear encounter"
|
label="Clear encounter"
|
||||||
onConfirm={onClearEncounter}
|
onConfirm={clearEncounter}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onAdvanceTurn}
|
onClick={advanceTurn}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
title="Next turn"
|
title="Next turn"
|
||||||
aria-label="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;
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
export interface BulkImportState {
|
interface BulkImportState {
|
||||||
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
readonly status: "idle" | "loading" | "complete" | "partial-failure";
|
||||||
readonly total: number;
|
readonly total: number;
|
||||||
readonly completed: 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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
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";
|
import "./index.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</InitiativeRollsProvider>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</BulkImportProvider>
|
||||||
|
</PlayerCharactersProvider>
|
||||||
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
"entry": ["scripts/*.mjs"]
|
"entry": ["scripts/*.mjs"]
|
||||||
},
|
},
|
||||||
"packages/*": {},
|
"packages/*": {},
|
||||||
"apps/*": {}
|
"apps/web": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && tsc --build && vitest run && jscpd"
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
|
"check": "pnpm audit --audit-level=high && knip && biome check . && oxlint --tsconfig apps/web/tsconfig.json --type-aware && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && tsc --build && vitest run && jscpd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
99
scripts/check-component-props.mjs
Normal file
99
scripts/check-component-props.mjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Enforce a maximum number of explicitly declared props per component
|
||||||
|
* interface.
|
||||||
|
*
|
||||||
|
* Components should consume shared application state via React context
|
||||||
|
* providers, not prop drilling. Props are reserved for per-instance
|
||||||
|
* configuration (a specific data item, a layout variant, a ref).
|
||||||
|
*
|
||||||
|
* Only scans component files (not hooks, adapters, etc.) and only
|
||||||
|
* counts properties declared directly in *Props interfaces — inherited
|
||||||
|
* or extended HTML attributes are not counted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { relative } from "node:path";
|
||||||
|
|
||||||
|
const MAX_PROPS = 8;
|
||||||
|
|
||||||
|
const files = execSync(
|
||||||
|
"git ls-files -- 'apps/web/src/components/*.tsx' 'apps/web/src/components/**/*.tsx'",
|
||||||
|
{ encoding: "utf-8" },
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, "utf-8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
let inInterface = false;
|
||||||
|
let interfaceName = "";
|
||||||
|
let braceDepth = 0;
|
||||||
|
let parenDepth = 0;
|
||||||
|
let propCount = 0;
|
||||||
|
let startLine = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (!inInterface) {
|
||||||
|
const match = propsRegex.exec(line);
|
||||||
|
if (match) {
|
||||||
|
inInterface = true;
|
||||||
|
interfaceName = match[1];
|
||||||
|
braceDepth = 0;
|
||||||
|
parenDepth = 0;
|
||||||
|
propCount = 0;
|
||||||
|
startLine = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inInterface) {
|
||||||
|
for (const ch of line) {
|
||||||
|
if (ch === "{") braceDepth++;
|
||||||
|
if (ch === "}") braceDepth--;
|
||||||
|
if (ch === "(") parenDepth++;
|
||||||
|
if (ch === ")") parenDepth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count prop lines at brace depth 1 and not inside function params:
|
||||||
|
// Matches " propName?: type" and " readonly propName: type"
|
||||||
|
if (
|
||||||
|
braceDepth === 1 &&
|
||||||
|
parenDepth === 0 &&
|
||||||
|
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
||||||
|
) {
|
||||||
|
propCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceDepth === 0) {
|
||||||
|
if (propCount > MAX_PROPS) {
|
||||||
|
const rel = relative(process.cwd(), file);
|
||||||
|
console.error(
|
||||||
|
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
||||||
|
);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
inInterface = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
console.error(
|
||||||
|
`\n${errors} component(s) exceed the ${MAX_PROPS}-prop limit. Use React context to reduce props.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`check-component-props: all component interfaces within ${MAX_PROPS}-prop limit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user