Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab9301b20 | ||
|
|
d653cfe489 | ||
|
|
228a2603e8 | ||
|
|
27ff8ba1ad | ||
|
|
4cfcefe6c3 | ||
|
|
8baccf3cd3 | ||
|
|
a9ca31e9bc | ||
|
|
64a1f0b8db | ||
|
|
5e5812bcaa | ||
|
|
9e09c8ae2a | ||
|
|
4d0ec0c7b2 | ||
|
|
fe62f2eb2f | ||
|
|
7092677273 | ||
|
|
e1a06c9d59 | ||
|
|
4043612ccf | ||
|
|
cfd4aef724 | ||
|
|
968cc7239b | ||
|
|
d9562f850c | ||
|
|
ec9f2e7877 | ||
|
|
c4079c384b | ||
|
|
a4285fc415 | ||
|
|
9c0e3398f1 | ||
|
|
9cdf004c15 | ||
|
|
8bf69fd47d | ||
|
|
7b83e3c3ea | ||
|
|
c3c2cad798 | ||
|
|
3f6140303d | ||
|
|
fd30278474 | ||
|
|
278c06221f | ||
|
|
722e8cc627 | ||
|
|
64741956dd | ||
|
|
6336dec38a | ||
|
|
9def2d7c24 | ||
|
|
f729e37689 | ||
|
|
86768842ff | ||
|
|
6584d8d064 | ||
|
|
7f38cbab73 | ||
|
|
2971898f0c | ||
|
|
43780772f6 | ||
|
|
7b3dbe2069 | ||
|
|
827a3978e9 | ||
|
|
f024562a7d | ||
|
|
dfef2194a5 | ||
|
|
502adca81b | ||
|
|
12e8bf6e69 | ||
|
|
472574ac31 | ||
|
|
f4a7b53393 | ||
|
|
8aec460ee4 | ||
|
|
6e10238fe0 | ||
|
|
b6e882add2 | ||
|
|
7a87d979bf | ||
|
|
02096bcee6 | ||
|
|
c092192b0e | ||
|
|
4d1a7c6420 | ||
|
|
46b444caba | ||
|
|
e68145319f | ||
|
|
d64e1f5e4a | ||
|
|
ef0b755eec | ||
|
|
4be816d10f | ||
|
|
e531d82d1b | ||
|
|
5a262c66cd | ||
|
|
32b69f8df1 | ||
|
|
8efba288f7 | ||
|
|
c94c30e459 | ||
|
|
36768d3aa1 | ||
|
|
473f1eaefe | ||
|
|
971e0ded49 | ||
|
|
36dcfc5076 | ||
|
|
127ed01064 | ||
|
|
179c3658ad | ||
|
|
01f2bb3ff1 | ||
|
|
930301de71 | ||
|
|
aa806d4fb9 | ||
|
|
61bc274715 | ||
|
|
1932e837fb | ||
|
|
cce87318fb | ||
|
|
3ef2370a34 | ||
|
|
c75d148d1e | ||
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 | ||
|
|
07cdd4867a | ||
|
|
85acb5c185 | ||
|
|
f9ef64bb00 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
docs/agents/plans/
|
||||||
|
|||||||
27
.oxlintrc.json
Normal file
27
.oxlintrc.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "jest"],
|
||||||
|
"categories": {},
|
||||||
|
"rules": {
|
||||||
|
"typescript/no-unnecessary-type-assertion": "error",
|
||||||
|
"typescript/no-deprecated": "warn",
|
||||||
|
"typescript/prefer-regexp-exec": "error",
|
||||||
|
"unicorn/prefer-string-replace-all": "error",
|
||||||
|
"unicorn/prefer-string-raw": "error",
|
||||||
|
"jest/expect-expect": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"assertFunctionNames": ["expect", "expectDomainError"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"dist",
|
||||||
|
"coverage",
|
||||||
|
".claude",
|
||||||
|
".specify",
|
||||||
|
"specs",
|
||||||
|
".pnpm-store",
|
||||||
|
"scripts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -5,13 +5,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + typecheck + test/coverage + jscpd)
|
pnpm check # Merge gate — must pass before every commit (audit + knip + biome + oxlint + typecheck + test/coverage + jscpd)
|
||||||
|
pnpm oxlint # Type-aware linting (oxlint — complements Biome)
|
||||||
pnpm knip # Unused code detection (Knip)
|
pnpm knip # Unused code detection (Knip)
|
||||||
pnpm test # Run all tests (Vitest)
|
pnpm test # Run all tests (Vitest)
|
||||||
pnpm test:watch # Tests in watch mode
|
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
|
||||||
```
|
```
|
||||||
@@ -58,19 +60,29 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- React 19, Vite 6, Tailwind CSS v4
|
- React 19, Vite 6, Tailwind CSS v4
|
||||||
- Lucide React (icons)
|
- Lucide React (icons)
|
||||||
- `idb` (IndexedDB wrapper for bestiary cache)
|
- `idb` (IndexedDB wrapper for bestiary cache)
|
||||||
- Biome 2.0 (formatting + linting), Knip (unused code), jscpd (copy-paste detection)
|
- Biome 2.4 (formatting + linting), oxlint (type-aware linting), Knip (unused code), jscpd (copy-paste detection)
|
||||||
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
- Vitest (testing, v8 coverage), Lefthook (pre-commit hooks)
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
- **Biome 2.4** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
|
- **oxlint** for type-aware linting that Biome can't do (unnecessary type assertions, deprecated APIs, `replaceAll` preference, `String.raw`). Configured in `.oxlintrc.json`.
|
||||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
||||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
- **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
|
||||||
|
|
||||||
|
Before finishing a change, consider:
|
||||||
|
- Is this the simplest approach that solves the current problem?
|
||||||
|
- Is there duplication that hurts readability? (But don't abstract prematurely.)
|
||||||
|
- Are errors handled correctly and communicated sensibly to the user?
|
||||||
|
- Does the UI follow modern patterns and feel intuitive to interact with?
|
||||||
|
|
||||||
## Speckit Workflow
|
## Speckit Workflow
|
||||||
|
|
||||||
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Specs are **living documents** that describe features, not individual changes.
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0e1a2e" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta property="og:title" content="Initiative Tracker" />
|
||||||
|
<meta property="og:description" content="D&D combat initiative tracker" />
|
||||||
|
<meta property="og:image" content="https://initiative.dostulata.rocks/icon-512.png" />
|
||||||
|
<meta property="og:url" content="https://initiative.dostulata.rocks/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
<title>Initiative Tracker</title>
|
<title>Initiative Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -20,14 +20,15 @@
|
|||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
BIN
apps/web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
21
apps/web/public/favicon.svg
Normal file
21
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="f" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(16 15) scale(1.55)" fill="none" stroke="url(#f)" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#f)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#f)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#f)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#f)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
31
apps/web/public/icon.svg
Normal file
31
apps/web/public/icon.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="50%" cy="40%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1a2e4a"/>
|
||||||
|
<stop offset="100%" stop-color="#0e1a2e"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="d20fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#60a5fa"/>
|
||||||
|
<stop offset="100%" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="d20stroke" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#93c5fd"/>
|
||||||
|
<stop offset="100%" stop-color="#3b82f6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(256 256) scale(8.5)" fill="none" stroke="url(#d20stroke)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76" fill="url(#d20fill)" fill-opacity="0.15"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49" fill="url(#d20fill)" fill-opacity="0.25"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.2"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44" fill="url(#d20fill)" fill-opacity="0.1"/>
|
||||||
|
<line x1="-4.75" y1="-2.74" x2="-8.19" y2="-4.74"/>
|
||||||
|
<line x1="8.18" y1="-4.74" x2="4.75" y2="-2.74"/>
|
||||||
|
<line x1="-0.01" y1="5.44" x2="-0.01" y2="9.51"/>
|
||||||
|
<polygon points="8.18 4.76 8.18 -4.74 -0.01 -9.49 -8.19 -4.74 -8.19 4.76 -0.01 9.51 8.18 4.76"/>
|
||||||
|
<polygon points="-0.01 -9.49 -4.75 -2.74 4.75 -2.74 -0.01 -9.49"/>
|
||||||
|
<polygon points="-0.01 5.44 8.18 4.76 4.75 -2.74 -0.01 5.44"/>
|
||||||
|
<polygon points="-0.01 5.44 -4.75 -2.74 -8.19 4.76 -0.01 5.44"/>
|
||||||
|
</g>
|
||||||
|
<text x="256" y="278" text-anchor="middle" dominant-baseline="central" font-family="Inter, system-ui, sans-serif" font-weight="700" font-size="52" fill="#93c5fd" letter-spacing="1">20</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
21
apps/web/public/manifest.json
Normal file
21
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Initiative Tracker",
|
||||||
|
"short_name": "Initiative",
|
||||||
|
"description": "D&D combat initiative tracker",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0e1a2e",
|
||||||
|
"theme_color": "#0e1a2e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,301 +1,80 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ActionBar } from "./components/action-bar.js";
|
||||||
|
import { BulkImportToasts } from "./components/bulk-import-toasts.js";
|
||||||
|
import { CombatantRow } from "./components/combatant-row.js";
|
||||||
import {
|
import {
|
||||||
rollAllInitiativeUseCase,
|
PlayerCharacterSection,
|
||||||
rollInitiativeUseCase,
|
type PlayerCharacterSectionHandle,
|
||||||
} from "@initiative/application";
|
} from "./components/player-character-section.js";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import { SettingsModal } from "./components/settings-modal.js";
|
||||||
import {
|
import { StatBlockPanel } from "./components/stat-block-panel.js";
|
||||||
useCallback,
|
import { Toast } from "./components/toast.js";
|
||||||
useEffect,
|
import { TurnNavigation } from "./components/turn-navigation.js";
|
||||||
useLayoutEffect,
|
import { useEncounterContext } from "./contexts/encounter-context.js";
|
||||||
useRef,
|
import { useInitiativeRollsContext } from "./contexts/initiative-rolls-context.js";
|
||||||
useState,
|
import { useSidePanelContext } from "./contexts/side-panel-context.js";
|
||||||
} from "react";
|
import { useActionBarAnimation } from "./hooks/use-action-bar-animation.js";
|
||||||
import { ActionBar } from "./components/action-bar";
|
import { useAutoStatBlock } from "./hooks/use-auto-stat-block.js";
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
import { cn } from "./lib/utils.js";
|
||||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
|
||||||
import { PlayerManagement } from "./components/player-management";
|
|
||||||
import { SourceManager } from "./components/source-manager";
|
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
|
||||||
import { Toast } from "./components/toast";
|
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
|
||||||
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
|
||||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
|
||||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
|
||||||
|
|
||||||
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 topBarClass = settling
|
|
||||||
? " animate-slide-down-in"
|
|
||||||
: topBarExiting
|
|
||||||
? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out"
|
|
||||||
: "";
|
|
||||||
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();
|
||||||
advanceTurn,
|
const rolls = useInitiativeRollsContext();
|
||||||
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 [createPlayerOpen, setCreatePlayerOpen] = useState(false);
|
|
||||||
const [managementOpen, setManagementOpen] = useState(false);
|
|
||||||
const [editingPlayer, setEditingPlayer] = useState<
|
|
||||||
(typeof playerCharacters)[number] | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const {
|
|
||||||
search,
|
|
||||||
getCreature,
|
|
||||||
isLoaded,
|
|
||||||
isSourceCached,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
uploadAndCacheSource,
|
|
||||||
refreshCache,
|
|
||||||
} = useBestiary();
|
|
||||||
|
|
||||||
const bulkImport = useBulkImport();
|
|
||||||
|
|
||||||
const [selectedCreatureId, setSelectedCreatureId] =
|
|
||||||
useState<CreatureId | null>(null);
|
|
||||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
|
||||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
|
||||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
|
||||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
|
||||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia("(min-width: 1280px)");
|
|
||||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
|
||||||
mq.addEventListener("change", handler);
|
|
||||||
return () => mq.removeEventListener("change", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedCreature: Creature | null = selectedCreatureId
|
|
||||||
? (getCreature(selectedCreatureId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const pinnedCreature: Creature | null = pinnedCreatureId
|
|
||||||
? (getCreature(pinnedCreatureId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
|
||||||
(result: SearchResult) => {
|
|
||||||
addFromBestiary(result);
|
|
||||||
},
|
|
||||||
[addFromBestiary],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
|
||||||
setSelectedCreatureId(creatureId as CreatureId);
|
|
||||||
setIsRightPanelFolded(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
|
||||||
(id: CombatantId) => {
|
|
||||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
|
||||||
},
|
|
||||||
[makeStore, getCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
|
||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
|
||||||
}, [makeStore, getCreature]);
|
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
|
||||||
const slug = result.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/(^-|-$)/g, "");
|
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
|
||||||
setSelectedCreatureId(cId);
|
|
||||||
setIsRightPanelFolded(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkImport = useCallback(() => {
|
|
||||||
setBulkImportMode(true);
|
|
||||||
setSelectedCreatureId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleStartBulkImport = useCallback(
|
|
||||||
(baseUrl: string) => {
|
|
||||||
bulkImport.startImport(
|
|
||||||
baseUrl,
|
|
||||||
fetchAndCacheSource,
|
|
||||||
isSourceCached,
|
|
||||||
refreshCache,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkImportDone = useCallback(() => {
|
|
||||||
setBulkImportMode(false);
|
|
||||||
bulkImport.reset();
|
|
||||||
}, [bulkImport.reset]);
|
|
||||||
|
|
||||||
const handleDismissBrowsePanel = useCallback(() => {
|
|
||||||
setSelectedCreatureId(null);
|
|
||||||
setBulkImportMode(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleFold = useCallback(() => {
|
|
||||||
setIsRightPanelFolded((f) => !f);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePin = useCallback(() => {
|
|
||||||
if (selectedCreatureId) {
|
|
||||||
setPinnedCreatureId((prev) =>
|
|
||||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selectedCreatureId]);
|
|
||||||
|
|
||||||
const handleUnpin = useCallback(() => {
|
|
||||||
setPinnedCreatureId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const playerCharacterRef = useRef<PlayerCharacterSectionHandle>(null);
|
||||||
const actionBarInputRef = useRef<HTMLInputElement>(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(() => {
|
||||||
|
if (activeIndex >= 0) {
|
||||||
activeRowRef.current?.scrollIntoView({
|
activeRowRef.current?.scrollIntoView({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, [encounter.activeIndex]);
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
// Auto-show stat block for the active combatant when turn changes,
|
|
||||||
// but only when the viewport is wide enough to show it alongside the tracker.
|
|
||||||
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
|
|
||||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevActiveIndexRef.current === encounter.activeIndex) return;
|
|
||||||
prevActiveIndexRef.current = encounter.activeIndex;
|
|
||||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
|
||||||
const active = encounter.combatants[encounter.activeIndex];
|
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
|
||||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
|
||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
|
||||||
const showRollAllInitiative = encounter.combatants.some(
|
|
||||||
(c) => c.creatureId != null && c.initiative == null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-dvh flex-col">
|
||||||
<div className="relative mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
|
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
|
||||||
{actionBarAnim.showTopBar && (
|
{!!actionBarAnim.showTopBar && (
|
||||||
<div
|
<div
|
||||||
className={`shrink-0 pt-8${actionBarAnim.topBarClass}`}
|
className={cn(
|
||||||
|
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
|
||||||
|
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 flex-1 items-center justify-center min-h-0 pb-[15%] pt-8">
|
|
||||||
<div
|
<div
|
||||||
className={`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={handleBulkImport}
|
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
onManagePlayers={() =>
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
playerCharacterRef.current?.openManagement()
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sourceManagerOpen && (
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
|
||||||
<SourceManager onCacheCleared={refreshCache} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex flex-col px-2 py-2">
|
||||||
{encounter.combatants.map((c, i) => (
|
{encounter.combatants.map((c, i) => (
|
||||||
<CombatantRow
|
<CombatantRow
|
||||||
@@ -303,160 +82,59 @@ 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={`shrink-0 pb-8${actionBarAnim.settlingClass}`}
|
className={cn(
|
||||||
|
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
|
||||||
|
actionBarAnim.settlingClass,
|
||||||
|
)}
|
||||||
onAnimationEnd={actionBarAnim.onSettleEnd}
|
onAnimationEnd={actionBarAnim.onSettleEnd}
|
||||||
>
|
>
|
||||||
<ActionBar
|
<ActionBar
|
||||||
onAddCombatant={addCombatant}
|
|
||||||
onAddFromBestiary={handleAddFromBestiary}
|
|
||||||
bestiarySearch={search}
|
|
||||||
bestiaryLoaded={isLoaded}
|
|
||||||
onViewStatBlock={handleViewStatBlock}
|
|
||||||
onBulkImport={handleBulkImport}
|
|
||||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
|
||||||
inputRef={actionBarInputRef}
|
inputRef={actionBarInputRef}
|
||||||
playerCharacters={playerCharacters}
|
onManagePlayers={() =>
|
||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
playerCharacterRef.current?.openManagement()
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pinned Stat Block Panel (left) */}
|
{!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||||
{pinnedCreatureId && isWideDesktop && (
|
<StatBlockPanel panelRole="pinned" side="left" />
|
||||||
<StatBlockPanel
|
|
||||||
creatureId={pinnedCreatureId}
|
|
||||||
creature={pinnedCreature}
|
|
||||||
isSourceCached={isSourceCached}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
|
||||||
refreshCache={refreshCache}
|
|
||||||
panelRole="pinned"
|
|
||||||
isFolded={false}
|
|
||||||
onToggleFold={() => {}}
|
|
||||||
onPin={() => {}}
|
|
||||||
onUnpin={handleUnpin}
|
|
||||||
showPinButton={false}
|
|
||||||
side="left"
|
|
||||||
onDismiss={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Browse Stat Block Panel (right) */}
|
<StatBlockPanel panelRole="browse" side="right" />
|
||||||
<StatBlockPanel
|
|
||||||
creatureId={selectedCreatureId}
|
|
||||||
creature={selectedCreature}
|
|
||||||
isSourceCached={isSourceCached}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
uploadAndCacheSource={uploadAndCacheSource}
|
|
||||||
refreshCache={refreshCache}
|
|
||||||
panelRole="browse"
|
|
||||||
isFolded={isRightPanelFolded}
|
|
||||||
onToggleFold={handleToggleFold}
|
|
||||||
onPin={handlePin}
|
|
||||||
onUnpin={() => {}}
|
|
||||||
showPinButton={isWideDesktop && !!selectedCreature}
|
|
||||||
side="right"
|
|
||||||
onDismiss={handleDismissBrowsePanel}
|
|
||||||
bulkImportMode={bulkImportMode}
|
|
||||||
bulkImportState={bulkImport.state}
|
|
||||||
onStartBulkImport={handleStartBulkImport}
|
|
||||||
onBulkImportDone={handleBulkImportDone}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Toast for bulk import progress when panel is closed */}
|
<BulkImportToasts />
|
||||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
|
||||||
|
{rolls.rollSkippedCount > 0 && (
|
||||||
<Toast
|
<Toast
|
||||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
message={`${rolls.rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
progress={
|
onDismiss={rolls.dismissRollSkipped}
|
||||||
bulkImport.state.total > 0
|
autoDismissMs={4000}
|
||||||
? (bulkImport.state.completed + bulkImport.state.failed) /
|
|
||||||
bulkImport.state.total
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onDismiss={() => {}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
|
||||||
<Toast
|
|
||||||
message="All sources loaded"
|
|
||||||
onDismiss={bulkImport.reset}
|
|
||||||
autoDismissMs={3000}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
|
||||||
<Toast
|
|
||||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
|
||||||
onDismiss={bulkImport.reset}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreatePlayerModal
|
{!!rolls.rollSingleSkipped && (
|
||||||
open={createPlayerOpen}
|
<Toast
|
||||||
onClose={() => {
|
message="Can't roll — bestiary source not loaded"
|
||||||
setCreatePlayerOpen(false);
|
onDismiss={rolls.dismissRollSingleSkipped}
|
||||||
setEditingPlayer(undefined);
|
autoDismissMs={4000}
|
||||||
}}
|
|
||||||
onSave={(name, ac, maxHp, color, icon) => {
|
|
||||||
if (editingPlayer) {
|
|
||||||
editPlayerCharacter?.(editingPlayer.id, {
|
|
||||||
name,
|
|
||||||
ac,
|
|
||||||
maxHp,
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
playerCharacter={editingPlayer}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayerManagement
|
<SettingsModal
|
||||||
open={managementOpen}
|
open={settingsOpen}
|
||||||
onClose={() => setManagementOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
characters={playerCharacters}
|
|
||||||
onEdit={(pc) => {
|
|
||||||
setEditingPlayer(pc);
|
|
||||||
setCreatePlayerOpen(true);
|
|
||||||
setManagementOpen(false);
|
|
||||||
}}
|
|
||||||
onDelete={(id) => deletePlayerCharacter?.(id)}
|
|
||||||
onCreate={() => {
|
|
||||||
setEditingPlayer(undefined);
|
|
||||||
setCreatePlayerOpen(true);
|
|
||||||
setManagementOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<PlayerCharacterSection ref={playerCharacterRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
163
apps/web/src/__tests__/app-integration.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { App } from "../App.js";
|
||||||
|
import { AllProviders } from "./test-providers.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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
async function addCombatant(
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
name: string,
|
||||||
|
opts?: { maxHp?: string },
|
||||||
|
) {
|
||||||
|
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
||||||
|
const input = inputs.at(-1)!;
|
||||||
|
await user.type(input, name);
|
||||||
|
|
||||||
|
if (opts?.maxHp) {
|
||||||
|
const maxHpInput = screen.getByPlaceholderText("MaxHP");
|
||||||
|
await user.type(maxHpInput, opts.maxHp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
|
await user.click(addButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("App integration", () => {
|
||||||
|
it("adds a combatant and removes it, returning to empty state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
|
// Empty state: centered input visible, no TurnNavigation
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("R1")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Add a combatant
|
||||||
|
await addCombatant(user, "Goblin");
|
||||||
|
|
||||||
|
// Verify combatant appears and TurnNavigation shows
|
||||||
|
expect(screen.getByRole("button", { name: "Goblin" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("Goblin").length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
// Remove combatant via ConfirmButton (two clicks)
|
||||||
|
const removeBtn = screen.getByRole("button", {
|
||||||
|
name: "Remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(removeBtn);
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
|
||||||
|
// Back to empty state (R1 badge may linger due to exit animation in jsdom)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Goblin" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances and retreats turns across two combatants", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
|
await addCombatant(user, "Fighter");
|
||||||
|
await addCombatant(user, "Wizard");
|
||||||
|
|
||||||
|
// Initial state — R1, Fighter active (Previous turn disabled)
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
// Advance turn — Wizard becomes active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||||
|
|
||||||
|
// Advance again — wraps to R2, Fighter active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next turn" }));
|
||||||
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Previous turn" })).toBeEnabled();
|
||||||
|
|
||||||
|
// Retreat — back to R1, Wizard active
|
||||||
|
await user.click(screen.getByRole("button", { name: "Previous turn" }));
|
||||||
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a combatant with HP, applies damage, and shows unconscious state", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />, { wrapper: AllProviders });
|
||||||
|
|
||||||
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
|
const hpButton = screen.getByRole("button", {
|
||||||
|
name: "Current HP: 59 (healthy)",
|
||||||
|
});
|
||||||
|
expect(hpButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click currentHp to open HpAdjustPopover, apply full damage
|
||||||
|
await user.click(hpButton);
|
||||||
|
const hpInput = screen.getByPlaceholderText("HP");
|
||||||
|
expect(hpInput).toBeInTheDocument();
|
||||||
|
await user.type(hpInput, "59");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||||
|
|
||||||
|
// Verify HP decreased to 0 and unconscious state
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Current HP: 0 (unconscious)" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -200,6 +200,7 @@ describe("ConfirmButton", () => {
|
|||||||
const parentHandler = vi.fn();
|
const parentHandler = vi.fn();
|
||||||
render(
|
render(
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
||||||
<div onKeyDown={parentHandler}>
|
<div onKeyDown={parentHandler}>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<XIcon />}
|
icon={<XIcon />}
|
||||||
|
|||||||
317
apps/web/src/__tests__/stat-block-collapse-pin.test.tsx
Normal file
317
apps/web/src/__tests__/stat-block-collapse-pin.test.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Creature, CreatureId } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock the context modules
|
||||||
|
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||||
|
useSidePanelContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/bestiary-context.js", () => ({
|
||||||
|
useBestiaryContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock adapters to avoid IndexedDB
|
||||||
|
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
||||||
|
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: () => "",
|
||||||
|
getSourceDisplayName: (code: string) => code,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||||
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
|
||||||
|
const CLOSE_REGEX = /close/i;
|
||||||
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
|
const CREATURE_ID = "srd:goblin" as CreatureId;
|
||||||
|
const CREATURE: Creature = {
|
||||||
|
id: CREATURE_ID,
|
||||||
|
name: "Goblin",
|
||||||
|
source: "SRD",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelOverrides {
|
||||||
|
creatureId?: CreatureId | null;
|
||||||
|
creature?: Creature | null;
|
||||||
|
panelRole?: "browse" | "pinned";
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
side?: "left" | "right";
|
||||||
|
bulkImportMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMocks(overrides: PanelOverrides = {}) {
|
||||||
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
|
const creatureId = overrides.creatureId ?? CREATURE_ID;
|
||||||
|
const creature = overrides.creature ?? CREATURE;
|
||||||
|
const isCollapsed = overrides.isCollapsed ?? false;
|
||||||
|
|
||||||
|
const onToggleCollapse = vi.fn();
|
||||||
|
const onPin = vi.fn();
|
||||||
|
const onUnpin = vi.fn();
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||||
|
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||||
|
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||||
|
isWideDesktop: false,
|
||||||
|
bulkImportMode: overrides.bulkImportMode ?? false,
|
||||||
|
sourceManagerMode: false,
|
||||||
|
panelView: creatureId
|
||||||
|
? { mode: "creature" as const, creatureId }
|
||||||
|
: { mode: "closed" as const },
|
||||||
|
showCreature: vi.fn(),
|
||||||
|
updateCreature: vi.fn(),
|
||||||
|
showBulkImport: vi.fn(),
|
||||||
|
showSourceManager: vi.fn(),
|
||||||
|
dismissPanel: onDismiss,
|
||||||
|
toggleCollapse: onToggleCollapse,
|
||||||
|
togglePin: onPin,
|
||||||
|
unpin: onUnpin,
|
||||||
|
} as ReturnType<typeof useSidePanelContext>);
|
||||||
|
|
||||||
|
mockUseBestiaryContext.mockReturnValue({
|
||||||
|
getCreature: (id: CreatureId) => (id === creatureId ? creature : undefined),
|
||||||
|
isSourceCached: vi.fn().mockResolvedValue(true),
|
||||||
|
search: vi.fn().mockReturnValue([]),
|
||||||
|
isLoaded: true,
|
||||||
|
fetchAndCacheSource: vi.fn(),
|
||||||
|
uploadAndCacheSource: vi.fn(),
|
||||||
|
refreshCache: vi.fn(),
|
||||||
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
|
||||||
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(overrides: PanelOverrides = {}) {
|
||||||
|
const callbacks = setupMocks(overrides);
|
||||||
|
const panelRole = overrides.panelRole ?? "browse";
|
||||||
|
const side = overrides.side ?? (panelRole === "pinned" ? "left" : "right");
|
||||||
|
render(<StatBlockPanel panelRole={panelRole} side={side} />);
|
||||||
|
return callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stat Block Panel Collapse/Expand and Pin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMatchMedia(true); // desktop by default
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("US1: Collapse and Expand", () => {
|
||||||
|
it("shows collapse button instead of close button on desktop", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: CLOSE_REGEX }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show 'Stat Block' heading", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders collapsed tab with creature name when isCollapsed is true", () => {
|
||||||
|
renderPanel({ isCollapsed: true });
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleCollapse when collapse button is clicked", () => {
|
||||||
|
const callbacks = renderPanel();
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
);
|
||||||
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onToggleCollapse when collapsed tab is clicked", () => {
|
||||||
|
const callbacks = renderPanel({ isCollapsed: true });
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Expand stat block panel" }),
|
||||||
|
);
|
||||||
|
expect(callbacks.onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translate-x class when collapsed (right side)", () => {
|
||||||
|
renderPanel({ isCollapsed: true, side: "right" });
|
||||||
|
const panel = screen
|
||||||
|
.getByRole("button", { name: "Expand stat block panel" })
|
||||||
|
.closest("div");
|
||||||
|
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies translate-x-0 when expanded", () => {
|
||||||
|
renderPanel({ isCollapsed: false });
|
||||||
|
const foldBtn = screen.getByRole("button", {
|
||||||
|
name: "Collapse stat block panel",
|
||||||
|
});
|
||||||
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("translate-x-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US1: Mobile behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockMatchMedia(false); // mobile
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows collapse button instead of X close button on mobile drawer", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse stat block panel" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// No X close icon button — only backdrop dismiss and collapse toggle
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
||||||
|
expect(buttonLabels).not.toContain("Close");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
||||||
|
const callbacks = renderPanel();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
||||||
|
expect(callbacks.onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render pinned panel on mobile", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(() => {
|
||||||
|
setupMocks({ panelRole: "pinned" });
|
||||||
|
return <StatBlockPanel panelRole="pinned" side="left" />;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US2: Pin and Unpin", () => {
|
||||||
|
it("shows pin button when isWideDesktop is true on desktop", () => {
|
||||||
|
setupMocks();
|
||||||
|
// Override to set isWideDesktop
|
||||||
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||||
|
typeof useSidePanelContext
|
||||||
|
>;
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
...ctx,
|
||||||
|
isWideDesktop: true,
|
||||||
|
});
|
||||||
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Pin creature" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides pin button when isWideDesktop is false", () => {
|
||||||
|
renderPanel();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Pin creature" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onPin when pin button is clicked", () => {
|
||||||
|
setupMocks();
|
||||||
|
const ctx = mockUseSidePanelContext.mock.results[0]?.value as ReturnType<
|
||||||
|
typeof useSidePanelContext
|
||||||
|
>;
|
||||||
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
|
...ctx,
|
||||||
|
isWideDesktop: true,
|
||||||
|
});
|
||||||
|
render(<StatBlockPanel panelRole="browse" side="right" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
||||||
|
expect(ctx.togglePin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unpin button for pinned role", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Unpin creature" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onUnpin when unpin button is clicked", () => {
|
||||||
|
const callbacks = renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
||||||
|
expect(callbacks.onUnpin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions pinned panel on the left side", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
const unpinBtn = screen.getByRole("button", {
|
||||||
|
name: "Unpin creature",
|
||||||
|
});
|
||||||
|
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("left-0");
|
||||||
|
expect(panel?.className).toContain("border-r");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions browse panel on the right side", () => {
|
||||||
|
renderPanel({ panelRole: "browse", side: "right" });
|
||||||
|
const foldBtn = screen.getByRole("button", {
|
||||||
|
name: "Collapse stat block panel",
|
||||||
|
});
|
||||||
|
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("right-0");
|
||||||
|
expect(panel?.className).toContain("border-l");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("US3: Collapse independence with pinned panel", () => {
|
||||||
|
it("pinned panel has no collapse button", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: COLLAPSE_REGEX }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pinned panel is always expanded (no translate offset)", () => {
|
||||||
|
renderPanel({ panelRole: "pinned", side: "left" });
|
||||||
|
const unpinBtn = screen.getByRole("button", {
|
||||||
|
name: "Unpin creature",
|
||||||
|
});
|
||||||
|
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
||||||
|
expect(panel?.className).toContain("translate-x-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
|
|
||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel";
|
|
||||||
|
|
||||||
const CREATURE_ID = "srd:goblin" as CreatureId;
|
|
||||||
const CREATURE: Creature = {
|
|
||||||
id: CREATURE_ID,
|
|
||||||
name: "Goblin",
|
|
||||||
source: "SRD",
|
|
||||||
sourceDisplayName: "SRD",
|
|
||||||
size: "Small",
|
|
||||||
type: "humanoid",
|
|
||||||
alignment: "neutral evil",
|
|
||||||
ac: 15,
|
|
||||||
hp: { average: 7, formula: "2d6" },
|
|
||||||
speed: "30 ft.",
|
|
||||||
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
|
||||||
cr: "1/4",
|
|
||||||
initiativeProficiency: 0,
|
|
||||||
proficiencyBonus: 2,
|
|
||||||
passive: 9,
|
|
||||||
};
|
|
||||||
|
|
||||||
function mockMatchMedia(matches: boolean) {
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
|
||||||
writable: true,
|
|
||||||
value: vi.fn().mockImplementation((query: string) => ({
|
|
||||||
matches,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PanelProps {
|
|
||||||
creatureId?: CreatureId | null;
|
|
||||||
creature?: Creature | null;
|
|
||||||
panelRole?: "browse" | "pinned";
|
|
||||||
isFolded?: boolean;
|
|
||||||
onToggleFold?: () => void;
|
|
||||||
onPin?: () => void;
|
|
||||||
onUnpin?: () => void;
|
|
||||||
showPinButton?: boolean;
|
|
||||||
side?: "left" | "right";
|
|
||||||
onDismiss?: () => void;
|
|
||||||
bulkImportMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPanel(overrides: PanelProps = {}) {
|
|
||||||
const props = {
|
|
||||||
creatureId: CREATURE_ID,
|
|
||||||
creature: CREATURE,
|
|
||||||
isSourceCached: vi.fn().mockResolvedValue(true),
|
|
||||||
fetchAndCacheSource: vi.fn(),
|
|
||||||
uploadAndCacheSource: vi.fn(),
|
|
||||||
refreshCache: vi.fn(),
|
|
||||||
panelRole: "browse" as const,
|
|
||||||
isFolded: false,
|
|
||||||
onToggleFold: vi.fn(),
|
|
||||||
onPin: vi.fn(),
|
|
||||||
onUnpin: vi.fn(),
|
|
||||||
showPinButton: false,
|
|
||||||
side: "right" as const,
|
|
||||||
onDismiss: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<StatBlockPanel {...props} />);
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Stat Block Panel Fold/Unfold and Pin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockMatchMedia(true); // desktop by default
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
describe("US1: Fold and Unfold", () => {
|
|
||||||
it("shows fold button instead of close button on desktop", () => {
|
|
||||||
renderPanel();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: /close/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show 'Stat Block' heading", () => {
|
|
||||||
renderPanel();
|
|
||||||
expect(screen.queryByText("Stat Block")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders folded tab with creature name when isFolded is true", () => {
|
|
||||||
renderPanel({ isFolded: true });
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onToggleFold when fold button is clicked", () => {
|
|
||||||
const props = renderPanel();
|
|
||||||
fireEvent.click(
|
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
|
||||||
);
|
|
||||||
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onToggleFold when folded tab is clicked", () => {
|
|
||||||
const props = renderPanel({ isFolded: true });
|
|
||||||
fireEvent.click(
|
|
||||||
screen.getByRole("button", { name: "Unfold stat block panel" }),
|
|
||||||
);
|
|
||||||
expect(props.onToggleFold).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies translate-x class when folded (right side)", () => {
|
|
||||||
renderPanel({ isFolded: true, side: "right" });
|
|
||||||
const panel = screen
|
|
||||||
.getByRole("button", { name: "Unfold stat block panel" })
|
|
||||||
.closest("div");
|
|
||||||
expect(panel?.className).toContain("translate-x-[calc(100%-40px)]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies translate-x-0 when expanded", () => {
|
|
||||||
renderPanel({ isFolded: false });
|
|
||||||
const foldBtn = screen.getByRole("button", {
|
|
||||||
name: "Fold stat block panel",
|
|
||||||
});
|
|
||||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
|
||||||
expect(panel?.className).toContain("translate-x-0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("US1: Mobile behavior", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockMatchMedia(false); // mobile
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows fold button instead of X close button on mobile drawer", () => {
|
|
||||||
renderPanel();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Fold stat block panel" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
// No X close icon button — only backdrop dismiss and fold toggle
|
|
||||||
const buttons = screen.getAllByRole("button");
|
|
||||||
const buttonLabels = buttons.map((b) => b.getAttribute("aria-label"));
|
|
||||||
expect(buttonLabels).not.toContain("Close");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onDismiss when backdrop is clicked on mobile", () => {
|
|
||||||
const props = renderPanel();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close stat block" }));
|
|
||||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render pinned panel on mobile", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<StatBlockPanel
|
|
||||||
creatureId={CREATURE_ID}
|
|
||||||
creature={CREATURE}
|
|
||||||
isSourceCached={vi.fn().mockResolvedValue(true)}
|
|
||||||
fetchAndCacheSource={vi.fn()}
|
|
||||||
uploadAndCacheSource={vi.fn()}
|
|
||||||
refreshCache={vi.fn()}
|
|
||||||
panelRole="pinned"
|
|
||||||
isFolded={false}
|
|
||||||
onToggleFold={vi.fn()}
|
|
||||||
onPin={vi.fn()}
|
|
||||||
onUnpin={vi.fn()}
|
|
||||||
showPinButton={false}
|
|
||||||
side="left"
|
|
||||||
onDismiss={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(container.innerHTML).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("US2: Pin and Unpin", () => {
|
|
||||||
it("shows pin button when showPinButton is true on desktop", () => {
|
|
||||||
renderPanel({ showPinButton: true });
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Pin creature" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides pin button when showPinButton is false", () => {
|
|
||||||
renderPanel({ showPinButton: false });
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: "Pin creature" }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onPin when pin button is clicked", () => {
|
|
||||||
const props = renderPanel({ showPinButton: true });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Pin creature" }));
|
|
||||||
expect(props.onPin).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows unpin button for pinned role", () => {
|
|
||||||
renderPanel({ panelRole: "pinned", side: "left" });
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Unpin creature" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onUnpin when unpin button is clicked", () => {
|
|
||||||
const props = renderPanel({ panelRole: "pinned", side: "left" });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Unpin creature" }));
|
|
||||||
expect(props.onUnpin).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("positions pinned panel on the left side", () => {
|
|
||||||
renderPanel({ panelRole: "pinned", side: "left" });
|
|
||||||
const unpinBtn = screen.getByRole("button", {
|
|
||||||
name: "Unpin creature",
|
|
||||||
});
|
|
||||||
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
|
||||||
expect(panel?.className).toContain("left-0");
|
|
||||||
expect(panel?.className).toContain("border-r");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("positions browse panel on the right side", () => {
|
|
||||||
renderPanel({ panelRole: "browse", side: "right" });
|
|
||||||
const foldBtn = screen.getByRole("button", {
|
|
||||||
name: "Fold stat block panel",
|
|
||||||
});
|
|
||||||
const panel = foldBtn.closest("div.fixed") as HTMLElement;
|
|
||||||
expect(panel?.className).toContain("right-0");
|
|
||||||
expect(panel?.className).toContain("border-l");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("US3: Fold independence with pinned panel", () => {
|
|
||||||
it("pinned panel has no fold button", () => {
|
|
||||||
renderPanel({ panelRole: "pinned", side: "left" });
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: /fold/i }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pinned panel is always expanded (no translate offset)", () => {
|
|
||||||
renderPanel({ panelRole: "pinned", side: "left", isFolded: false });
|
|
||||||
const unpinBtn = screen.getByRole("button", {
|
|
||||||
name: "Unpin creature",
|
|
||||||
});
|
|
||||||
const panel = unpinBtn.closest("div.fixed") as HTMLElement;
|
|
||||||
expect(panel?.className).toContain("translate-x-0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
31
apps/web/src/__tests__/test-providers.tsx
Normal file
31
apps/web/src/__tests__/test-providers.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
BestiaryProvider,
|
||||||
|
BulkImportProvider,
|
||||||
|
EncounterProvider,
|
||||||
|
InitiativeRollsProvider,
|
||||||
|
PlayerCharactersProvider,
|
||||||
|
RulesEditionProvider,
|
||||||
|
SidePanelProvider,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "../contexts/index.js";
|
||||||
|
|
||||||
|
export function AllProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</BulkImportProvider>
|
||||||
|
</PlayerCharactersProvider>
|
||||||
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -300,6 +300,44 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(creatures[0].proficiencyBonus).toBe(6);
|
expect(creatures[0].proficiencyBonus).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes pre-2024 {@atk mw} tags to full attack type text", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Adult Black Dragon",
|
||||||
|
source: "MM",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [19],
|
||||||
|
hp: { average: 195, formula: "17d12 + 85" },
|
||||||
|
speed: { walk: 40, fly: 80, swim: 40 },
|
||||||
|
str: 23,
|
||||||
|
dex: 14,
|
||||||
|
con: 21,
|
||||||
|
int: 14,
|
||||||
|
wis: 13,
|
||||||
|
cha: 17,
|
||||||
|
passive: 21,
|
||||||
|
cr: "14",
|
||||||
|
action: [
|
||||||
|
{
|
||||||
|
name: "Bite",
|
||||||
|
entries: [
|
||||||
|
"{@atk mw} {@hit 11} to hit, reach 10 ft., one target. {@h}17 ({@damage 2d10 + 6}) piercing damage plus 4 ({@damage 1d8}) acid damage.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const bite = creatures[0].actions?.[0];
|
||||||
|
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||||
|
expect(bite?.text).not.toContain("mw");
|
||||||
|
expect(bite?.text).not.toContain("{@");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles fly speed with hover condition", () => {
|
it("handles fly speed with hover condition", () => {
|
||||||
const raw = {
|
const raw = {
|
||||||
monster: [
|
monster: [
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ describe("stripTags", () => {
|
|||||||
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw} to Melee Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw}")).toBe("Melee Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rw} to Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rw}")).toBe("Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk ms} to Melee Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk ms}")).toBe("Melee Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk rs} to Ranged Spell Attack:", () => {
|
||||||
|
expect(stripTags("{@atk rs}")).toBe("Ranged Spell Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips {@atk mw,rw} to Melee or Ranged Weapon Attack:", () => {
|
||||||
|
expect(stripTags("{@atk mw,rw}")).toBe("Melee or Ranged Weapon Attack:");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
it("strips {@recharge 5} to (Recharge 5-6)", () => {
|
||||||
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
import { stripTags } from "./strip-tags.js";
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
const LEADING_DIGITS_REGEX = /^(\d+)/;
|
||||||
|
|
||||||
// --- Raw 5etools types (minimal, for parsing) ---
|
// --- Raw 5etools types (minimal, for parsing) ---
|
||||||
|
|
||||||
interface RawMonster {
|
interface RawMonster {
|
||||||
@@ -49,6 +51,7 @@ interface RawMonster {
|
|||||||
legendaryHeader?: string[];
|
legendaryHeader?: string[];
|
||||||
spellcasting?: RawSpellcasting[];
|
spellcasting?: RawSpellcasting[];
|
||||||
initiative?: { proficiency?: number };
|
initiative?: { proficiency?: number };
|
||||||
|
_copy?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawEntry {
|
interface RawEntry {
|
||||||
@@ -168,7 +171,7 @@ function extractAc(ac: RawMonster["ac"]): {
|
|||||||
}
|
}
|
||||||
if ("special" in first) {
|
if ("special" in first) {
|
||||||
// Variable AC (e.g. spell summons) — parse leading number if possible
|
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||||
const match = first.special.match(/^(\d+)/);
|
const match = LEADING_DIGITS_REGEX.exec(first.special);
|
||||||
return {
|
return {
|
||||||
value: match ? Number(match[1]) : 0,
|
value: match ? Number(match[1]) : 0,
|
||||||
source: first.special,
|
source: first.special,
|
||||||
@@ -371,8 +374,8 @@ function extractCr(cr: string | { cr: string } | undefined): string {
|
|||||||
function makeCreatureId(source: string, name: string): CreatureId {
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
const slug = name
|
const slug = name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
return creatureId(`${source.toLowerCase()}:${slug}`);
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,8 +386,7 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
|||||||
// Filter out _copy entries (reference another source's monster) and
|
// Filter out _copy entries (reference another source's monster) and
|
||||||
// monsters missing required fields (ac, hp, size, type)
|
// monsters missing required fields (ac, hp, size, type)
|
||||||
const monsters = raw.monster.filter((m) => {
|
const monsters = raw.monster.filter((m) => {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
if (m._copy) return false;
|
||||||
if ((m as any)._copy) return false;
|
|
||||||
return (
|
return (
|
||||||
Array.isArray(m.ac) &&
|
Array.isArray(m.ac) &&
|
||||||
m.ac.length > 0 &&
|
m.ac.length > 0 &&
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
export interface CachedSourceInfo {
|
export interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
@@ -32,12 +32,16 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
db = await openDB(DB_NAME, DB_VERSION, {
|
db = await openDB(DB_NAME, DB_VERSION, {
|
||||||
upgrade(database) {
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
if (oldVersion < 1) {
|
||||||
database.createObjectStore(STORE_NAME, {
|
database.createObjectStore(STORE_NAME, {
|
||||||
keyPath: "sourceCode",
|
keyPath: "sourceCode",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
// Clear cached creatures to pick up improved tag processing
|
||||||
|
transaction.objectStore(STORE_NAME).clear();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ const ATKR_MAP: Record<string, string> = {
|
|||||||
"r,m": "Melee or Ranged Attack Roll:",
|
"r,m": "Melee or Ranged Attack Roll:",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ATK_MAP: Record<string, string> = {
|
||||||
|
mw: "Melee Weapon Attack:",
|
||||||
|
rw: "Ranged Weapon Attack:",
|
||||||
|
ms: "Melee Spell Attack:",
|
||||||
|
rs: "Ranged Spell Attack:",
|
||||||
|
"mw,rw": "Melee or Ranged Weapon Attack:",
|
||||||
|
"rw,mw": "Melee or Ranged Weapon Attack:",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
|
||||||
*
|
*
|
||||||
@@ -25,55 +34,63 @@ export function stripTags(text: string): string {
|
|||||||
let result = text;
|
let result = text;
|
||||||
|
|
||||||
// {@h} → "Hit: "
|
// {@h} → "Hit: "
|
||||||
result = result.replace(/\{@h\}/g, "Hit: ");
|
result = result.replaceAll("{@h}", "Hit: ");
|
||||||
|
|
||||||
// {@hom} → "Hit or Miss: "
|
// {@hom} → "Hit or Miss: "
|
||||||
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
|
result = result.replaceAll("{@hom}", "Hit or Miss: ");
|
||||||
|
|
||||||
// {@actTrigger} → "Trigger:"
|
// {@actTrigger} → "Trigger:"
|
||||||
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
|
result = result.replaceAll("{@actTrigger}", "Trigger:");
|
||||||
|
|
||||||
// {@actResponse} → "Response:"
|
// {@actResponse} → "Response:"
|
||||||
result = result.replace(/\{@actResponse\}/g, "Response:");
|
result = result.replaceAll("{@actResponse}", "Response:");
|
||||||
|
|
||||||
// {@actSaveSuccess} → "Success:"
|
// {@actSaveSuccess} → "Success:"
|
||||||
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
|
result = result.replaceAll("{@actSaveSuccess}", "Success:");
|
||||||
|
|
||||||
// {@actSaveSuccessOrFail} → handled below as parameterized
|
// {@actSaveSuccessOrFail} → handled below as parameterized
|
||||||
|
|
||||||
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
|
||||||
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
result = result.replaceAll(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
|
||||||
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
|
result = result.replaceAll("{@recharge}", "(Recharge 6)");
|
||||||
|
|
||||||
// {@dc N} → "DC N"
|
// {@dc N} → "DC N"
|
||||||
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
|
result = result.replaceAll(/\{@dc\s+(\d+)\}/g, "DC $1");
|
||||||
|
|
||||||
// {@hit N} → "+N"
|
// {@hit N} → "+N"
|
||||||
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
|
result = result.replaceAll(/\{@hit\s+(\d+)\}/g, "+$1");
|
||||||
|
|
||||||
// {@atkr type} → mapped attack roll text
|
// {@atkr type} → mapped attack roll text (2024 rules)
|
||||||
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
result = result.replaceAll(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
|
return ATKR_MAP[type.trim()] ?? "Attack Roll:";
|
||||||
|
});
|
||||||
|
|
||||||
|
// {@atk type} → mapped attack type text (pre-2024 data)
|
||||||
|
result = result.replaceAll(/\{@atk\s+([^}]+)\}/g, (_, type: string) => {
|
||||||
|
return ATK_MAP[type.trim()] ?? "Attack:";
|
||||||
});
|
});
|
||||||
|
|
||||||
// {@actSave ability} → "Ability saving throw"
|
// {@actSave ability} → "Ability saving throw"
|
||||||
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
|
result = result.replaceAll(
|
||||||
|
/\{@actSave\s+([^}]+)\}/g,
|
||||||
|
(_, ability: string) => {
|
||||||
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
const name = ABILITY_MAP[ability.trim().toLowerCase()];
|
||||||
return name ? `${name} saving throw` : `${ability} saving throw`;
|
return name ? `${name} saving throw` : `${ability} saving throw`;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
|
||||||
result = result.replace(
|
result = result.replaceAll(
|
||||||
/\{@actSaveFail\s+(\d+)\}/g,
|
/\{@actSaveFail\s+(\d+)\}/g,
|
||||||
"Failure by $1 or More:",
|
"Failure by $1 or More:",
|
||||||
);
|
);
|
||||||
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
|
result = result.replaceAll("{@actSaveFail}", "Failure:");
|
||||||
|
|
||||||
// {@actSaveSuccessOrFail} → keep as-is label
|
// {@actSaveSuccessOrFail} → keep as-is label
|
||||||
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
|
result = result.replaceAll("{@actSaveSuccessOrFail}", "Success or Failure:");
|
||||||
|
|
||||||
// {@actSaveFailBy N} → "Failure by N or More:"
|
// {@actSaveFailBy N} → "Failure by N or More:"
|
||||||
result = result.replace(
|
result = result.replaceAll(
|
||||||
/\{@actSaveFailBy\s+(\d+)\}/g,
|
/\{@actSaveFailBy\s+(\d+)\}/g,
|
||||||
"Failure by $1 or More:",
|
"Failure by $1 or More:",
|
||||||
);
|
);
|
||||||
@@ -81,7 +98,7 @@ export function stripTags(text: string): string {
|
|||||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||||
// creature, hazard, status, plus any unknown tags
|
// creature, hazard, status, plus any unknown tags
|
||||||
result = result.replace(
|
result = result.replaceAll(
|
||||||
/\{@(\w+)\s+([^}]+)\}/g,
|
/\{@(\w+)\s+([^}]+)\}/g,
|
||||||
(_, tag: string, content: string) => {
|
(_, tag: string, content: string) => {
|
||||||
// For tags with Display|Source format, extract first segment
|
// For tags with Display|Source format, extract first segment
|
||||||
|
|||||||
121
apps/web/src/components/__tests__/action-bar.test.tsx
Normal file
121
apps/web/src/components/__tests__/action-bar.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { 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);
|
||||||
|
|
||||||
|
function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
||||||
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ActionBar", () => {
|
||||||
|
it("renders input with placeholder '+ Add combatants'", () => {
|
||||||
|
renderBar();
|
||||||
|
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with a name adds a combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Goblin");
|
||||||
|
// The Add button appears when name >= 2 chars and no suggestions
|
||||||
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
|
await user.click(addButton);
|
||||||
|
// Input is cleared after adding (context handles the state)
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting with empty name does nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
// Submit the form directly (Enter on empty input)
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "{Enter}");
|
||||||
|
// Input stays empty, no error
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows custom fields (Init, AC, MaxHP) when name >= 2 chars and no bestiary suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
expect(screen.getByPlaceholderText("Init")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("MaxHP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Add button when name >= 2 chars and no suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show roll all initiative button when no creature combatants", () => {
|
||||||
|
renderBar();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Roll all initiative" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows overflow menu items", () => {
|
||||||
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
|
// The overflow menu should be present (it contains Player Characters etc.)
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
300
apps/web/src/components/__tests__/combatant-row.test.tsx
Normal file
300
apps/web/src/components/__tests__/combatant-row.test.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { type CreatureId, combatantId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { CombatantRow } from "../combatant-row.js";
|
||||||
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
function renderRow(
|
||||||
|
overrides: Partial<{
|
||||||
|
combatant: Parameters<typeof CombatantRow>[0]["combatant"];
|
||||||
|
isActive: boolean;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const combatant = overrides.combatant ?? {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 10,
|
||||||
|
ac: 13,
|
||||||
|
};
|
||||||
|
return render(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={combatant}
|
||||||
|
isActive={overrides.isActive ?? false}
|
||||||
|
/>,
|
||||||
|
{ wrapper: AllProviders },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CombatantRow", () => {
|
||||||
|
it("renders combatant name", () => {
|
||||||
|
renderRow();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders initiative value", () => {
|
||||||
|
renderRow();
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders current HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active combatant gets active border styling", () => {
|
||||||
|
const { container } = renderRow({ isActive: true });
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("border-active-row-border");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unconscious combatant (currentHp === 0) gets dimmed styling", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The name area should have opacity-50
|
||||||
|
const nameEl = screen.getByText("Goblin");
|
||||||
|
const nameContainer = nameEl.closest(".opacity-50");
|
||||||
|
expect(nameContainer).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows concentration icon when isConcentrating is true", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
isConcentrating: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const concButton = screen.getByRole("button", {
|
||||||
|
name: "Toggle concentration",
|
||||||
|
});
|
||||||
|
expect(concButton.className).toContain("text-purple-400");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows player character icon and color when set", () => {
|
||||||
|
const { container } = renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Aragorn",
|
||||||
|
color: "red",
|
||||||
|
icon: "sword",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The icon should be rendered with the player color
|
||||||
|
const svgIcon = container.querySelector("svg[style]");
|
||||||
|
expect(svgIcon).not.toBeNull();
|
||||||
|
expect(svgIcon).toHaveStyle({ color: PLAYER_COLOR_HEX.red });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove button removes after confirmation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow();
|
||||||
|
const removeBtn = screen.getByRole("button", {
|
||||||
|
name: "Remove combatant",
|
||||||
|
});
|
||||||
|
// First click enters confirm state
|
||||||
|
await user.click(removeBtn);
|
||||||
|
// Second click confirms
|
||||||
|
const confirmBtn = screen.getByRole("button", {
|
||||||
|
name: "Confirm remove combatant",
|
||||||
|
});
|
||||||
|
await user.click(confirmBtn);
|
||||||
|
// 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 combatant has creatureId", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: "srd:goblin" as CreatureId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("concentration pulse", () => {
|
||||||
|
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pulse when not concentrating", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
isConcentrating: false,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, currentHp: 10 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||||
|
const combatant = {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
isConcentrating: true,
|
||||||
|
};
|
||||||
|
const { rerender, container } = renderRow({ combatant });
|
||||||
|
// Temp HP absorbs all damage, currentHp unchanged
|
||||||
|
rerender(
|
||||||
|
<CombatantRow
|
||||||
|
combatant={{ ...combatant, tempHp: 3 }}
|
||||||
|
isActive={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const row = container.firstElementChild;
|
||||||
|
expect(row?.className).toContain("animate-concentration-pulse");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temp HP display", () => {
|
||||||
|
it("shows +N when combatant has temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show +N when combatant has no temp HP", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("temp HP display uses cyan color", () => {
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 15,
|
||||||
|
tempHp: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tempHpEl = screen.getByText("+8");
|
||||||
|
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/web/src/components/__tests__/condition-picker.test.tsx
Normal file
72
apps/web/src/components/__tests__/condition-picker.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { createRef, type RefObject } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPicker(
|
||||||
|
overrides: Partial<{
|
||||||
|
activeConditions: readonly ConditionId[];
|
||||||
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
|
const anchor = document.createElement("div");
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
|
const result = render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<ConditionPicker
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
return { ...result, onToggle, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConditionPicker", () => {
|
||||||
|
it("renders all condition definitions from domain", () => {
|
||||||
|
renderPicker();
|
||||||
|
for (const def of CONDITION_DEFINITIONS) {
|
||||||
|
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active conditions are visually distinguished", () => {
|
||||||
|
renderPicker({ activeConditions: ["blinded"] });
|
||||||
|
const blindedButton = screen.getByText("Blinded").closest("button");
|
||||||
|
expect(blindedButton?.className).toContain("bg-card/50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onToggle } = renderPicker();
|
||||||
|
await user.click(screen.getByText("Poisoned"));
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("poisoned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-active conditions render with muted styling", () => {
|
||||||
|
renderPicker({ activeConditions: [] });
|
||||||
|
const label = screen.getByText("Charmed");
|
||||||
|
expect(label.className).toContain("text-muted-foreground");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active condition labels use foreground color", () => {
|
||||||
|
renderPicker({ activeConditions: ["charmed"] });
|
||||||
|
const label = screen.getByText("Charmed");
|
||||||
|
expect(label.className).toContain("text-foreground");
|
||||||
|
});
|
||||||
|
});
|
||||||
148
apps/web/src/components/__tests__/hp-adjust-popover.test.tsx
Normal file
148
apps/web/src/components/__tests__/hp-adjust-popover.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { HpAdjustPopover } from "../hp-adjust-popover";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPopover(
|
||||||
|
overrides: Partial<{
|
||||||
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||||
|
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HpAdjustPopover", () => {
|
||||||
|
it("renders input with placeholder 'HP'", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(screen.getByPlaceholderText("HP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage and heal buttons are disabled when input is empty", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damage and heal buttons are disabled when input is '0'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply damage" })).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing a valid number enables both buttons", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "5");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply damage" }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Apply healing" }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking damage button calls onAdjust with negative value and onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "7");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply damage" }));
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(-7);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking heal button calls onAdjust with positive value and onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "3");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Apply healing" }));
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(3);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter key applies damage (negative)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "4");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(-4);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Shift+Enter applies healing (positive)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdjust, onClose } = renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "6");
|
||||||
|
await user.keyboard("{Shift>}{Enter}{/Shift}");
|
||||||
|
expect(onAdjust).toHaveBeenCalledWith(6);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape key calls onClose", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onClose } = renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "2");
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only accepts digit characters in input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
const input = screen.getByPlaceholderText("HP");
|
||||||
|
await user.type(input, "12abc34");
|
||||||
|
expect(input).toHaveValue("1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("temp HP", () => {
|
||||||
|
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSetTempHp, onClose } = renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||||
|
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is empty", () => {
|
||||||
|
renderPopover();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shield button is disabled when input is '0'", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPopover();
|
||||||
|
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Set temp HP" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
150
apps/web/src/components/__tests__/source-manager.test.tsx
Normal file
150
apps/web/src/components/__tests__/source-manager.test.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
||||||
|
getCachedSources: vi.fn(),
|
||||||
|
clearSource: 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 { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
||||||
|
import { SourceManager } from "../source-manager.js";
|
||||||
|
|
||||||
|
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
||||||
|
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
||||||
|
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
||||||
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupMockContext() {
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockUseBestiaryContext.mockReturnValue({
|
||||||
|
refreshCache,
|
||||||
|
search: vi.fn().mockReturnValue([]),
|
||||||
|
getCreature: vi.fn(),
|
||||||
|
isLoaded: true,
|
||||||
|
isSourceCached: vi.fn().mockResolvedValue(false),
|
||||||
|
fetchAndCacheSource: vi.fn(),
|
||||||
|
uploadAndCacheSource: vi.fn(),
|
||||||
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
return { refreshCache };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SourceManager", () => {
|
||||||
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
|
setupMockContext();
|
||||||
|
mockGetCachedSources.mockResolvedValue([]);
|
||||||
|
render(<SourceManager />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
|
setupMockContext();
|
||||||
|
mockGetCachedSources.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceCode: "vgm",
|
||||||
|
displayName: "Volo's Guide",
|
||||||
|
creatureCount: 100,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<SourceManager />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("300 creatures")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Volo's Guide")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Clear All button calls cache clear and refreshCache", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { refreshCache } = setupMockContext();
|
||||||
|
mockGetCachedSources
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
mockClearAll.mockResolvedValue(undefined);
|
||||||
|
render(<SourceManager />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClearAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(refreshCache).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("individual source delete button calls clear for that source", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { refreshCache } = setupMockContext();
|
||||||
|
mockGetCachedSources
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
sourceCode: "mm",
|
||||||
|
displayName: "Monster Manual",
|
||||||
|
creatureCount: 300,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceCode: "vgm",
|
||||||
|
displayName: "Volo's Guide",
|
||||||
|
creatureCount: 100,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceCode: "vgm",
|
||||||
|
displayName: "Volo's Guide",
|
||||||
|
creatureCount: 100,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockClearSource.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<SourceManager />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
||||||
|
});
|
||||||
|
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,40 @@ 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(),
|
||||||
|
setTempHp: vi.fn(),
|
||||||
|
hasTempHp: false,
|
||||||
|
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,80 +87,39 @@ 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", () => {
|
||||||
renderNav();
|
renderNav();
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
expect(badge.parentElement).toBe(name.parentElement);
|
// badge text is inside inner span > outer span, name is a direct child
|
||||||
|
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
it("updates the round badge when round changes", () => {
|
||||||
const { rerender } = render(
|
mockContext({ roundNumber: 2 });
|
||||||
<TurnNavigation
|
const { rerender } = render(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 2,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
expect(screen.getByText("R2")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
mockContext({ roundNumber: 3 });
|
||||||
<TurnNavigation
|
rerender(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [{ id: combatantId("1"), name: "Goblin" }],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 3,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the next combatant name when turn advances", () => {
|
it("renders the next combatant name when turn advances", () => {
|
||||||
const { rerender } = render(
|
const combatants = [
|
||||||
<TurnNavigation
|
|
||||||
encounter={{
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
{ id: combatantId("1"), name: "Goblin" },
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
{ id: combatantId("2"), name: "Conjurer" },
|
||||||
],
|
];
|
||||||
activeIndex: 0,
|
mockContext({ combatants, activeIndex: 0 });
|
||||||
roundNumber: 1,
|
const { rerender } = render(<TurnNavigation />);
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
mockContext({ combatants, activeIndex: 1 });
|
||||||
<TurnNavigation
|
rerender(<TurnNavigation />);
|
||||||
encounter={{
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
],
|
|
||||||
activeIndex: 1,
|
|
||||||
roundNumber: 1,
|
|
||||||
}}
|
|
||||||
onAdvanceTurn={vi.fn()}
|
|
||||||
onRetreatTurn={vi.fn()}
|
|
||||||
onClearEncounter={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,25 +12,23 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral",
|
"relative inline-flex items-center justify-center text-muted-foreground text-sm tabular-nums transition-colors hover:text-hover-neutral",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width: 28, height: 32 }}
|
style={{ width: 28, height: 32 }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 28 32"
|
viewBox="0 0 28 32"
|
||||||
fill="none"
|
fill="var(--color-border)"
|
||||||
stroke="currentColor"
|
fillOpacity={0.5}
|
||||||
strokeWidth={1.5}
|
stroke="none"
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="relative text-xs font-medium leading-none">
|
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||||
{value !== undefined ? value : "\u2014"}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -7,45 +7,43 @@ import {
|
|||||||
Library,
|
Library,
|
||||||
Minus,
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FormEvent, type RefObject, useState } from "react";
|
import React, { type RefObject, useCallback, useState } from "react";
|
||||||
import type { SearchResult } from "../hooks/use-bestiary.js";
|
import type { SearchResult } 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 {
|
||||||
|
creatureKey,
|
||||||
|
type QueuedCreature,
|
||||||
|
type SuggestionActions,
|
||||||
|
useActionBarState,
|
||||||
|
} from "../hooks/use-action-bar-state.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";
|
||||||
|
|
||||||
interface QueuedCreature {
|
|
||||||
result: SearchResult;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
onOpenSourceManager?: () => void;
|
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
onManagePlayers?: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function creatureKey(r: SearchResult): string {
|
interface AddModeSuggestionsProps {
|
||||||
return `${r.source}:${r.name}`;
|
nameInput: string;
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
pcMatches: PlayerCharacter[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
queued: QueuedCreature | null;
|
||||||
|
actions: SuggestionActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddModeSuggestions({
|
function AddModeSuggestions({
|
||||||
@@ -54,66 +52,50 @@ function AddModeSuggestions({
|
|||||||
pcMatches,
|
pcMatches,
|
||||||
suggestionIndex,
|
suggestionIndex,
|
||||||
queued,
|
queued,
|
||||||
onDismiss,
|
actions,
|
||||||
onClickSuggestion,
|
}: Readonly<AddModeSuggestionsProps>) {
|
||||||
onSetSuggestionIndex,
|
|
||||||
onSetQueued,
|
|
||||||
onConfirmQueued,
|
|
||||||
onAddFromPlayerCharacter,
|
|
||||||
}: {
|
|
||||||
nameInput: string;
|
|
||||||
suggestions: SearchResult[];
|
|
||||||
pcMatches: PlayerCharacter[];
|
|
||||||
suggestionIndex: number;
|
|
||||||
queued: QueuedCreature | null;
|
|
||||||
onDismiss: () => void;
|
|
||||||
onClickSuggestion: (result: SearchResult) => void;
|
|
||||||
onSetSuggestionIndex: (i: number) => void;
|
|
||||||
onSetQueued: (q: QueuedCreature | null) => void;
|
|
||||||
onConfirmQueued: () => void;
|
|
||||||
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onDismiss}
|
onClick={actions.dismiss}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1">Add "{nameInput}" as custom</span>
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
||||||
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||||
Esc
|
Esc
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="max-h-48 overflow-y-auto py-1">
|
<div className="max-h-48 overflow-y-auto py-1">
|
||||||
{pcMatches.length > 0 && (
|
{pcMatches.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
|
||||||
Players
|
Players
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{pcMatches.map((pc) => {
|
{pcMatches.map((pc) => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
const pcColor =
|
const pcColor = pc.color
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
? PLAYER_COLOR_HEX[pc.color]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<li key={pc.id}>
|
<li key={pc.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
actions.addFromPlayerCharacter?.(pc);
|
||||||
onDismiss();
|
actions.clear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PcIcon && (
|
{!!PcIcon && (
|
||||||
<PcIcon size={14} style={{ color: pcColor }} />
|
<PcIcon size={14} style={{ color: pcColor }} />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate">{pc.name}</span>
|
<span className="flex-1 truncate">{pc.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
Player
|
Player
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -133,19 +115,20 @@ function AddModeSuggestions({
|
|||||||
<li key={key}>
|
<li key={key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={cn(
|
||||||
isQueued
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
||||||
? "bg-accent/30 text-foreground"
|
isQueued && "bg-accent/30",
|
||||||
: i === suggestionIndex
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
||||||
? "bg-accent/20 text-foreground"
|
!isQueued &&
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
i !== suggestionIndex &&
|
||||||
}`}
|
"hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => onClickSuggestion(result)}
|
onClick={() => actions.clickSuggestion(result)}
|
||||||
onMouseEnter={() => onSetSuggestionIndex(i)}
|
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{result.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||||
{isQueued ? (
|
{isQueued ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -155,9 +138,9 @@ function AddModeSuggestions({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (queued.count <= 1) {
|
if (queued.count <= 1) {
|
||||||
onSetQueued(null);
|
actions.setQueued(null);
|
||||||
} else {
|
} else {
|
||||||
onSetQueued({
|
actions.setQueued({
|
||||||
...queued,
|
...queued,
|
||||||
count: queued.count - 1,
|
count: queued.count - 1,
|
||||||
});
|
});
|
||||||
@@ -166,7 +149,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
|
||||||
@@ -175,7 +158,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSetQueued({
|
actions.setQueued({
|
||||||
...queued,
|
...queued,
|
||||||
count: queued.count + 1,
|
count: queued.count + 1,
|
||||||
});
|
});
|
||||||
@@ -189,7 +172,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onConfirmQueued();
|
actions.confirmQueued();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@@ -210,12 +193,159 @@ function AddModeSuggestions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrowseSuggestionsProps {
|
||||||
|
suggestions: SearchResult[];
|
||||||
|
suggestionIndex: number;
|
||||||
|
onSelect: (result: SearchResult) => void;
|
||||||
|
onHover: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseSuggestions({
|
||||||
|
suggestions,
|
||||||
|
suggestionIndex,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: Readonly<BrowseSuggestionsProps>) {
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
||||||
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
|
{suggestions.map((result, i) => (
|
||||||
|
<li key={creatureKey(result)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-sm",
|
||||||
|
i === suggestionIndex
|
||||||
|
? "bg-accent/20 text-foreground"
|
||||||
|
: "text-foreground hover:bg-hover-neutral-bg",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect(result)}
|
||||||
|
onMouseEnter={() => onHover(i)}
|
||||||
|
>
|
||||||
|
<span>{result.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{result.sourceDisplayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomStatFieldsProps {
|
||||||
|
customInit: string;
|
||||||
|
customAc: string;
|
||||||
|
customMaxHp: string;
|
||||||
|
onInitChange: (v: string) => void;
|
||||||
|
onAcChange: (v: string) => void;
|
||||||
|
onMaxHpChange: (v: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomStatFields({
|
||||||
|
customInit,
|
||||||
|
customAc,
|
||||||
|
customMaxHp,
|
||||||
|
onInitChange,
|
||||||
|
onAcChange,
|
||||||
|
onMaxHpChange,
|
||||||
|
}: Readonly<CustomStatFieldsProps>) {
|
||||||
|
return (
|
||||||
|
<div className="hidden items-center gap-2 sm:flex">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customInit}
|
||||||
|
onChange={(e) => onInitChange(e.target.value)}
|
||||||
|
placeholder="Init"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customAc}
|
||||||
|
onChange={(e) => onAcChange(e.target.value)}
|
||||||
|
placeholder="AC"
|
||||||
|
className="w-16 text-center"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={customMaxHp}
|
||||||
|
onChange={(e) => onMaxHpChange(e.target.value)}
|
||||||
|
placeholder="MaxHP"
|
||||||
|
className="w-18 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollAllButton() {
|
||||||
|
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
|
||||||
|
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
||||||
|
|
||||||
|
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],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasCreatureCombatants) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
|
onClick={() => handleRollAllInitiative()}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
|
disabled={!canRollAllInitiative}
|
||||||
|
title="Roll all initiative"
|
||||||
|
aria-label="Roll all initiative"
|
||||||
|
>
|
||||||
|
<D20Icon className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}): OverflowMenuItem[] {
|
}): OverflowMenuItem[] {
|
||||||
const items: OverflowMenuItem[] = [];
|
const items: OverflowMenuItem[] = [];
|
||||||
if (opts.onManagePlayers) {
|
if (opts.onManagePlayers) {
|
||||||
@@ -235,216 +365,69 @@ function buildOverflowItems(opts: {
|
|||||||
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Import className="h-4 w-4" />,
|
icon: <Import className="h-4 w-4" />,
|
||||||
label: "Bulk Import",
|
label: "Import All Sources",
|
||||||
onClick: opts.onBulkImport,
|
onClick: opts.onBulkImport,
|
||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (opts.onOpenSettings) {
|
||||||
|
items.push({
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
label: "Settings",
|
||||||
|
onClick: opts.onOpenSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
onAddCombatant,
|
|
||||||
onAddFromBestiary,
|
|
||||||
bestiarySearch,
|
|
||||||
bestiaryLoaded,
|
|
||||||
onViewStatBlock,
|
|
||||||
onBulkImport,
|
|
||||||
bulkImportDisabled,
|
|
||||||
inputRef,
|
inputRef,
|
||||||
playerCharacters,
|
|
||||||
onAddFromPlayerCharacter,
|
|
||||||
onManagePlayers,
|
|
||||||
onRollAllInitiative,
|
|
||||||
showRollAllInitiative,
|
|
||||||
onOpenSourceManager,
|
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: ActionBarProps) {
|
onManagePlayers,
|
||||||
const [nameInput, setNameInput] = useState("");
|
onOpenSettings,
|
||||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
}: Readonly<ActionBarProps>) {
|
||||||
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
const {
|
||||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
nameInput,
|
||||||
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
suggestions,
|
||||||
const [customInit, setCustomInit] = useState("");
|
pcMatches,
|
||||||
const [customAc, setCustomAc] = useState("");
|
suggestionIndex,
|
||||||
const [customMaxHp, setCustomMaxHp] = useState("");
|
queued,
|
||||||
const [browseMode, setBrowseMode] = useState(false);
|
customInit,
|
||||||
|
customAc,
|
||||||
|
customMaxHp,
|
||||||
|
browseMode,
|
||||||
|
bestiaryLoaded,
|
||||||
|
hasSuggestions,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
suggestionActions,
|
||||||
|
handleNameChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBrowseKeyDown,
|
||||||
|
handleAdd,
|
||||||
|
handleBrowseSelect,
|
||||||
|
toggleBrowseMode,
|
||||||
|
setCustomInit,
|
||||||
|
setCustomAc,
|
||||||
|
setCustomMaxHp,
|
||||||
|
} = useActionBarState();
|
||||||
|
|
||||||
const clearCustomFields = () => {
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
setCustomInit("");
|
|
||||||
setCustomAc("");
|
|
||||||
setCustomMaxHp("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearInput = () => {
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setPcMatches([]);
|
|
||||||
setQueued(null);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmQueued = () => {
|
|
||||||
if (!queued) return;
|
|
||||||
for (let i = 0; i < queued.count; i++) {
|
|
||||||
onAddFromBestiary(queued.result);
|
|
||||||
}
|
|
||||||
clearInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseNum = (v: string): number | undefined => {
|
|
||||||
if (v.trim() === "") return undefined;
|
|
||||||
const n = Number(v);
|
|
||||||
return Number.isNaN(n) ? undefined : n;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (browseMode) return;
|
|
||||||
if (queued) {
|
|
||||||
confirmQueued();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nameInput.trim() === "") return;
|
|
||||||
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
|
||||||
const init = parseNum(customInit);
|
|
||||||
const ac = parseNum(customAc);
|
|
||||||
const maxHp = parseNum(customMaxHp);
|
|
||||||
if (init !== undefined) opts.initiative = init;
|
|
||||||
if (ac !== undefined) opts.ac = ac;
|
|
||||||
if (maxHp !== undefined) opts.maxHp = maxHp;
|
|
||||||
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setPcMatches([]);
|
|
||||||
clearCustomFields();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBrowseSearch = (value: string) => {
|
|
||||||
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSearch = (value: string) => {
|
|
||||||
let newSuggestions: SearchResult[] = [];
|
|
||||||
let newPcMatches: PlayerCharacter[] = [];
|
|
||||||
if (value.length >= 2) {
|
|
||||||
newSuggestions = bestiarySearch(value);
|
|
||||||
setSuggestions(newSuggestions);
|
|
||||||
if (playerCharacters && playerCharacters.length > 0) {
|
|
||||||
const lower = value.toLowerCase();
|
|
||||||
newPcMatches = playerCharacters.filter((pc) =>
|
|
||||||
pc.name.toLowerCase().includes(lower),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setPcMatches(newPcMatches);
|
|
||||||
} else {
|
|
||||||
setSuggestions([]);
|
|
||||||
setPcMatches([]);
|
|
||||||
}
|
|
||||||
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
|
||||||
clearCustomFields();
|
|
||||||
}
|
|
||||||
if (queued) {
|
|
||||||
const qKey = creatureKey(queued.result);
|
|
||||||
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
|
||||||
if (!stillVisible) {
|
|
||||||
setQueued(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
|
||||||
setNameInput(value);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
if (browseMode) {
|
|
||||||
handleBrowseSearch(value);
|
|
||||||
} else {
|
|
||||||
handleAddSearch(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClickSuggestion = (result: SearchResult) => {
|
|
||||||
const key = creatureKey(result);
|
|
||||||
if (queued && creatureKey(queued.result) === key) {
|
|
||||||
setQueued({ ...queued, count: queued.count + 1 });
|
|
||||||
} else {
|
|
||||||
setQueued({ result, count: 1 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnter = () => {
|
|
||||||
if (queued) {
|
|
||||||
confirmQueued();
|
|
||||||
} else if (suggestionIndex >= 0) {
|
|
||||||
handleClickSuggestion(suggestions[suggestionIndex]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (!hasSuggestions) return;
|
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleEnter();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
clearInput();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setBrowseMode(false);
|
|
||||||
clearInput();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (suggestions.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
|
||||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
|
||||||
setBrowseMode(false);
|
|
||||||
clearInput();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBrowseSelect = (result: SearchResult) => {
|
|
||||||
onViewStatBlock?.(result);
|
|
||||||
setBrowseMode(false);
|
|
||||||
clearInput();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBrowseMode = () => {
|
|
||||||
setBrowseMode((m) => !m);
|
|
||||||
clearInput();
|
|
||||||
clearCustomFields();
|
|
||||||
};
|
|
||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager: showSourceManager,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled,
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
|
onOpenSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAdd}
|
onSubmit={handleAdd}
|
||||||
className="relative flex flex-1 items-center gap-2"
|
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative max-w-xs">
|
<div className="relative max-w-xs">
|
||||||
@@ -460,14 +443,15 @@ 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}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
browseMode && "text-accent",
|
browseMode && "text-accent",
|
||||||
)}
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={toggleBrowseMode}
|
onClick={toggleBrowseMode}
|
||||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -481,31 +465,13 @@ export function ActionBar({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{browseMode && suggestions.length > 0 && (
|
{!!browseMode && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
<BrowseSuggestions
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
suggestions={suggestions}
|
||||||
{suggestions.map((result, i) => (
|
suggestionIndex={suggestionIndex}
|
||||||
<li key={creatureKey(result)}>
|
onSelect={handleBrowseSelect}
|
||||||
<button
|
onHover={suggestionActions.setSuggestionIndex}
|
||||||
type="button"
|
/>
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
|
||||||
i === suggestionIndex
|
|
||||||
? "bg-accent/20 text-foreground"
|
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => handleBrowseSelect(result)}
|
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
|
||||||
>
|
|
||||||
<span>{result.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{result.sourceDisplayName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!browseMode && hasSuggestions && (
|
{!browseMode && hasSuggestions && (
|
||||||
<AddModeSuggestions
|
<AddModeSuggestions
|
||||||
@@ -514,62 +480,25 @@ export function ActionBar({
|
|||||||
pcMatches={pcMatches}
|
pcMatches={pcMatches}
|
||||||
suggestionIndex={suggestionIndex}
|
suggestionIndex={suggestionIndex}
|
||||||
queued={queued}
|
queued={queued}
|
||||||
onDismiss={clearInput}
|
actions={suggestionActions}
|
||||||
onClickSuggestion={handleClickSuggestion}
|
|
||||||
onSetSuggestionIndex={setSuggestionIndex}
|
|
||||||
onSetQueued={setQueued}
|
|
||||||
onConfirmQueued={confirmQueued}
|
|
||||||
onAddFromPlayerCharacter={onAddFromPlayerCharacter}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<div className="flex items-center gap-2">
|
<CustomStatFields
|
||||||
<Input
|
customInit={customInit}
|
||||||
type="text"
|
customAc={customAc}
|
||||||
inputMode="numeric"
|
customMaxHp={customMaxHp}
|
||||||
value={customInit}
|
onInitChange={setCustomInit}
|
||||||
onChange={(e) => setCustomInit(e.target.value)}
|
onAcChange={setCustomAc}
|
||||||
placeholder="Init"
|
onMaxHpChange={setCustomMaxHp}
|
||||||
className="w-16 text-center"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={customAc}
|
|
||||||
onChange={(e) => setCustomAc(e.target.value)}
|
|
||||||
placeholder="AC"
|
|
||||||
className="w-16 text-center"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={customMaxHp}
|
|
||||||
onChange={(e) => setCustomMaxHp(e.target.value)}
|
|
||||||
placeholder="MaxHP"
|
|
||||||
className="w-18 text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
||||||
<Button type="submit" size="sm">
|
<Button type="submit">Add</Button>
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{showRollAllInitiative && onRollAllInitiative && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
|
||||||
onClick={onRollAllInitiative}
|
|
||||||
title="Roll all initiative"
|
|
||||||
aria-label="Roll all initiative"
|
|
||||||
>
|
|
||||||
<D20Icon className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
<RollAllButton />
|
||||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { 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,
|
|
||||||
}: BulkImportPromptProps) {
|
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
|
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-sm text-green-400">
|
<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 size="sm" onClick={onDone}>
|
<Button onClick={handleDone}>Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,9 +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 size="sm" onClick={onDone}>
|
<Button onClick={handleDone}>Done</Button>
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@ export function BulkImportPrompt({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading sources... {processed}/{importState.total}
|
Loading sources... {processed}/{importState.total}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,24 +81,20 @@ export function BulkImportPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
Bulk Import Sources
|
Import All Sources
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
Load stat block data for all {totalSources} sources at once. This will
|
Load stat block data for all {totalSources} sources at once.
|
||||||
download approximately 12.5 MB of data.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label
|
<label htmlFor={baseUrlId} className="text-muted-foreground text-xs">
|
||||||
htmlFor="bulk-base-url"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Base URL
|
Base URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="bulk-base-url"
|
id={baseUrlId}
|
||||||
type="url"
|
type="url"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
@@ -103,11 +102,7 @@ export function BulkImportPrompt({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => handleStart(baseUrl)} disabled={isDisabled}>
|
||||||
size="sm"
|
|
||||||
onClick={() => onStartImport(baseUrl)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
Load All
|
Load All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
apps/web/src/components/bulk-import-toasts.tsx
Normal file
44
apps/web/src/components/bulk-import-toasts.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
|
|
||||||
|
export function BulkImportToasts() {
|
||||||
|
const { state, reset } = useBulkImportContext();
|
||||||
|
const { bulkImportMode, isRightPanelCollapsed } = useSidePanelContext();
|
||||||
|
const visible = !bulkImportMode || isRightPanelCollapsed;
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loading sources... ${state.completed + state.failed}/${state.total}`}
|
||||||
|
progress={
|
||||||
|
state.total > 0 ? (state.completed + state.failed) / state.total : 0
|
||||||
|
}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "complete") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message="All sources loaded"
|
||||||
|
onDismiss={reset}
|
||||||
|
autoDismissMs={3000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
message={`Loaded ${state.completed}/${state.total} sources (${state.failed} failed)`}
|
||||||
|
onDismiss={reset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,18 +9,18 @@ interface ColorPaletteProps {
|
|||||||
|
|
||||||
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
const COLORS = [...VALID_PLAYER_COLORS] as string[];
|
||||||
|
|
||||||
export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
export function ColorPalette({ value, onChange }: Readonly<ColorPaletteProps>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{COLORS.map((color) => (
|
{COLORS.map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(color)}
|
onClick={() => onChange(value === color ? "" : color)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full transition-all",
|
"h-8 w-8 rounded-full transition-all",
|
||||||
value === color
|
value === color
|
||||||
? "ring-2 ring-foreground ring-offset-2 ring-offset-background scale-110"
|
? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background"
|
||||||
: "hover:scale-110",
|
: "hover:scale-110",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -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 { Brain, X } from "lucide-react";
|
import { Brain, Pencil, 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;
|
||||||
@@ -22,47 +29,36 @@ interface Combatant {
|
|||||||
readonly initiative?: number;
|
readonly initiative?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
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({
|
||||||
name,
|
name,
|
||||||
combatantId,
|
combatantId,
|
||||||
onRename,
|
onRename,
|
||||||
onShowStatBlock,
|
|
||||||
color,
|
color,
|
||||||
}: {
|
onToggleStatBlock,
|
||||||
|
}: Readonly<{
|
||||||
name: string;
|
name: string;
|
||||||
combatantId: CombatantId;
|
combatantId: CombatantId;
|
||||||
onRename: (id: CombatantId, newName: string) => void;
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
onShowStatBlock?: () => void;
|
|
||||||
color?: string;
|
color?: string;
|
||||||
}) {
|
onToggleStatBlock?: () => void;
|
||||||
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(name);
|
const [draft, setDraft] = useState(name);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const longPressTriggeredRef = useRef(false);
|
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
const commit = useCallback(() => {
|
||||||
const trimmed = draft.trim();
|
const trimmed = draft.trim();
|
||||||
@@ -78,53 +74,13 @@ function EditableName({
|
|||||||
requestAnimationFrame(() => inputRef.current?.select());
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearTimeout(clickTimerRef.current);
|
|
||||||
clearTimeout(longPressTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (longPressTriggeredRef.current) {
|
|
||||||
longPressTriggeredRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (clickTimerRef.current) {
|
|
||||||
clearTimeout(clickTimerRef.current);
|
|
||||||
clickTimerRef.current = undefined;
|
|
||||||
startEditing();
|
|
||||||
} else {
|
|
||||||
clickTimerRef.current = setTimeout(() => {
|
|
||||||
clickTimerRef.current = undefined;
|
|
||||||
onShowStatBlock?.();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[startEditing, onShowStatBlock],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTouchStart = useCallback(() => {
|
|
||||||
longPressTriggeredRef.current = false;
|
|
||||||
longPressTimerRef.current = setTimeout(() => {
|
|
||||||
longPressTriggeredRef.current = true;
|
|
||||||
startEditing();
|
|
||||||
}, 500);
|
|
||||||
}, [startEditing]);
|
|
||||||
|
|
||||||
const cancelLongPress = useCallback(() => {
|
|
||||||
clearTimeout(longPressTimerRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
className="h-7 text-sm"
|
className="h-7 max-w-48 text-sm"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -139,16 +95,27 @@ function EditableName({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={onToggleStatBlock}
|
||||||
onTouchStart={handleTouchStart}
|
disabled={!onToggleStatBlock}
|
||||||
onTouchEnd={cancelLongPress}
|
className={cn(
|
||||||
onTouchCancel={cancelLongPress}
|
"truncate text-left text-sm transition-colors",
|
||||||
onTouchMove={cancelLongPress}
|
onToggleStatBlock
|
||||||
className="truncate text-left text-sm text-foreground cursor-text hover:text-hover-neutral transition-colors"
|
? "cursor-pointer text-foreground hover:text-hover-neutral"
|
||||||
|
: "cursor-default text-foreground",
|
||||||
|
)}
|
||||||
style={color ? { color } : undefined}
|
style={color ? { color } : undefined}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEditing}
|
||||||
|
title="Rename"
|
||||||
|
aria-label="Rename"
|
||||||
|
className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,10 +123,10 @@ function EditableName({
|
|||||||
function MaxHpDisplay({
|
function MaxHpDisplay({
|
||||||
maxHp,
|
maxHp,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: Readonly<{
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onCommit: (value: number | undefined) => void;
|
onCommit: (value: number | undefined) => void;
|
||||||
}) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -190,7 +157,7 @@ function MaxHpDisplay({
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="Max"
|
placeholder="Max"
|
||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className="h-7 w-[7ch] text-center tabular-nums"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -205,7 +172,12 @@ function MaxHpDisplay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-hover-neutral"
|
className={cn(
|
||||||
|
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
||||||
|
maxHp === undefined
|
||||||
|
? "text-muted-foreground text-sm"
|
||||||
|
: "text-muted-foreground text-xs",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{maxHp ?? "Max"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -215,48 +187,47 @@ function MaxHpDisplay({
|
|||||||
function ClickableHp({
|
function ClickableHp({
|
||||||
currentHp,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
|
tempHp,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
dimmed,
|
onSetTempHp,
|
||||||
}: {
|
}: Readonly<{
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
|
tempHp: number | undefined;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
dimmed?: boolean;
|
onSetTempHp: (value: number) => void;
|
||||||
}) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
if (maxHp === undefined) {
|
if (maxHp === undefined) {
|
||||||
return (
|
return null;
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
|
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
--
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
|
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-hover-neutral",
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
||||||
status === "bloodied" && "text-amber-400",
|
status === "bloodied" && "text-amber-400",
|
||||||
status === "unconscious" && "text-red-400",
|
status === "unconscious" && "text-red-400",
|
||||||
status === "healthy" && "text-foreground",
|
status === "healthy" && "text-foreground",
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
{popoverOpen && (
|
{!!tempHp && (
|
||||||
|
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||||
|
+{tempHp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -267,10 +238,10 @@ function ClickableHp({
|
|||||||
function AcDisplay({
|
function AcDisplay({
|
||||||
ac,
|
ac,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: Readonly<{
|
||||||
ac: number | undefined;
|
ac: number | undefined;
|
||||||
onCommit: (value: number | undefined) => void;
|
onCommit: (value: number | undefined) => void;
|
||||||
}) {
|
}>) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -301,7 +272,7 @@ function AcDisplay({
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="AC"
|
placeholder="AC"
|
||||||
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
className="h-7 w-[6ch] text-center tabular-nums"
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -321,16 +292,34 @@ function InitiativeDisplay({
|
|||||||
dimmed,
|
dimmed,
|
||||||
onSetInitiative,
|
onSetInitiative,
|
||||||
onRollInitiative,
|
onRollInitiative,
|
||||||
}: {
|
}: Readonly<{
|
||||||
initiative: number | undefined;
|
initiative: number | undefined;
|
||||||
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 === "") {
|
||||||
@@ -359,7 +348,7 @@ function InitiativeDisplay({
|
|||||||
value={draft}
|
value={draft}
|
||||||
placeholder="--"
|
placeholder="--"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
"h-7 w-full text-center tabular-nums",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -372,12 +361,18 @@ 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRollInitiative(combatantId)}
|
onClick={() => onRollInitiative(combatantId)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
{...longPress}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
@@ -387,20 +382,28 @@ function InitiativeDisplay({
|
|||||||
>
|
>
|
||||||
<D20Icon className="h-7 w-7" />
|
<D20Icon className="h-7 w-7" />
|
||||||
</button>
|
</button>
|
||||||
|
{!!menuPos && (
|
||||||
|
<RollModeMenu
|
||||||
|
position={menuPos}
|
||||||
|
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
|
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
||||||
initiative !== undefined
|
initiative === undefined
|
||||||
? "font-medium text-foreground hover:text-hover-neutral"
|
? "text-muted-foreground hover:text-hover-neutral"
|
||||||
: "text-muted-foreground hover:text-hover-neutral",
|
: "font-medium text-foreground hover:text-hover-neutral",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -413,9 +416,13 @@ function rowBorderClass(
|
|||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
isConcentrating: boolean | undefined,
|
isConcentrating: boolean | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (isActive) return "border-l-2 border-l-accent bg-accent/10";
|
if (isActive && isConcentrating)
|
||||||
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||||
return "border-l-2 border-l-transparent";
|
if (isActive)
|
||||||
|
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||||
|
if (isConcentrating)
|
||||||
|
return "border border-l-2 border-transparent border-l-purple-400";
|
||||||
|
return "border border-l-2 border-transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
function concentrationIconClass(
|
function concentrationIconClass(
|
||||||
@@ -427,56 +434,71 @@ function concentrationIconClass(
|
|||||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateOnKeyDown(
|
|
||||||
handler: () => void,
|
|
||||||
): (e: { key: string; preventDefault: () => void }) => void {
|
|
||||||
return (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CombatantRow({
|
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,
|
||||||
|
setTempHp,
|
||||||
|
setAc,
|
||||||
|
toggleCondition,
|
||||||
|
toggleConcentration,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||||
|
useSidePanelContext();
|
||||||
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||||
|
|
||||||
|
// Derive what was previously conditional props
|
||||||
|
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||||
|
const { creatureId } = combatant;
|
||||||
|
const hasStatBlock = !!creatureId;
|
||||||
|
const onToggleStatBlock = hasStatBlock
|
||||||
|
? () => {
|
||||||
|
if (isStatBlockOpen) {
|
||||||
|
toggleCollapse();
|
||||||
|
} else {
|
||||||
|
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";
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const prevHpRef = useRef(currentHp);
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const prevTempHpRef = useRef(combatant.tempHp);
|
||||||
const [isPulsing, setIsPulsing] = useState(false);
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevHp = prevHpRef.current;
|
const prevHp = prevHpRef.current;
|
||||||
|
const prevTempHp = prevTempHpRef.current;
|
||||||
prevHpRef.current = currentHp;
|
prevHpRef.current = currentHp;
|
||||||
|
prevTempHpRef.current = combatant.tempHp;
|
||||||
|
|
||||||
if (
|
const realHpDropped =
|
||||||
prevHp !== undefined &&
|
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||||
currentHp !== undefined &&
|
const tempHpDropped =
|
||||||
currentHp < prevHp &&
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
combatant.isConcentrating
|
|
||||||
) {
|
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||||
setIsPulsing(true);
|
setIsPulsing(true);
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
}
|
}
|
||||||
}, [currentHp, combatant.isConcentrating]);
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!combatant.isConcentrating) {
|
if (!combatant.isConcentrating) {
|
||||||
@@ -490,34 +512,23 @@ export function CombatantRow({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* biome-ignore lint/a11y/noStaticElementInteractions: role="button" is set conditionally when onShowStatBlock exists */
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={onShowStatBlock ? "button" : undefined}
|
|
||||||
tabIndex={onShowStatBlock ? 0 : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-md pr-3 transition-colors",
|
"group rounded-lg pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
onShowStatBlock && "cursor-pointer",
|
|
||||||
)}
|
)}
|
||||||
onClick={onShowStatBlock}
|
|
||||||
onKeyDown={
|
|
||||||
onShowStatBlock ? activateOnKeyDown(onShowStatBlock) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
|
||||||
{/* Concentration */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={() => toggleConcentration(id)}
|
||||||
e.stopPropagation();
|
|
||||||
onToggleConcentration(id);
|
|
||||||
}}
|
|
||||||
title="Concentrating"
|
title="Concentrating"
|
||||||
aria-label="Toggle concentration"
|
aria-label="Toggle concentration"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||||
concentrationIconClass(combatant.isConcentrating, dimmed),
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -525,39 +536,40 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<InitiativeDisplay
|
<InitiativeDisplay
|
||||||
initiative={initiative}
|
initiative={initiative}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
onSetInitiative={onSetInitiative}
|
onSetInitiative={setInitiative}
|
||||||
onRollInitiative={onRollInitiative}
|
onRollInitiative={onRollInitiative}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AC */}
|
||||||
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
|
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-wrap items-center gap-1 min-w-0",
|
"relative flex min-w-0 flex-wrap items-center gap-1",
|
||||||
dimmed && "opacity-50",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{combatant.icon &&
|
{!!combatant.icon &&
|
||||||
combatant.color &&
|
!!combatant.color &&
|
||||||
(() => {
|
(() => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
||||||
const pcColor =
|
const iconColor =
|
||||||
PLAYER_COLOR_HEX[
|
PLAYER_COLOR_HEX[
|
||||||
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
||||||
];
|
];
|
||||||
return PcIcon ? (
|
return PcIcon ? (
|
||||||
<PcIcon
|
<PcIcon
|
||||||
size={14}
|
size={16}
|
||||||
style={{ color: pcColor }}
|
style={{ color: iconColor }}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -565,68 +577,56 @@ export function CombatantRow({
|
|||||||
<EditableName
|
<EditableName
|
||||||
name={name}
|
name={name}
|
||||||
combatantId={id}
|
combatantId={id}
|
||||||
onRename={onRename}
|
onRename={editCombatant}
|
||||||
onShowStatBlock={onShowStatBlock}
|
|
||||||
color={pcColor}
|
color={pcColor}
|
||||||
|
onToggleStatBlock={onToggleStatBlock}
|
||||||
/>
|
/>
|
||||||
|
<div ref={conditionAnchorRef}>
|
||||||
<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 && (
|
</div>
|
||||||
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
|
anchorRef={conditionAnchorRef}
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
|
||||||
<div
|
|
||||||
className={cn(dimmed && "opacity-50")}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper for interactive children */}
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1"
|
className={cn(
|
||||||
onClick={(e) => e.stopPropagation()}
|
"flex items-center rounded-md tabular-nums",
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
maxHp === undefined
|
||||||
|
? ""
|
||||||
|
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
||||||
|
dimmed && "opacity-50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ClickableHp
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
onAdjust={(delta) => onAdjustHp(id, delta)}
|
tempHp={combatant.tempHp}
|
||||||
dimmed={dimmed}
|
onAdjust={(delta) => adjustHp(id, delta)}
|
||||||
|
onSetTempHp={(value) => setTempHp(id, value)}
|
||||||
/>
|
/>
|
||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
<span
|
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||||
className={cn(
|
|
||||||
"text-sm tabular-nums text-muted-foreground",
|
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||||
/
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
|
||||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<X size={16} />}
|
icon={<X size={16} />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={() => onRemove(id)}
|
onConfirm={() => removeCombatant(id)}
|
||||||
className="h-7 w-7 text-muted-foreground opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto transition-opacity"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import {
|
||||||
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
getConditionsForEdition,
|
||||||
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -13,12 +17,17 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
ShieldMinus,
|
||||||
Siren,
|
Siren,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -34,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Link,
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Moon,
|
||||||
};
|
};
|
||||||
@@ -49,37 +60,49 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionId[] | undefined;
|
activeConditions: readonly ConditionId[] | undefined;
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionPicker({
|
export function ConditionPicker({
|
||||||
|
anchorRef,
|
||||||
activeConditions,
|
activeConditions,
|
||||||
onToggle,
|
onToggle,
|
||||||
onClose,
|
onClose,
|
||||||
}: ConditionPickerProps) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [pos, setPos] = useState<{
|
||||||
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
top: number;
|
||||||
|
left: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
const anchor = anchorRef.current;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!anchor || !el) return;
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const spaceBelow = window.innerHeight - rect.top;
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
const spaceAbove = rect.bottom;
|
const menuHeight = el.scrollHeight;
|
||||||
const shouldFlip =
|
const pad = 8;
|
||||||
rect.bottom > window.innerHeight && spaceAbove > spaceBelow;
|
|
||||||
setFlipped(shouldFlip);
|
const spaceBelow = window.innerHeight - anchorRect.bottom - pad;
|
||||||
const available = shouldFlip ? spaceAbove : spaceBelow;
|
const spaceAbove = anchorRect.top - pad;
|
||||||
if (rect.height > available) {
|
const openBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
|
||||||
setMaxHeight(available - 16);
|
|
||||||
}
|
const top = openBelow
|
||||||
}, []);
|
? anchorRect.bottom + 4
|
||||||
|
: Math.max(pad, anchorRect.top - Math.min(menuHeight, spaceAbove) - 4);
|
||||||
|
const maxHeight = openBelow ? spaceBelow : Math.min(menuHeight, spaceAbove);
|
||||||
|
|
||||||
|
setPos({ top, left: anchorRect.left, maxHeight });
|
||||||
|
}, [anchorRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
@@ -91,25 +114,32 @@ export function ConditionPicker({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const conditions = getConditionsForEdition(edition);
|
||||||
const active = new Set(activeConditions ?? []);
|
const active = new Set(activeConditions ?? []);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className="card-glow fixed z-50 w-fit overflow-y-auto rounded-lg border border-border bg-background p-1"
|
||||||
"absolute left-0 z-10 w-fit overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg",
|
style={
|
||||||
flipped ? "bottom-full mb-1" : "top-full mt-1",
|
pos
|
||||||
)}
|
? { top: pos.top, left: pos.left, maxHeight: pos.maxHeight }
|
||||||
style={maxHeight ? { maxHeight } : undefined}
|
: { visibility: "hidden" as const }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{CONDITION_DEFINITIONS.map((def) => {
|
{conditions.map((def) => {
|
||||||
const Icon = ICON_MAP[def.iconName];
|
const Icon = ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip
|
||||||
key={def.id}
|
key={def.id}
|
||||||
|
content={getConditionDescription(def, edition)}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||||
@@ -122,13 +152,17 @@ export function ConditionPicker({
|
|||||||
className={isActive ? colorClass : "text-muted-foreground"}
|
className={isActive ? colorClass : "text-muted-foreground"}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
className={
|
||||||
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{def.label}
|
{def.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import {
|
||||||
|
CONDITION_DEFINITIONS,
|
||||||
|
type ConditionId,
|
||||||
|
getConditionDescription,
|
||||||
|
} from "@initiative/domain";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -14,10 +18,15 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldMinus,
|
||||||
Siren,
|
Siren,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -33,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Link,
|
Link,
|
||||||
|
ShieldMinus,
|
||||||
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Moon,
|
||||||
};
|
};
|
||||||
@@ -48,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
|
|||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
|
sky: "text-sky-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
@@ -60,7 +72,8 @@ export function ConditionTags({
|
|||||||
conditions,
|
conditions,
|
||||||
onRemove,
|
onRemove,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: ConditionTagsProps) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{conditions?.map((condId) => {
|
{conditions?.map((condId) => {
|
||||||
@@ -70,12 +83,17 @@ export function ConditionTags({
|
|||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip
|
||||||
key={condId}
|
key={condId}
|
||||||
|
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={`inline-flex items-center rounded p-0.5 hover:bg-hover-neutral-bg transition-colors ${colorClass}`}
|
className={cn(
|
||||||
|
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
|
colorClass,
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(condId);
|
onRemove(condId);
|
||||||
@@ -83,13 +101,14 @@ export function ConditionTags({
|
|||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Add condition"
|
title="Add condition"
|
||||||
aria-label="Add condition"
|
aria-label="Add condition"
|
||||||
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-hover-neutral hover:bg-hover-neutral-bg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 pointer-coarse:opacity-100 transition-opacity"
|
className="inline-flex pointer-coarse:w-auto w-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenPicker();
|
onOpenPicker();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
playerCharacter?: PlayerCharacter;
|
playerCharacter?: PlayerCharacter;
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,8 @@ export function CreatePlayerModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: CreatePlayerModalProps) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
@@ -40,31 +41,48 @@ export function CreatePlayerModal({
|
|||||||
setName(playerCharacter.name);
|
setName(playerCharacter.name);
|
||||||
setAc(String(playerCharacter.ac));
|
setAc(String(playerCharacter.ac));
|
||||||
setMaxHp(String(playerCharacter.maxHp));
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
setColor(playerCharacter.color);
|
setColor(playerCharacter.color ?? "");
|
||||||
setIcon(playerCharacter.icon);
|
setIcon(playerCharacter.icon ?? "");
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setAc("10");
|
setAc("10");
|
||||||
setMaxHp("10");
|
setMaxHp("10");
|
||||||
setColor("blue");
|
setColor("");
|
||||||
setIcon("sword");
|
setIcon("");
|
||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
}, [open, playerCharacter]);
|
}, [open, playerCharacter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
const dialog = dialogRef.current;
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
if (!dialog) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
}, [open]);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
@@ -81,39 +99,32 @@ export function CreatePlayerModal({
|
|||||||
setError("Max HP must be at least 1");
|
setError("Max HP must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(trimmed, acNum, hpNum, color, icon);
|
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
<div
|
ref={dialogRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">Name</span>
|
||||||
Name
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
@@ -125,14 +136,12 @@ export function CreatePlayerModal({
|
|||||||
aria-label="Name"
|
aria-label="Name"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
|
{!!error && <p className="mt-1 text-destructive text-sm">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">AC</span>
|
||||||
AC
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -144,7 +153,7 @@ export function CreatePlayerModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<span className="mb-1 block text-sm text-muted-foreground">
|
<span className="mb-1 block text-muted-foreground text-sm">
|
||||||
Max HP
|
Max HP
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
@@ -160,16 +169,14 @@ export function CreatePlayerModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm text-muted-foreground">
|
<span className="mb-2 block text-muted-foreground text-sm">
|
||||||
Color
|
Color
|
||||||
</span>
|
</span>
|
||||||
<ColorPalette value={color} onChange={setColor} />
|
<ColorPalette value={color} onChange={setColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block text-sm text-muted-foreground">
|
<span className="mb-2 block text-muted-foreground text-sm">Icon</span>
|
||||||
Icon
|
|
||||||
</span>
|
|
||||||
<IconGrid value={icon} onChange={setIcon} />
|
<IconGrid value={icon} onChange={setIcon} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,7 +187,6 @@ export function CreatePlayerModal({
|
|||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Heart, Sword } from "lucide-react";
|
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -6,15 +6,21 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
|
readonly onSetTempHp: (value: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
export function HpAdjustPopover({
|
||||||
|
onAdjust,
|
||||||
|
onSetTempHp,
|
||||||
|
onClose,
|
||||||
|
}: HpAdjustPopoverProps) {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -86,7 +92,7 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="fixed z-10 rounded-md border border-border bg-background p-2 shadow-lg"
|
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||||
style={
|
style={
|
||||||
pos
|
pos
|
||||||
? { top: pos.top, left: pos.left }
|
? { top: pos.top, left: pos.left }
|
||||||
@@ -100,39 +106,50 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
placeholder="HP"
|
placeholder="HP"
|
||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className="h-7 w-[7ch] text-center tabular-nums"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
if (v === "" || /^\d+$/.test(v)) {
|
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
|
||||||
setInputValue(v);
|
setInputValue(v);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
|
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"
|
||||||
>
|
>
|
||||||
<Sword size={14} />
|
<Sword size={14} />
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
|
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"
|
||||||
>
|
>
|
||||||
<Heart size={14} />
|
<Heart size={14} />
|
||||||
</Button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
if (isValid && parsedValue) {
|
||||||
|
onSetTempHp(parsedValue);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Set temp HP"
|
||||||
|
aria-label="Set temp HP"
|
||||||
|
>
|
||||||
|
<ShieldPlus size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface IconGridProps {
|
|||||||
|
|
||||||
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
const ICONS = [...VALID_PLAYER_ICONS] as PlayerIcon[];
|
||||||
|
|
||||||
export function IconGrid({ value, onChange }: IconGridProps) {
|
export function IconGrid({ value, onChange }: Readonly<IconGridProps>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ICONS.map((iconId) => {
|
{ICONS.map((iconId) => {
|
||||||
@@ -19,11 +19,11 @@ export function IconGrid({ value, onChange }: IconGridProps) {
|
|||||||
<button
|
<button
|
||||||
key={iconId}
|
key={iconId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(iconId)}
|
onClick={() => onChange(value === iconId ? "" : iconId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||||
value === iconId
|
value === iconId
|
||||||
? "bg-primary/20 ring-2 ring-primary text-foreground"
|
? "bg-primary/20 text-foreground ring-2 ring-primary"
|
||||||
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
: "text-muted-foreground hover:bg-card hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
aria-label={iconId}
|
aria-label={iconId}
|
||||||
|
|||||||
71
apps/web/src/components/player-character-section.tsx
Normal file
71
apps/web/src/components/player-character-section.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
|
import { PlayerManagement } from "./player-management.js";
|
||||||
|
|
||||||
|
export interface PlayerCharacterSectionHandle {
|
||||||
|
openManagement: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||||
|
ref,
|
||||||
|
}: {
|
||||||
|
ref?: RefObject<PlayerCharacterSectionHandle | null>;
|
||||||
|
}) {
|
||||||
|
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editingPlayer, setEditingPlayer] = useState<
|
||||||
|
PlayerCharacter | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openManagement: () => setManagementOpen(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreatePlayerModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setManagementOpen(true);
|
||||||
|
}}
|
||||||
|
onSave={(name, ac, maxHp, color, icon) => {
|
||||||
|
if (editingPlayer) {
|
||||||
|
editCharacter(editingPlayer.id, {
|
||||||
|
name,
|
||||||
|
ac,
|
||||||
|
maxHp,
|
||||||
|
color: color ?? null,
|
||||||
|
icon: icon ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createCharacter(name, ac, maxHp, color, icon);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
playerCharacter={editingPlayer}
|
||||||
|
/>
|
||||||
|
<PlayerManagement
|
||||||
|
open={managementOpen}
|
||||||
|
onClose={() => setManagementOpen(false)}
|
||||||
|
characters={characters}
|
||||||
|
onEdit={(pc) => {
|
||||||
|
setEditingPlayer(pc);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
onDelete={(id) => deleteCharacter(id)}
|
||||||
|
onCreate={() => {
|
||||||
|
setEditingPlayer(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import type {
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
PlayerCharacter,
|
|
||||||
PlayerCharacterId,
|
|
||||||
PlayerIcon,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
@@ -25,46 +21,60 @@ export function PlayerManagement({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: PlayerManagementProps) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
useEffect(() => {
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
if (!open) return;
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
} else if (!open && dialog.open) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: backdrop click to close
|
<dialog
|
||||||
<div
|
ref={dialogRef}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
onMouseDown={onClose}
|
|
||||||
>
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: prevent close when clicking modal content */}
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
Player Characters
|
Player Characters
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{characters.length === 0 ? (
|
{characters.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
<p className="text-muted-foreground">No player characters yet</p>
|
<p className="text-muted-foreground">No player characters yet</p>
|
||||||
<Button onClick={onCreate} size="sm">
|
<Button onClick={onCreate}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Create your first player character
|
Create your first player character
|
||||||
</Button>
|
</Button>
|
||||||
@@ -72,52 +82,52 @@ export function PlayerManagement({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{characters.map((pc) => {
|
{characters.map((pc) => {
|
||||||
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
const color =
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pc.id}
|
key={pc.id}
|
||||||
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-background/50"
|
className="group flex items-center gap-3 rounded-md px-3 py-2 hover:bg-hover-neutral-bg"
|
||||||
>
|
>
|
||||||
{Icon && (
|
{!!Icon && (
|
||||||
<Icon size={18} style={{ color }} className="shrink-0" />
|
<Icon size={18} style={{ color }} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="flex-1 truncate text-sm text-foreground">
|
<span className="flex-1 truncate text-foreground text-sm">
|
||||||
{pc.name}
|
{pc.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
AC {pc.ac}
|
AC {pc.ac}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
HP {pc.maxHp}
|
HP {pc.maxHp}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={() => onEdit(pc)}
|
onClick={() => onEdit(pc)}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 size={14} />}
|
icon={<Trash2 size={14} />}
|
||||||
label="Delete player character"
|
label="Delete player character"
|
||||||
onConfirm={() => onDelete(pc.id)}
|
onConfirm={() => onDelete(pc.id)}
|
||||||
className="h-6 w-6 text-muted-foreground"
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">
|
||||||
<Button onClick={onCreate} size="sm" variant="ghost">
|
<Button onClick={onCreate} variant="ghost">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/web/src/components/settings-modal.tsx
Normal file
129
apps/web/src/components/settings-modal.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
|
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||||
|
{ value: "5e", label: "5e (2014)" },
|
||||||
|
{ value: "5.5e", label: "5.5e (2024)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const THEME_OPTIONS: {
|
||||||
|
value: "system" | "light" | "dark";
|
||||||
|
label: string;
|
||||||
|
icon: typeof Sun;
|
||||||
|
}[] = [
|
||||||
|
{ value: "system", label: "System", icon: Monitor },
|
||||||
|
{ value: "light", label: "Light", icon: Sun },
|
||||||
|
{ value: "dark", label: "Dark", icon: Moon },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const { edition, setEdition } = useRulesEditionContext();
|
||||||
|
const { preference, setPreference } = useThemeContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
else if (!open && dialog.open) dialog.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
function handleCancel(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onClose();
|
||||||
|
}
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => {
|
||||||
|
dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
|
Conditions
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{EDITION_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||||
|
edition === opt.value
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setEdition(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
|
Theme
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{THEME_OPTIONS.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||||
|
preference === opt.value
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setPreference(opt.value)}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { 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>) {
|
||||||
}: 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>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sourceUrlId = useId();
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
setStatus("fetching");
|
setStatus("fetching");
|
||||||
@@ -46,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");
|
||||||
@@ -64,21 +65,21 @@ export function SourceFetchPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground text-sm">
|
||||||
Load {sourceDisplayName}
|
Load {sourceDisplayName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
Stat block data for this source needs to be loaded. Enter a URL or
|
Stat block data for this source needs to be loaded. Enter a URL or
|
||||||
upload a JSON file.
|
upload a JSON file.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor="source-url" className="text-xs text-muted-foreground">
|
<label htmlFor={sourceUrlId} className="text-muted-foreground text-xs">
|
||||||
Source URL
|
Source URL
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="source-url"
|
id={sourceUrlId}
|
||||||
type="url"
|
type="url"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
@@ -88,11 +89,7 @@ export function SourceFetchPrompt({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button onClick={handleFetch} disabled={status === "fetching" || !url}>
|
||||||
size="sm"
|
|
||||||
onClick={handleFetch}
|
|
||||||
disabled={status === "fetching" || !url}
|
|
||||||
>
|
|
||||||
{status === "fetching" ? (
|
{status === "fetching" ? (
|
||||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -101,10 +98,9 @@ export function SourceFetchPrompt({
|
|||||||
{status === "fetching" ? "Loading..." : "Load"}
|
{status === "fetching" ? "Loading..." : "Load"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">or</span>
|
<span className="text-muted-foreground text-xs">or</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={status === "fetching"}
|
disabled={status === "fetching"}
|
||||||
@@ -122,7 +118,7 @@ export function SourceFetchPrompt({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-destructive text-xs">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { Database, Trash2 } from "lucide-react";
|
import { Database, Search, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useOptimistic,
|
||||||
|
useState,
|
||||||
|
} 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";
|
||||||
|
|
||||||
interface SourceManagerProps {
|
export function SourceManager() {
|
||||||
onCacheCleared: () => void;
|
const { refreshCache } = useBestiaryContext();
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
|
sources,
|
||||||
|
(
|
||||||
|
state,
|
||||||
|
action: { type: "remove"; sourceCode: string } | { type: "clear" },
|
||||||
|
) =>
|
||||||
|
action.type === "clear"
|
||||||
|
? []
|
||||||
|
: state.filter((s) => s.sourceCode !== action.sourceCode),
|
||||||
|
);
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
const cached = await bestiaryCache.getCachedSources();
|
const cached = await bestiaryCache.getCachedSources();
|
||||||
@@ -17,26 +33,37 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSources();
|
void loadSources();
|
||||||
}, [loadSources]);
|
}, [loadSources]);
|
||||||
|
|
||||||
const handleClearSource = async (sourceCode: string) => {
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
|
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" });
|
||||||
await bestiaryCache.clearAll();
|
await bestiaryCache.clearAll();
|
||||||
await loadSources();
|
await loadSources();
|
||||||
onCacheCleared();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sources.length === 0) {
|
const filteredSources = useMemo(() => {
|
||||||
|
const term = filter.toLowerCase();
|
||||||
|
return term
|
||||||
|
? optimisticSources.filter((s) =>
|
||||||
|
s.displayName.toLowerCase().includes(term),
|
||||||
|
)
|
||||||
|
: optimisticSources;
|
||||||
|
}, [optimisticSources, filter]);
|
||||||
|
|
||||||
|
if (optimisticSources.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
<Database className="h-8 w-8 text-muted-foreground" />
|
<Database className="h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">No cached sources</p>
|
<p className="text-muted-foreground text-sm">No cached sources</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,30 +71,38 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="font-semibold text-foreground text-sm">
|
||||||
Cached Sources
|
Cached Sources
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="hover:text-hover-destructive hover:border-hover-destructive"
|
className="hover:border-hover-destructive hover:text-hover-destructive"
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1 h-3 w-3" />
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
Clear All
|
Clear All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter sources…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{sources.map((source) => (
|
{filteredSources.map((source) => (
|
||||||
<li
|
<li
|
||||||
key={source.sourceCode}
|
key={source.sourceCode}
|
||||||
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-foreground text-sm">
|
||||||
{source.displayName}
|
{source.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
{source.creatureCount} creatures
|
{source.creatureCount} creatures
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +110,7 @@ export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleClearSource(source.sourceCode)}
|
onClick={() => handleClearSource(source.sourceCode)}
|
||||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-hover-destructive-bg hover:text-hover-destructive"
|
||||||
|
aria-label={`Remove ${source.displayName}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
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 { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
|
import { SourceManager } from "./source-manager.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.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";
|
||||||
isFolded: boolean;
|
|
||||||
onToggleFold: () => 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -39,25 +23,26 @@ function extractSourceCode(cId: CreatureId): string {
|
|||||||
return cId.slice(0, colonIndex).toUpperCase();
|
return cId.slice(0, colonIndex).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function FoldedTab({
|
function CollapsedTab({
|
||||||
creatureName,
|
creatureName,
|
||||||
side,
|
side,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
}: {
|
}: Readonly<{
|
||||||
creatureName: string;
|
creatureName: string;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleFold}
|
onClick={onToggleCollapse}
|
||||||
className={`flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral ${
|
className={cn(
|
||||||
side === "right" ? "self-start" : "self-end"
|
"flex h-full w-[40px] cursor-pointer items-center justify-center text-muted-foreground hover:text-hover-neutral",
|
||||||
}`}
|
side === "right" ? "self-start" : "self-end",
|
||||||
aria-label="Unfold stat block panel"
|
)}
|
||||||
|
aria-label="Expand stat block panel"
|
||||||
>
|
>
|
||||||
<span className="writing-vertical-rl text-sm font-medium">
|
<span className="writing-vertical-rl font-medium text-sm">
|
||||||
{creatureName}
|
{creatureName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -67,50 +52,53 @@ function FoldedTab({
|
|||||||
function PanelHeader({
|
function PanelHeader({
|
||||||
panelRole,
|
panelRole,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
}: {
|
}: Readonly<{
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && (
|
{panelRole === "browse" && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
onClick={onToggleFold}
|
size="icon-sm"
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
onClick={onToggleCollapse}
|
||||||
aria-label="Fold stat block panel"
|
className="text-muted-foreground"
|
||||||
|
aria-label="Collapse stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{panelRole === "browse" && showPinButton && (
|
{panelRole === "browse" && showPinButton && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onPin}
|
onClick={onPin}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Pin creature"
|
aria-label="Pin creature"
|
||||||
>
|
>
|
||||||
<Pin className="h-4 w-4" />
|
<Pin className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{panelRole === "pinned" && (
|
{panelRole === "pinned" && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onUnpin}
|
onClick={onUnpin}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Unpin creature"
|
aria-label="Unpin creature"
|
||||||
>
|
>
|
||||||
<PinOff className="h-4 w-4" />
|
<PinOff className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,48 +106,52 @@ function PanelHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopPanel({
|
function DesktopPanel({
|
||||||
isFolded,
|
isCollapsed,
|
||||||
side,
|
side,
|
||||||
creatureName,
|
creatureName,
|
||||||
panelRole,
|
panelRole,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
onToggleFold,
|
onToggleCollapse,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: Readonly<{
|
||||||
isFolded: boolean;
|
isCollapsed: boolean;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
creatureName: string;
|
creatureName: string;
|
||||||
panelRole: "browse" | "pinned";
|
panelRole: "browse" | "pinned";
|
||||||
showPinButton: boolean;
|
showPinButton: boolean;
|
||||||
onToggleFold: () => void;
|
onToggleCollapse: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}>) {
|
||||||
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
const sideClasses = side === "left" ? "left-0 border-r" : "right-0 border-l";
|
||||||
const foldedTranslate =
|
const collapsedTranslate =
|
||||||
side === "right"
|
side === "right"
|
||||||
? "translate-x-[calc(100%-40px)]"
|
? "translate-x-[calc(100%-40px)]"
|
||||||
: "translate-x-[calc(-100%+40px)]";
|
: "translate-x-[calc(-100%+40px)]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel ${sideClasses} ${isFolded ? foldedTranslate : "translate-x-0"}`}
|
className={cn(
|
||||||
|
"panel-glow fixed top-0 bottom-0 flex w-[400px] flex-col border-border bg-card transition-slide-panel",
|
||||||
|
sideClasses,
|
||||||
|
isCollapsed ? collapsedTranslate : "translate-x-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isFolded ? (
|
{isCollapsed ? (
|
||||||
<FoldedTab
|
<CollapsedTab
|
||||||
creatureName={creatureName}
|
creatureName={creatureName}
|
||||||
side={side}
|
side={side}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
panelRole={panelRole}
|
panelRole={panelRole}
|
||||||
showPinButton={showPinButton}
|
showPinButton={showPinButton}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
/>
|
/>
|
||||||
@@ -173,36 +165,40 @@ function DesktopPanel({
|
|||||||
function MobileDrawer({
|
function MobileDrawer({
|
||||||
onDismiss,
|
onDismiss,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: Readonly<{
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}>) {
|
||||||
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
const { offsetX, isSwiping, handlers } = useSwipeToDismiss(onDismiss);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 bg-black/50 animate-in fade-in"
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
aria-label="Close stat block"
|
aria-label="Close stat block"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-l border-border bg-card shadow-xl ${isSwiping ? "" : "animate-slide-in-right"}`}
|
className={cn(
|
||||||
|
"panel-glow absolute top-0 right-0 bottom-0 w-[85%] max-w-md border-border border-l bg-card",
|
||||||
|
!isSwiping && "animate-slide-in-right",
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
isSwiping ? { transform: `translateX(${offsetX}px)` } : undefined
|
||||||
}
|
}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-border border-b px-4 py-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
aria-label="Fold stat block panel"
|
aria-label="Collapse stat block panel"
|
||||||
>
|
>
|
||||||
<PanelRightClose className="h-4 w-4" />
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||||
{children}
|
{children}
|
||||||
@@ -212,34 +208,57 @@ function MobileDrawer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||||
|
const sidePanel = useSidePanelContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
|
||||||
|
const creatureId =
|
||||||
|
panelRole === "browse"
|
||||||
|
? sidePanel.selectedCreatureId
|
||||||
|
: sidePanel.pinnedCreatureId;
|
||||||
|
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||||
|
|
||||||
|
const isBrowse = panelRole === "browse";
|
||||||
|
return {
|
||||||
creatureId,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
isSourceCached,
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
fetchAndCacheSource,
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
uploadAndCacheSource,
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
refreshCache,
|
onPin: isBrowse ? sidePanel.togglePin : () => {},
|
||||||
|
onUnpin: panelRole === "pinned" ? sidePanel.unpin : () => {},
|
||||||
|
showPinButton: isBrowse && sidePanel.isWideDesktop && !!creature,
|
||||||
|
bulkImportMode: isBrowse && sidePanel.bulkImportMode,
|
||||||
|
sourceManagerMode: isBrowse && sidePanel.sourceManagerMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatBlockPanel({
|
||||||
panelRole,
|
panelRole,
|
||||||
isFolded,
|
side,
|
||||||
onToggleFold,
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
|
const { isSourceCached } = useBestiaryContext();
|
||||||
|
const {
|
||||||
|
creatureId,
|
||||||
|
creature,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onDismiss,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
showPinButton,
|
showPinButton,
|
||||||
side,
|
|
||||||
onDismiss,
|
|
||||||
bulkImportMode,
|
bulkImportMode,
|
||||||
bulkImportState,
|
sourceManagerMode,
|
||||||
onStartBulkImport,
|
} = usePanelRole(panelRole);
|
||||||
onBulkImportDone,
|
|
||||||
}: StatBlockPanelProps) {
|
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
);
|
);
|
||||||
const [needsFetch, setNeedsFetch] = useState(false);
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
const [checkingCache, setCheckingCache] = useState(false);
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(min-width: 1024px)");
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
@@ -258,40 +277,32 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCheckingCache(true);
|
setCheckingCache(true);
|
||||||
isSourceCached(sourceCode).then((cached) => {
|
void isSourceCached(sourceCode).then((cached) => {
|
||||||
setNeedsFetch(!cached);
|
setNeedsFetch(!cached);
|
||||||
setCheckingCache(false);
|
setCheckingCache(false);
|
||||||
});
|
});
|
||||||
}, [creatureId, creature, isSourceCached]);
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
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 (
|
if (sourceManagerMode) {
|
||||||
bulkImportMode &&
|
return <SourceManager />;
|
||||||
bulkImportState &&
|
}
|
||||||
onStartBulkImport &&
|
|
||||||
onBulkImportDone
|
if (bulkImportMode) {
|
||||||
) {
|
return <BulkImportPrompt />;
|
||||||
return (
|
|
||||||
<BulkImportPrompt
|
|
||||||
importState={bulkImportState}
|
|
||||||
onStartImport={onStartBulkImport}
|
|
||||||
onDone={onBulkImportDone}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkingCache) {
|
if (checkingCache) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
<div className="p-4 text-muted-foreground text-sm">Loading...</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,33 +314,32 @@ export function StatBlockPanel({
|
|||||||
return (
|
return (
|
||||||
<SourceFetchPrompt
|
<SourceFetchPrompt
|
||||||
sourceCode={sourceCode}
|
sourceCode={sourceCode}
|
||||||
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
|
||||||
fetchAndCacheSource={fetchAndCacheSource}
|
|
||||||
onSourceLoaded={handleSourceLoaded}
|
onSourceLoaded={handleSourceLoaded}
|
||||||
onUploadSource={uploadAndCacheSource}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-muted-foreground text-sm">
|
||||||
No stat block available
|
No stat block available
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const creatureName =
|
let fallbackName = "Creature";
|
||||||
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
if (sourceManagerMode) fallbackName = "Sources";
|
||||||
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||||
|
const creatureName = creature?.name ?? fallbackName;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<DesktopPanel
|
<DesktopPanel
|
||||||
isFolded={isFolded}
|
isCollapsed={isCollapsed}
|
||||||
side={side}
|
side={side}
|
||||||
creatureName={creatureName}
|
creatureName={creatureName}
|
||||||
panelRole={panelRole}
|
panelRole={panelRole}
|
||||||
showPinButton={showPinButton}
|
showPinButton={showPinButton}
|
||||||
onToggleFold={onToggleFold}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
>
|
>
|
||||||
@@ -338,7 +348,7 @@ export function StatBlockPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (panelRole === "pinned") return null;
|
if (panelRole === "pinned" || isCollapsed) return null;
|
||||||
|
|
||||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ function abilityMod(score: number): string {
|
|||||||
function PropertyLine({
|
function PropertyLine({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
}: {
|
}: Readonly<{
|
||||||
label: string;
|
label: string;
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
}) {
|
}>) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
@@ -30,11 +30,11 @@ 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" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatBlock({ creature }: StatBlockProps) {
|
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||||
const abilities = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
{ label: "DEX", score: creature.abilities.dex },
|
{ label: "DEX", score: creature.abilities.dex },
|
||||||
@@ -54,11 +54,11 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
<div className="space-y-1 text-foreground">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
|
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.size} {creature.type}, {creature.alignment}
|
{creature.size} {creature.type}, {creature.alignment}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{creature.sourceDisplayName}
|
{creature.sourceDisplayName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Armor Class</span> {creature.ac}
|
<span className="font-semibold">Armor Class</span> {creature.ac}
|
||||||
{creature.acSource && (
|
{!!creature.acSource && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
{" "}
|
||||||
({creature.acSource})
|
({creature.acSource})
|
||||||
@@ -194,7 +194,7 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
{creature.actions && creature.actions.length > 0 && (
|
{creature.actions && creature.actions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">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 }: StatBlockProps) {
|
|||||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">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 }: StatBlockProps) {
|
|||||||
{creature.reactions && creature.reactions.length > 0 && (
|
{creature.reactions && creature.reactions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">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">
|
||||||
@@ -236,13 +238,13 @@ export function StatBlock({ creature }: StatBlockProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legendary Actions */}
|
{/* Legendary Actions */}
|
||||||
{creature.legendaryActions && (
|
{!!creature.legendaryActions && (
|
||||||
<>
|
<>
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
<h3 className="text-base font-bold text-amber-400">
|
<h3 className="font-bold text-base text-stat-heading">
|
||||||
Legendary Actions
|
Legendary Actions
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm italic text-muted-foreground">
|
<p className="text-muted-foreground text-sm italic">
|
||||||
{creature.legendaryActions.preamble}
|
{creature.legendaryActions.preamble}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -22,9 +23,9 @@ export function Toast({
|
|||||||
}, [autoDismissMs, onDismiss]);
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
<div className="card-glow flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
<span className="text-foreground text-sm">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
@@ -33,13 +34,14 @@ export function Toast({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
className="text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
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,
|
|
||||||
}: 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];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
||||||
<Button
|
<Button
|
||||||
|
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"
|
||||||
@@ -32,10 +24,12 @@ export function TurnNavigation({
|
|||||||
<StepBack className="h-5 w-5" />
|
<StepBack className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 flex items-center justify-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||||
<span className="rounded-full bg-muted text-foreground text-sm px-2 py-0.5 font-semibold shrink-0">
|
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
|
<span className="-mt-[3px] inline-block">
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -47,13 +41,14 @@ export function TurnNavigation({
|
|||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 className="h-5 w-5" />}
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
label="Clear encounter"
|
label="Clear encounter"
|
||||||
onConfirm={onClearEncounter}
|
onConfirm={clearEncounter}
|
||||||
disabled={!hasCombatants}
|
disabled={!hasCombatants}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
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"
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-border bg-transparent hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
"border border-border bg-background/50 text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
ghost: "hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
ghost:
|
||||||
|
"text-foreground hover:bg-hover-neutral-bg hover:text-hover-neutral",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-8 px-3 text-xs",
|
||||||
sm: "h-8 px-3 text-xs",
|
|
||||||
icon: "h-8 w-8",
|
icon: "h-8 w-8",
|
||||||
|
"icon-sm": "h-6 w-6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface ConfirmButtonProps {
|
|||||||
readonly onConfirm: () => void;
|
readonly onConfirm: () => void;
|
||||||
readonly icon: ReactElement;
|
readonly icon: ReactElement;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly size?: "icon" | "icon-sm";
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export function ConfirmButton({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
size = "icon",
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
}: ConfirmButtonProps) {
|
}: ConfirmButtonProps) {
|
||||||
@@ -53,17 +55,17 @@ export function ConfirmButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleEscapeKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
revert();
|
revert();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
document.addEventListener("mousedown", handleMouseDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleEscapeKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleEscapeKey);
|
||||||
};
|
};
|
||||||
}, [isConfirming, revert]);
|
}, [isConfirming, revert]);
|
||||||
|
|
||||||
@@ -94,11 +96,11 @@ export function ConfirmButton({
|
|||||||
<div ref={wrapperRef} className="inline-flex">
|
<div ref={wrapperRef} className="inline-flex">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
isConfirming
|
isConfirming
|
||||||
? "bg-destructive text-primary-foreground rounded-md animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground"
|
? "animate-confirm-pulse rounded-md bg-destructive text-primary-foreground hover:bg-destructive hover:text-primary-foreground"
|
||||||
: "hover:text-hover-destructive",
|
: "hover:text-hover-destructive",
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -108,7 +110,8 @@ export function ConfirmButton({
|
|||||||
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
aria-label={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
title={isConfirming ? `Confirm ${label.toLowerCase()}` : label}
|
||||||
>
|
>
|
||||||
{isConfirming ? <Check size={16} /> : icon}
|
{isConfirming ? <Check size={16} /> : null}
|
||||||
|
{!isConfirming && icon}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
import type { InputHTMLAttributes, RefObject } from "react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
export const Input = ({
|
||||||
({ className, ...props }, ref) => {
|
className,
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: InputProps & { ref?: RefObject<HTMLInputElement | null> }) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -48,17 +49,17 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
|||||||
>
|
>
|
||||||
<EllipsisVertical className="h-5 w-5" />
|
<EllipsisVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{!!open && (
|
||||||
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
<div className="card-glow absolute right-0 bottom-full z-50 mb-1 min-w-48 rounded-lg border border-border bg-card py-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-muted/20 disabled:pointer-events-none disabled:opacity-50"
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
setOpen(false);
|
if (!item.keepOpen) setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
|||||||
55
apps/web/src/components/ui/tooltip.tsx
Normal file
55
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type ReactNode, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: Readonly<TooltipProps>) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setPos({
|
||||||
|
top: rect.top - 4,
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
setPos(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
onPointerEnter={show}
|
||||||
|
onPointerLeave={hide}
|
||||||
|
className={className ?? "inline-flex"}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
{pos !== null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full whitespace-pre-line rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||||
|
style={{ top: pos.top, left: pos.left }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
8
apps/web/src/contexts/index.ts
Normal file
8
apps/web/src/contexts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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 { RulesEditionProvider } from "./rules-edition-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;
|
||||||
|
}
|
||||||
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
24
apps/web/src/contexts/rules-edition-context.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useRulesEdition } from "../hooks/use-rules-edition.js";
|
||||||
|
|
||||||
|
type RulesEditionContextValue = ReturnType<typeof useRulesEdition>;
|
||||||
|
|
||||||
|
const RulesEditionContext = createContext<RulesEditionContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function RulesEditionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useRulesEdition();
|
||||||
|
return (
|
||||||
|
<RulesEditionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</RulesEditionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRulesEditionContext(): RulesEditionContextValue {
|
||||||
|
const ctx = useContext(RulesEditionContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("useRulesEditionContext requires RulesEditionProvider");
|
||||||
|
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;
|
||||||
|
}
|
||||||
225
apps/web/src/hooks/__tests__/use-encounter.test.ts
Normal file
225
apps/web/src/hooks/__tests__/use-encounter.test.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useEncounter } from "../use-encounter.js";
|
||||||
|
|
||||||
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
|
loadEncounter: vi.fn().mockReturnValue(null),
|
||||||
|
saveEncounter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
||||||
|
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
||||||
|
"../../persistence/encounter-storage.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("useEncounter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockLoad.mockReturnValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes with empty encounter when persistence returns null", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
|
expect(result.current.encounter.activeIndex).toBe(0);
|
||||||
|
expect(result.current.encounter.roundNumber).toBe(1);
|
||||||
|
expect(result.current.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes from stored encounter", () => {
|
||||||
|
const stored = {
|
||||||
|
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 2,
|
||||||
|
};
|
||||||
|
mockLoad.mockReturnValue(stored);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(1);
|
||||||
|
expect(result.current.encounter.roundNumber).toBe(2);
|
||||||
|
expect(result.current.isEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
act(() => result.current.addCombatant("Orc"));
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(2);
|
||||||
|
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
||||||
|
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
||||||
|
expect(result.current.isEmpty).toBe(false);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeCombatant removes a combatant and persists", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
const id = result.current.encounter.combatants[0].id;
|
||||||
|
|
||||||
|
act(() => result.current.removeCombatant(id));
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(result.current.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advanceTurn and retreatTurn update encounter state", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
act(() => result.current.addCombatant("Orc"));
|
||||||
|
|
||||||
|
const initialActive = result.current.encounter.activeIndex;
|
||||||
|
|
||||||
|
act(() => result.current.advanceTurn());
|
||||||
|
expect(result.current.encounter.activeIndex).not.toBe(initialActive);
|
||||||
|
|
||||||
|
act(() => result.current.retreatTurn());
|
||||||
|
expect(result.current.encounter.activeIndex).toBe(initialActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearEncounter resets to empty and resets ID counter", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
|
act(() => result.current.clearEncounter());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toHaveLength(0);
|
||||||
|
expect(result.current.isEmpty).toBe(true);
|
||||||
|
|
||||||
|
// After clear, IDs restart from c-1
|
||||||
|
act(() => result.current.addCombatant("Orc"));
|
||||||
|
expect(result.current.encounter.combatants[0].id).toBe("c-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
result.current.addCombatant("Goblin", {
|
||||||
|
initiative: 15,
|
||||||
|
ac: 13,
|
||||||
|
maxHp: 7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const goblin = result.current.encounter.combatants[0];
|
||||||
|
expect(goblin.initiative).toBe(15);
|
||||||
|
expect(goblin.ac).toBe(13);
|
||||||
|
expect(goblin.maxHp).toBe(7);
|
||||||
|
expect(goblin.currentHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
// No creatures yet
|
||||||
|
expect(result.current.hasCreatureCombatants).toBe(false);
|
||||||
|
expect(result.current.canRollAllInitiative).toBe(false);
|
||||||
|
|
||||||
|
// Add from bestiary to get a creature combatant
|
||||||
|
const entry: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasCreatureCombatants).toBe(true);
|
||||||
|
expect(result.current.canRollAllInitiative).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
const entry: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
const combatant = result.current.encounter.combatants[0];
|
||||||
|
expect(combatant.name).toBe("Goblin");
|
||||||
|
expect(combatant.maxHp).toBe(7);
|
||||||
|
expect(combatant.currentHp).toBe(7);
|
||||||
|
expect(combatant.ac).toBe(15);
|
||||||
|
expect(combatant.creatureId).toBe(creatureId("mm:goblin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
const entry: BestiaryIndexEntry = {
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.addFromBestiary(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||||
|
expect(names).toContain("Goblin 1");
|
||||||
|
expect(names).toContain("Goblin 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter());
|
||||||
|
|
||||||
|
const pc: PlayerCharacter = {
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
color: "blue",
|
||||||
|
icon: "sword",
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => result.current.addFromPlayerCharacter(pc));
|
||||||
|
|
||||||
|
const combatant = result.current.encounter.combatants[0];
|
||||||
|
expect(combatant.name).toBe("Aria");
|
||||||
|
expect(combatant.maxHp).toBe(30);
|
||||||
|
expect(combatant.currentHp).toBe(30);
|
||||||
|
expect(combatant.ac).toBe(16);
|
||||||
|
expect(combatant.color).toBe("blue");
|
||||||
|
expect(combatant.icon).toBe("sword");
|
||||||
|
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
100
apps/web/src/hooks/__tests__/use-player-characters.test.ts
Normal file
100
apps/web/src/hooks/__tests__/use-player-characters.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { usePlayerCharacters } from "../use-player-characters.js";
|
||||||
|
|
||||||
|
vi.mock("../../persistence/player-character-storage.js", () => ({
|
||||||
|
loadPlayerCharacters: vi.fn().mockReturnValue([]),
|
||||||
|
savePlayerCharacters: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
|
||||||
|
await vi.importMock<
|
||||||
|
typeof import("../../persistence/player-character-storage.js")
|
||||||
|
>("../../persistence/player-character-storage.js");
|
||||||
|
|
||||||
|
describe("usePlayerCharacters", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockLoad.mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes with characters from persistence", () => {
|
||||||
|
const stored = [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Aria",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
color: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockLoad.mockReturnValue(stored);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
|
expect(result.current.characters).toEqual(stored);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createCharacter adds a character and persists", () => {
|
||||||
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.characters).toHaveLength(1);
|
||||||
|
expect(result.current.characters[0].name).toBe("Vex");
|
||||||
|
expect(result.current.characters[0].ac).toBe(15);
|
||||||
|
expect(result.current.characters[0].maxHp).toBe(28);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createCharacter returns domain error for empty name", () => {
|
||||||
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
act(() => {
|
||||||
|
error = result.current.createCharacter("", 15, 28, undefined, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error).toMatchObject({ kind: "domain-error" });
|
||||||
|
expect(result.current.characters).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editCharacter updates character and persists", () => {
|
||||||
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = result.current.characters[0].id;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.editCharacter(id, { name: "Vex'ahlia" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteCharacter removes character and persists", () => {
|
||||||
|
const { result } = renderHook(() => usePlayerCharacters());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = result.current.characters[0].id;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.deleteCharacter(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.characters).toHaveLength(0);
|
||||||
|
expect(mockSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
159
apps/web/src/hooks/__tests__/use-side-panel-state.test.ts
Normal file
159
apps/web/src/hooks/__tests__/use-side-panel-state.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSidePanelState } from "../use-side-panel-state.js";
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||||
|
const mql = {
|
||||||
|
matches,
|
||||||
|
addEventListener: vi.fn(
|
||||||
|
(_event: string, handler: (e: MediaQueryListEvent) => void) => {
|
||||||
|
listeners.push(handler);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
globalThis.matchMedia = vi.fn().mockReturnValue(mql) as typeof matchMedia;
|
||||||
|
return { mql, listeners };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREATURE_A = creatureId("creature-a");
|
||||||
|
|
||||||
|
describe("useSidePanelState", () => {
|
||||||
|
it("starts with closed panel, no selection, not collapsed", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
expect(result.current.panelView).toEqual({ mode: "closed" });
|
||||||
|
expect(result.current.selectedCreatureId).toBeNull();
|
||||||
|
expect(result.current.isRightPanelCollapsed).toBe(false);
|
||||||
|
expect(result.current.bulkImportMode).toBe(false);
|
||||||
|
expect(result.current.sourceManagerMode).toBe(false);
|
||||||
|
expect(result.current.pinnedCreatureId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showCreature sets creature mode and selectedCreatureId", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showCreature(CREATURE_A));
|
||||||
|
|
||||||
|
expect(result.current.panelView).toEqual({
|
||||||
|
mode: "creature",
|
||||||
|
creatureId: CREATURE_A,
|
||||||
|
});
|
||||||
|
expect(result.current.selectedCreatureId).toBe(CREATURE_A);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showBulkImport sets bulk-import mode, selectedCreatureId null", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showBulkImport());
|
||||||
|
|
||||||
|
expect(result.current.panelView).toEqual({ mode: "bulk-import" });
|
||||||
|
expect(result.current.selectedCreatureId).toBeNull();
|
||||||
|
expect(result.current.bulkImportMode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showSourceManager sets source-manager mode", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showSourceManager());
|
||||||
|
|
||||||
|
expect(result.current.panelView).toEqual({ mode: "source-manager" });
|
||||||
|
expect(result.current.sourceManagerMode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismissPanel sets mode to closed", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showCreature(CREATURE_A));
|
||||||
|
act(() => result.current.dismissPanel());
|
||||||
|
|
||||||
|
expect(result.current.panelView).toEqual({ mode: "closed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleCollapse flips isRightPanelCollapsed", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
expect(result.current.isRightPanelCollapsed).toBe(false);
|
||||||
|
|
||||||
|
act(() => result.current.toggleCollapse());
|
||||||
|
expect(result.current.isRightPanelCollapsed).toBe(true);
|
||||||
|
|
||||||
|
act(() => result.current.toggleCollapse());
|
||||||
|
expect(result.current.isRightPanelCollapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showCreature resets collapse state", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.toggleCollapse());
|
||||||
|
expect(result.current.isRightPanelCollapsed).toBe(true);
|
||||||
|
|
||||||
|
act(() => result.current.showCreature(CREATURE_A));
|
||||||
|
expect(result.current.isRightPanelCollapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("togglePin pins the selected creature", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showCreature(CREATURE_A));
|
||||||
|
act(() => result.current.togglePin());
|
||||||
|
|
||||||
|
expect(result.current.pinnedCreatureId).toBe(CREATURE_A);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("togglePin unpins when already pinned to same creature", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showCreature(CREATURE_A));
|
||||||
|
act(() => result.current.togglePin());
|
||||||
|
act(() => result.current.togglePin());
|
||||||
|
|
||||||
|
expect(result.current.pinnedCreatureId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("togglePin does nothing when no creature is selected", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.togglePin());
|
||||||
|
|
||||||
|
expect(result.current.pinnedCreatureId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpin clears pinned creature", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
act(() => result.current.showCreature(CREATURE_A));
|
||||||
|
act(() => result.current.togglePin());
|
||||||
|
act(() => result.current.unpin());
|
||||||
|
|
||||||
|
expect(result.current.pinnedCreatureId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isWideDesktop reflects matchMedia result", () => {
|
||||||
|
mockMatchMedia(true);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
expect(result.current.isWideDesktop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isWideDesktop is false on narrow viewport", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const { result } = renderHook(() => useSidePanelState());
|
||||||
|
|
||||||
|
expect(result.current.isWideDesktop).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
299
apps/web/src/hooks/use-action-bar-state.ts
Normal file
299
apps/web/src/hooks/use-action-bar-state.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { useCallback, useDeferredValue, useMemo, useState } from "react";
|
||||||
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
|
export interface QueuedCreature {
|
||||||
|
result: SearchResult;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionActions {
|
||||||
|
dismiss: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
clickSuggestion: (result: SearchResult) => void;
|
||||||
|
setSuggestionIndex: (i: number) => void;
|
||||||
|
setQueued: (q: QueuedCreature | null) => void;
|
||||||
|
confirmQueued: () => void;
|
||||||
|
addFromPlayerCharacter?: (pc: PlayerCharacter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creatureKey(r: SearchResult): string {
|
||||||
|
return `${r.source}:${r.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActionBarState() {
|
||||||
|
const { addCombatant, addFromBestiary, addFromPlayerCharacter } =
|
||||||
|
useEncounterContext();
|
||||||
|
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
|
||||||
|
useBestiaryContext();
|
||||||
|
const { characters: playerCharacters } = usePlayerCharactersContext();
|
||||||
|
const { showBulkImport, showSourceManager, showCreature, panelView } =
|
||||||
|
useSidePanelContext();
|
||||||
|
|
||||||
|
const [nameInput, setNameInput] = useState("");
|
||||||
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
||||||
|
const deferredSuggestions = useDeferredValue(suggestions);
|
||||||
|
const deferredPcMatches = useDeferredValue(pcMatches);
|
||||||
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||||
|
const [queued, setQueued] = useState<QueuedCreature | null>(null);
|
||||||
|
const [customInit, setCustomInit] = useState("");
|
||||||
|
const [customAc, setCustomAc] = useState("");
|
||||||
|
const [customMaxHp, setCustomMaxHp] = useState("");
|
||||||
|
const [browseMode, setBrowseMode] = useState(false);
|
||||||
|
|
||||||
|
const clearCustomFields = () => {
|
||||||
|
setCustomInit("");
|
||||||
|
setCustomAc("");
|
||||||
|
setCustomMaxHp("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => {
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissSuggestions = useCallback(() => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
setQueued(null);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddFromBestiary = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
const creatureId = addFromBestiary(result);
|
||||||
|
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
|
||||||
|
if (creatureId && panelView.mode === "closed" && isDesktop) {
|
||||||
|
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 confirmQueued = useCallback(() => {
|
||||||
|
if (!queued) return;
|
||||||
|
for (let i = 0; i < queued.count; i++) {
|
||||||
|
handleAddFromBestiary(queued.result);
|
||||||
|
}
|
||||||
|
clearInput();
|
||||||
|
}, [queued, handleAddFromBestiary, clearInput]);
|
||||||
|
|
||||||
|
const parseNum = (v: string): number | undefined => {
|
||||||
|
if (v.trim() === "") return undefined;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? undefined : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (browseMode) return;
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nameInput.trim() === "") return;
|
||||||
|
const opts: { initiative?: number; ac?: number; maxHp?: number } = {};
|
||||||
|
const init = parseNum(customInit);
|
||||||
|
const ac = parseNum(customAc);
|
||||||
|
const maxHp = parseNum(customMaxHp);
|
||||||
|
if (init !== undefined) opts.initiative = init;
|
||||||
|
if (ac !== undefined) opts.ac = ac;
|
||||||
|
if (maxHp !== undefined) opts.maxHp = maxHp;
|
||||||
|
addCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
|
||||||
|
setNameInput("");
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
clearCustomFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseSearch = (value: string) => {
|
||||||
|
setSuggestions(value.length >= 2 ? bestiarySearch(value) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSearch = (value: string) => {
|
||||||
|
let newSuggestions: SearchResult[] = [];
|
||||||
|
let newPcMatches: PlayerCharacter[] = [];
|
||||||
|
if (value.length >= 2) {
|
||||||
|
newSuggestions = bestiarySearch(value);
|
||||||
|
setSuggestions(newSuggestions);
|
||||||
|
if (playerCharacters && playerCharacters.length > 0) {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
newPcMatches = playerCharacters.filter((pc) =>
|
||||||
|
pc.name.toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPcMatches(newPcMatches);
|
||||||
|
} else {
|
||||||
|
setSuggestions([]);
|
||||||
|
setPcMatches([]);
|
||||||
|
}
|
||||||
|
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
|
||||||
|
clearCustomFields();
|
||||||
|
}
|
||||||
|
if (queued) {
|
||||||
|
const qKey = creatureKey(queued.result);
|
||||||
|
const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey);
|
||||||
|
if (!stillVisible) {
|
||||||
|
setQueued(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setNameInput(value);
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
if (browseMode) {
|
||||||
|
handleBrowseSearch(value);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickSuggestion = useCallback((result: SearchResult) => {
|
||||||
|
const key = creatureKey(result);
|
||||||
|
setQueued((prev) => {
|
||||||
|
if (prev && creatureKey(prev.result) === key) {
|
||||||
|
return { ...prev, count: prev.count + 1 };
|
||||||
|
}
|
||||||
|
return { result, count: 1 };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
if (queued) {
|
||||||
|
confirmQueued();
|
||||||
|
} else if (suggestionIndex >= 0) {
|
||||||
|
handleClickSuggestion(suggestions[suggestionIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSuggestions =
|
||||||
|
deferredSuggestions.length > 0 || deferredPcMatches.length > 0;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!hasSuggestions) return;
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEnter();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
dismissSuggestions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestions.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
||||||
|
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleViewStatBlock(suggestions[suggestionIndex]);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseSelect = (result: SearchResult) => {
|
||||||
|
handleViewStatBlock(result);
|
||||||
|
setBrowseMode(false);
|
||||||
|
clearInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBrowseMode = () => {
|
||||||
|
setBrowseMode((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
setQueued(null);
|
||||||
|
if (next) {
|
||||||
|
handleBrowseSearch(nameInput);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(nameInput);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
clearCustomFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestionActions: SuggestionActions = useMemo(
|
||||||
|
() => ({
|
||||||
|
dismiss: dismissSuggestions,
|
||||||
|
clear: clearInput,
|
||||||
|
clickSuggestion: handleClickSuggestion,
|
||||||
|
setSuggestionIndex,
|
||||||
|
setQueued,
|
||||||
|
confirmQueued,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
dismissSuggestions,
|
||||||
|
clearInput,
|
||||||
|
handleClickSuggestion,
|
||||||
|
confirmQueued,
|
||||||
|
addFromPlayerCharacter,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
nameInput,
|
||||||
|
suggestions: deferredSuggestions,
|
||||||
|
pcMatches: deferredPcMatches,
|
||||||
|
suggestionIndex,
|
||||||
|
queued,
|
||||||
|
customInit,
|
||||||
|
customAc,
|
||||||
|
customMaxHp,
|
||||||
|
browseMode,
|
||||||
|
bestiaryLoaded,
|
||||||
|
hasSuggestions,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
suggestionActions,
|
||||||
|
handleNameChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBrowseKeyDown,
|
||||||
|
handleAdd,
|
||||||
|
handleBrowseSelect,
|
||||||
|
toggleBrowseMode,
|
||||||
|
setCustomInit,
|
||||||
|
setCustomAc,
|
||||||
|
setCustomMaxHp,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
27
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
27
apps/web/src/hooks/use-auto-stat-block.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useRef } 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;
|
||||||
|
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevIndex = prevActiveIndexRef.current;
|
||||||
|
prevActiveIndexRef.current = encounter.activeIndex;
|
||||||
|
|
||||||
|
// Only auto-update when the active turn changes (advance/retreat),
|
||||||
|
// not when the panel mode changes (user clicking a different creature).
|
||||||
|
if (
|
||||||
|
encounter.activeIndex !== prevIndex &&
|
||||||
|
activeCreatureId &&
|
||||||
|
panelView.mode === "creature"
|
||||||
|
) {
|
||||||
|
updateCreature(activeCreatureId);
|
||||||
|
}
|
||||||
|
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
Creature,
|
Creature,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
@@ -33,8 +33,9 @@ interface BestiaryHook {
|
|||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
const [, setTick] = useState(0);
|
() => new Map<CreatureId, Creature>(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = loadBestiaryIndex();
|
const index = loadBestiaryIndex();
|
||||||
@@ -43,9 +44,8 @@ export function useBestiary(): BestiaryHook {
|
|||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
creatureMapRef.current = map;
|
setCreatureMap(map);
|
||||||
setTick((t) => t + 1);
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -63,9 +63,12 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
const getCreature = useCallback(
|
||||||
return creatureMapRef.current.get(id);
|
(id: CreatureId): Creature | undefined => {
|
||||||
}, []);
|
return creatureMap.get(id);
|
||||||
|
},
|
||||||
|
[creatureMap],
|
||||||
|
);
|
||||||
|
|
||||||
const isSourceCachedFn = useCallback(
|
const isSourceCachedFn = useCallback(
|
||||||
(sourceCode: string): Promise<boolean> => {
|
(sourceCode: string): Promise<boolean> => {
|
||||||
@@ -86,10 +89,13 @@ export function useBestiary(): BestiaryHook {
|
|||||||
const creatures = normalizeBestiary(json);
|
const creatures = normalizeBestiary(json);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
|
setCreatureMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
creatureMapRef.current.set(c.id, c);
|
next.set(c.id, c);
|
||||||
}
|
}
|
||||||
setTick((t) => t + 1);
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -100,18 +106,20 @@ export function useBestiary(): BestiaryHook {
|
|||||||
const creatures = normalizeBestiary(jsonData as any);
|
const creatures = normalizeBestiary(jsonData as any);
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
|
setCreatureMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
creatureMapRef.current.set(c.id, c);
|
next.set(c.id, c);
|
||||||
}
|
}
|
||||||
setTick((t) => t + 1);
|
return next;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
creatureMapRef.current = map;
|
setCreatureMap(map);
|
||||||
setTick((t) => t + 1);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -48,7 +48,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
countersRef.current = { completed: 0, failed: 0 };
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
setState({ status: "loading", total, completed: 0, failed: 0 });
|
setState({ status: "loading", total, completed: 0, failed: 0 });
|
||||||
|
|
||||||
(async () => {
|
void (async () => {
|
||||||
const cacheChecks = await Promise.all(
|
const cacheChecks = await Promise.all(
|
||||||
allCodes.map(async (code) => ({
|
allCodes.map(async (code) => ({
|
||||||
code,
|
code,
|
||||||
@@ -73,9 +73,15 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
|
|
||||||
setState((s) => ({ ...s, completed: alreadyCached }));
|
setState((s) => ({ ...s, completed: alreadyCached }));
|
||||||
|
|
||||||
|
const batches: { code: string }[][] = [];
|
||||||
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
const batch = uncached.slice(i, i + BATCH_SIZE);
|
batches.push(uncached.slice(i, i + BATCH_SIZE));
|
||||||
await Promise.allSettled(
|
}
|
||||||
|
|
||||||
|
await batches.reduce(
|
||||||
|
(chain, batch) =>
|
||||||
|
chain.then(() =>
|
||||||
|
Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = getDefaultFetchUrl(code, baseUrl);
|
const url = getDefaultFetchUrl(code, baseUrl);
|
||||||
try {
|
try {
|
||||||
@@ -95,8 +101,10 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
failed: countersRef.current.failed,
|
failed: countersRef.current.failed,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Promise.resolve() as Promise<unknown>,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
await refreshCache();
|
await refreshCache();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
@@ -17,6 +18,7 @@ import type {
|
|||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
CombatantId,
|
CombatantId,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
|
CreatureId,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -33,6 +35,8 @@ import {
|
|||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
|
||||||
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
const EMPTY_ENCOUNTER: Encounter = {
|
const EMPTY_ENCOUNTER: Encounter = {
|
||||||
combatants: [],
|
combatants: [],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
@@ -48,7 +52,7 @@ function initializeEncounter(): Encounter {
|
|||||||
function deriveNextId(encounter: Encounter): number {
|
function deriveNextId(encounter: Encounter): number {
|
||||||
let max = 0;
|
let max = 0;
|
||||||
for (const c of encounter.combatants) {
|
for (const c of encounter.combatants) {
|
||||||
const match = /^c-(\d+)$/.exec(c.id);
|
const match = COMBATANT_ID_REGEX.exec(c.id);
|
||||||
if (match) {
|
if (match) {
|
||||||
const n = Number.parseInt(match[1], 10);
|
const n = Number.parseInt(match[1], 10);
|
||||||
if (n > max) max = n;
|
if (n > max) max = n;
|
||||||
@@ -212,6 +216,19 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setTempHp = useCallback(
|
||||||
|
(id: CombatantId, tempHp: number | undefined) => {
|
||||||
|
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
const setAc = useCallback(
|
const setAc = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setAcUseCase(makeStore(), id, value);
|
const result = setAcUseCase(makeStore(), id, value);
|
||||||
@@ -263,7 +280,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(
|
||||||
@@ -282,7 +299,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);
|
||||||
@@ -301,8 +318,8 @@ export function useEncounter() {
|
|||||||
// Derive creatureId from source + name
|
// Derive creatureId from source + name
|
||||||
const slug = entry.name
|
const slug = entry.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||||
@@ -315,8 +332,10 @@ export function useEncounter() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
|
|
||||||
|
return cId;
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
const addFromPlayerCharacter = useCallback(
|
||||||
@@ -368,12 +387,28 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...addResult]);
|
setEvents((prev) => [...prev, ...addResult]);
|
||||||
},
|
},
|
||||||
[makeStore, editCombatant],
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTempHp = encounter.combatants.some(
|
||||||
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null,
|
||||||
|
);
|
||||||
|
const canRollAllInitiative = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
|
isEmpty,
|
||||||
|
hasTempHp,
|
||||||
|
hasCreatureCombatants,
|
||||||
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -383,6 +418,7 @@ export function useEncounter() {
|
|||||||
setInitiative,
|
setInitiative,
|
||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createCharacter = useCallback(
|
const createCharacter = useCallback(
|
||||||
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
|
(
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId();
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
makeStore(),
|
makeStore(),
|
||||||
|
|||||||
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
52
apps/web/src/hooks/use-rules-edition.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let currentEdition: RulesEdition = loadEdition();
|
||||||
|
|
||||||
|
function loadEdition(): RulesEdition {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === "5e" || raw === "5.5e") return raw;
|
||||||
|
} catch {
|
||||||
|
// storage unavailable
|
||||||
|
}
|
||||||
|
return "5.5e";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdition(edition: RulesEdition): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, edition);
|
||||||
|
} catch {
|
||||||
|
// quota exceeded or storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyAll(): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(callback: () => void): () => void {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): RulesEdition {
|
||||||
|
return currentEdition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRulesEdition() {
|
||||||
|
const edition = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
const setEdition = useCallback((next: RulesEdition) => {
|
||||||
|
currentEdition = next;
|
||||||
|
saveEdition(next);
|
||||||
|
notifyAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { edition, setEdition } as const;
|
||||||
|
}
|
||||||
107
apps/web/src/hooks/use-side-panel-state.ts
Normal file
107
apps/web/src/hooks/use-side-panel-state.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { CreatureId } from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type PanelView =
|
||||||
|
| { mode: "closed" }
|
||||||
|
| { mode: "creature"; creatureId: CreatureId }
|
||||||
|
| { mode: "bulk-import" }
|
||||||
|
| { mode: "source-manager" };
|
||||||
|
|
||||||
|
interface SidePanelState {
|
||||||
|
panelView: PanelView;
|
||||||
|
selectedCreatureId: CreatureId | null;
|
||||||
|
bulkImportMode: boolean;
|
||||||
|
sourceManagerMode: boolean;
|
||||||
|
isRightPanelCollapsed: boolean;
|
||||||
|
pinnedCreatureId: CreatureId | null;
|
||||||
|
isWideDesktop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidePanelActions {
|
||||||
|
showCreature: (creatureId: CreatureId) => void;
|
||||||
|
updateCreature: (creatureId: CreatureId) => void;
|
||||||
|
showBulkImport: () => void;
|
||||||
|
showSourceManager: () => void;
|
||||||
|
dismissPanel: () => void;
|
||||||
|
toggleCollapse: () => void;
|
||||||
|
togglePin: () => void;
|
||||||
|
unpin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||||
|
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
|
||||||
|
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
|
||||||
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||||
|
() => globalThis.matchMedia("(min-width: 1280px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = globalThis.matchMedia("(min-width: 1280px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCreatureId =
|
||||||
|
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||||
|
|
||||||
|
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showBulkImport = useCallback(() => {
|
||||||
|
setPanelView({ mode: "bulk-import" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSourceManager = useCallback(() => {
|
||||||
|
setPanelView({ mode: "source-manager" });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissPanel = useCallback(() => {
|
||||||
|
setPanelView({ mode: "closed" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback(() => {
|
||||||
|
setIsRightPanelCollapsed((f) => !f);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePin = useCallback(() => {
|
||||||
|
if (selectedCreatureId) {
|
||||||
|
setPinnedCreatureId((prev) =>
|
||||||
|
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedCreatureId]);
|
||||||
|
|
||||||
|
const unpin = useCallback(() => {
|
||||||
|
setPinnedCreatureId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelView,
|
||||||
|
selectedCreatureId,
|
||||||
|
bulkImportMode: panelView.mode === "bulk-import",
|
||||||
|
sourceManagerMode: panelView.mode === "source-manager",
|
||||||
|
isRightPanelCollapsed,
|
||||||
|
pinnedCreatureId,
|
||||||
|
isWideDesktop,
|
||||||
|
showCreature,
|
||||||
|
updateCreature,
|
||||||
|
showBulkImport,
|
||||||
|
showSourceManager,
|
||||||
|
dismissPanel,
|
||||||
|
toggleCollapse,
|
||||||
|
togglePin,
|
||||||
|
unpin,
|
||||||
|
};
|
||||||
|
}
|
||||||
93
apps/web/src/hooks/use-theme.ts
Normal file
93
apps/web/src/hooks/use-theme.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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;
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute("content", resolved === "light" ? "#eeecea" : "#0e1a2e");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { preference, resolved, setPreference } as const;
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #0f172a;
|
--color-background: #0e1a2e;
|
||||||
--color-foreground: #e2e8f0;
|
--color-foreground: #e2e8f0;
|
||||||
--color-muted: #64748b;
|
--color-muted: #7a8ba4;
|
||||||
--color-muted-foreground: #94a3b8;
|
--color-muted-foreground: #94a3b8;
|
||||||
--color-card: #1e293b;
|
--color-card: #1a2e4a;
|
||||||
--color-card-foreground: #e2e8f0;
|
--color-card-foreground: #e2e8f0;
|
||||||
--color-border: #334155;
|
--color-border: #2a5088;
|
||||||
--color-input: #334155;
|
--color-input: #2a5088;
|
||||||
--color-primary: #3b82f6;
|
--color-primary: #3b82f6;
|
||||||
--color-primary-foreground: #ffffff;
|
--color-primary-foreground: #ffffff;
|
||||||
--color-accent: #3b82f6;
|
--color-accent: #3b82f6;
|
||||||
@@ -16,15 +16,50 @@
|
|||||||
--color-hover-neutral: var(--color-primary);
|
--color-hover-neutral: var(--color-primary);
|
||||||
--color-hover-action: var(--color-primary);
|
--color-hover-action: var(--color-primary);
|
||||||
--color-hover-destructive: var(--color-destructive);
|
--color-hover-destructive: var(--color-destructive);
|
||||||
--color-hover-neutral-bg: var(--color-card);
|
--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.375rem;
|
--radius-md: 0.5rem;
|
||||||
--radius-lg: 0.5rem;
|
--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;
|
||||||
@@ -169,6 +204,38 @@
|
|||||||
concentration-glow 1200ms ease-out;
|
concentration-glow 1200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility card-glow {
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse at 50% 50%,
|
||||||
|
oklch(0.35 0.05 250 / 0.5) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
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);
|
||||||
|
|
||||||
|
[data-theme="light"] & {
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: 0 1px 3px 0 oklch(0 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility panel-glow {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
oklch(0.35 0.05 250 / 0.4) 0%,
|
||||||
|
transparent 40%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
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);
|
||||||
|
|
||||||
|
[data-theme="light"] & {
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: -1px 0 6px 0 oklch(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-color: var(--color-border) transparent;
|
scrollbar-color: var(--color-border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -176,6 +243,16 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse at 50% 40%,
|
||||||
|
oklch(0.26 0.055 250) 0%,
|
||||||
|
var(--color-background) 70%
|
||||||
|
);
|
||||||
|
background-attachment: fixed;
|
||||||
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,39 @@
|
|||||||
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,
|
||||||
|
RulesEditionProvider,
|
||||||
|
SidePanelProvider,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "./contexts/index.js";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<EncounterProvider>
|
||||||
|
<BestiaryProvider>
|
||||||
|
<PlayerCharactersProvider>
|
||||||
|
<BulkImportProvider>
|
||||||
|
<SidePanelProvider>
|
||||||
|
<InitiativeRollsProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</InitiativeRollsProvider>
|
||||||
|
</SidePanelProvider>
|
||||||
|
</BulkImportProvider>
|
||||||
|
</PlayerCharactersProvider>
|
||||||
|
</BestiaryProvider>
|
||||||
|
</EncounterProvider>
|
||||||
|
</RulesEditionProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
return null;
|
return null;
|
||||||
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|||||||
entry.maxHp < 1
|
entry.maxHp < 1
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
return null;
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: playerCharacterId(entry.id),
|
id: playerCharacterId(entry.id),
|
||||||
|
|||||||
102
biome.json
102
biome.json
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
"!**/dist/**",
|
"!**/dist",
|
||||||
"!.claude/**",
|
"!.claude",
|
||||||
"!.specify/**",
|
"!.specify",
|
||||||
"!specs/**",
|
"!specs",
|
||||||
"!coverage/**",
|
"!coverage",
|
||||||
"!.pnpm-store/**"
|
"!.pnpm-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"cssModules": false,
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
@@ -30,13 +36,93 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"noNoninteractiveElementInteractions": "error"
|
||||||
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noExcessiveCognitiveComplexity": {
|
"noExcessiveCognitiveComplexity": {
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"options": {
|
"options": {
|
||||||
"maxAllowedComplexity": 15
|
"maxAllowedComplexity": 15
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"noUselessStringConcat": "error"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noNestedComponentDefinitions": "error",
|
||||||
|
"noReactPropAssignments": "error"
|
||||||
|
},
|
||||||
|
"nursery": {
|
||||||
|
"noConditionalExpect": "error",
|
||||||
|
"noDuplicatedSpreadProps": "error",
|
||||||
|
"noFloatingPromises": "error",
|
||||||
|
"noLeakedRender": "error",
|
||||||
|
"noMisusedPromises": "error",
|
||||||
|
"noNestedPromises": "error",
|
||||||
|
"noReturnAssign": "error",
|
||||||
|
"noScriptUrl": "error",
|
||||||
|
"noShadow": "error",
|
||||||
|
"noUnnecessaryConditions": "error",
|
||||||
|
"noUselessReturn": "error",
|
||||||
|
"useArraySome": "error",
|
||||||
|
"useArraySortCompare": "error",
|
||||||
|
"useAwaitThenable": "error",
|
||||||
|
"useErrorCause": "error",
|
||||||
|
"useExhaustiveSwitchCases": "error",
|
||||||
|
"useFind": "error",
|
||||||
|
"useGlobalThis": "error",
|
||||||
|
"useNullishCoalescing": "error",
|
||||||
|
"useRegexpExec": "error",
|
||||||
|
"useSortedClasses": "error",
|
||||||
|
"useSpread": "error"
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noAwaitInLoops": "error",
|
||||||
|
"useTopLevelRegex": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noCommonJs": "error",
|
||||||
|
"noDoneCallback": "error",
|
||||||
|
"noExportedImports": "error",
|
||||||
|
"noInferrableTypes": "error",
|
||||||
|
"noNamespace": "error",
|
||||||
|
"noNegationElse": "error",
|
||||||
|
"noNestedTernary": "error",
|
||||||
|
"noParameterAssign": "error",
|
||||||
|
"noSubstr": "error",
|
||||||
|
"noUnusedTemplateLiteral": "error",
|
||||||
|
"noUselessElse": "error",
|
||||||
|
"noYodaExpression": "error",
|
||||||
|
"useAsConstAssertion": "error",
|
||||||
|
"useAtIndex": "error",
|
||||||
|
"useCollapsedElseIf": "error",
|
||||||
|
"useCollapsedIf": "error",
|
||||||
|
"useConsistentBuiltinInstantiation": "error",
|
||||||
|
"useDefaultParameterLast": "error",
|
||||||
|
"useExplicitLengthCheck": "error",
|
||||||
|
"useForOf": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"useNumberNamespace": "error",
|
||||||
|
"useSelfClosingElements": "error",
|
||||||
|
"useShorthandAssign": "error",
|
||||||
|
"useThrowNewError": "error",
|
||||||
|
"useThrowOnlyError": "error",
|
||||||
|
"useTrimStartEnd": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noAlert": "error",
|
||||||
|
"noConstantBinaryExpressions": "error",
|
||||||
|
"noDeprecatedImports": "error",
|
||||||
|
"noEvolvingTypes": "error",
|
||||||
|
"noImportCycles": "error",
|
||||||
|
"noReactForwardRef": "error",
|
||||||
|
"noSkippedTests": "error",
|
||||||
|
"noTemplateCurlyInString": "error",
|
||||||
|
"noTsIgnore": "error",
|
||||||
|
"noUnusedExpressions": "error",
|
||||||
|
"noVar": "error",
|
||||||
|
"useAwait": "error",
|
||||||
|
"useErrorMessage": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
docs/adr/000-template.md
Normal file
20
docs/adr/000-template.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# ADR-NNN: [Title]
|
||||||
|
|
||||||
|
**Date**: YYYY-MM-DD
|
||||||
|
**Status**: accepted | superseded | deprecated
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
What is the problem or situation that motivates this decision?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
What did we decide, and why?
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
What other approaches were evaluated?
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
What are the trade-offs — both positive and negative?
|
||||||
45
docs/adr/001-errors-as-values.md
Normal file
45
docs/adr/001-errors-as-values.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-001: Errors as Values, Not Exceptions
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect.
|
||||||
|
|
||||||
|
This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DomainError {
|
||||||
|
readonly kind: "domain-error";
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers).
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure.
|
||||||
|
|
||||||
|
**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient.
|
||||||
|
|
||||||
|
**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields.
|
||||||
|
- Domain functions remain pure — they return data, never alter control flow.
|
||||||
|
- Error codes are stable, machine-readable identifiers that UI code can match on.
|
||||||
|
- Testing is straightforward: assert the return value, no try/catch in tests.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls.
|
||||||
|
- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly.
|
||||||
|
- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar.
|
||||||
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
46
docs/adr/002-domain-events-as-plain-data.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-002: Domain Events as Plain Data Objects
|
||||||
|
|
||||||
|
**Date**: 2026-03-25
|
||||||
|
**Status**: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Domain events are plain data objects with a `type` string discriminant. They form a discriminated union (`DomainEvent`) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example event
|
||||||
|
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }
|
||||||
|
|
||||||
|
// Domain function returns both state and events
|
||||||
|
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are consumed ephemerally by the UI layer and are not persisted.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**Class-based events** (e.g., `class TurnAdvanced extends DomainEvent { ... }`) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and `instanceof` checks. No benefit here: TypeScript's discriminated union narrowing (`switch (event.type)`) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.
|
||||||
|
|
||||||
|
**Event emitter / pub-sub** (Node `EventEmitter`, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.
|
||||||
|
|
||||||
|
**Observable streams** (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.
|
||||||
|
|
||||||
|
**No events** (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
|
||||||
|
- TypeScript's `switch (event.type)` provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
|
||||||
|
- No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
|
||||||
|
- Domain functions remain pure — events are returned, not emitted.
|
||||||
|
- Testing is trivial: assert that `result.events` contains the expected objects.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
|
||||||
|
- Adding a new event type requires updating the `DomainEvent` union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
|
||||||
|
- No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
---
|
|
||||||
date: "2026-03-13T14:58:42.882813+00:00"
|
|
||||||
git_commit: 75778884bd1be7d135b2f5ea9b8a8e77a0149f7b
|
|
||||||
branch: main
|
|
||||||
topic: "Declutter Action Bars"
|
|
||||||
tags: [plan, turn-navigation, action-bar, overflow-menu, ux]
|
|
||||||
status: draft
|
|
||||||
---
|
|
||||||
|
|
||||||
# Declutter Action Bars — Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Reorganize buttons across the top bar (TurnNavigation) and bottom bar (ActionBar) to reduce visual clutter and improve UX. Each bar gets a clear purpose: the top bar is for turn navigation + encounter lifecycle, the bottom bar is for adding combatants + setup actions.
|
|
||||||
|
|
||||||
## Current State Analysis
|
|
||||||
|
|
||||||
**Top bar** (`turn-navigation.tsx`) has 5 buttons + center info:
|
|
||||||
```
|
|
||||||
[ Prev ] | [ R1 Dwarf ] | [ D20 Library Trash ] [ Next ]
|
|
||||||
```
|
|
||||||
The D20 (roll all initiative) and Library (manage sources) buttons are unrelated to turn navigation — they're setup/utility actions that add noise.
|
|
||||||
|
|
||||||
**Bottom bar** (`action-bar.tsx`) has an input, Add button, and 3 icon buttons:
|
|
||||||
```
|
|
||||||
[ + Add combatants... ] [ Add ] [ Users Eye Import ]
|
|
||||||
```
|
|
||||||
The icon cluster (Users, Eye, Import) is cryptic — three ghost icon buttons with no labels, requiring hover to discover purpose. The Eye button opens a separate search dropdown for browsing stat blocks, which duplicates the existing search input.
|
|
||||||
|
|
||||||
### Key Discoveries:
|
|
||||||
- `rollAllInitiativeUseCase` (`packages/application/src/roll-all-initiative-use-case.ts`) applies to combatants with `creatureId` AND no `initiative` set — this defines the conditional visibility logic
|
|
||||||
- `Combatant.initiative` is `number | undefined` and `Combatant.creatureId` is `CreatureId | undefined` (`packages/domain/src/types.ts`)
|
|
||||||
- No existing dropdown/menu UI component — the overflow menu needs a new component
|
|
||||||
- Lucide provides `EllipsisVertical` for the kebab menu trigger
|
|
||||||
- The stat block viewer already has its own search input, results list, and keyboard navigation (`action-bar.tsx:65-236`) — in browse mode, we reuse the main input for this instead
|
|
||||||
|
|
||||||
## Desired End State
|
|
||||||
|
|
||||||
### UI Mockups
|
|
||||||
|
|
||||||
**Top bar (after):**
|
|
||||||
```
|
|
||||||
[ Prev ] [ R1 Dwarf ] [ Trash ] [ Next ]
|
|
||||||
```
|
|
||||||
4 elements. Clean, focused on turn flow + encounter lifecycle.
|
|
||||||
|
|
||||||
**Bottom bar — add mode (default):**
|
|
||||||
```
|
|
||||||
[ + Add combatants... 👁 ] [ Add ] [ D20? ] [ ⋮ ]
|
|
||||||
```
|
|
||||||
The Eye icon sits inside/beside the input as a toggle. D20 appears conditionally. Kebab menu holds infrequent actions.
|
|
||||||
|
|
||||||
**Bottom bar — browse mode (Eye toggled on):**
|
|
||||||
```
|
|
||||||
[ 🔍 Search stat blocks... 👁 ] [ ⋮ ]
|
|
||||||
```
|
|
||||||
The input switches purpose: placeholder changes, typing searches stat blocks instead of adding combatants. The Add button and D20 hide (irrelevant in browse mode). Eye icon stays as the toggle to switch back. Selecting a result opens the stat block panel and exits browse mode.
|
|
||||||
|
|
||||||
**Overflow menu (⋮ clicked):**
|
|
||||||
```
|
|
||||||
┌──────────────────────┐
|
|
||||||
│ 👥 Player Characters │
|
|
||||||
│ 📚 Manage Sources │
|
|
||||||
│ 📥 Bulk Import │
|
|
||||||
└──────────────────────┘
|
|
||||||
```
|
|
||||||
Labeled items with icons — discoverable without hover.
|
|
||||||
|
|
||||||
### Key Discoveries:
|
|
||||||
- `sourceManagerOpen` state lives in App.tsx:116 — the overflow menu's "Manage Sources" item needs the same toggle callback
|
|
||||||
- The stat block viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex) in action-bar.tsx:66-71 gets replaced by a `browseMode` boolean that repurposes the main input
|
|
||||||
- The viewer's separate input, dropdown, and keyboard handling (action-bar.tsx:188-248) can be removed — browse mode reuses the existing input and suggestion dropdown infrastructure
|
|
||||||
|
|
||||||
## What We're NOT Doing
|
|
||||||
|
|
||||||
- Changing domain logic or use cases
|
|
||||||
- Modifying ConfirmButton behavior
|
|
||||||
- Changing the stat block panel itself
|
|
||||||
- Altering animation logic (useActionBarAnimation)
|
|
||||||
- Modifying combatant row buttons
|
|
||||||
- Changing how SourceManager works (just moving where the trigger lives)
|
|
||||||
|
|
||||||
## Implementation Approach
|
|
||||||
|
|
||||||
Four phases, each independently testable. Phase 1 simplifies the top bar (pure removal). Phase 2 adds the overflow menu component. Phase 3 reworks the ActionBar (browse toggle + conditional D20 + overflow integration). Phase 4 wires everything together in App.tsx.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Simplify TurnNavigation
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Strip TurnNavigation down to just turn controls + clear encounter. Remove Roll All Initiative and Manage Sources buttons and their associated props.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Update TurnNavigation component
|
|
||||||
**File**: `apps/web/src/components/turn-navigation.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- Remove `onRollAllInitiative` and `onOpenSourceManager` from props interface
|
|
||||||
- Remove the D20 button (lines 53-62)
|
|
||||||
- Remove the Library button (lines 63-72)
|
|
||||||
- Remove the inner `gap-0` div wrapper (lines 52, 80) since only the ConfirmButton remains
|
|
||||||
- Remove unused imports: `Library` from lucide-react, `D20Icon`
|
|
||||||
- Adjust layout: ConfirmButton + Next button grouped with `gap-3`
|
|
||||||
|
|
||||||
Result:
|
|
||||||
```tsx
|
|
||||||
interface TurnNavigationProps {
|
|
||||||
encounter: Encounter;
|
|
||||||
onAdvanceTurn: () => void;
|
|
||||||
onRetreatTurn: () => void;
|
|
||||||
onClearEncounter: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout becomes:
|
|
||||||
// [ Prev ] | [ R1 Name ] | [ Trash ] [ Next ]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 2. Update TurnNavigation usage in App.tsx
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- Remove `onRollAllInitiative` and `onOpenSourceManager` props from the `<TurnNavigation>` call (lines 256-257)
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes (typecheck catches removed props, lint catches unused imports)
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] Top bar shows only: Prev, round badge + name, trash, Next
|
|
||||||
- [ ] Prev/Next/Clear buttons still work as before
|
|
||||||
- [ ] Top bar animation (slide in/out) unchanged
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Create Overflow Menu Component
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Build a reusable overflow menu (kebab menu) component with click-outside and Escape handling, following the same patterns as ConfirmButton and the existing viewer dropdown.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Create OverflowMenu component
|
|
||||||
**File**: `apps/web/src/components/ui/overflow-menu.tsx` (new file)
|
|
||||||
**Changes**: Create a dropdown menu triggered by an EllipsisVertical icon button. Features:
|
|
||||||
- Toggle open/close on button click
|
|
||||||
- Close on click outside (document mousedown listener, same pattern as confirm-button.tsx:44-67)
|
|
||||||
- Close on Escape key
|
|
||||||
- Renders above the trigger (bottom-full positioning, same as action-bar suggestion dropdown)
|
|
||||||
- Each item: icon + label, full-width clickable row
|
|
||||||
- Clicking an item calls its action and closes the menu
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { EllipsisVertical } from "lucide-react";
|
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
import { Button } from "./button";
|
|
||||||
|
|
||||||
export interface OverflowMenuItem {
|
|
||||||
readonly icon: ReactNode;
|
|
||||||
readonly label: string;
|
|
||||||
readonly onClick: () => void;
|
|
||||||
readonly disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverflowMenuProps {
|
|
||||||
readonly items: readonly OverflowMenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverflowMenu({ items }: OverflowMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function handleMouseDown(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleMouseDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-muted-foreground hover:text-hover-neutral"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
aria-label="More actions"
|
|
||||||
title="More actions"
|
|
||||||
>
|
|
||||||
<EllipsisVertical className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute bottom-full right-0 z-50 mb-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg">
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.label}
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
disabled={item.disabled}
|
|
||||||
onClick={() => {
|
|
||||||
item.onClick();
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes (new file compiles, no unused exports yet — will be used in phase 3)
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] N/A — component not yet wired into the UI
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Rework ActionBar
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Replace the icon button cluster with: (1) an Eye toggle on the input that switches between add mode and browse mode, (2) a conditional Roll All Initiative button, and (3) the overflow menu for infrequent actions.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Update ActionBarProps
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Add new props, keep existing ones needed for overflow menu items:
|
|
||||||
```tsx
|
|
||||||
interface ActionBarProps {
|
|
||||||
// ... existing props stay ...
|
|
||||||
onRollAllInitiative?: () => void; // new — moved from top bar
|
|
||||||
showRollAllInitiative?: boolean; // new — conditional visibility
|
|
||||||
onOpenSourceManager?: () => void; // new — moved from top bar
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 2. Add browse mode state
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Replace the separate viewer state (viewerOpen, viewerQuery, viewerResults, viewerIndex, viewerRef, viewerInputRef — lines 66-71) with a single `browseMode` boolean:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const [browseMode, setBrowseMode] = useState(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove all viewer-specific state variables and handlers:
|
|
||||||
- `viewerOpen`, `viewerQuery`, `viewerResults`, `viewerIndex` (lines 66-69)
|
|
||||||
- `viewerRef`, `viewerInputRef` (lines 70-71)
|
|
||||||
- `openViewer`, `closeViewer` (lines 189-202)
|
|
||||||
- `handleViewerQueryChange`, `handleViewerSelect`, `handleViewerKeyDown` (lines 204-236)
|
|
||||||
- The viewer click-outside effect (lines 239-248)
|
|
||||||
|
|
||||||
#### [x] 3. Rework the input area with Eye toggle
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Add an Eye icon button inside the input wrapper that toggles browse mode. When browse mode is active:
|
|
||||||
- Placeholder changes to "Search stat blocks..."
|
|
||||||
- Typing calls `bestiarySearch` but selecting a result calls `onViewStatBlock` instead of queuing/adding
|
|
||||||
- The suggestion dropdown shows results but clicking opens stat block panel instead of adding
|
|
||||||
- Add button and custom fields (Init/AC/MaxHP) are hidden
|
|
||||||
- D20 button is hidden
|
|
||||||
|
|
||||||
When toggling browse mode off, clear the input and suggestions.
|
|
||||||
|
|
||||||
The Eye icon sits to the right of the input inside the `relative flex-1` wrapper:
|
|
||||||
```tsx
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={nameInput}
|
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
|
||||||
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
|
||||||
placeholder={browseMode ? "Search stat blocks..." : "+ Add combatants"}
|
|
||||||
className="max-w-xs pr-8"
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
/>
|
|
||||||
{bestiaryLoaded && onViewStatBlock && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
|
||||||
browseMode && "text-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setBrowseMode((m) => !m);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setPcMatches([]);
|
|
||||||
setQueued(null);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
}}
|
|
||||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
||||||
aria-label={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* suggestion dropdown — behavior changes based on browseMode */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Import `cn` from `../../lib/utils` (already used by other components).
|
|
||||||
|
|
||||||
#### [x] 4. Update suggestion dropdown for browse mode
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: In browse mode, the suggestion dropdown behaves differently:
|
|
||||||
- No "Add as custom" row at the top
|
|
||||||
- No player character matches section
|
|
||||||
- No queuing (plus/minus/confirm) — clicking a result calls `onViewStatBlock` and exits browse mode
|
|
||||||
- Keyboard Enter on a highlighted result calls `onViewStatBlock` and exits browse mode
|
|
||||||
|
|
||||||
Add a `handleBrowseKeyDown` handler:
|
|
||||||
```tsx
|
|
||||||
const handleBrowseKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setBrowseMode(false);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (suggestions.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
|
|
||||||
} else if (e.key === "Enter" && suggestionIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
onViewStatBlock?.(suggestions[suggestionIndex]);
|
|
||||||
setBrowseMode(false);
|
|
||||||
setNameInput("");
|
|
||||||
setSuggestions([]);
|
|
||||||
setSuggestionIndex(-1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
In the suggestion dropdown JSX, conditionally render based on `browseMode`:
|
|
||||||
- Browse mode: simple list of creature results, click → `onViewStatBlock` + exit browse mode
|
|
||||||
- Add mode: existing behavior (custom row, PC matches, queuing)
|
|
||||||
|
|
||||||
#### [x] 5. Replace icon button cluster with D20 + overflow menu
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**: Replace the `div.flex.items-center.gap-0` block (lines 443-529) containing Users, Eye, and Import buttons with:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
{!browseMode && (
|
|
||||||
<>
|
|
||||||
<Button type="submit" size="sm">
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
{showRollAllInitiative && onRollAllInitiative && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
|
||||||
onClick={onRollAllInitiative}
|
|
||||||
title="Roll all initiative"
|
|
||||||
aria-label="Roll all initiative"
|
|
||||||
>
|
|
||||||
<D20Icon className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<OverflowMenu items={overflowItems} />
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the `overflowItems` array from props:
|
|
||||||
```tsx
|
|
||||||
const overflowItems: OverflowMenuItem[] = [];
|
|
||||||
if (onManagePlayers) {
|
|
||||||
overflowItems.push({
|
|
||||||
icon: <Users className="h-4 w-4" />,
|
|
||||||
label: "Player Characters",
|
|
||||||
onClick: onManagePlayers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (onOpenSourceManager) {
|
|
||||||
overflowItems.push({
|
|
||||||
icon: <Library className="h-4 w-4" />,
|
|
||||||
label: "Manage Sources",
|
|
||||||
onClick: onOpenSourceManager,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (bestiaryLoaded && onBulkImport) {
|
|
||||||
overflowItems.push({
|
|
||||||
icon: <Import className="h-4 w-4" />,
|
|
||||||
label: "Bulk Import",
|
|
||||||
onClick: onBulkImport,
|
|
||||||
disabled: bulkImportDisabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 6. Clean up imports
|
|
||||||
**File**: `apps/web/src/components/action-bar.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- Add imports: `D20Icon`, `OverflowMenu` + `OverflowMenuItem`, `Library` from lucide-react, `cn` from utils
|
|
||||||
- Remove imports that are no longer needed after removing the standalone viewer: check which of `Eye`, `Import`, `Users` are still used (Eye stays for the toggle, Users and Import stay for overflow item icons, Library is new)
|
|
||||||
- The `Check`, `Minus`, `Plus` imports stay (used in queuing UI)
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] Bottom bar shows: input with Eye toggle, Add button, (conditional D20), kebab menu
|
|
||||||
- [ ] Eye toggle switches input between "add" and "browse" modes
|
|
||||||
- [ ] In browse mode: typing shows bestiary results, clicking one opens stat block panel, exits browse mode
|
|
||||||
- [ ] In browse mode: Add button and D20 are hidden, overflow menu stays visible
|
|
||||||
- [ ] In add mode: existing behavior works (search, queue, custom fields, PC matches)
|
|
||||||
- [ ] Overflow menu opens/closes on click, closes on Escape and click-outside
|
|
||||||
- [ ] Overflow menu items (Player Characters, Manage Sources, Bulk Import) trigger correct actions
|
|
||||||
- [ ] D20 button appears only when bestiary combatants lack initiative, disappears when all have values
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Wire Up App.tsx
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Pass the new props to ActionBar — roll all initiative handler, conditional visibility flag, and source manager toggle. Remove the now-unused `onOpenSourceManager` callback from the TurnNavigation call (already removed in Phase 1) and ensure sourceManagerOpen toggle is routed through the overflow menu.
|
|
||||||
|
|
||||||
### Changes Required:
|
|
||||||
|
|
||||||
#### [x] 1. Compute showRollAllInitiative flag
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**: Add a derived boolean that checks if any combatant with a `creatureId` lacks an `initiative` value:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const showRollAllInitiative = encounter.combatants.some(
|
|
||||||
(c) => c.creatureId != null && c.initiative == null,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Place this near `const isEmpty = ...` (line 241).
|
|
||||||
|
|
||||||
#### [x] 2. Pass new props to both ActionBar instances
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**: Add to both `<ActionBar>` calls (empty state at ~line 269 and populated state at ~line 328):
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<ActionBar
|
|
||||||
// ... existing props ...
|
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### [x] 3. Remove stale code
|
|
||||||
**File**: `apps/web/src/App.tsx`
|
|
||||||
**Changes**:
|
|
||||||
- The `onRollAllInitiative` and `onOpenSourceManager` props were already removed from `<TurnNavigation>` in Phase 1 — verify no references remain
|
|
||||||
- Verify `sourceManagerOpen` state and the `<SourceManager>` rendering block (lines 287-291) still work correctly — the SourceManager inline panel is still toggled by the same state, just from a different trigger location
|
|
||||||
|
|
||||||
### Success Criteria:
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
- [x] `pnpm check` passes
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
- [ ] Top bar: only Prev, round badge + name, trash, Next — no D20 or Library buttons
|
|
||||||
- [ ] Bottom bar: input with Eye toggle, Add, conditional D20, overflow menu
|
|
||||||
- [ ] Roll All Initiative (D20 in bottom bar): visible when bestiary creatures lack initiative, hidden after rolling
|
|
||||||
- [ ] Overflow → Player Characters: opens player management modal
|
|
||||||
- [ ] Overflow → Manage Sources: toggles source manager panel (same as before, just different trigger)
|
|
||||||
- [ ] Overflow → Bulk Import: opens bulk import mode
|
|
||||||
- [ ] Browse mode (Eye toggle): search stat blocks without adding, selecting opens panel
|
|
||||||
- [ ] Clear encounter (top bar trash): still works with two-click confirmation
|
|
||||||
- [ ] All animations (bar transitions) unchanged
|
|
||||||
- [ ] Empty state: ActionBar centered with all functionality accessible
|
|
||||||
|
|
||||||
**Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation from the human before proceeding to the next phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests:
|
|
||||||
- No domain/application changes — existing tests should pass unchanged
|
|
||||||
- `pnpm check` covers typecheck + lint + existing test suite
|
|
||||||
|
|
||||||
### Manual Testing Steps:
|
|
||||||
1. Start with empty encounter — verify ActionBar is centered with Eye toggle and overflow menu
|
|
||||||
2. Add a bestiary creature — verify D20 appears in bottom bar, top bar slides in with just 4 elements
|
|
||||||
3. Click D20 → initiative rolls → D20 disappears from bottom bar
|
|
||||||
4. Toggle Eye → input switches to browse mode → search and select → stat block opens → exits browse mode
|
|
||||||
5. Open overflow menu → click each item → verify correct modal/panel opens
|
|
||||||
6. Click trash in top bar → confirm → encounter clears, back to empty state
|
|
||||||
7. Add custom creature (no creatureId) → D20 should not appear (no bestiary creatures)
|
|
||||||
8. Add mix of custom + bestiary creatures → D20 visible → roll all → D20 hidden
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
None — this is a pure UI reorganization with no new data fetching, state management changes, or rendering overhead. The `showRollAllInitiative` computation is a simple `.some()` over the combatant array, which is negligible.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Research: `docs/agents/research/2026-03-13-action-bars-and-buttons.md`
|
|
||||||
- Top bar: `apps/web/src/components/turn-navigation.tsx`
|
|
||||||
- Bottom bar: `apps/web/src/components/action-bar.tsx`
|
|
||||||
- App layout: `apps/web/src/App.tsx`
|
|
||||||
- Button: `apps/web/src/components/ui/button.tsx`
|
|
||||||
- ConfirmButton: `apps/web/src/components/ui/confirm-button.tsx`
|
|
||||||
- Roll all use case: `packages/application/src/roll-all-initiative-use-case.ts`
|
|
||||||
- Combatant type: `packages/domain/src/types.ts`
|
|
||||||
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
188
docs/agents/research/2026-03-13-css-classes-buttons-hover.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-13T15:35:07.699570+00:00"
|
||||||
|
git_commit: bd398080008349b47726d0016f4b03587f453833
|
||||||
|
branch: main
|
||||||
|
topic: "CSS class usage, button categorization, and hover effects across all components"
|
||||||
|
tags: [research, codebase, css, tailwind, buttons, hover, ui]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: CSS Class Usage, Button Categorization, and Hover Effects
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
How are CSS classes used across all components? How are buttons categorized — are there primary and secondary buttons? What hover effects exist, and are they unified?
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project uses **Tailwind CSS v4** with a custom dark theme defined in `index.css` via `@theme`. All class merging goes through a `cn()` utility (clsx + tailwind-merge). Buttons are built on a shared `Button` component using **class-variance-authority (CVA)** with three variants: **default** (primary), **outline**, and **ghost**. Hover effects are partially unified through semantic color tokens (`hover-neutral`, `hover-action`, `hover-destructive`) defined in the theme, but several components use **one-off hardcoded hover colors** that bypass the token system.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### Theme System (`index.css`)
|
||||||
|
|
||||||
|
All colors are defined as CSS custom properties via Tailwind v4's `@theme` directive (`index.css:3-26`):
|
||||||
|
|
||||||
|
| Token | Value | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-background` | `#0f172a` | Page background |
|
||||||
|
| `--color-foreground` | `#e2e8f0` | Default text |
|
||||||
|
| `--color-muted` | `#64748b` | Subdued elements |
|
||||||
|
| `--color-muted-foreground` | `#94a3b8` | Secondary text |
|
||||||
|
| `--color-card` | `#1e293b` | Card/panel surfaces |
|
||||||
|
| `--color-border` | `#334155` | Borders |
|
||||||
|
| `--color-primary` | `#3b82f6` | Primary actions (blue) |
|
||||||
|
| `--color-accent` | `#3b82f6` | Accent (same as primary) |
|
||||||
|
| `--color-destructive` | `#ef4444` | Destructive actions (red) |
|
||||||
|
|
||||||
|
**Hover tokens** (semantic layer for hover states):
|
||||||
|
|
||||||
|
| Token | Resolves to | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover-neutral` | `primary` (blue) | Text color on neutral hover |
|
||||||
|
| `hover-action` | `primary` (blue) | Text color on action hover |
|
||||||
|
| `hover-destructive` | `destructive` (red) | Text color on destructive hover |
|
||||||
|
| `hover-neutral-bg` | `card` (slate) | Background on neutral hover |
|
||||||
|
| `hover-action-bg` | `muted` | Background on action hover |
|
||||||
|
| `hover-destructive-bg` | `transparent` | Background on destructive hover |
|
||||||
|
|
||||||
|
### Button Component (`components/ui/button.tsx`)
|
||||||
|
|
||||||
|
Uses CVA with three variants and three sizes:
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
| Variant | Base styles | Hover |
|
||||||
|
|---|---|---|
|
||||||
|
| `default` (primary) | `bg-primary text-primary-foreground` | `hover:bg-primary/90` |
|
||||||
|
| `outline` | `border border-border bg-transparent` | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
| `ghost` | (no background/border) | `hover:bg-hover-neutral-bg hover:text-hover-neutral` |
|
||||||
|
|
||||||
|
**Sizes:**
|
||||||
|
|
||||||
|
| Size | Classes |
|
||||||
|
|---|---|
|
||||||
|
| `default` | `h-9 px-4 py-2` |
|
||||||
|
| `sm` | `h-8 px-3 text-xs` |
|
||||||
|
| `icon` | `h-8 w-8` |
|
||||||
|
|
||||||
|
All variants share: `rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50`.
|
||||||
|
|
||||||
|
There is **no "secondary" variant** — the outline variant is the closest equivalent.
|
||||||
|
|
||||||
|
### Composite Button Components
|
||||||
|
|
||||||
|
**ConfirmButton** (`components/ui/confirm-button.tsx`):
|
||||||
|
- Wraps `Button variant="ghost" size="icon"`
|
||||||
|
- Default state: `hover:text-hover-destructive` (uses token)
|
||||||
|
- Confirming state: `bg-destructive text-primary-foreground animate-confirm-pulse hover:bg-destructive hover:text-primary-foreground`
|
||||||
|
|
||||||
|
**OverflowMenu** (`components/ui/overflow-menu.tsx`):
|
||||||
|
- Trigger: `Button variant="ghost" size="icon"` with `text-muted-foreground hover:text-hover-neutral`
|
||||||
|
- Menu items: raw `<button>` elements with `hover:bg-muted/20` (**not using the token system**)
|
||||||
|
|
||||||
|
### Button Usage Across Components
|
||||||
|
|
||||||
|
| Component | Button type | Variant/Style |
|
||||||
|
|---|---|---|
|
||||||
|
| `action-bar.tsx:556` | `<Button type="submit">` | default (primary) — "Add" |
|
||||||
|
| `action-bar.tsx:561` | `<Button type="button">` | default (primary) — "Roll all" |
|
||||||
|
| `turn-navigation.tsx:25,54` | `<Button size="icon">` | default — prev/next turn |
|
||||||
|
| `turn-navigation.tsx:47` | `<ConfirmButton>` | ghost+destructive — clear encounter |
|
||||||
|
| `source-fetch-prompt.tsx:91` | `<Button size="sm">` | default — "Load" |
|
||||||
|
| `source-fetch-prompt.tsx:106` | `<Button size="sm" variant="outline">` | outline — "Upload file" |
|
||||||
|
| `bulk-import-prompt.tsx:31,45,106` | `<Button size="sm">` | default — "Done"/"Load All" |
|
||||||
|
| `source-manager.tsx:50` | `<Button size="sm" variant="outline">` | outline — "Clear all" |
|
||||||
|
| `hp-adjust-popover.tsx:112` | `<Button variant="ghost" size="icon">` | ghost + custom red — damage |
|
||||||
|
| `hp-adjust-popover.tsx:124` | `<Button variant="ghost" size="icon">` | ghost + custom green — heal |
|
||||||
|
| `player-management.tsx:67` | `<Button>` | default — "Create first player" |
|
||||||
|
| `player-management.tsx:113` | `<Button variant="ghost">` | ghost — "Add player" |
|
||||||
|
| `create-player-modal.tsx:177` | `<Button variant="ghost">` | ghost — "Cancel" |
|
||||||
|
| `create-player-modal.tsx:180` | `<Button type="submit">` | default — "Save"/"Create" |
|
||||||
|
| `combatant-row.tsx:625` | `<ConfirmButton>` | ghost+destructive — remove combatant |
|
||||||
|
|
||||||
|
**Raw `<button>` elements** (not using the Button component):
|
||||||
|
- `action-bar.tsx` — suggestion items, count increment/decrement, browse toggle, custom add (all inline-styled)
|
||||||
|
- `combatant-row.tsx` — editable name, HP display, AC, initiative, concentration toggle
|
||||||
|
- `stat-block-panel.tsx` — fold/close/pin/unpin buttons
|
||||||
|
- `condition-picker.tsx` — condition items
|
||||||
|
- `condition-tags.tsx` — condition tags, add condition button
|
||||||
|
- `toast.tsx` — dismiss button
|
||||||
|
- `player-management.tsx` — close modal, edit player
|
||||||
|
- `create-player-modal.tsx` — close modal
|
||||||
|
- `color-palette.tsx` — color swatches
|
||||||
|
- `icon-grid.tsx` — icon options
|
||||||
|
|
||||||
|
### Hover Effects Inventory
|
||||||
|
|
||||||
|
**Using semantic tokens (unified):**
|
||||||
|
|
||||||
|
| Hover class | Meaning | Used in |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-hover-neutral-bg` | Neutral background highlight | button.tsx (outline/ghost), action-bar.tsx, condition-picker.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-neutral` | Text turns primary blue | button.tsx (outline/ghost), action-bar.tsx, combatant-row.tsx, stat-block-panel.tsx, ac-shield.tsx, toast.tsx, overflow-menu.tsx, condition-tags.tsx |
|
||||||
|
| `hover:text-hover-action` | Action text (same as neutral) | action-bar.tsx (overflow trigger) |
|
||||||
|
| `hover:text-hover-destructive` | Destructive text turns red | confirm-button.tsx, source-manager.tsx |
|
||||||
|
| `hover:bg-hover-destructive-bg` | Destructive background (transparent) | source-manager.tsx |
|
||||||
|
|
||||||
|
**One-off / hardcoded hover colors (NOT using tokens):**
|
||||||
|
|
||||||
|
| Hover class | Used in | Context |
|
||||||
|
|---|---|---|
|
||||||
|
| `hover:bg-primary/90` | button.tsx (default variant) | Primary button darken |
|
||||||
|
| `hover:bg-accent/20` | action-bar.tsx | Suggestion highlight, custom add |
|
||||||
|
| `hover:bg-accent/40` | action-bar.tsx | Count +/- buttons, confirm queued |
|
||||||
|
| `hover:bg-muted/20` | overflow-menu.tsx | Menu item highlight |
|
||||||
|
| `hover:bg-red-950` | hp-adjust-popover.tsx | Damage button |
|
||||||
|
| `hover:text-red-300` | hp-adjust-popover.tsx | Damage button text |
|
||||||
|
| `hover:bg-emerald-950` | hp-adjust-popover.tsx | Heal button |
|
||||||
|
| `hover:text-emerald-300` | hp-adjust-popover.tsx | Heal button text |
|
||||||
|
| `hover:text-foreground` | player-management.tsx, create-player-modal.tsx, icon-grid.tsx | Close/edit buttons |
|
||||||
|
| `hover:bg-background/50` | player-management.tsx | Player row hover |
|
||||||
|
| `hover:bg-card` | icon-grid.tsx | Icon option hover |
|
||||||
|
| `hover:border-hover-destructive` | source-manager.tsx | Clear all button border |
|
||||||
|
| `hover:scale-110` | color-palette.tsx | Color swatch enlarge |
|
||||||
|
| `hover:bg-destructive` | confirm-button.tsx (confirming state) | Maintain red bg on hover |
|
||||||
|
| `hover:text-primary-foreground` | confirm-button.tsx (confirming state) | Maintain white text on hover |
|
||||||
|
|
||||||
|
### Hover unification assessment
|
||||||
|
|
||||||
|
The hover token system (`hover-neutral`, `hover-action`, `hover-destructive`) provides a consistent pattern for the most common interactions. The `Button` component's outline and ghost variants use these tokens, and many inline buttons in action-bar, combatant-row, stat-block-panel, and condition components also use them.
|
||||||
|
|
||||||
|
However, there are notable gaps:
|
||||||
|
1. **HP adjust popover** uses hardcoded red/green colors (`red-950`, `emerald-950`) instead of tokens
|
||||||
|
2. **Overflow menu items** use `hover:bg-muted/20` instead of `hover:bg-hover-neutral-bg`
|
||||||
|
3. **Player management modals** use `hover:text-foreground` and `hover:bg-background/50` instead of the semantic tokens
|
||||||
|
4. **Action-bar suggestion items** use `hover:bg-accent/20` and `hover:bg-accent/40` — accent-specific patterns not in the token system
|
||||||
|
5. **Icon grid** and **color palette** use their own hover patterns (`hover:bg-card`, `hover:scale-110`)
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `apps/web/src/index.css:3-26` — Theme color definitions including hover tokens
|
||||||
|
- `apps/web/src/components/ui/button.tsx:1-38` — Button component with CVA variants
|
||||||
|
- `apps/web/src/components/ui/confirm-button.tsx:93-115` — ConfirmButton with destructive hover states
|
||||||
|
- `apps/web/src/components/ui/overflow-menu.tsx:38-72` — OverflowMenu with non-token hover
|
||||||
|
- `apps/web/src/components/hp-adjust-popover.tsx:117-129` — Hardcoded red/green hover colors
|
||||||
|
- `apps/web/src/components/action-bar.tsx:80-188` — Mixed token and accent-based hovers
|
||||||
|
- `apps/web/src/components/combatant-row.tsx:147-629` — Inline buttons with token hovers
|
||||||
|
- `apps/web/src/components/player-management.tsx:58-98` — Non-token hover patterns
|
||||||
|
- `apps/web/src/components/stat-block-panel.tsx:55-109` — Consistent token usage
|
||||||
|
- `apps/web/src/lib/utils.ts:1-5` — `cn()` utility (clsx + twMerge)
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
The styling architecture follows this pattern:
|
||||||
|
|
||||||
|
1. **Theme layer**: `index.css` defines all color tokens via `@theme`, including semantic hover tokens
|
||||||
|
2. **Component layer**: `Button` (CVA) provides the shared button abstraction with three variants
|
||||||
|
3. **Composite layer**: `ConfirmButton` and `OverflowMenu` wrap `Button` with additional behavior
|
||||||
|
4. **Usage layer**: Components use either `Button` component or raw `<button>` elements with inline Tailwind classes
|
||||||
|
|
||||||
|
The `cn()` utility from `lib/utils.ts` is used in 9+ components for conditional class merging.
|
||||||
|
|
||||||
|
Custom animations are defined in `index.css` via `@keyframes` + `@utility` pairs: slide-in-right, confirm-pulse, settle-to-bottom, rise-to-center, slide-down-in, slide-up-out, concentration-pulse.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. The `hover-action` and `hover-action-bg` tokens are defined but rarely used — `hover-action` appears only once in `action-bar.tsx:565`. Is this intentional or an incomplete migration?
|
||||||
|
2. The `accent` color (`#3b82f6`) is identical to `primary` — are they intended to diverge in the future, or is this redundancy?
|
||||||
|
3. Should the hardcoded HP adjust colors (red/emerald) be promoted to theme tokens (e.g., `hover-damage`, `hover-heal`)?
|
||||||
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
256
docs/agents/research/2026-03-24-rules-edition-settings-modal.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
---
|
||||||
|
date: "2026-03-24T10:22:04.341906+00:00"
|
||||||
|
git_commit: cfd4aef724487a681e425cedfa08f3e89255f91a
|
||||||
|
branch: main
|
||||||
|
topic: "Rules edition setting for condition tooltips + settings modal"
|
||||||
|
tags: [research, codebase, conditions, settings, theme, modal, issue-12]
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Research: Rules Edition Setting for Condition Tooltips + Settings Modal
|
||||||
|
|
||||||
|
## Research Question
|
||||||
|
|
||||||
|
Map the codebase for implementing issue #12: a rules edition setting (5e 2014 / 5.5e 2024) that controls condition tooltip descriptions, delivered via a new settings modal that also absorbs the existing theme toggle. Target spec: `specs/003-combatant-state/spec.md` (stories CC-3, CC-8, FR-095–FR-102).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The implementation touches five areas: (1) the domain condition definitions, (2) the tooltip rendering in two web components, (3) the kebab overflow menu in the action bar, (4) the theme system (hook + context), and (5) a new settings modal following existing `<dialog>` patterns. The localStorage persistence pattern is well-established with a consistent `"initiative:<key>"` convention. The context provider tree in `main.tsx` is the integration point for a new settings context.
|
||||||
|
|
||||||
|
## Detailed Findings
|
||||||
|
|
||||||
|
### 1. Condition Definitions and Tooltip Data Flow
|
||||||
|
|
||||||
|
**Domain layer** — `packages/domain/src/conditions.ts`
|
||||||
|
|
||||||
|
The `ConditionDefinition` interface (line 18) carries a single `description: string` field. The `CONDITION_DEFINITIONS` array (line 26) holds all 15 conditions as `readonly` objects with `id`, `label`, `description`, `iconName`, and `color`. This is the single source of truth for condition data.
|
||||||
|
|
||||||
|
Exported types: `ConditionId` (union of 15 string literals), `ConditionDefinition`, `CONDITION_DEFINITIONS`, `VALID_CONDITION_IDS`.
|
||||||
|
|
||||||
|
**Web layer — condition-tags.tsx** (`apps/web/src/components/condition-tags.tsx`)
|
||||||
|
|
||||||
|
- Line 69: Looks up definition via `CONDITION_DEFINITIONS.find((d) => d.id === condId)`
|
||||||
|
- Line 75: Passes tooltip content as `` `${def.label}: ${def.description}` `` — combines label + description into a single string
|
||||||
|
- This is the tooltip shown when hovering active condition icons in the combatant row
|
||||||
|
|
||||||
|
**Web layer — condition-picker.tsx** (`apps/web/src/components/condition-picker.tsx`)
|
||||||
|
|
||||||
|
- Line 119: Iterates `CONDITION_DEFINITIONS.map(...)` directly
|
||||||
|
- Line 125: Passes `content={def.description}` to Tooltip — description only, no label prefix
|
||||||
|
- This is the tooltip shown when hovering conditions in the dropdown picker
|
||||||
|
|
||||||
|
**Key observation:** Both components read `def.description` directly from the imported domain constant. To make descriptions edition-aware, either (a) the domain type needs dual descriptions and consumers select by edition, or (b) a higher-level hook resolves the correct description before passing to components.
|
||||||
|
|
||||||
|
### 2. Tooltip Component
|
||||||
|
|
||||||
|
**File:** `apps/web/src/components/ui/tooltip.tsx`
|
||||||
|
|
||||||
|
- Props: `content: string`, `children: ReactNode`, optional `className`
|
||||||
|
- Positioning: Uses `getBoundingClientRect()` to place tooltip 4px above the trigger element, centered horizontally
|
||||||
|
- Rendered via `createPortal` to `document.body` at z-index 60
|
||||||
|
- Max width: `max-w-64` (256px / 16rem) with `text-xs leading-snug`
|
||||||
|
- Text wraps naturally within the max-width constraint — no explicit truncation
|
||||||
|
- The tooltip accepts only `string` content, not ReactNode
|
||||||
|
|
||||||
|
The current descriptions are short (1-2 sentences). The 5e (2014) exhaustion description will be longer (6-level table as text), which may benefit from the existing 256px wrapping. No changes to the tooltip component itself should be needed.
|
||||||
|
|
||||||
|
### 3. Kebab Menu (Overflow Menu)
|
||||||
|
|
||||||
|
**OverflowMenu component** — `apps/web/src/components/ui/overflow-menu.tsx`
|
||||||
|
|
||||||
|
- Generic menu component accepting `items: readonly OverflowMenuItem[]`
|
||||||
|
- Each item has: `icon: ReactNode`, `label: string`, `onClick: () => void`, optional `disabled` and `keepOpen`
|
||||||
|
- Opens upward (`bottom-full`) from the kebab button, right-aligned
|
||||||
|
- Close on click-outside (mousedown) and Escape key
|
||||||
|
|
||||||
|
**ActionBar integration** — `apps/web/src/components/action-bar.tsx`
|
||||||
|
|
||||||
|
- `buildOverflowItems()` function (line 231) constructs the menu items array
|
||||||
|
- Current items in order:
|
||||||
|
1. **Player Characters** (Users icon) — calls `opts.onManagePlayers`
|
||||||
|
2. **Manage Sources** (Library icon) — calls `opts.onOpenSourceManager`
|
||||||
|
3. **Import All Sources** (Import icon) — conditional on bestiary loaded
|
||||||
|
4. **Theme cycle** (Monitor/Sun/Moon icon) — calls `opts.onCycleTheme`, uses `keepOpen: true`
|
||||||
|
- Theme constants at lines 219-229: `THEME_ICONS` and `THEME_LABELS` maps
|
||||||
|
- Line 293: `useThemeContext()` provides `preference` and `cycleTheme`
|
||||||
|
- Line 529-537: Overflow items built with all options passed in
|
||||||
|
|
||||||
|
**To add a "Settings" item:** Add a new entry to `buildOverflowItems()` and remove the theme cycle entry. The new item would call a callback to open the settings modal.
|
||||||
|
|
||||||
|
### 4. Theme System
|
||||||
|
|
||||||
|
**Hook** — `apps/web/src/hooks/use-theme.ts`
|
||||||
|
|
||||||
|
- Module-level state: `currentPreference` initialized from localStorage on import (line 9)
|
||||||
|
- `ThemePreference` type: `"system" | "light" | "dark"`
|
||||||
|
- `ResolvedTheme` type: `"light" | "dark"`
|
||||||
|
- Storage key: `"initiative:theme"` (line 6)
|
||||||
|
- `loadPreference()` — reads localStorage, defaults to `"system"` (lines 11-19)
|
||||||
|
- `savePreference()` — writes to localStorage, silent on error (lines 21-27)
|
||||||
|
- `resolve()` — resolves "system" via `matchMedia("(prefers-color-scheme: light)")` (lines 29-38)
|
||||||
|
- `applyTheme()` — sets `document.documentElement.dataset.theme` (lines 40-42)
|
||||||
|
- Uses `useSyncExternalStore` for React integration (line 77)
|
||||||
|
- Exposes: `preference`, `resolved`, `setPreference`, `cycleTheme`
|
||||||
|
- OS preference change listener updates theme when preference is "system" (lines 54-63)
|
||||||
|
|
||||||
|
**Context** — `apps/web/src/contexts/theme-context.tsx`
|
||||||
|
|
||||||
|
- Simple wrapper: `ThemeProvider` calls `useTheme()` and provides via React context
|
||||||
|
- `useThemeContext()` hook for consumers (line 15)
|
||||||
|
|
||||||
|
**For settings modal:** The theme system already exposes `setPreference(pref)` which is exactly what the settings modal needs — direct selection instead of cycling.
|
||||||
|
|
||||||
|
### 5. localStorage Persistence Patterns
|
||||||
|
|
||||||
|
All storage follows a consistent pattern:
|
||||||
|
|
||||||
|
| Key | Content | Format |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `initiative:encounter` | Full encounter state | JSON object |
|
||||||
|
| `initiative:player-characters` | Player character array | JSON array |
|
||||||
|
| `initiative:theme` | Theme preference | Plain string |
|
||||||
|
|
||||||
|
**Common patterns:**
|
||||||
|
- Read: `try { localStorage.getItem(key) } catch { return default }`
|
||||||
|
- Write: `try { localStorage.setItem(key, value) } catch { /* silent */ }`
|
||||||
|
- Validation on read: type-check, range-check, reject invalid, return fallback
|
||||||
|
- Bootstrap: `useState(initializeFunction)` where initializer loads from storage
|
||||||
|
- Persistence: `useEffect([data], () => saveToStorage(data))`
|
||||||
|
|
||||||
|
**For rules edition:** Key would be `"initiative:rules-edition"`. Value would be a plain string (`"5e"` or `"5.5e"`), matching the theme pattern (simple string, not JSON). Default: `"5.5e"`.
|
||||||
|
|
||||||
|
### 6. Modal Patterns
|
||||||
|
|
||||||
|
Two modal implementations exist, both using HTML `<dialog>`:
|
||||||
|
|
||||||
|
**PlayerManagement** (`apps/web/src/components/player-management.tsx`)
|
||||||
|
- Controlled by `open` prop
|
||||||
|
- `useEffect` calls `dialog.showModal()` / `dialog.close()` based on `open`
|
||||||
|
- Cancel event (Escape) prevented and routed to `onClose`
|
||||||
|
- Backdrop click (mousedown on dialog element itself) routes to `onClose`
|
||||||
|
- Styling: `card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50`
|
||||||
|
- Header: title + X close button (ghost variant, muted foreground)
|
||||||
|
|
||||||
|
**CreatePlayerModal** (`apps/web/src/components/create-player-modal.tsx`)
|
||||||
|
- Same `<dialog>` pattern with identical open/close/cancel/backdrop handling
|
||||||
|
- Has form submission with validation and error display
|
||||||
|
- Same styling as PlayerManagement
|
||||||
|
|
||||||
|
**Shared dialog pattern (extract from both):**
|
||||||
|
```tsx
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
else if (!open && dialog.open) dialog.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||||
|
const handleBackdropClick = (e: MouseEvent) => { if (e.target === dialog) onClose(); };
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||||
|
return () => { /* cleanup */ };
|
||||||
|
}, [onClose]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Context Provider Tree
|
||||||
|
|
||||||
|
**File:** `apps/web/src/main.tsx`
|
||||||
|
|
||||||
|
Provider nesting order (outermost first):
|
||||||
|
1. `ThemeProvider`
|
||||||
|
2. `EncounterProvider`
|
||||||
|
3. `BestiaryProvider`
|
||||||
|
4. `PlayerCharactersProvider`
|
||||||
|
5. `BulkImportProvider`
|
||||||
|
6. `SidePanelProvider`
|
||||||
|
7. `InitiativeRollsProvider`
|
||||||
|
|
||||||
|
A new `SettingsProvider` (or `RulesEditionProvider`) would slot in early — before any component that reads condition descriptions. Since `ThemeProvider` is already the outermost, and the settings modal manages both theme and rules edition, one option is a `SettingsProvider` that wraps or replaces `ThemeProvider`.
|
||||||
|
|
||||||
|
### 8. 5e vs 5.5e Condition Text Differences
|
||||||
|
|
||||||
|
Based on research, here are the conditions with meaningful mechanical differences between editions. Conditions not listed are functionally identical across editions.
|
||||||
|
|
||||||
|
**Major changes:**
|
||||||
|
|
||||||
|
| Condition | 5e (2014) | 5.5e (2024) — current text |
|
||||||
|
|---|---|---|
|
||||||
|
| **Exhaustion** | 6 escalating levels: L1 disadvantage on ability checks, L2 speed halved, L3 disadvantage on attacks/saves, L4 HP max halved, L5 speed 0, L6 death | −level from d20 tests and spell save DCs. Speed reduced by 5 ft. × level. Death at 10 levels. (current) |
|
||||||
|
| **Grappled** | Speed 0. Ends if grappler incapacitated or moved out of reach. | Speed 0, can't benefit from speed bonuses. Ends if grappler incapacitated or moved out of reach. (current — but 2024 also adds disadvantage on attacks vs non-grappler) |
|
||||||
|
| **Invisible** | Can't be seen without magic/special sense. Heavily obscured. Advantage on attacks; disadvantage on attacks against. | 2024 broadened: can be gained from Hide action; grants Surprise (advantage on initiative), Concealed (unaffected by sight effects), attacks advantage/disadvantage. (current text is closer to 2014) |
|
||||||
|
| **Stunned** | Incapacitated. Can't move. Speak falteringly. Auto-fail Str/Dex saves. Attacks against have advantage. | 2024: same but can still move (controversial). (current text says "Can't move" — matches 2014) |
|
||||||
|
|
||||||
|
**Moderate changes:**
|
||||||
|
|
||||||
|
| Condition | 5e (2014) | 5.5e (2024) |
|
||||||
|
|---|---|---|
|
||||||
|
| **Incapacitated** | Can't take actions or reactions. | Can't take actions, bonus actions, or reactions. Speed 0. Auto-fail Str/Dex saves. Attacks against have advantage. Concentration broken. (current is partial 2024) |
|
||||||
|
| **Petrified** | Unaware of surroundings. | Aware of surroundings (2024 change). Current text doesn't mention awareness. |
|
||||||
|
| **Poisoned** | Disadvantage on attacks and ability checks. | Same, but 2024 consolidates disease into poisoned. |
|
||||||
|
|
||||||
|
**Minor/identical:**
|
||||||
|
|
||||||
|
Blinded, Charmed ("harmful" → "damaging"), Deafened, Frightened, Paralyzed, Prone, Restrained, Unconscious — functionally identical between editions.
|
||||||
|
|
||||||
|
**Note on current descriptions:** The existing `conditions.ts` descriptions are a mix — exhaustion is clearly 2024, but stunned says "Can't move" which matches 2014. A full audit of each description against both editions will be needed during implementation to ensure accuracy.
|
||||||
|
|
||||||
|
## Code References
|
||||||
|
|
||||||
|
- `packages/domain/src/conditions.ts:18-24` — `ConditionDefinition` interface (single `description` field)
|
||||||
|
- `packages/domain/src/conditions.ts:26-145` — `CONDITION_DEFINITIONS` array with current (mixed edition) descriptions
|
||||||
|
- `apps/web/src/components/condition-tags.tsx:75` — Tooltip with `${def.label}: ${def.description}`
|
||||||
|
- `apps/web/src/components/condition-picker.tsx:125` — Tooltip with `def.description`
|
||||||
|
- `apps/web/src/components/ui/tooltip.tsx:1-55` — Tooltip component (string content, 256px max-width)
|
||||||
|
- `apps/web/src/components/ui/overflow-menu.tsx:1-73` — Generic overflow menu
|
||||||
|
- `apps/web/src/components/action-bar.tsx:231-274` — `buildOverflowItems()` (current menu items)
|
||||||
|
- `apps/web/src/components/action-bar.tsx:293` — `useThemeContext()` usage in ActionBar
|
||||||
|
- `apps/web/src/hooks/use-theme.ts:1-98` — Theme hook with localStorage, `useSyncExternalStore`, cycle/set
|
||||||
|
- `apps/web/src/contexts/theme-context.tsx:1-19` — Theme context provider
|
||||||
|
- `apps/web/src/main.tsx:17-35` — Provider nesting order
|
||||||
|
- `apps/web/src/components/player-management.tsx:55-131` — `<dialog>` modal pattern (reference for settings modal)
|
||||||
|
- `apps/web/src/components/create-player-modal.tsx:106-191` — Form-based `<dialog>` modal pattern
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts` — localStorage persistence pattern (read/write/validate)
|
||||||
|
- `apps/web/src/persistence/player-character-storage.ts` — localStorage persistence pattern
|
||||||
|
|
||||||
|
## Architecture Documentation
|
||||||
|
|
||||||
|
### Data Flow: Condition Description → Tooltip
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain: CONDITION_DEFINITIONS[].description (single string)
|
||||||
|
↓ imported by
|
||||||
|
Web: condition-tags.tsx → Tooltip content={`${label}: ${description}`}
|
||||||
|
Web: condition-picker.tsx → Tooltip content={description}
|
||||||
|
↓ rendered by
|
||||||
|
UI: tooltip.tsx → createPortal → fixed-position div (max-w-64)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings/Preference Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
localStorage → use-theme.ts (useSyncExternalStore) → theme-context.tsx → consumers
|
||||||
|
localStorage → encounter-storage.ts → use-encounter.ts (useState) → encounter-context.tsx
|
||||||
|
localStorage → player-character-storage.ts → use-player-characters.ts (useState) → pc-context.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal Triggering Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
ActionBar overflow menu item click
|
||||||
|
→ callback prop (e.g., onManagePlayers)
|
||||||
|
→ App.tsx calls imperative handle (e.g., playerCharacterRef.current.openManagement())
|
||||||
|
→ Section component sets open state
|
||||||
|
→ <dialog>.showModal() via useEffect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Current description accuracy:** The existing descriptions are a mix of 2014 and 2024 text (e.g., exhaustion is 2024, stunned "Can't move" is 2014). Both sets of descriptions need careful authoring against official sources during implementation.
|
||||||
|
2. **Domain type change:** Should `ConditionDefinition` carry `description5e` and `description55e` fields, or should description resolution happen at the application/web layer? The domain-level approach is simpler and keeps the data co-located with condition definitions.
|
||||||
|
3. **Settings context scope:** Should a new `SettingsProvider` manage both rules edition and theme, or should rules edition be its own context? The theme system already has its own well-structured hook/context; combining them may add unnecessary coupling.
|
||||||
@@ -5,6 +5,6 @@
|
|||||||
"entry": ["scripts/*.mjs"]
|
"entry": ["scripts/*.mjs"]
|
||||||
},
|
},
|
||||||
"packages/*": {},
|
"packages/*": {},
|
||||||
"apps/*": {}
|
"apps/web": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -1,14 +1,21 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"undici": ">=7.24.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.88.1",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^2.1.4",
|
||||||
|
"oxlint": "^1.56.0",
|
||||||
|
"oxlint-tsgolint": "^0.17.1",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "lefthook install",
|
"prepare": "lefthook install",
|
||||||
@@ -21,6 +28,10 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && tsc --build && vitest run && jscpd"
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware",
|
||||||
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
packages/application/src/__tests__/helpers.ts
Normal file
54
packages/application/src/__tests__/helpers.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Encounter, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { isDomainError } from "@initiative/domain";
|
||||||
|
import type { EncounterStore, PlayerCharacterStore } from "../ports.js";
|
||||||
|
|
||||||
|
export function requireSaved<T>(value: T | null): T {
|
||||||
|
if (value === null) throw new Error("Expected store.saved to be non-null");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectSuccess<T>(
|
||||||
|
result: T,
|
||||||
|
): asserts result is Exclude<T, { kind: "domain-error" }> {
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got domain error: ${result.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectError(result: unknown): asserts result is {
|
||||||
|
kind: "domain-error";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
} {
|
||||||
|
if (!isDomainError(result)) {
|
||||||
|
throw new Error("Expected domain error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stubEncounterStore(
|
||||||
|
initial: Encounter,
|
||||||
|
): EncounterStore & { saved: Encounter | null } {
|
||||||
|
const stub = {
|
||||||
|
saved: null as Encounter | null,
|
||||||
|
get: () => initial,
|
||||||
|
save: (e: Encounter) => {
|
||||||
|
stub.saved = e;
|
||||||
|
stub.get = () => e;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stubPlayerCharacterStore(
|
||||||
|
initial: readonly PlayerCharacter[],
|
||||||
|
): PlayerCharacterStore & { saved: readonly PlayerCharacter[] | null } {
|
||||||
|
const stub = {
|
||||||
|
saved: null as readonly PlayerCharacter[] | null,
|
||||||
|
getAll: () => [...initial],
|
||||||
|
save: (characters: PlayerCharacter[]) => {
|
||||||
|
stub.saved = characters;
|
||||||
|
stub.getAll = () => [...characters];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
type Creature,
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
creatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { rollAllInitiativeUseCase } from "../roll-all-initiative-use-case.js";
|
||||||
|
import {
|
||||||
|
expectError,
|
||||||
|
expectSuccess,
|
||||||
|
requireSaved,
|
||||||
|
stubEncounterStore,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
const CREATURE_A = creatureId("creature-a");
|
||||||
|
const CREATURE_B = creatureId("creature-b");
|
||||||
|
|
||||||
|
function makeCreature(id: string, dex = 14): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name: `Creature ${id}`,
|
||||||
|
source: "mm",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Medium",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral",
|
||||||
|
ac: 12,
|
||||||
|
hp: { average: 10, formula: "2d8+2" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 10, dex, con: 10, int: 10, wis: 10, cha: 10 },
|
||||||
|
cr: "1",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encounterWithCombatants(
|
||||||
|
combatants: Array<{
|
||||||
|
name: string;
|
||||||
|
creatureId?: string;
|
||||||
|
initiative?: number;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const result = createEncounter(
|
||||||
|
combatants.map((c) => ({
|
||||||
|
id: combatantId(c.name),
|
||||||
|
name: c.name,
|
||||||
|
creatureId: c.creatureId ? creatureId(c.creatureId) : undefined,
|
||||||
|
initiative: c.initiative,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("Setup failed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rollAllInitiativeUseCase", () => {
|
||||||
|
it("skips combatants without creatureId", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "Fighter" },
|
||||||
|
{ name: "Goblin", creatureId: "creature-a" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const creature = makeCreature("creature-a");
|
||||||
|
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => 10,
|
||||||
|
(id) => (id === CREATURE_A ? creature : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectSuccess(result);
|
||||||
|
expect(result.events.length).toBeGreaterThan(0);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
const fighter = saved.combatants.find((c) => c.name === "Fighter");
|
||||||
|
const goblin = saved.combatants.find((c) => c.name === "Goblin");
|
||||||
|
expect(fighter?.initiative).toBeUndefined();
|
||||||
|
expect(goblin?.initiative).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips combatants that already have initiative", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "Goblin", creatureId: "creature-a", initiative: 15 },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => 10,
|
||||||
|
() => makeCreature("creature-a"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectSuccess(result);
|
||||||
|
expect(result.events).toHaveLength(0);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts skippedNoSource when creature lookup returns undefined", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "Unknown", creatureId: "missing" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => 10,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expectSuccess(result);
|
||||||
|
expect(result.skippedNoSource).toBe(1);
|
||||||
|
expect(result.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates events from multiple setInitiative calls", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
{ name: "B", creatureId: "creature-b" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const creatureA = makeCreature("creature-a");
|
||||||
|
const creatureB = makeCreature("creature-b");
|
||||||
|
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => 10,
|
||||||
|
(id) => {
|
||||||
|
if (id === CREATURE_A) return creatureA;
|
||||||
|
if (id === CREATURE_B) return creatureB;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expectSuccess(result);
|
||||||
|
expect(result.events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early with domain error on invalid dice roll", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
{ name: "B", creatureId: "creature-b" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
// rollDice returns 0 (invalid — must be 1–20), triggers early return
|
||||||
|
const result = rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => 0,
|
||||||
|
(id) => {
|
||||||
|
if (id === CREATURE_A) return makeCreature("creature-a");
|
||||||
|
if (id === CREATURE_B) return makeCreature("creature-b");
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expectError(result);
|
||||||
|
expect(result.code).toBe("invalid-dice-roll");
|
||||||
|
// Store should NOT have been saved since the loop aborted
|
||||||
|
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", () => {
|
||||||
|
const enc = encounterWithCombatants([
|
||||||
|
{ name: "A", creatureId: "creature-a" },
|
||||||
|
{ name: "B", creatureId: "creature-b" },
|
||||||
|
]);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const creatureA = makeCreature("creature-a");
|
||||||
|
const creatureB = makeCreature("creature-b");
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
const originalSave = store.save.bind(store);
|
||||||
|
store.save = (e) => {
|
||||||
|
saveCount++;
|
||||||
|
originalSave(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
rollAllInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
() => 10,
|
||||||
|
(id) => {
|
||||||
|
if (id === CREATURE_A) return creatureA;
|
||||||
|
if (id === CREATURE_B) return creatureB;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saveCount).toBe(1);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.combatants[0].initiative).toBeDefined();
|
||||||
|
expect(saved.combatants[1].initiative).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
creatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
||||||
|
import { rollInitiativeUseCase } from "../roll-initiative-use-case.js";
|
||||||
|
import { expectError, requireSaved, stubEncounterStore } from "./helpers.js";
|
||||||
|
|
||||||
|
const GOBLIN_ID = creatureId("goblin");
|
||||||
|
|
||||||
|
function makeCreature(overrides?: Partial<Creature>): Creature {
|
||||||
|
return {
|
||||||
|
id: GOBLIN_ID,
|
||||||
|
name: "Goblin",
|
||||||
|
source: "mm",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encounterWithCreatureLink(name: string, creature: CreatureId) {
|
||||||
|
const enc = createEncounter([]);
|
||||||
|
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||||
|
const id = combatantId(name);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
addCombatantUseCase(store, id, name);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
const result = createEncounter(
|
||||||
|
saved.combatants.map((c) =>
|
||||||
|
c.id === id ? { ...c, creatureId: creature } : c,
|
||||||
|
),
|
||||||
|
saved.activeIndex,
|
||||||
|
saved.roundNumber,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("Setup failed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rollInitiativeUseCase", () => {
|
||||||
|
it("returns domain error when combatant not found", () => {
|
||||||
|
const enc = createEncounter([]);
|
||||||
|
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("unknown"),
|
||||||
|
[10],
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expectError(result);
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when combatant has no creature link", () => {
|
||||||
|
const enc = createEncounter([]);
|
||||||
|
if (isDomainError(enc)) throw new Error("Setup failed");
|
||||||
|
const store1 = stubEncounterStore(enc);
|
||||||
|
addCombatantUseCase(store1, combatantId("Fighter"), "Fighter");
|
||||||
|
|
||||||
|
const store = stubEncounterStore(requireSaved(store1.saved));
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Fighter"),
|
||||||
|
[10],
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expectError(result);
|
||||||
|
expect(result.code).toBe("no-creature-link");
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when creature not found in getter", () => {
|
||||||
|
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Goblin"),
|
||||||
|
[10],
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expectError(result);
|
||||||
|
expect(result.code).toBe("creature-not-found");
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates initiative from creature and saves", () => {
|
||||||
|
const creature = makeCreature();
|
||||||
|
const enc = encounterWithCreatureLink("Goblin", GOBLIN_ID);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
// Dex 14 -> modifier +2, CR 1/4 -> PB 2, initiativeProficiency 0
|
||||||
|
// So initiative modifier = 2 + 0*2 = 2
|
||||||
|
// Roll 10 + modifier 2 = 12
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Goblin"),
|
||||||
|
[10],
|
||||||
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
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", () => {
|
||||||
|
// CR 5 -> PB 3, dex 16 -> mod +3, initiativeProficiency 1
|
||||||
|
// modifier = 3 + 1*3 = 6, roll 8 + 6 = 14
|
||||||
|
const creature = makeCreature({
|
||||||
|
abilities: {
|
||||||
|
str: 10,
|
||||||
|
dex: 16,
|
||||||
|
con: 10,
|
||||||
|
int: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
},
|
||||||
|
cr: "5",
|
||||||
|
initiativeProficiency: 1,
|
||||||
|
});
|
||||||
|
const enc = encounterWithCreatureLink("Monster", GOBLIN_ID);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
|
||||||
|
const result = rollInitiativeUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Monster"),
|
||||||
|
[8],
|
||||||
|
(id) => (id === GOBLIN_ID ? creature : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
388
packages/application/src/__tests__/use-cases.test.ts
Normal file
388
packages/application/src/__tests__/use-cases.test.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import {
|
||||||
|
type ConditionId,
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
isDomainError,
|
||||||
|
playerCharacterId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { addCombatantUseCase } from "../add-combatant-use-case.js";
|
||||||
|
import { adjustHpUseCase } from "../adjust-hp-use-case.js";
|
||||||
|
import { advanceTurnUseCase } from "../advance-turn-use-case.js";
|
||||||
|
import { clearEncounterUseCase } from "../clear-encounter-use-case.js";
|
||||||
|
import { createPlayerCharacterUseCase } from "../create-player-character-use-case.js";
|
||||||
|
import { deletePlayerCharacterUseCase } from "../delete-player-character-use-case.js";
|
||||||
|
import { editCombatantUseCase } from "../edit-combatant-use-case.js";
|
||||||
|
import { editPlayerCharacterUseCase } from "../edit-player-character-use-case.js";
|
||||||
|
import { removeCombatantUseCase } from "../remove-combatant-use-case.js";
|
||||||
|
import { retreatTurnUseCase } from "../retreat-turn-use-case.js";
|
||||||
|
import { setAcUseCase } from "../set-ac-use-case.js";
|
||||||
|
import { setHpUseCase } from "../set-hp-use-case.js";
|
||||||
|
import { setInitiativeUseCase } from "../set-initiative-use-case.js";
|
||||||
|
import { toggleConcentrationUseCase } from "../toggle-concentration-use-case.js";
|
||||||
|
import { toggleConditionUseCase } from "../toggle-condition-use-case.js";
|
||||||
|
import {
|
||||||
|
requireSaved,
|
||||||
|
stubEncounterStore,
|
||||||
|
stubPlayerCharacterStore,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
const ID_A = combatantId("a");
|
||||||
|
|
||||||
|
function emptyEncounter() {
|
||||||
|
const result = createEncounter([]);
|
||||||
|
if (isDomainError(result)) throw new Error("Test setup failed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encounterWith(...names: string[]) {
|
||||||
|
let enc = emptyEncounter();
|
||||||
|
for (const name of names) {
|
||||||
|
const id = combatantId(name);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = addCombatantUseCase(store, id, name);
|
||||||
|
if (isDomainError(result)) throw new Error(`Setup failed: ${name}`);
|
||||||
|
enc = requireSaved(store.saved);
|
||||||
|
}
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encounterWithHp(name: string, maxHp: number) {
|
||||||
|
const enc = encounterWith(name);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const id = combatantId(name);
|
||||||
|
setHpUseCase(store, id, maxHp);
|
||||||
|
return requireSaved(store.saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPc(name: string) {
|
||||||
|
const store = stubPlayerCharacterStore([]);
|
||||||
|
const id = playerCharacterId("pc-1");
|
||||||
|
createPlayerCharacterUseCase(store, id, name, 15, 40, undefined, undefined);
|
||||||
|
return { id, characters: requireSaved(store.saved) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("addCombatantUseCase", () => {
|
||||||
|
it("adds a combatant and saves", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = addCombatantUseCase(store, ID_A, "Goblin");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.combatants).toHaveLength(1);
|
||||||
|
expect(saved.combatants[0].name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for empty name", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = addCombatantUseCase(store, ID_A, "");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adjustHpUseCase", () => {
|
||||||
|
it("adjusts HP and saves", () => {
|
||||||
|
const enc = encounterWithHp("Goblin", 10);
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = adjustHpUseCase(store, combatantId("Goblin"), -3);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.combatants[0].currentHp).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = adjustHpUseCase(store, ID_A, -5);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("advanceTurnUseCase", () => {
|
||||||
|
it("advances turn and saves", () => {
|
||||||
|
const enc = encounterWith("A", "B");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = advanceTurnUseCase(store);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.activeIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error on empty encounter", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = advanceTurnUseCase(store);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearEncounterUseCase", () => {
|
||||||
|
it("clears encounter and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = clearEncounterUseCase(store);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.combatants).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editCombatantUseCase", () => {
|
||||||
|
it("edits combatant name and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = editCombatantUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Goblin"),
|
||||||
|
"Hobgoblin",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.combatants[0].name).toBe("Hobgoblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = editCombatantUseCase(store, ID_A, "X");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeCombatantUseCase", () => {
|
||||||
|
it("removes combatant and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = removeCombatantUseCase(store, combatantId("Goblin"));
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
const saved = requireSaved(store.saved);
|
||||||
|
expect(saved.combatants).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = removeCombatantUseCase(store, ID_A);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("retreatTurnUseCase", () => {
|
||||||
|
it("retreats turn and saves", () => {
|
||||||
|
const enc = encounterWith("A", "B");
|
||||||
|
const store1 = stubEncounterStore(enc);
|
||||||
|
advanceTurnUseCase(store1);
|
||||||
|
const store = stubEncounterStore(requireSaved(store1.saved));
|
||||||
|
const result = retreatTurnUseCase(store);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(store.saved).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error on empty encounter", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = retreatTurnUseCase(store);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setAcUseCase", () => {
|
||||||
|
it("sets AC and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = setAcUseCase(store, combatantId("Goblin"), 15);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].ac).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = setAcUseCase(store, ID_A, 15);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setHpUseCase", () => {
|
||||||
|
it("sets max HP and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = setHpUseCase(store, combatantId("Goblin"), 20);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].maxHp).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = setHpUseCase(store, ID_A, 20);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setInitiativeUseCase", () => {
|
||||||
|
it("sets initiative and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = setInitiativeUseCase(store, combatantId("Goblin"), 15);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].initiative).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = setInitiativeUseCase(store, ID_A, 15);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleConcentrationUseCase", () => {
|
||||||
|
it("toggles concentration and saves", () => {
|
||||||
|
const enc = encounterWith("Wizard");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = toggleConcentrationUseCase(store, combatantId("Wizard"));
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].isConcentrating).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = toggleConcentrationUseCase(store, ID_A);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleConditionUseCase", () => {
|
||||||
|
it("toggles condition and saves", () => {
|
||||||
|
const enc = encounterWith("Goblin");
|
||||||
|
const store = stubEncounterStore(enc);
|
||||||
|
const result = toggleConditionUseCase(
|
||||||
|
store,
|
||||||
|
combatantId("Goblin"),
|
||||||
|
"blinded" as ConditionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved).combatants[0].conditions).toContain(
|
||||||
|
"blinded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const store = stubEncounterStore(emptyEncounter());
|
||||||
|
const result = toggleConditionUseCase(
|
||||||
|
store,
|
||||||
|
ID_A,
|
||||||
|
"blinded" as ConditionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createPlayerCharacterUseCase", () => {
|
||||||
|
it("creates a player character and saves", () => {
|
||||||
|
const store = stubPlayerCharacterStore([]);
|
||||||
|
const id = playerCharacterId("pc-1");
|
||||||
|
const result = createPlayerCharacterUseCase(
|
||||||
|
store,
|
||||||
|
id,
|
||||||
|
"Gandalf",
|
||||||
|
15,
|
||||||
|
40,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for invalid input", () => {
|
||||||
|
const store = stubPlayerCharacterStore([]);
|
||||||
|
const id = playerCharacterId("pc-1");
|
||||||
|
const result = createPlayerCharacterUseCase(
|
||||||
|
store,
|
||||||
|
id,
|
||||||
|
"",
|
||||||
|
15,
|
||||||
|
40,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deletePlayerCharacterUseCase", () => {
|
||||||
|
it("deletes a player character and saves", () => {
|
||||||
|
const { id, characters } = createPc("Gandalf");
|
||||||
|
const store = stubPlayerCharacterStore(characters);
|
||||||
|
const result = deletePlayerCharacterUseCase(store, id);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown character", () => {
|
||||||
|
const store = stubPlayerCharacterStore([]);
|
||||||
|
const result = deletePlayerCharacterUseCase(
|
||||||
|
store,
|
||||||
|
playerCharacterId("unknown"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editPlayerCharacterUseCase", () => {
|
||||||
|
it("edits a player character and saves", () => {
|
||||||
|
const { id, characters } = createPc("Gandalf");
|
||||||
|
const store = stubPlayerCharacterStore(characters);
|
||||||
|
const result = editPlayerCharacterUseCase(store, id, {
|
||||||
|
name: "Gandalf the White",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
expect(requireSaved(store.saved)[0].name).toBe("Gandalf the White");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown character", () => {
|
||||||
|
const store = stubPlayerCharacterStore([]);
|
||||||
|
const result = editPlayerCharacterUseCase(
|
||||||
|
store,
|
||||||
|
playerCharacterId("unknown"),
|
||||||
|
{ name: "X" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
expect(store.saved).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const characters = store.getAll();
|
const characters = store.getAll();
|
||||||
const result = createPlayerCharacter(
|
const result = createPlayerCharacter(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editPlayerCharacterUseCase(
|
export function editPlayerCharacterUseCase(
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
export {
|
||||||
|
type RollAllResult,
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
|||||||
@@ -5,32 +5,47 @@ 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";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
mode: RollMode = "normal",
|
||||||
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
for (const combatant of encounter.combatants) {
|
for (const combatant of encounter.combatants) {
|
||||||
if (!combatant.creatureId) continue;
|
if (!combatant.creatureId) continue;
|
||||||
if (combatant.initiative !== undefined) continue;
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
const creature = getCreature(combatant.creatureId);
|
const creature = getCreature(combatant.creatureId);
|
||||||
if (!creature) continue;
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const { modifier } = calculateInitiative({
|
||||||
dexScore: creature.abilities.dex,
|
dexScore: creature.abilities.dex,
|
||||||
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;
|
||||||
@@ -47,5 +62,5 @@ export function rollAllInitiativeUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.save(encounter);
|
store.save(encounter);
|
||||||
return allEvents;
|
return { events: allEvents, skippedNoSource };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user