Compare commits
8 Commits
dfef2194a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86768842ff | ||
|
|
6584d8d064 | ||
|
|
7f38cbab73 | ||
|
|
2971898f0c | ||
|
|
43780772f6 | ||
|
|
7b3dbe2069 | ||
|
|
827a3978e9 | ||
|
|
f024562a7d |
@@ -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,200 +1,43 @@
|
|||||||
import {
|
import { useEffect, useRef } 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,
|
|
||||||
} 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 { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
|
||||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils.js";
|
||||||
|
|
||||||
function rollDice(): number {
|
|
||||||
return Math.floor(Math.random() * 20) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useActionBarAnimation(combatantCount: number) {
|
|
||||||
const wasEmptyRef = useRef(combatantCount === 0);
|
|
||||||
const [settling, setSettling] = useState(false);
|
|
||||||
const [rising, setRising] = useState(false);
|
|
||||||
const [topBarExiting, setTopBarExiting] = useState(false);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const nowEmpty = combatantCount === 0;
|
|
||||||
if (wasEmptyRef.current && !nowEmpty) {
|
|
||||||
setSettling(true);
|
|
||||||
} else if (!wasEmptyRef.current && nowEmpty) {
|
|
||||||
setRising(true);
|
|
||||||
setTopBarExiting(true);
|
|
||||||
}
|
|
||||||
wasEmptyRef.current = nowEmpty;
|
|
||||||
}, [combatantCount]);
|
|
||||||
|
|
||||||
const empty = combatantCount === 0;
|
|
||||||
const risingClass = rising ? "animate-rise-to-center" : "";
|
|
||||||
const settlingClass = settling ? "animate-settle-to-bottom" : "";
|
|
||||||
const exitingClass = topBarExiting
|
|
||||||
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
|
||||||
: "";
|
|
||||||
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
|
|
||||||
const showTopBar = !empty || topBarExiting;
|
|
||||||
|
|
||||||
return {
|
|
||||||
risingClass,
|
|
||||||
settlingClass,
|
|
||||||
topBarClass,
|
|
||||||
showTopBar,
|
|
||||||
onSettleEnd: () => setSettling(false),
|
|
||||||
onRiseEnd: () => setRising(false),
|
|
||||||
onTopBarExitEnd: () => setTopBarExiting(false),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
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 [rollSkippedCount, setRollSkippedCount] = useState(0);
|
|
||||||
|
|
||||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
|
||||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
|
||||||
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
|
||||||
(result: SearchResult) => {
|
|
||||||
addFromBestiary(result);
|
|
||||||
},
|
|
||||||
[addFromBestiary],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCombatantStatBlock = useCallback(
|
|
||||||
(creatureId: string) => {
|
|
||||||
sidePanel.showCreature(creatureId as CreatureId);
|
|
||||||
},
|
|
||||||
[sidePanel.showCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
|
||||||
(id: CombatantId) => {
|
|
||||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
|
||||||
},
|
|
||||||
[makeStore, getCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
|
||||||
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
|
||||||
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
|
||||||
setRollSkippedCount(result.skippedNoSource);
|
|
||||||
}
|
|
||||||
}, [makeStore, getCreature]);
|
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback(
|
|
||||||
(result: SearchResult) => {
|
|
||||||
const slug = result.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
||||||
.replaceAll(/(^-|-$)/g, "");
|
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
|
||||||
sidePanel.showCreature(cId);
|
|
||||||
},
|
|
||||||
[sidePanel.showCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStartBulkImport = useCallback(
|
|
||||||
(baseUrl: string) => {
|
|
||||||
bulkImport.startImport(
|
|
||||||
baseUrl,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
isSourceCached,
|
|
||||||
refreshCache,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkImportDone = useCallback(() => {
|
|
||||||
sidePanel.dismissPanel();
|
|
||||||
bulkImport.reset();
|
|
||||||
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
|
||||||
|
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
const 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-scroll to the active combatant when the turn changes
|
// Auto-scroll to active combatant when turn changes
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeIndex = encounter.activeIndex;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRowRef.current?.scrollIntoView({
|
if (activeIndex >= 0) {
|
||||||
block: "nearest",
|
activeRowRef.current?.scrollIntoView({
|
||||||
behavior: "smooth",
|
block: "nearest",
|
||||||
});
|
behavior: "smooth",
|
||||||
}, []);
|
});
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
@@ -204,47 +47,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}
|
|
||||||
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) => (
|
||||||
@@ -253,120 +76,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
|
|
||||||
}
|
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayerCharacterSection
|
{!!rolls.rollSingleSkipped && (
|
||||||
ref={playerCharacterRef}
|
<Toast
|
||||||
characters={playerCharacters}
|
message="Can't roll — bestiary source not loaded"
|
||||||
onCreateCharacter={createPlayerCharacter}
|
onDismiss={rolls.dismissRollSingleSkipped}
|
||||||
onEditCharacter={editPlayerCharacter}
|
autoDismissMs={4000}
|
||||||
onDeleteCharacter={deletePlayerCharacter}
|
/>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
</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", () => {
|
||||||
@@ -75,7 +105,7 @@ describe("CombatantRow", () => {
|
|||||||
it("active combatant gets active border styling", () => {
|
it("active combatant gets active border styling", () => {
|
||||||
const { container } = renderRow({ isActive: true });
|
const { container } = renderRow({ isActive: true });
|
||||||
const row = container.firstElementChild;
|
const row = container.firstElementChild;
|
||||||
expect(row?.className).toContain("border-accent/40");
|
expect(row?.className).toContain("border-active-row-border");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||||
@@ -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
|
{ id: combatantId("1"), name: "Goblin" },
|
||||||
encounter={{
|
{ id: combatantId("2"), name: "Conjurer" },
|
||||||
combatants: [
|
];
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
mockContext({ combatants, activeIndex: 0 });
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
const { rerender } = render(<TurnNavigation />);
|
||||||
],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
}}
|
|
||||||
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 } from "@initiative/domain";
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -6,14 +6,31 @@ import {
|
|||||||
Import,
|
Import,
|
||||||
Library,
|
Library,
|
||||||
Minus,
|
Minus,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { type RefObject, useDeferredValue, useState } from "react";
|
import React, {
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { 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 { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||||
@@ -24,25 +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?: () => void;
|
|
||||||
showRollAllInitiative?: boolean;
|
|
||||||
rollAllInitiativeDisabled?: boolean;
|
|
||||||
onOpenSourceManager?: () => void;
|
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
onManagePlayers?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
function creatureKey(r: SearchResult): string {
|
||||||
@@ -171,7 +172,7 @@ function AddModeSuggestions({
|
|||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground">
|
||||||
{queued.count}
|
{queued.count}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -215,12 +216,26 @@ function AddModeSuggestions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_ICONS = {
|
||||||
|
system: Monitor,
|
||||||
|
light: Sun,
|
||||||
|
dark: Moon,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const THEME_LABELS = {
|
||||||
|
system: "Theme: System",
|
||||||
|
light: "Theme: Light",
|
||||||
|
dark: "Theme: Dark",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function buildOverflowItems(opts: {
|
function buildOverflowItems(opts: {
|
||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
onBulkImport?: () => void;
|
onBulkImport?: () => void;
|
||||||
bulkImportDisabled?: boolean;
|
bulkImportDisabled?: boolean;
|
||||||
|
themePreference?: "system" | "light" | "dark";
|
||||||
|
onCycleTheme?: () => void;
|
||||||
}): OverflowMenuItem[] {
|
}): OverflowMenuItem[] {
|
||||||
const items: OverflowMenuItem[] = [];
|
const items: OverflowMenuItem[] = [];
|
||||||
if (opts.onManagePlayers) {
|
if (opts.onManagePlayers) {
|
||||||
@@ -245,27 +260,62 @@ function buildOverflowItems(opts: {
|
|||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (opts.onCycleTheme) {
|
||||||
|
const pref = opts.themePreference ?? "system";
|
||||||
|
const ThemeIcon = THEME_ICONS[pref];
|
||||||
|
items.push({
|
||||||
|
icon: <ThemeIcon className="h-4 w-4" />,
|
||||||
|
label: THEME_LABELS[pref],
|
||||||
|
onClick: opts.onCycleTheme,
|
||||||
|
keepOpen: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
onAddCombatant,
|
|
||||||
onAddFromBestiary,
|
|
||||||
bestiarySearch,
|
|
||||||
bestiaryLoaded,
|
|
||||||
onViewStatBlock,
|
|
||||||
onBulkImport,
|
|
||||||
bulkImportDisabled,
|
|
||||||
inputRef,
|
inputRef,
|
||||||
playerCharacters,
|
|
||||||
onAddFromPlayerCharacter,
|
|
||||||
onManagePlayers,
|
|
||||||
onRollAllInitiative,
|
|
||||||
showRollAllInitiative,
|
|
||||||
rollAllInitiativeDisabled,
|
|
||||||
onOpenSourceManager,
|
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
onManagePlayers,
|
||||||
}: 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[]>([]);
|
||||||
@@ -302,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();
|
||||||
};
|
};
|
||||||
@@ -328,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([]);
|
||||||
@@ -430,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();
|
||||||
};
|
};
|
||||||
@@ -448,12 +498,33 @@ export function ActionBar({
|
|||||||
clearCustomFields();
|
clearCustomFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [rollAllMenuPos, setRollAllMenuPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const openRollAllMenu = useCallback((x: number, y: number) => {
|
||||||
|
setRollAllMenuPos({ x, y });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rollAllLongPress = useLongPress(
|
||||||
|
useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (touch) openRollAllMenu(touch.clientX, touch.clientY);
|
||||||
|
},
|
||||||
|
[openRollAllMenu],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager: showSourceManager,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
|
themePreference,
|
||||||
|
onCycleTheme: cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -476,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}
|
||||||
@@ -537,7 +608,7 @@ export function ActionBar({
|
|||||||
onSetSuggestionIndex={setSuggestionIndex}
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
onSetQueued={setQueued}
|
onSetQueued={setQueued}
|
||||||
onConfirmQueued={confirmQueued}
|
onConfirmQueued={confirmQueued}
|
||||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -573,19 +644,33 @@ 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
|
<>
|
||||||
type="button"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
variant="ghost"
|
||||||
onClick={onRollAllInitiative}
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
disabled={rollAllInitiativeDisabled}
|
onClick={() => handleRollAllInitiative()}
|
||||||
title="Roll all initiative"
|
onContextMenu={(e) => {
|
||||||
aria-label="Roll all initiative"
|
e.preventDefault();
|
||||||
>
|
openRollAllMenu(e.clientX, e.clientY);
|
||||||
<D20Icon className="h-6 w-6" />
|
}}
|
||||||
</Button>
|
{...rollAllLongPress}
|
||||||
|
disabled={!canRollAllInitiative}
|
||||||
|
title="Roll all initiative"
|
||||||
|
aria-label="Roll all initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
{!!rollAllMenuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={rollAllMenuPos}
|
||||||
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||||
|
onClose={() => setRollAllMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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,20 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { 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 { cn } from "../lib/utils";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { AcShield } from "./ac-shield";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
import { ConditionPicker } from "./condition-picker";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { ConditionTags } from "./condition-tags";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { D20Icon } from "./d20-icon";
|
import { cn } from "../lib/utils.js";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover";
|
import { AcShield } from "./ac-shield.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { ConditionPicker } from "./condition-picker.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
import { Input } from "./ui/input";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||||
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface Combatant {
|
interface Combatant {
|
||||||
readonly id: CombatantId;
|
readonly id: CombatantId;
|
||||||
@@ -27,21 +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;
|
|
||||||
onRollInitiative?: (id: CombatantId) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableName({
|
function EditableName({
|
||||||
@@ -278,11 +276,29 @@ function InitiativeDisplay({
|
|||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
dimmed: boolean;
|
dimmed: boolean;
|
||||||
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
||||||
onRollInitiative?: (id: CombatantId) => void;
|
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const openMenu = useCallback((x: number, y: number) => {
|
||||||
|
setMenuPos({ x, y });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const longPress = useLongPress(
|
||||||
|
useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (touch) openMenu(touch.clientX, touch.clientY);
|
||||||
|
},
|
||||||
|
[openMenu],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
const commit = useCallback(() => {
|
||||||
if (draft === "") {
|
if (draft === "") {
|
||||||
@@ -324,26 +340,40 @@ function InitiativeDisplay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty + bestiary creature → d20 roll button
|
// Empty + bestiary creature -> d20 roll button
|
||||||
if (initiative === undefined && onRollInitiative) {
|
if (initiative === undefined && onRollInitiative) {
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onRollInitiative(combatantId)}
|
type="button"
|
||||||
className={cn(
|
onClick={() => onRollInitiative(combatantId)}
|
||||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
onContextMenu={(e) => {
|
||||||
dimmed && "opacity-50",
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
|
title="Roll initiative"
|
||||||
|
aria-label="Roll initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-7 w-7" />
|
||||||
|
</button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
title="Roll initiative"
|
</>
|
||||||
aria-label="Roll initiative"
|
|
||||||
>
|
|
||||||
<D20Icon className="h-7 w-7" />
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"
|
||||||
@@ -366,9 +396,9 @@ function rowBorderClass(
|
|||||||
isConcentrating: boolean | undefined,
|
isConcentrating: boolean | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (isActive && isConcentrating)
|
if (isActive && isConcentrating)
|
||||||
return "border border-l-2 border-accent/40 border-l-purple-400 bg-accent/10 card-glow";
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||||
if (isActive)
|
if (isActive)
|
||||||
return "border border-l-2 border-accent/40 bg-accent/10 card-glow";
|
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||||
if (isConcentrating)
|
if (isConcentrating)
|
||||||
return "border border-l-2 border-transparent border-l-purple-400";
|
return "border border-l-2 border-transparent border-l-purple-400";
|
||||||
return "border border-l-2 border-transparent";
|
return "border border-l-2 border-transparent";
|
||||||
@@ -387,17 +417,30 @@ export function CombatantRow({
|
|||||||
ref,
|
ref,
|
||||||
combatant,
|
combatant,
|
||||||
isActive,
|
isActive,
|
||||||
onRename,
|
|
||||||
onSetInitiative,
|
|
||||||
onRemove,
|
|
||||||
onSetHp,
|
|
||||||
onAdjustHp,
|
|
||||||
onSetAc,
|
|
||||||
onToggleCondition,
|
|
||||||
onToggleConcentration,
|
|
||||||
onShowStatBlock,
|
|
||||||
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";
|
||||||
@@ -447,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(
|
||||||
@@ -463,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -480,9 +523,9 @@ export function CombatantRow({
|
|||||||
onClick={onShowStatBlock}
|
onClick={onShowStatBlock}
|
||||||
title="View stat block"
|
title="View stat block"
|
||||||
aria-label="View stat block"
|
aria-label="View stat block"
|
||||||
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
className="shrink-0 text-foreground transition-colors hover:text-hover-neutral"
|
||||||
>
|
>
|
||||||
<BookOpen size={14} />
|
{isStatBlockOpen ? <BookOpen size={16} /> : <Book size={16} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!!combatant.icon &&
|
{!!combatant.icon &&
|
||||||
@@ -495,7 +538,7 @@ export function CombatantRow({
|
|||||||
];
|
];
|
||||||
return PcIcon ? (
|
return PcIcon ? (
|
||||||
<PcIcon
|
<PcIcon
|
||||||
size={14}
|
size={16}
|
||||||
style={{ color: iconColor }}
|
style={{ color: iconColor }}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
@@ -504,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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -523,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 */}
|
||||||
@@ -531,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 && (
|
||||||
@@ -545,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>
|
||||||
|
|
||||||
@@ -553,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>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-hp-damage-hover-bg hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
onClick={() => applyDelta(-1)}
|
onClick={() => applyDelta(-1)}
|
||||||
title="Apply damage"
|
title="Apply damage"
|
||||||
aria-label="Apply damage"
|
aria-label="Apply damage"
|
||||||
@@ -123,7 +123,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-hp-heal-hover-bg hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
onClick={() => applyDelta(1)}
|
onClick={() => applyDelta(1)}
|
||||||
title="Apply healing"
|
title="Apply healing"
|
||||||
aria-label="Apply healing"
|
aria-label="Apply healing"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
88
apps/web/src/components/roll-mode-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { RollMode } from "@initiative/domain";
|
||||||
|
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface RollModeMenuProps {
|
||||||
|
readonly position: { x: number; y: number };
|
||||||
|
readonly onSelect: (mode: RollMode) => void;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RollModeMenu({
|
||||||
|
position,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: RollModeMenuProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const vw = document.documentElement.clientWidth;
|
||||||
|
const vh = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
let left = position.x;
|
||||||
|
let top = position.y;
|
||||||
|
|
||||||
|
if (left + rect.width > vw) left = vw - rect.width - 8;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
if (top + rect.height > vh) top = position.y - rect.height;
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
|
||||||
|
setPos({ top, left });
|
||||||
|
}, [position.x, position.y]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="card-glow fixed z-50 min-w-40 rounded-lg border border-border bg-card py-1"
|
||||||
|
style={
|
||||||
|
pos
|
||||||
|
? { top: pos.top, left: pos.left }
|
||||||
|
: { visibility: "hidden" as const }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-emerald-400 text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("advantage");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronsUp className="h-4 w-4" />
|
||||||
|
Advantage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-red-400 text-sm hover:bg-hover-neutral-bg"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect("disadvantage");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronsDown className="h-4 w-4" />
|
||||||
|
Disadvantage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||||
|
const sidePanel = useSidePanelContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
|
||||||
|
const creatureId =
|
||||||
|
panelRole === "browse"
|
||||||
|
? sidePanel.selectedCreatureId
|
||||||
|
: sidePanel.pinnedCreatureId;
|
||||||
|
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||||
|
|
||||||
|
const isBrowse = panelRole === "browse";
|
||||||
|
return {
|
||||||
|
creatureId,
|
||||||
|
creature,
|
||||||
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
|
onPin: isBrowse ? sidePanel.togglePin : () => {},
|
||||||
|
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
|
||||||
|
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
|
||||||
|
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
|
||||||
|
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({
|
export function StatBlockPanel({
|
||||||
creatureId,
|
|
||||||
creature,
|
|
||||||
isSourceCached,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
uploadAndCacheSource,
|
|
||||||
refreshCache,
|
|
||||||
panelRole,
|
panelRole,
|
||||||
isCollapsed,
|
|
||||||
onToggleCollapse,
|
|
||||||
onPin,
|
|
||||||
onUnpin,
|
|
||||||
showPinButton,
|
|
||||||
side,
|
side,
|
||||||
onDismiss,
|
|
||||||
bulkImportMode,
|
|
||||||
bulkImportState,
|
|
||||||
onStartBulkImport,
|
|
||||||
onBulkImportDone,
|
|
||||||
sourceManagerMode,
|
|
||||||
}: Readonly<StatBlockPanelProps>) {
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
|
const { isSourceCached } = useBestiaryContext();
|
||||||
|
const {
|
||||||
|
creatureId,
|
||||||
|
creature,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onDismiss,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
|
showPinButton,
|
||||||
|
bulkImportMode,
|
||||||
|
sourceManagerMode,
|
||||||
|
} = 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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function PropertyLine({
|
|||||||
|
|
||||||
function SectionDivider() {
|
function SectionDivider() {
|
||||||
return (
|
return (
|
||||||
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
|
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
<div className="space-y-1 text-foreground">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-bold text-amber-400 text-xl">{creature.name}</h2>
|
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
|
||||||
<p className="text-muted-foreground text-sm italic">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.size} {creature.type}, {creature.alignment}
|
{creature.size} {creature.type}, {creature.alignment}
|
||||||
</p>
|
</p>
|
||||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{creature.actions && creature.actions.length > 0 && (
|
{creature.actions && creature.actions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="font-bold text-amber-400 text-base">Actions</h3>
|
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.actions.map((a) => (
|
{creature.actions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -209,7 +209,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="font-bold text-amber-400 text-base">Bonus Actions</h3>
|
<h3 className="font-bold text-base text-stat-heading">
|
||||||
|
Bonus Actions
|
||||||
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.bonusActions.map((a) => (
|
{creature.bonusActions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -224,7 +226,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{creature.reactions && creature.reactions.length > 0 && (
|
{creature.reactions && creature.reactions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="font-bold text-amber-400 text-base">Reactions</h3>
|
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.reactions.map((a) => (
|
{creature.reactions.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<div key={a.name} className="text-sm">
|
||||||
@@ -239,7 +241,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{!!creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="font-bold text-amber-400 text-base">
|
<h3 className="font-bold text-base text-stat-heading">
|
||||||
Legendary Actions
|
Legendary Actions
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground text-sm italic">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -23,9 +14,9 @@ export function TurnNavigation({
|
|||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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="outline"
|
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"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface OverflowMenuItem {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly onClick: () => void;
|
readonly onClick: () => void;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
|
readonly keepOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OverflowMenuProps {
|
interface OverflowMenuProps {
|
||||||
@@ -58,7 +59,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
setOpen(false);
|
if (!item.keepOpen) setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -137,7 +137,9 @@ describe("useEncounter", () => {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.addFromBestiary(entry));
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.hasCreatureCombatants).toBe(true);
|
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||||
expect(result.current.canRollAllInitiative).toBe(true);
|
expect(result.current.canRollAllInitiative).toBe(true);
|
||||||
@@ -158,7 +160,9 @@ describe("useEncounter", () => {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.addFromBestiary(entry));
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
const combatant = result.current.encounter.combatants[0];
|
const combatant = result.current.encounter.combatants[0];
|
||||||
expect(combatant.name).toBe("Goblin");
|
expect(combatant.name).toBe("Goblin");
|
||||||
@@ -183,8 +187,12 @@ describe("useEncounter", () => {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
act(() => result.current.addFromBestiary(entry));
|
act(() => {
|
||||||
act(() => result.current.addFromBestiary(entry));
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
const names = result.current.encounter.combatants.map((c) => c.name);
|
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||||
expect(names).toContain("Goblin 1");
|
expect(names).toContain("Goblin 1");
|
||||||
|
|||||||
38
apps/web/src/hooks/use-action-bar-animation.ts
Normal file
38
apps/web/src/hooks/use-action-bar-animation.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useActionBarAnimation(combatantCount: number) {
|
||||||
|
const wasEmptyRef = useRef(combatantCount === 0);
|
||||||
|
const [settling, setSettling] = useState(false);
|
||||||
|
const [rising, setRising] = useState(false);
|
||||||
|
const [topBarExiting, setTopBarExiting] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const nowEmpty = combatantCount === 0;
|
||||||
|
if (wasEmptyRef.current && !nowEmpty) {
|
||||||
|
setSettling(true);
|
||||||
|
} else if (!wasEmptyRef.current && nowEmpty) {
|
||||||
|
setRising(true);
|
||||||
|
setTopBarExiting(true);
|
||||||
|
}
|
||||||
|
wasEmptyRef.current = nowEmpty;
|
||||||
|
}, [combatantCount]);
|
||||||
|
|
||||||
|
const empty = combatantCount === 0;
|
||||||
|
const risingClass = rising ? "animate-rise-to-center" : "";
|
||||||
|
const settlingClass = settling ? "animate-settle-to-bottom" : "";
|
||||||
|
const exitingClass = topBarExiting
|
||||||
|
? "absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
||||||
|
: "";
|
||||||
|
const topBarClass = settling ? "animate-slide-down-in" : exitingClass;
|
||||||
|
const showTopBar = !empty || topBarExiting;
|
||||||
|
|
||||||
|
return {
|
||||||
|
risingClass,
|
||||||
|
settlingClass,
|
||||||
|
topBarClass,
|
||||||
|
showTopBar,
|
||||||
|
onSettleEnd: () => setSettling(false),
|
||||||
|
onRiseEnd: () => setRising(false),
|
||||||
|
onTopBarExitEnd: () => setTopBarExiting(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
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;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
CombatantId,
|
CombatantId,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
|
CreatureId,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -265,7 +266,7 @@ export function useEncounter() {
|
|||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
const addFromBestiary = useCallback(
|
||||||
(entry: BestiaryIndexEntry) => {
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
const { newName, renames } = resolveCreatureName(
|
const { newName, renames } = resolveCreatureName(
|
||||||
@@ -284,7 +285,7 @@ export function useEncounter() {
|
|||||||
// Add combatant with resolved name
|
// Add combatant with resolved name
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||||
if (isDomainError(addResult)) return;
|
if (isDomainError(addResult)) return null;
|
||||||
|
|
||||||
// Set HP
|
// Set HP
|
||||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||||
@@ -317,6 +318,8 @@ export function useEncounter() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
|
|
||||||
|
return cId;
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
32
apps/web/src/hooks/use-long-press.ts
Normal file
32
apps/web/src/hooks/use-long-press.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 500;
|
||||||
|
|
||||||
|
export function useLongPress(onLongPress: (e: React.TouchEvent) => void) {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const firedRef = useRef(false);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
firedRef.current = false;
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
firedRef.current = true;
|
||||||
|
onLongPress(e);
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
},
|
||||||
|
[onLongPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
if (firedRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback(() => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { onTouchStart, onTouchEnd, onTouchMove };
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ interface SidePanelState {
|
|||||||
|
|
||||||
interface SidePanelActions {
|
interface SidePanelActions {
|
||||||
showCreature: (creatureId: CreatureId) => void;
|
showCreature: (creatureId: CreatureId) => void;
|
||||||
|
updateCreature: (creatureId: CreatureId) => void;
|
||||||
showBulkImport: () => void;
|
showBulkImport: () => void;
|
||||||
showSourceManager: () => void;
|
showSourceManager: () => void;
|
||||||
dismissPanel: () => void;
|
dismissPanel: () => void;
|
||||||
@@ -52,6 +53,10 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
setIsRightPanelCollapsed(false);
|
setIsRightPanelCollapsed(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showBulkImport = useCallback(() => {
|
const showBulkImport = useCallback(() => {
|
||||||
setPanelView({ mode: "bulk-import" });
|
setPanelView({ mode: "bulk-import" });
|
||||||
setIsRightPanelCollapsed(false);
|
setIsRightPanelCollapsed(false);
|
||||||
@@ -91,6 +96,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
pinnedCreatureId,
|
pinnedCreatureId,
|
||||||
isWideDesktop,
|
isWideDesktop,
|
||||||
showCreature,
|
showCreature,
|
||||||
|
updateCreature,
|
||||||
showBulkImport,
|
showBulkImport,
|
||||||
showSourceManager,
|
showSourceManager,
|
||||||
dismissPanel,
|
dismissPanel,
|
||||||
|
|||||||
98
apps/web/src/hooks/use-theme.ts
Normal file
98
apps/web/src/hooks/use-theme.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
type ThemePreference = "system" | "light" | "dark";
|
||||||
|
type ResolvedTheme = "light" | "dark";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:theme";
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let currentPreference: ThemePreference = loadPreference();
|
||||||
|
|
||||||
|
function loadPreference(): ThemePreference {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreference(pref: ThemePreference): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, pref);
|
||||||
|
} catch {
|
||||||
|
// quota exceeded or storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme(): ResolvedTheme {
|
||||||
|
if (typeof globalThis.matchMedia !== "function") return "dark";
|
||||||
|
return globalThis.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(pref: ThemePreference): ResolvedTheme {
|
||||||
|
return pref === "system" ? getSystemTheme() : pref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(resolved: ResolvedTheme): void {
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyAll(): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply on load
|
||||||
|
applyTheme(resolve(currentPreference));
|
||||||
|
|
||||||
|
// Listen for OS preference changes
|
||||||
|
if (typeof globalThis.matchMedia === "function") {
|
||||||
|
globalThis
|
||||||
|
.matchMedia("(prefers-color-scheme: light)")
|
||||||
|
.addEventListener("change", () => {
|
||||||
|
if (currentPreference === "system") {
|
||||||
|
applyTheme(resolve("system"));
|
||||||
|
notifyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(callback: () => void): () => void {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): ThemePreference {
|
||||||
|
return currentPreference;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CYCLE: ThemePreference[] = ["system", "light", "dark"];
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const preference = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
const resolved = resolve(preference);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(resolved);
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
const setPreference = useCallback((pref: ThemePreference) => {
|
||||||
|
currentPreference = pref;
|
||||||
|
savePreference(pref);
|
||||||
|
applyTheme(resolve(pref));
|
||||||
|
notifyAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cycleTheme = useCallback(() => {
|
||||||
|
const idx = CYCLE.indexOf(currentPreference);
|
||||||
|
const next = CYCLE[(idx + 1) % CYCLE.length];
|
||||||
|
setPreference(next);
|
||||||
|
}, [setPreference]);
|
||||||
|
|
||||||
|
return { preference, resolved, setPreference, cycleTheme } as const;
|
||||||
|
}
|
||||||
@@ -19,12 +19,47 @@
|
|||||||
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
|
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.15);
|
||||||
--color-hover-action-bg: var(--color-muted);
|
--color-hover-action-bg: var(--color-muted);
|
||||||
--color-hover-destructive-bg: transparent;
|
--color-hover-destructive-bg: transparent;
|
||||||
|
--color-stat-heading: #fbbf24;
|
||||||
|
--color-stat-divider-from: oklch(0.5 0.1 65 / 0.6);
|
||||||
|
--color-stat-divider-via: oklch(0.5 0.1 65 / 0.4);
|
||||||
|
--color-hp-damage-hover-bg: oklch(0.25 0.05 25);
|
||||||
|
--color-hp-heal-hover-bg: oklch(0.25 0.05 155);
|
||||||
|
--color-active-row-bg: oklch(0.623 0.214 259 / 0.1);
|
||||||
|
--color-active-row-border: oklch(0.623 0.214 259 / 0.4);
|
||||||
--radius-sm: 0.25rem;
|
--radius-sm: 0.25rem;
|
||||||
--radius-md: 0.5rem;
|
--radius-md: 0.5rem;
|
||||||
--radius-lg: 0.75rem;
|
--radius-lg: 0.75rem;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-background: #eeecea;
|
||||||
|
--color-foreground: #374151;
|
||||||
|
--color-muted: #e0ddd9;
|
||||||
|
--color-muted-foreground: #6b7280;
|
||||||
|
--color-card: #f7f6f4;
|
||||||
|
--color-card-foreground: #374151;
|
||||||
|
--color-border: #ddd9d5;
|
||||||
|
--color-input: #cdc8c3;
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-accent: #2563eb;
|
||||||
|
--color-destructive: #dc2626;
|
||||||
|
--color-hover-neutral: var(--color-primary);
|
||||||
|
--color-hover-action: var(--color-primary);
|
||||||
|
--color-hover-destructive: var(--color-destructive);
|
||||||
|
--color-hover-neutral-bg: oklch(0.623 0.214 259 / 0.08);
|
||||||
|
--color-hover-action-bg: var(--color-muted);
|
||||||
|
--color-hover-destructive-bg: transparent;
|
||||||
|
--color-stat-heading: #92400e;
|
||||||
|
--color-stat-divider-from: oklch(0.55 0.1 65 / 0.5);
|
||||||
|
--color-stat-divider-via: oklch(0.55 0.1 65 / 0.25);
|
||||||
|
--color-hp-damage-hover-bg: #fef2f2;
|
||||||
|
--color-hp-heal-hover-bg: #ecfdf5;
|
||||||
|
--color-active-row-bg: oklch(0.623 0.214 259 / 0.08);
|
||||||
|
--color-active-row-border: oklch(0.623 0.214 259 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes concentration-shake {
|
@keyframes concentration-shake {
|
||||||
0% {
|
0% {
|
||||||
translate: 0;
|
translate: 0;
|
||||||
@@ -178,6 +213,11 @@
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
|
0 0 15px -2px oklch(0.623 0.214 259 / 0.2),
|
||||||
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
||||||
|
|
||||||
|
[data-theme="light"] & {
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility panel-glow {
|
@utility panel-glow {
|
||||||
@@ -189,6 +229,11 @@
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
|
0 0 20px -2px oklch(0.623 0.214 259 / 0.15),
|
||||||
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
inset 0 1px 0 0 oklch(0.7 0.15 259 / 0.1);
|
||||||
|
|
||||||
|
[data-theme="light"] & {
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -207,3 +252,7 @@ body {
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] body {
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>
|
||||||
|
<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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,48 @@ describe("rollAllInitiativeUseCase", () => {
|
|||||||
expect(store.saved).toBeNull();
|
expect(store.saved).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses higher roll with advantage", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const creature = makeCreature("creature-a");
|
||||||
|
|
||||||
|
// Alternating rolls: 5, 15 → advantage picks 15
|
||||||
|
// Dex 14 → modifier +2, so 15 + 2 = 17
|
||||||
|
let call = 0;
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => (++call % 2 === 1 ? 5 : 15),
|
||||||
|
(id) => (id === CREATURE_A ? creature : undefined),
|
||||||
|
"advantage",
|
||||||
|
);
|
||||||
|
|
||||||
|
expectSuccess(result);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses lower roll with disadvantage", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const creature = makeCreature("creature-a");
|
||||||
|
|
||||||
|
// Alternating rolls: 15, 5 → disadvantage picks 5
|
||||||
|
// Dex 14 → modifier +2, so 5 + 2 = 7
|
||||||
|
let call = 0;
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => (++call % 2 === 1 ? 15 : 5),
|
||||||
|
(id) => (id === CREATURE_A ? creature : undefined),
|
||||||
|
"disadvantage",
|
||||||
|
);
|
||||||
|
|
||||||
|
expectSuccess(result);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
it("saves encounter once at the end", () => {
|
it("saves encounter once at the end", () => {
|
||||||
const enc = encounterWithCombatants([
|
const enc = encounterWithCombatants([
|
||||||
{ name: "A", creatureId: "creature-a" },
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("unknown"),
|
combatantId("unknown"),
|
||||||
10,
|
[10],
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Fighter"),
|
combatantId("Fighter"),
|
||||||
10,
|
[10],
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Goblin"),
|
combatantId("Goblin"),
|
||||||
10,
|
[10],
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Goblin"),
|
combatantId("Goblin"),
|
||||||
10,
|
[10],
|
||||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,6 +124,42 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses higher roll with advantage", () => {
|
||||||
|
const creature = makeCreature();
|
||||||
|
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
// Dex 14 -> modifier +2, advantage picks max(5, 15) = 15, 15 + 2 = 17
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Goblin"),
|
||||||
|
[5, 15],
|
||||||
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
|
"advantage",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses lower roll with disadvantage", () => {
|
||||||
|
const creature = makeCreature();
|
||||||
|
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
// Dex 14 -> modifier +2, disadvantage picks min(5, 15) = 5, 5 + 2 = 7
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Goblin"),
|
||||||
|
[5, 15],
|
||||||
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
|
"disadvantage",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
it("applies initiative proficiency bonus correctly", () => {
|
it("applies initiative proficiency bonus correctly", () => {
|
||||||
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
||||||
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
||||||
@@ -145,7 +181,7 @@ describe("rollInitiativeUseCase", () => {
|
|||||||
const result = rollInitiativeUseCase(
|
const result = rollInitiativeUseCase(
|
||||||
store,
|
store,
|
||||||
combatantId("Monster"),
|
combatantId("Monster"),
|
||||||
8,
|
[8],
|
||||||
(id) => (id === GOBLIN_ID ? creature : undefined),
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
type RollMode,
|
||||||
rollInitiative,
|
rollInitiative,
|
||||||
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
@@ -19,6 +21,7 @@ export function rollAllInitiativeUseCase(
|
|||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
|
mode: RollMode = "normal",
|
||||||
): RollAllResult | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
@@ -39,7 +42,10 @@ export function rollAllInitiativeUseCase(
|
|||||||
cr: creature.cr,
|
cr: creature.cr,
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
initiativeProficiency: creature.initiativeProficiency,
|
||||||
});
|
});
|
||||||
const value = rollInitiative(rollDice(), modifier);
|
const roll1 = rollDice();
|
||||||
|
const effectiveRoll =
|
||||||
|
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
||||||
|
const value = rollInitiative(effectiveRoll, modifier);
|
||||||
|
|
||||||
if (isDomainError(value)) {
|
if (isDomainError(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
type RollMode,
|
||||||
rollInitiative,
|
rollInitiative,
|
||||||
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
@@ -14,8 +16,9 @@ import type { EncounterStore } from "./ports.js";
|
|||||||
export function rollInitiativeUseCase(
|
export function rollInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
diceRoll: number,
|
diceRolls: readonly [number, ...number[]],
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
|
mode: RollMode = "normal",
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
const encounter = store.get();
|
||||||
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
const combatant = encounter.combatants.find((c) => c.id === combatantId);
|
||||||
@@ -50,7 +53,11 @@ export function rollInitiativeUseCase(
|
|||||||
cr: creature.cr,
|
cr: creature.cr,
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
initiativeProficiency: creature.initiativeProficiency,
|
||||||
});
|
});
|
||||||
const value = rollInitiative(diceRoll, modifier);
|
const effectiveRoll =
|
||||||
|
mode === "normal"
|
||||||
|
? diceRolls[0]
|
||||||
|
: selectRoll(diceRolls[0], diceRolls[1] ?? diceRolls[0], mode);
|
||||||
|
const value = rollInitiative(effectiveRoll, modifier);
|
||||||
|
|
||||||
if (isDomainError(value)) {
|
if (isDomainError(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { rollInitiative } from "../roll-initiative.js";
|
import { rollInitiative, selectRoll } from "../roll-initiative.js";
|
||||||
import { isDomainError } from "../types.js";
|
import { isDomainError } from "../types.js";
|
||||||
import { expectDomainError } from "./test-helpers.js";
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
@@ -63,3 +63,31 @@ describe("rollInitiative", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("selectRoll", () => {
|
||||||
|
it("normal mode returns the first roll", () => {
|
||||||
|
expect(selectRoll(8, 15, "normal")).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advantage returns the higher roll", () => {
|
||||||
|
expect(selectRoll(8, 15, "advantage")).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advantage returns the higher roll (reversed)", () => {
|
||||||
|
expect(selectRoll(15, 8, "advantage")).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disadvantage returns the lower roll", () => {
|
||||||
|
expect(selectRoll(8, 15, "disadvantage")).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disadvantage returns the lower roll (reversed)", () => {
|
||||||
|
expect(selectRoll(15, 8, "disadvantage")).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equal rolls return the same value for all modes", () => {
|
||||||
|
expect(selectRoll(12, 12, "normal")).toBe(12);
|
||||||
|
expect(selectRoll(12, 12, "advantage")).toBe(12);
|
||||||
|
expect(selectRoll(12, 12, "disadvantage")).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export {
|
|||||||
removeCombatant,
|
removeCombatant,
|
||||||
} from "./remove-combatant.js";
|
} from "./remove-combatant.js";
|
||||||
export { retreatTurn } from "./retreat-turn.js";
|
export { retreatTurn } from "./retreat-turn.js";
|
||||||
export { rollInitiative } from "./roll-initiative.js";
|
export {
|
||||||
|
type RollMode,
|
||||||
|
rollInitiative,
|
||||||
|
selectRoll,
|
||||||
|
} from "./roll-initiative.js";
|
||||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import type { DomainError } from "./types.js";
|
import type { DomainError } from "./types.js";
|
||||||
|
|
||||||
|
export type RollMode = "normal" | "advantage" | "disadvantage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the effective roll from two dice values based on the roll mode.
|
||||||
|
* Advantage takes the higher, disadvantage takes the lower.
|
||||||
|
*/
|
||||||
|
export function selectRoll(
|
||||||
|
roll1: number,
|
||||||
|
roll2: number,
|
||||||
|
mode: RollMode,
|
||||||
|
): number {
|
||||||
|
if (mode === "advantage") return Math.max(roll1, roll2);
|
||||||
|
if (mode === "disadvantage") return Math.min(roll1, roll2);
|
||||||
|
return roll1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure function that computes initiative from a resolved dice roll and modifier.
|
* Pure function that computes initiative from a resolved dice roll and modifier.
|
||||||
* The dice roll must be an integer in [1, 20].
|
* The dice roll must be an integer in [1, 20].
|
||||||
|
|||||||
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
|
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
|
||||||
- Active combatant changes while panel is open or collapsed: advancing turns does not auto-show or update the stat block panel. The panel only changes when the user explicitly clicks a book icon. If the panel is collapsed, it stays collapsed.
|
- Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
|
||||||
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
|
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
|
||||||
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
|
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
|
||||||
- Panel showing a source fetch prompt: the pin button is hidden.
|
- Panel showing a source fetch prompt: the pin button is hidden.
|
||||||
|
|||||||
Reference in New Issue
Block a user