Compare commits

...

25 Commits

Author SHA1 Message Date
yaroslav k
ae2fa1c723 ac fix 2026-08-16 14:33:11 +03:00
yaroslav k
5e7cd0e709 docs: second-pass character page notes
- ROADMAP: Phase 2 done-note — player card removed, plain RU headings,
  growable attunements, manual AC
- ARCHITECTURE: character tree line and route contents updated
- README: character page and sidebar lines updated (no player card, AC)
2026-08-16 13:08:03 +03:00
yaroslav k
f26f3bdf50 e2e: character spec — extra attunement slot, AC persistence
- drop the player-card test (components removed)
- new: '+ слот' grows attunements to 4+ and a new slot binds an item
- new: armor class starts at 16 (mock) and persists through a reload
2026-08-16 13:07:07 +03:00
yaroslav k
294bf430fd character: drop player card; RU headings; growable attunements; AC
- PlayerCard removed — it duplicated the sidebar editors; the character
  page now shows only prepared spells and attunements
- headings are plain RU: Заготовленные заклинания / Атюнменты
  (no EN/RU double labels; h6 no longer lowercased)
- Attunements is Vec<Option<String>> instead of a fixed 3-slot array;
  '+ слот' appends a slot, so custom campaigns can track more items
  (old saved JSON parses: arrays map to Vec)
- PlayerData.ac (u8, serde default 0): manual armor-class input in the
  sidebar HP block (hp/оз, ac/КД), editable like the HP numbers
2026-08-16 13:07:07 +03:00
yaroslav k
6d0846d160 docs: mark Phase 2 done; sync architecture, readme
- ROADMAP: all Phase 2 boxes checked; done-notes (HP block already in
  the sidebar; optional Spell fields keep old JSON parseable; rows are
  plain functions inside For's let children)
- ARCHITECTURE: character/*.rs in the tree, Character route contents,
  gaps list drops the character-page items
- README: character page in 'What works today'
2026-08-16 12:44:31 +03:00
yaroslav k
0fb9329916 e2e: character page spec, verified against a live server
- player card shows the mock character (name, class, level, XP)
- add a spell via a vacant slot, undo with Ctrl+Z, redo with Ctrl+Y
- remove a spell empties its slot back to vacant
- a prepared spell survives a reload (autosave round trip)
- attune an item, reload keeps it, clear empties the slot
2026-08-16 12:43:55 +03:00
yaroslav k
fddd97d926 character page: player card, prepared spells, attunements
- PlayerCard: read-only portrait, name, class, level, XP (the sidebar
  holds the editors for this data)
- PreparedSpells: growable slot list; a vacant slot has a free-text
  input and '+' to fill it, an occupied slot shows name/level/
  description and '×' to empty it; '+ слот' appends a vacant slot
- Attunements: three slots, free-text entry and per-slot clear
- rows are plain functions called from For's let(...) children —
  component tags are not allowed directly inside them (view macro)
- Field<...> conversions for the Subfield prop types, state-in-key
  keys so changed rows re-render (same convention as sidebar pips)
- SCSS: _character.scss with card, slot rows, buttons (mixins)
2026-08-16 12:43:52 +03:00
yaroslav k
c825b867d1 entities: Spell gains optional level/description; demo spells
- Spell is no longer a name-only stub: optional level and description
  (serde skips missing fields, so old saved JSON stays parseable)
- mock: demo prepared spells — two vacant slots, Огненный шар (level 3,
  with description) and Магическая броня (level 1)
2026-08-16 12:43:52 +03:00
yaroslav k
bb304bed6a docs: mark Phase 1.5 done; sync architecture, modernize, readme
- ROADMAP: all Phase 1.5 boxes checked; done-notes (clone snapshots,
  Copy handle, no Instant on wasm, button enables with the pending burst)
- ARCHITECTURE: undo/redo in the header tree and a paragraph on the
  snapshot history; gaps list no longer mentions undo
- README: undo/redo in 'What works today'
2026-08-16 12:32:25 +03:00
yaroslav k
ff5ec498b2 e2e: undo and redo spec, verified against a live server
- undo/redo via buttons and via Ctrl+Z outside text fields
- focus guard: Ctrl+Z inside the campaign name field leaves the app
  history untouched (the browser owns the field's undo)
- an undone state survives a reload (undo writes through autosave)
- Clear is undoable: one undo step restores the wiped dashboard
2026-08-16 12:31:22 +03:00
yaroslav k
1f3184baf6 undo: header buttons, shortcuts, wiring
- App provides HistoryHandle as context (both build modes; stacks stay
  empty on the server) and passes it to persist::init_hydrated
- persist: init_hydrated starts undo tracking after the localStorage
  load (first captured state = saved state, not mock); clear_all and
  apply_import call undo::before_replace so they are undoable
- header: Отменить/Вернуть buttons with disabled states driven by the
  reactive stack depths; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y via a window
  keydown listener that skips editable targets (browser owns those)
- SCSS: disabled control buttons dim out
2026-08-16 12:31:22 +03:00
yaroslav k
99ccb0cdc7 undo: snapshot history engine (state::undo)
- History: capped undo/redo stacks of whole Dashboard clones; a pending
  pre-burst snapshot becomes a real undo step when the tracking effect's
  settle timer fires (1 s of quiet) — one undo step per edit burst
- undo/redo consume the pending burst first, so a fresh edit is
  undoable before it settles; a new edit clears the redo branch
- suppress flag: wholesale replacements (Clear, import) flush the
  pending burst, push the current state, and mark their own write as
  non-capturable — Clear and import are undoable themselves
- HistoryHandle is Copy (RwSignal fields): context-friendly (Send+Sync)
  and safe to capture in view closures under edition-2024 rules
- no std::time::Instant: it panics on wasm32-unknown-unknown; the
  browser timer is the only clock
- 9 unit tests: burst coalescing, redo, cap, flush order, suppress
2026-08-16 12:31:17 +03:00
yaroslav k
42b106c793 roadmap: add Phase 1.5 — undo & redo (snapshot-based)
- effort S (~0.5-1 day): rides on Phase 1 machinery (serde snapshots,
  replace_state, the store.track() autosave trigger) — no component
  changes needed, page-agnostic
- coalesced snapshots (one per ~1 s window), capped stacks, Ctrl+Z /
  Ctrl+Shift+Z with focus guard for text fields
- sequencing note: lands before the edit-heavy Phases 2-7 so their edits
  are undoable from day one; nothing depends on it
- Phase 8 e2e item: drop the persistence round trip (already covered by
  persistence.spec.ts), add undo shortcuts
- ARCHITECTURE gaps list: note the missing undo/redo
2026-08-16 11:57:31 +03:00
yaroslav k
cc57da0d83 e2e: ignore node_modules and playwright artifacts 2026-08-16 11:50:51 +03:00
yaroslav k
9c4d95220d docs: mark Phase 1 done; sync architecture, modernize, readme
- ROADMAP: all Phase 1 boxes checked, done-notes added
- MODERNIZE: serde item marked APPLIED (persistence uses the derives)
- ARCHITECTURE: persist module + modal in the component tree, gaps list
  updated (persistence no longer a gap)
- README: header buttons + autosave in 'What works today', tests section
  describes both specs, playwright command pins the chromium project
2026-08-16 11:50:41 +03:00
yaroslav k
cc11700621 e2e: persistence tests, verified against a live server
- campaign name survives a reload (autosave debounce respected)
- export/import round trip: Save downloads dashboard.json with the
  current state, Clear blanks it via the confirm modal, Load restores it
- specs take BASE_URL env override (default stays localhost:3000)
- both new tests and the three smoke tests pass on chromium
2026-08-16 11:49:00 +03:00
yaroslav k
b9a399be64 header: Save/Load/Clear buttons wired to persistence
Save downloads dashboard.json, Load opens a file picker and imports
through apply_import with errors reported in the modal, Clear asks for
confirmation then wipes to Dashboard::blank(). App provides the store
and ModalState and mounts the modal; init_hydrated starts autosave.
2026-08-16 11:28:19 +03:00
yaroslav k
0c2eda7621 modal: in-app dialog for confirms and import errors
ModalState carries title/message/action as RwSignals; the action is
read at click time so a late confirm wins. Overlay click and Cancel
close; the confirm button runs the stored action. Edition-2024 note:
method calls on non-Copy captures move by value inside nested move
closures, so handlers touch state.open/state.action fields directly.
2026-08-16 11:28:19 +03:00
yaroslav k
043977cc1f persist: localStorage autosave + JSON export/import engine
- STORAGE_KEY aex.dashboard, 500 ms debounced autosave via store.track()
- deferred load after hydration keeps SSR markup authoritative
- parse_dashboard version-checks the file (the migration hook), errors
  are RU messages via ImportError (thiserror)
- clear writes the blank dashboard back so a reload stays blank
- export_download builds a Blob URL and revokes it after the click
- 5 unit tests: round trip, blank, garbage, newer and older versions
2026-08-16 11:28:19 +03:00
yaroslav k
0409d8edad add web-sys as a direct dependency
Needed for localStorage, FileReader and Blob download in the client.
The features were already enabled transitively by leptos-use; this
makes the dependency explicit.
2026-08-16 11:28:19 +03:00
yaroslav k
f3b0ceb3e7 derive serde on all persisted entities; add Settings.version and Dashboard::blank
Store and serde derives coexist on all 23 dashboard types (prototype
confirmed). Settings carries the saved-format version (1) as the
migration hook anchor; Dashboard::blank() is the post-Clear empty
campaign. mock() now builds Settings via Default.
2026-08-15 15:14:43 +03:00
yaroslav k
a11e86e839 update docs for phase 0 completion
ARCHITECTURE: LazyLock, tracked Cargo.lock, dedup'd foundry.json, warnings
cleared. ROADMAP: phase 0 checkboxes closed; MODERNIZE: lazy_static marked
applied, rest pending.
2026-08-15 14:58:01 +03:00
yaroslav k
8bdf1c9be3 rewrite README; replace example e2e spec with a smoke test
README is now project-specific (features, stack, dev commands, docs).
The Playwright spec checks home/inventories/wiki render and asserts no
console errors.
2026-08-15 14:57:35 +03:00
yaroslav k
6a07bd668d clear all compiler warnings
Remove unused imports (nav, sidebar, state, mock, inventory, item,
numput), the unused qty closure, the unused #![feature] gate, and a
module-scoped allow(dead_code) for the unmounted search stubs.
2026-08-15 14:56:57 +03:00
yaroslav k
444f046990 replace lazy_static with std::sync::LazyLock; commit Cargo.lock
lazy_static removed from Cargo.toml (the only dependency removal for
now, per decision). ITEMS_REFS/ITEMS_NAMES are now LazyLock statics; the
duplicate foundry.json load is dropped and the no-op HashMap roundtrip
is gone. Cargo.lock is now tracked: the app is a binary and builds
should be reproducible.
2026-08-15 14:56:54 +03:00
40 changed files with 5449 additions and 220 deletions

4
.gitignore vendored
View File

@ -3,9 +3,7 @@
debug/ debug/
target/ target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # Cargo.lock is tracked: the app is a binary, builds should be reproducible
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk

View File

@ -20,12 +20,12 @@ notes, and contacts.
- Leptos 0.8.20 (SSR + hydration), reactive_stores 0.4.3 for state - Leptos 0.8.20 (SSR + hydration), reactive_stores 0.4.3 for state
- leptos_actix 0.8.7 + actix-web 4 for the server - leptos_actix 0.8.7 + actix-web 4 for the server
- nucleo-matcher for fuzzy item search, num-format for number display, - nucleo-matcher for fuzzy item search, num-format for number display,
chrono for dates, lazy_static for one-time data loading chrono for dates, std::sync::LazyLock for one-time data loading
- SCSS sources in `style/` (compiled by cargo-leptos) - SCSS sources in `style/` (compiled by cargo-leptos)
- Bundled game data in `data/` (5e.tools-format JSON, ~3.9 MB total) - Bundled game data in `data/` (5e.tools-format JSON, ~3.9 MB total)
Features: `ssr` (server binary), `hydrate` (WASM), `csr` (optional pure-client Features: `ssr` (server binary), `hydrate` (WASM), `csr` (optional pure-client
mode for `trunk`). `Cargo.lock` is gitignored. mode for `trunk`). `Cargo.lock` is tracked for reproducible builds.
## Directory layout ## Directory layout
@ -48,6 +48,7 @@ src/
inventory.rs One inventory section (rows + money) inventory.rs One inventory section (rows + money)
item.rs One inventory row item.rs One inventory row
numput.rs Numeric input components (Numput, NumputChange) numput.rs Numeric input components (Numput, NumputChange)
character/*.rs Character page: prepared_spells, attunements
mod.rs Pages (Character, Inventories, Wiki) + item search mod.rs Pages (Character, Inventories, Wiki) + item search
``` ```
@ -73,10 +74,9 @@ nothing is persisted. The header's Save/Load/Clear buttons do nothing yet
## Data layer (items) ## Data layer (items)
- `statics::ITEMS_REFS` is a `lazy_static` map of name → `BookItem`, built - `statics::ITEMS_REFS` is a `LazyLock` map of name → `BookItem`, built
once by `utils::read_items` over the bundled JSON files (`items.json`, once by `utils::read_items` over the bundled JSON files (`items.json`,
`base.json`, `foundry.json` loaded twice — a known duplication — and `base.json`, `foundry.json`, `magic.json`), roughly 5k items.
`magic.json`), roughly 5k items.
- `BookItem` keeps `name`, `weight`, `rarity`, and `rest`, which holds all - `BookItem` keeps `name`, `weight`, `rarity`, and `rest`, which holds all
other JSON fields verbatim for future detail views. other JSON fields verbatim for future detail views.
- Duplicate names merge: the later entry fills a missing weight and appends - Duplicate names merge: the later entry fills a missing weight and appends
@ -100,9 +100,10 @@ JSON as-is.
## Component tree ## Component tree
```
App App
├─ Header name, image, date steppers, session steppers, Save/Load/Clear (dead) │ Save (download)/Load (file import)/Clear (wipe),
│ ↩ Undo/redo (buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y)
├─ Modal overlay dialog: confirm (action) or error (close)
├─ Sidebar ├─ Sidebar
│ ├─ Image portrait │ ├─ Image portrait
│ ├─ Names first / alias «» / last │ ├─ Names first / alias «» / last
@ -113,7 +114,8 @@ App
│ └─ SpellSlots 9 levels of clickable pips │ └─ SpellSlots 9 levels of clickable pips
├─ Nav links to the three routes ├─ Nav links to the three routes
└─ Routes └─ Routes
├─ Character stub text ├─ Character prepared spells (vacant/occupied slots with
│ free-text entry); attunements (growable slots)
├─ Inventories two Inventory sections (Personal/Common) ├─ Inventories two Inventory sections (Personal/Common)
│ └─ Inventory balance (5 × Numput), rows (ForEnumerate) │ └─ Inventory balance (5 × Numput), rows (ForEnumerate)
│ └─ Item name, quantity ±, weight ±, more/swap/remove │ └─ Item name, quantity ±, weight ±, more/swap/remove
@ -126,6 +128,20 @@ closures by index; "move" transfers a row between the personal and common
inventories, "remove" deletes it. The "…" (details) button and the search inventories, "remove" deletes it. The "…" (details) button and the search
"+" button are unwired. "+" button are unwired.
Persistence lives in `state::persist`: on hydration a deferred timer loads
the saved dashboard from localStorage (SSR markup stays authoritative),
then a debounced (500 ms) effect writes on any store change via
`store.track()`. `parse_dashboard` version-checks the JSON against
`Settings::VERSION` (the migration hook). `ModalState` holds title/message/
action as signals; the confirm button reads the action at click time.
Undo/redo lives in `state::undo`: the same store trigger feeds a
snapshot history (`HistoryHandle` in context). The first change of a
burst is remembered; when the burst goes quiet for 1 s, the pre-burst
state becomes an undo step. Undo/redo restore via `replace_state`
the autosave effect persists the undone state too. Clear and import
push a snapshot first, so they are undoable themselves.
`Numput` is the shared numeric input: `+n`/`-n` adjust relative to the `Numput` is the shared numeric input: `+n`/`-n` adjust relative to the
current value, a plain number sets absolutely, invalid input is reverted, current value, a plain number sets absolutely, invalid input is reverted,
and the value renders with thousands separators. `NumputChange` is the same and the value renders with thousands separators. `NumputChange` is the same
@ -149,17 +165,14 @@ currently mounted on any page.
## Current gaps ## Current gaps
- No persistence; state is recreated from `mock()` each load
- `Character` and `Wiki` pages render placeholders
- Quests, notes, contacts: models and mock data only - Quests, notes, contacts: models and mock data only
- Prepared spells list is empty; `Spell` is a name-only stub; attunements - `Wiki` page renders a placeholder
have no UI; `Settings` is empty - `Settings` has only `version`
- Save/Load/Clear buttons and the item "+"/"…" buttons are dead - The item "+"/"…" buttons are dead
- `foundry.json` is loaded twice; ~3.9 MB of JSON is bundled into the WASM - `foundry.json` was loaded twice (fixed); ~3.9 MB of JSON is bundled
and parsed on startup into the WASM and parsed on startup
- Several unused dependencies (`leptos-use`, `dotenvy`, - Several unused dependencies (see `MODERNIZE.md`); kept for now by
`pretty_env_logger`, `seq_macro`, `itertools`, `thiserror`) and 12 decision
compiler warnings - `Cargo.lock` is tracked since 2026-08-04; builds are reproducible
- `Cargo.lock` is gitignored, so builds are not reproducible
See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these. See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.

3394
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,6 @@ thiserror = "2.0.16"
itertools = "0.14.0" itertools = "0.14.0"
dotenvy = "0.15.7" dotenvy = "0.15.7"
pretty_env_logger = "0.5.0" pretty_env_logger = "0.5.0"
lazy_static = "1.5.0"
leptos-use = "0.16.2" leptos-use = "0.16.2"
log = "0.4.28" log = "0.4.28"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
@ -33,6 +32,7 @@ console_log = "1.0.0"
num-traits = "0.2.19" num-traits = "0.2.19"
num-format = "0.4.4" num-format = "0.4.4"
js-sys = "=0.3.103" js-sys = "=0.3.103"
web-sys = "=0.3.103"
seq-macro = "0.3.6" seq-macro = "0.3.6"
@ -72,9 +72,9 @@ style-file = "style/main.scss"
# Optional. Env: LEPTOS_ASSETS_DIR. # Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "assets" assets-dir = "assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. # The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000" site-addr = "127.0.0.1:3110"
# The port to use for automatic reload monitoring # The port to use for automatic reload monitoring
reload-port = 3001 reload-port = 3111
# [Optional] Command to use when running end2end tests. It will run in the end2end dir. # [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test" # [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx # This binary name can be checked in Powershell with Get-Command npx

View File

@ -8,10 +8,11 @@ Status notes:
- Every "unused" claim below was verified with a grep: 0 references in `src/`. - Every "unused" claim below was verified with a grep: 0 references in `src/`.
- Toolchain decision: stay on nightly (channel = "nightly"). The stable-Rust - Toolchain decision: stay on nightly (channel = "nightly"). The stable-Rust
item is listed for awareness only. item is listed for awareness only.
- The item 3.2 note about the double `foundry.json` load stays true.
- Dependency decision (2026-08-04): `itertools`, `thiserror`, `anyhow`, - Dependency decision (2026-08-04): `itertools`, `thiserror`, `anyhow`,
`pretty_env_logger`, `dotenvy` stay for certain. The rest of section 1 `pretty_env_logger`, `dotenvy` stay for certain. The rest of section 1
is under consideration. is under consideration.
- Applied 2026-08-04: `lazy_static``std::sync::LazyLock` (section 2.1).
Everything else in this file is still pending.
## 1. Unused dependencies ## 1. Unused dependencies
@ -35,7 +36,8 @@ under consideration.
## 2. Replace crates with std or small local code ## 2. Replace crates with std or small local code
1. **`lazy_static``std::sync::LazyLock`** (stable since 1.80). 1. **`lazy_static``std::sync::LazyLock`** (stable since 1.80).
Rewrite `src/statics/mod.rs`: APPLIED 2026-08-04: `src/statics/mod.rs` now uses `LazyLock`;
`lazy_static` removed from `Cargo.toml`. Reference sketch:
```rust ```rust
use std::sync::LazyLock; use std::sync::LazyLock;
@ -69,9 +71,11 @@ under consideration.
`usize`. `Numput` keeps its generics. `usize`. `Numput` keeps its generics.
4. **`serde` (direct) → remove now, re-add in ROADMAP phase 1.** 4. **`serde` (direct) → remove now, re-add in ROADMAP phase 1.**
No derives exist today; `serde_json::Value` works without naming `serde` No derives existed at the time; `serde_json::Value` works without naming
in `Cargo.toml`. Phase 1 (persistence) re-adds `serde` in `Cargo.toml`. Phase 1 (persistence) re-added
`serde = { version = "1", features = ["derive"] }`. `serde = { version = "1", features = ["derive"] }`.
**APPLIED 2026-08-16:** persistence is in; `serde` is used by every entity
derive — keep it.
Result: dropping the under-consideration items takes `[dependencies]` from Result: dropping the under-consideration items takes `[dependencies]` from
27 entries to about 18. If the five kept deps are dropped later too, it goes 27 entries to about 18. If the five kept deps are dropped later too, it goes

118
README.md
View File

@ -1,72 +1,78 @@
<picture> # aex
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Starter Template A D&D 5e character sheet and campaign dashboard for a text-based play-by-post
game. The UI is in Russian.
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool. ## What works today
## Creating your template repo - Campaign header: name, in-game date steppers, session counter, and
Save / Load / Clear: download `dashboard.json`, import a file back,
wipe to a blank campaign
- Undo/redo: header buttons and Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y over
edit bursts; Clear and import are undoable too
- Character page: prepared spells (free-text slots with
level/description) and attunements (growable slots, clearable)
- Character sidebar: portrait, names, class, level, XP, hit points with
temp HP bar, manual AC, hit dice, death saves, spell slots
- Inventories page (`/inv`): personal and party-common inventories,
currency per denomination, item rows with quantity and weight steppers,
move between inventories, remove
- Bundled 5e.tools item data (~5k items) with fuzzy search components
(search is not mounted on a page yet)
If you don't have `cargo-leptos` installed you can install it with State autosaves to localStorage (debounced) and survives reloads; export/
import round-trips through a JSON file. The phased plan is in
[ROADMAP.md](./ROADMAP.md).
`cargo install cargo-leptos --locked` ## Tech stack
Then run - Rust, edition 2024, nightly toolchain (pinned in `rust-toolchain.toml`)
- Leptos 0.8 SSR (actix-web) + client-side hydration
- `reactive_stores` for the reactive state model
- SCSS (`style/main.scss`), compiled by dart-sass
- Playwright for end-to-end tests
`cargo leptos new --git leptos-rs/start-actix` ## Development
to generate a new project template (you will be prompted to enter a project name). Prerequisites: `cargo-leptos`, dart-sass, and the `wasm32-unknown-unknown`
target. The nightly toolchain installs itself via `rust-toolchain.toml`.
`cd {projectname}`
to go to your newly created project.
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
## Running your project
`cargo leptos watch`
By default, you can access your local project at `http://localhost:3000`
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
leptos_start
site/
```
Set the following environment variables (updating for your project as needed):
```sh ```sh
export LEPTOS_OUTPUT_NAME="leptos_start" cargo install cargo-leptos
export LEPTOS_SITE_ROOT="site" cargo leptos watch # dev server with hot reload
export LEPTOS_SITE_PKG_DIR="pkg" # open http://127.0.0.1:3000
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
export LEPTOS_RELOAD_PORT="3001"
``` ```
Finally, run the server binary.
## Notes about CSR and Trunk: Other commands:
Although it is not recommended, you can also run your project without server integration using the feature `csr` and `trunk serve`:
`trunk serve --open --features csr` ```sh
cargo leptos build --release # production build
cargo leptos serve # dev server without the watcher
cargo check --no-default-features --features ssr --bin aex
cargo check --no-default-features --features hydrate --lib
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib
```
This may be useful for integrating external tools which require a static site, e.g. `tauri`. ## Tests
## Licensing End-to-end tests live in `end2end/` (Playwright): a smoke spec and a
persistence spec (reload keeps state; export/import round trip). With the
dev server running:
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly. ```sh
cd end2end
npm install
npx playwright test --project=chromium
```
or let cargo-leptos manage the server: `cargo leptos end2end`.
## Project docs
- [ARCHITECTURE.md](./ARCHITECTURE.md) — how the code is organized
- [ROADMAP.md](./ROADMAP.md) — the phased plan to MVP
- [MODERNIZE.md](./MODERNIZE.md) — dependency and idiom cleanup list
## License
Public domain (Unlicense), see [LICENSE](./LICENSE).

View File

@ -54,50 +54,108 @@ The MVP is done when a player can run a real session with the app:
Cheap, unblocks the rest. Can happen any time; do it first. Cheap, unblocks the rest. Can happen any time; do it first.
- [ ] rewrite README (currently the stock template) - [x] rewrite README (currently the stock template)
- [ ] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice) - [x] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice)
- [ ] clear compiler warnings (unused imports, unused `qty` in `item.rs`) - [x] clear compiler warnings (unused imports, unused `qty` in `item.rs`)
- [ ] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it - [x] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it
- [ ] dependency and idiom cleanup — see `MODERNIZE.md` (drop unused deps, (done: committed 2026-08-04)
- [~] dependency and idiom cleanup — see `MODERNIZE.md` (drop unused deps,
`lazy_static``LazyLock`, local replacements for `num-format`/`num-traits`) `lazy_static``LazyLock`, local replacements for `num-format`/`num-traits`)
- [ ] decide on the under-consideration unused deps from `MODERNIZE.md` (done: `lazy_static``LazyLock` only; the rest stays pending)
- [~] decide on the under-consideration unused deps from `MODERNIZE.md`
(`seq-macro`, `leptos-use`, `http`, `console_log`, `js-sys`); the rest (`seq-macro`, `leptos-use`, `http`, `console_log`, `js-sys`); the rest
(`itertools`, `thiserror`, `anyhow`, `pretty_env_logger`, `dotenvy`) stay (`itertools`, `thiserror`, `anyhow`, `pretty_env_logger`, `dotenvy`) stay
- [ ] replace `end2end/tests/example.spec.ts` with a smoke test (app boots, nav renders, no console errors) (decision: nothing removed; revisit later)
- [x] replace `end2end/tests/example.spec.ts` with a smoke test (app boots, nav renders, no console errors)
## Phase 1 — Persistence: localStorage + JSON export/import (L, ~3-5 days) ## Phase 1 — Persistence: localStorage + JSON export/import (L, ~3-5 days)
The foundation. Every later phase depends on it. The foundation. Every later phase depends on it.
- [ ] derive `serde::Serialize` / `Deserialize` on all entities in `src/entities/mod.rs` - [x] derive `serde::Serialize` / `Deserialize` on all entities in `src/entities/mod.rs`
(Dashboard, PlayerData, Balance, Inventory, InventoryEntry, InventoryItem, (Dashboard, PlayerData, Balance, Inventory, InventoryEntry, InventoryItem,
QuestBook, Quest, NoteBook, Note, ContactBook, Contact, SpellSlots, Attunements, QuestBook, Quest, NoteBook, Note, ContactBook, Contact, SpellSlots, Attunements,
PreparedSpells, Spell, HitDice, DeathSaveThrows, Name, Settings; enums: PreparedSpells, Spell, HitDice, DeathSaveThrows, Name, Settings; enums:
InventoryKind, ContactStatus, PreparedSpell) InventoryKind, ContactStatus, PreparedSpell)
- [ ] verify `Store` derive and `serde` derive coexist on the same structs (prototype first) - [x] verify `Store` derive and `serde` derive coexist on the same structs (prototype first)
- [ ] add a `version` field to `Settings`; add a migration hook for future format changes - [x] add a `version` field to `Settings`; add a migration hook for future format changes
- [ ] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage` - [x] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage`
(feature-gate to `hydrate`/`csr`; SSR renders mock/empty) (feature-gate to `hydrate`/`csr`; SSR renders mock/empty)
- [ ] auto-save on change — debounced effect over the `Store` — plus manual Save button - [x] auto-save on change — debounced effect over the `Store` — plus manual Save button
- [ ] Load button: restore from localStorage with an overwrite confirm - [x] Load button: restore from localStorage with an overwrite confirm
- [ ] Clear button: wipe localStorage with a confirm - [x] Clear button: wipe localStorage with a confirm
- [ ] export: download `dashboard.json` as a blob - [x] export: download `dashboard.json` as a blob
- [ ] import: file input → parse → validate version → replace state - [x] import: file input → parse → validate version → replace state
- [ ] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard" - [x] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard"
- **Acceptance:** close the tab, reopen — state is intact. Export → import into a - **Acceptance:** close the tab, reopen — state is intact. Export → import into a
fresh browser — identical state. fresh browser — identical state.
**Done (2026-08-16):** implementation in `src/state/persist.rs` + header buttons +
`components/modal.rs`; e2e-verified in chromium (reload persistence and
export/import round trip). Notes:
- Save = download; Load = file import; Clear = wipe (merged per user decision).
- Clear writes the blank dashboard back to storage; mock stays the first-visit fallback.
- Version check is the migration hook: `parse_dashboard` returns
`ImportError::{Parse, NewerVersion, OldVersion}` (RU messages).
## Phase 1.5 — Undo & redo (S, ~0.5-1 day)
Snapshot-based undo rides on the Phase 1 machinery: serialize → push →
`replace_state`. It is page-agnostic: the `store.track()` trigger already
fires on any mutation, so no component changes are needed and every later
phase's edits are undoable from day one.
- [x] `state::undo.rs` (hydrate-gated): undo/redo stacks of serialized
`Dashboard`, capped (e.g. 100); push coalesced on the store trigger
(one snapshot per ~1 s window → one undo step = one edit burst)
- [x] a new edit after undo clears the redo stack; Clear and import push a
snapshot first, so they are undoable too
- [x] `undo()` / `redo()` call `replace_state`; expose stack depths as
`RwSignal<usize>` for button disabled states
- [x] header buttons: ↩ Отменить / Вернуть (disabled when empty)
- [x] shortcuts: Ctrl+Z, Ctrl+Shift+Z (Ctrl+Y); ignore when focus is in an
input/textarea/select, so typing never triggers undo
- [x] unit tests for stack semantics; e2e: edit → undo → redo; reload keeps
the undone state (undo writes through autosave)
- **Acceptance:** every edit since the last window is reversible with a
keystroke; undo survives a reload.
**Done (2026-08-16):** implementation in `src/state/undo.rs`; e2e-verified
in chromium (buttons, shortcuts, reload, undoable Clear). Notes:
- Snapshots are `Dashboard` clones, not JSON: the dashboard is small.
- `HistoryHandle` is `Copy` (RwSignal fields) — context-friendly and
capture-safe under edition-2024 closure rules.
- No `std::time::Instant`: it panics on wasm32; the settle timer is the
only clock. The undo button enables with the pending burst, so a
fresh edit is undoable before the 1 s window settles.
## Phase 2 — Character page (M, ~1-2 days) ## Phase 2 — Character page (M, ~1-2 days)
`/` currently renders the literal string "character". `/` currently renders the literal string "character".
- [ ] player card: portrait, name, class, level, XP (data already in sidebar) - [x] player card: portrait, name, class, level, XP (data already in sidebar)
- [ ] HP block: temp / current / max, `Numput`-style editing - [x] HP block: temp / current / max, `Numput`-style editing
- [ ] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry - [x] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry
(upgrade the `Spell` stub from `name`-only to name + optional level/description) (upgrade the `Spell` stub from `name`-only to name + optional level/description)
- [ ] attunements UI: 3 slots, free text, clear button (model exists) - [x] attunements UI: 3 slots, free text, clear button (model exists)
- **Acceptance:** player can set prepared spells and attunements; they persist. - **Acceptance:** player can set prepared spells and attunements; they persist.
**Done (2026-08-16):** page in `src/components/character/`; e2e-verified
in chromium (add/undo/redo a spell, remove, reload persistence, attunements
with clear). Notes:
- The HP block was already in the sidebar (`hit_points.rs`, editable
`type="number"` inputs via `utils::adjust_checked`) — nothing to add.
- `Spell` fields are optional, so name-only spells in old saved JSON
still parse.
- Rows are plain functions called from `For`'s `let(...)` children:
component tags are not allowed directly inside them (view macro).
**Second pass (2026-08-16):** the player card is gone (it duplicated the
sidebar); headings are plain RU (`Заготовленные заклинания`, `Атюнменты`);
attunements grew from a fixed 3-slot array to a `Vec` with a "+ слот"
button (custom campaigns); `PlayerData.ac` (manual armor class) lives in
the sidebar HP block, `#[serde(default)]` so old saves still load.
## Phase 3 — Inventory & item search end-to-end (L, ~3-5 days) ## Phase 3 — Inventory & item search end-to-end (L, ~3-5 days)
Rows are editable and move/remove works. Search exists but is orphaned; "+" and Rows are editable and move/remove works. Search exists but is orphaned; "+" and
@ -156,8 +214,8 @@ New route `/contacts`. Model and mock data exist; zero UI.
## Phase 8 — Polish, tests, release (L, ~2-4 days) ## Phase 8 — Polish, tests, release (L, ~2-4 days)
- [ ] responsive SCSS pass (sidebar and header on mobile widths) - [ ] responsive SCSS pass (sidebar and header on mobile widths)
- [ ] Playwright e2e: persistence round-trip, add item from search, quest CRUD, - Playwright e2e: add item from search, quest CRUD, notes filter, undo
notes filter shortcuts (persistence round trip is covered by `persistence.spec.ts`)
- [ ] rewrite README: usage, dev workflow, deploy - [ ] rewrite README: usage, dev workflow, deploy
- [ ] release build via cargo-leptos; verify SSR + hydration; pick a deploy target - [ ] release build via cargo-leptos; verify SSR + hydration; pick a deploy target
- **Acceptance:** fresh clone → `cargo leptos build` → app runs; e2e green. - **Acceptance:** fresh clone → `cargo leptos build` → app runs; e2e green.
@ -168,7 +226,9 @@ New route `/contacts`. Model and mock data exist; zero UI.
- Phase 1 before Phases 2-7: every new page must persist. Building pages first - Phase 1 before Phases 2-7: every new page must persist. Building pages first
against mock data and persisting later costs a data-migration pass instead. against mock data and persisting later costs a data-migration pass instead.
- Phases 2 and 3 can run in parallel after Phase 1 (different components). - Phases 2 and 3 can run in parallel after Phase 1.5 (different components).
- Phase 1.5 (undo) is page-agnostic: it must land before the edit-heavy
phases so their edits are undoable; nothing in Phases 2-7 depends on it.
- Phases 4-6 are independent of each other and share one CRUD-list pattern — - Phases 4-6 are independent of each other and share one CRUD-list pattern —
build the pattern once (e.g. a generic list editor) and reuse it. build the pattern once (e.g. a generic list editor) and reuse it.
- Phase 7 reuses the Phase 3 add-to-inventory path; keep that path shared. - Phase 7 reuses the Phase 3 add-to-inventory path; keep that path shared.

3
end2end/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
playwright-report
test-results

View File

@ -0,0 +1,120 @@
import { test, expect, type Page } from "@playwright/test";
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
test.describe("character page", () => {
test("add a spell, undo it, redo it", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const vacant = page.locator(".prepared-slot.vacant").first();
await vacant.locator("input").fill("Щит веры");
await vacant.locator("button[title='Записать заклинание']").click();
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
// Ctrl+Z on the page (focus is on the button, not an input) undoes
// the add; Ctrl+Y redoes it.
await page.keyboard.press("Control+z");
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toHaveCount(0);
await page.keyboard.press("Control+y");
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
expect(errors).toEqual([]);
});
test("remove a spell empties its slot", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const fireball = page.locator(".prepared-slot.occupied", { hasText: "Огненный шар" });
await fireball.locator("button[title='Убрать заклинание']").click();
await expect(page.locator(".spell-name", { hasText: "Огненный шар" })).toHaveCount(0);
await expect(page.locator(".prepared-slot.vacant")).toHaveCount(3);
expect(errors).toEqual([]);
});
test("a prepared spell survives a reload", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const vacant = page.locator(".prepared-slot.vacant").first();
await vacant.locator("input").fill("Щит веры");
await vacant.locator("button[title='Записать заклинание']").click();
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
// Autosave is debounced by 500 ms; give it time before reloading.
await page.waitForTimeout(700);
await page.reload();
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
expect(errors).toEqual([]);
});
test("attune an item, clear it, reload keeps the attunement", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const slot = page.locator(".attunement-slot").first();
await slot.locator("input").fill("Посох погоды");
await slot.locator("button[title='Привязать предмет']").click();
await expect(page.locator(".attuned-item")).toHaveText("Посох погоды");
// Reload: the attunement persists.
await page.waitForTimeout(700);
await page.reload();
await expect(page.locator(".attuned-item")).toHaveText("Посох погоды");
// Clear it.
await page.locator("button[title='Снять привязку']").click();
await expect(page.locator(".attuned-item")).toHaveCount(0);
expect(errors).toEqual([]);
});
test("more attunement slots can be added for custom campaigns", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
await expect(page.locator(".attunement-slot")).toHaveCount(3);
await page.locator("button[title='Добавить слот привязки']").click();
await expect(page.locator(".attunement-slot")).toHaveCount(4);
const fourth = page.locator(".attunement-slot").nth(3);
await fourth.locator("input").fill("Волшебный рог");
await fourth.locator("button[title='Привязать предмет']").click();
await expect(page.locator(".attuned-item")).toHaveText("Волшебный рог");
expect(errors).toEqual([]);
});
test("armor class is set manually and persists", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const ac = page.locator("#ac");
await expect(ac).toHaveValue("16");
await ac.fill("18");
// Autosave is debounced by 500 ms; give it time before reloading.
await page.waitForTimeout(700);
await page.reload();
await expect(page.locator("#ac")).toHaveValue("18");
expect(errors).toEqual([]);
});
});

View File

@ -1,9 +0,0 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
});

View File

@ -0,0 +1,74 @@
import { test, expect, type Page } from "@playwright/test";
import * as fs from "node:fs";
// Override with BASE_URL (e.g. when the dev server runs on another port).
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
// Collects browser console errors and page errors for the duration of a test.
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
// Autosave is debounced at 500 ms; give it time before reloading.
const DEBOUNCE_MS = 700;
test("campaign name survives a reload", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const name = "Кампания с сохранением";
const input = page.locator("#campaign-name");
await input.fill(name);
await page.waitForTimeout(DEBOUNCE_MS);
await page.reload();
await expect(input).toHaveValue(name);
expect(errors).toEqual([]);
});
test("export/import round trip through the file", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
// Save: the header button downloads dashboard.json with the current state.
const name = "Кампания экспорта";
await page.locator("#campaign-name").fill(name);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "Сохранить" }).click(),
]);
expect(download.suggestedFilename()).toBe("dashboard.json");
const filePath = await download.path();
expect(filePath).not.toBeNull();
const saved = JSON.parse(fs.readFileSync(filePath!, "utf8"));
expect(saved.campaign).toBe(name);
// Clear: confirm the dialog, the dashboard goes blank.
await page.getByRole("button", { name: "Очистить" }).click();
await page.locator(".modal").waitFor();
await page.locator(".modal").getByRole("button", { name: "Очистить" }).click();
await expect(page.locator(".modal")).toBeHidden();
await expect(page.locator("#campaign-name")).toHaveValue("");
// Load: pick the file we just saved, the state comes back.
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Загрузить" }).click(),
]);
await chooser.setFiles(filePath!);
await expect(page.locator("#campaign-name")).toHaveValue(name);
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,57 @@
import { test, expect, type Page } from "@playwright/test";
// Override with BASE_URL (e.g. when the dev server runs on another port).
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
// Collects browser console errors and page errors for the duration of a test.
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
test("home page: campaign name, version, nav", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
await expect(page.locator("#campaign-name")).toHaveValue("В Поисках Дмитрия Шардалина");
await expect(page.locator(".version")).toContainText("aex");
await expect(page.locator(".main-nav a")).toHaveCount(3);
await expect(page.locator(".main-nav")).toContainText("Персонаж");
await expect(page.locator(".main-nav")).toContainText("Инвентарь");
await expect(page.locator(".main-nav")).toContainText("Информация");
expect(errors).toEqual([]);
});
test("inventories page: personal and common sections", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/inv`);
await expect(page.locator(".inventory-personal")).toHaveCount(1);
await expect(page.locator(".inventory-common")).toHaveCount(1);
// each section has one balance block with five currency inputs
await expect(page.locator(".inventory-personal .balance .numput")).toHaveCount(5);
await expect(page.locator(".inventory-common .balance .numput")).toHaveCount(5);
// and at least one item row each
await expect(page.locator(".inventory-personal .item").first()).toBeVisible();
await expect(page.locator(".inventory-common .item").first()).toBeVisible();
expect(errors).toEqual([]);
});
test("wiki page: stub renders", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/wiki`);
await expect(page.locator("body")).toContainText("books");
expect(errors).toEqual([]);
});

106
end2end/tests/undo.spec.ts Normal file
View File

@ -0,0 +1,106 @@
import { test, expect, type Page } from "@playwright/test";
// Override with BASE_URL (e.g. when the dev server runs on another port).
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
// Collects browser console errors and page errors for the duration of a test.
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
// Autosave is debounced at 500 ms; give it time before reloading.
const DEBOUNCE_MS = 700;
test("undo and redo a campaign name edit", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const original = await page.locator("#campaign-name").inputValue();
const undo = page.getByRole("button", { name: "Отменить" });
const redo = page.getByRole("button", { name: "Вернуть" });
// Fresh history: both buttons are disabled.
await expect(undo).toBeDisabled();
await expect(redo).toBeDisabled();
// One edit burst; the undo button wakes up immediately (the burst does
// not need to settle first).
const edited = "Имя для отмены";
await page.locator("#campaign-name").fill(edited);
await expect(undo).toBeEnabled();
await undo.click();
await expect(page.locator("#campaign-name")).toHaveValue(original);
await redo.click();
await expect(page.locator("#campaign-name")).toHaveValue(edited);
// Keyboard: Ctrl+Z outside a text field undoes the redo.
await page.locator(".version").click();
await page.keyboard.press("Control+z");
await expect(page.locator("#campaign-name")).toHaveValue(original);
expect(errors).toEqual([]);
});
test("Ctrl+Z inside a text field keeps the browser's native undo", async ({
page,
}) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const input = page.locator("#campaign-name");
await input.fill("Текст набран в поле");
await input.press("Control+z");
// The app-wide handler must not fire while the field has focus: the
// undo button stays enabled (the pending burst is still undoable).
// The field's own value change is the browser's native undo — it
// owns Ctrl+Z inside text fields by design.
await expect(page.getByRole("button", { name: "Отменить" })).toBeEnabled();
expect(errors).toEqual([]);
});
test("undone state survives a reload", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const original = await page.locator("#campaign-name").inputValue();
const edited = "Будет отменено до перезагрузки";
await page.locator("#campaign-name").fill(edited);
await page.getByRole("button", { name: "Отменить" }).click();
await expect(page.locator("#campaign-name")).toHaveValue(original);
// The undo went through the autosave path; wait for the debounce.
await page.waitForTimeout(DEBOUNCE_MS);
await page.reload();
await expect(page.locator("#campaign-name")).toHaveValue(original);
expect(errors).toEqual([]);
});
test("clear is undoable", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const original = await page.locator("#campaign-name").inputValue();
await page.getByRole("button", { name: "Очистить" }).click();
await page.locator(".modal").getByRole("button", { name: "Очистить" }).click();
await expect(page.locator("#campaign-name")).toHaveValue("");
// One undo step restores everything the clear replaced.
await page.getByRole("button", { name: "Отменить" }).click();
await expect(page.locator("#campaign-name")).toHaveValue(original);
expect(errors).toEqual([]);
});

View File

@ -8,6 +8,7 @@ use leptos_router::{
use crate::{ use crate::{
components::{ components::{
header::Header, header::Header,
modal::{ Modal, ModalState },
nav::Nav, nav::Nav,
sidebar::Sidebar, sidebar::Sidebar,
Character, Character,
@ -19,17 +20,27 @@ use crate::{
/// Root component of the app. /// Root component of the app.
/// ///
/// Sets up meta context, creates the `Store<Dashboard>` (mock data for now) /// Sets up meta context, creates the `Store<Dashboard>` (mock data; the
/// and injects it as context, then renders the shell: header, sidebar, nav /// hydrated client swaps in the saved state, see `state::persist`) and
/// and the routed page. Routes: `/` character, `/inv` inventories, /// injects it as context, then renders the shell: header, sidebar, nav,
/// `/wiki` wiki; anything else falls to `NotFound`. /// the routed page and the modal overlay. Routes: `/` character, `/inv`
/// inventories, `/wiki` wiki; anything else falls to `NotFound`.
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc. // Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context(); provide_meta_context();
provide_context(Store::new(Dashboard::mock())); let store = Store::new(Dashboard::mock());
provide_context(store.clone());
provide_context(ModalState::new());
provide_context(crate::state::undo::HistoryHandle::new(store.clone()));
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
// Client-only: load the saved state after mount, then autosave + undo.
#[cfg(feature = "hydrate")]
crate::state::persist::init_hydrated(&store, &expect_context::<crate::state::undo::HistoryHandle>());
let modal = expect_context::<ModalState>();
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet // id=leptos means cargo-leptos will hot-reload this stylesheet
@ -53,6 +64,7 @@ pub fn App() -> impl IntoView {
</Routes> </Routes>
</main> </main>
</div> </div>
<Modal state=modal />
</Router> </Router>
} }
} }

View File

@ -0,0 +1,72 @@
use reactive_stores::Field;
use crate::prelude::*;
use leptos::prelude::*;
/// The three attunement slots: each is either empty (free-text input and
/// "+" button) or holds an item name with a "×" clear button.
#[component]
pub fn Attunements () -> impl IntoView {
let state = expect_context::<Context>();
let attunements: Field<Attunements> = state.player().attunements().into();
view! {
<div class="attunements">
<h6>"Атюнменты:"</h6>
<For
// Same state-in-key convention as prepared spells, so a
// slot whose value changed re-renders.
each=move || attunements.get().0.into_iter().enumerate()
key=|(i, s)| format!("{}-{}", i, s.as_deref().unwrap_or(""))
let((idx, slot))
>
{slot_row(attunements, idx, slot)}
</For>
<button
class="add-slot"
title="Добавить слот привязки"
on:click=move |_| attunements.update(|a| a.0.push(None))
>
"+ слот"
</button>
</div>
}
}
/// One attunement slot: the free-text entry (empty) or the item itself.
/// A plain function: component tags are not allowed directly inside
/// `For`'s `let(...)` children.
fn slot_row (
attunements: Field<Attunements>,
idx: usize,
slot: Option<String>,
) -> impl IntoView {
let text_sig = RwSignal::new(String::new());
// Binds the typed item name; empty text is ignored.
let bind = move |_| {
let text = text_sig.get_untracked();
if text.trim().is_empty() {
return;
}
attunements.update(|a| a.0[idx] = Some(text.trim().to_string()));
};
let clear = move |_| attunements.update(|a| a.0[idx] = None);
view! {
<div class="attunement-slot">
{if slot.is_some() {
view! {
<div class="attuned-item">{slot.clone().unwrap_or_default()}</div>
<button title="Снять привязку" on:click=clear>"×"</button>
}.into_any()
} else {
view! {
<input type="text" placeholder="Предмет…" bind:value=text_sig />
<button title="Привязать предмет" on:click=bind>"+"</button>
}.into_any()
}}
</div>
}
}

View File

@ -0,0 +1,7 @@
//! Character page blocks (route `/`): prepared spells and attunements.
//! The sidebar carries the character editors (names, class/level/XP,
//! HP, hit dice, death saves, spell slots); this module renders the
//! page-level view.
pub mod attunements;
pub mod prepared_spells;

View File

@ -0,0 +1,96 @@
use reactive_stores::Field;
use crate::prelude::*;
use leptos::prelude::*;
/// Prepared spells: a growable list of vacant or occupied slots.
///
/// A vacant slot offers a free-text input and a "+" button; entering a
/// name fills the slot with a spell. An occupied slot shows the name
/// (plus level and description when set) and a "×" button that empties
/// it back to vacant. "+ слот" appends a new vacant slot.
#[component]
pub fn PreparedSpells () -> impl IntoView {
let state = expect_context::<Context>();
let prepared: Field<PreparedSpells> = state.player().prepared().into();
view! {
<div class="prepared-spells">
<h6>"Заготовленные заклинания:"</h6>
<For
// The key carries the slot state, so a slot whose state
// changed re-renders (same convention as sidebar pips).
each=move || prepared.get().0.into_iter().enumerate()
key=|(i, s)| format!("{}-{}", i, match &s {
PreparedSpell::Vacant => "vacant".to_string(),
PreparedSpell::Occupied(spell) => spell.name.clone(),
})
let((idx, slot))
>
{slot_row(prepared, idx, slot)}
</For>
<button
class="add-slot"
title="Добавить слот заклинания"
on:click=move |_| prepared.update(|p| p.0.push(PreparedSpell::Vacant))
>
"+ слот"
</button>
</div>
}
}
/// One prepared-spell slot: the free-text entry (vacant) or the spell
/// itself (occupied). A plain function: component tags are not allowed
/// directly inside `For`'s `let(...)` children.
fn slot_row (
prepared: Field<PreparedSpells>,
idx: usize,
slot: PreparedSpell,
) -> impl IntoView {
let name_sig = RwSignal::new(String::new());
// Fills this slot with the typed name; empty text is ignored.
let add = move |_| {
let text = name_sig.get_untracked();
if text.trim().is_empty() {
return;
}
prepared.update(|p| {
p.0[idx] = PreparedSpell::Occupied(Spell {
name: text.trim().to_string(),
..Default::default()
});
});
};
let remove = move |_| prepared.update(|p| p.0[idx] = PreparedSpell::Vacant);
let vacant = matches!(slot, PreparedSpell::Vacant);
view! {
<div class=format!("prepared-slot {}", if vacant { "vacant" } else { "occupied" })>
{if vacant {
view! {
<input
type="text"
placeholder="Название заклинания…"
bind:value=name_sig
/>
<button title="Записать заклинание" on:click=add>"+"</button>
}.into_any()
} else {
let spell = match slot {
PreparedSpell::Occupied(spell) => spell,
PreparedSpell::Vacant => unreachable!(),
};
view! {
<div class="spell-name">{spell.name.clone()}</div>
<div class="spell-level">{move || spell.level.clone().map(|l| format!("ур. {l}")).unwrap_or_default()}</div>
<div class="spell-desc">{move || spell.description.clone().unwrap_or_default()}</div>
<button title="Убрать заклинание" on:click=remove>"×"</button>
}.into_any()
}}
</div>
}
}

View File

@ -1,13 +1,30 @@
use crate::prelude::*;
use chrono::TimeDelta; use chrono::TimeDelta;
use leptos::prelude::*; use leptos::prelude::*;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use crate::components::modal::ModalState;
use crate::prelude::*;
use crate::state::persist::{ apply_import, clear_all, export_download };
use crate::state::undo::HistoryHandle;
/// Campaign header: editable name, campaign image, in-game date and /// Campaign header: editable name, campaign image, in-game date and
/// session steppers, and Save/Load/Clear buttons (not wired yet — see /// session steppers, the persistence controls, and undo/redo.
/// ROADMAP phase 1). ///
/// Save downloads the dashboard as `dashboard.json`, Load imports one via
/// a hidden file picker (with a confirm), Clear wipes to the blank
/// campaign (with a confirm). Changes also autosave to localStorage
/// (see `crate::state::persist`) and feed the undo history (see
/// `crate::state::undo`); the undo/redo buttons and the Ctrl+Z /
/// Ctrl+Shift+Z / Ctrl+Y shortcuts revert the last edit bursts.
#[component] #[component]
pub fn Header () -> impl IntoView { pub fn Header () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
let modal = expect_context::<ModalState>();
let history = expect_context::<HistoryHandle>();
let undo_depth = history.undo_depth();
let redo_depth = history.redo_depth();
let file_input = NodeRef::<leptos::html::Input>::new();
let campaign = state.campaign(); let campaign = state.campaign();
let date = state.date(); let date = state.date();
let session = state.session(); let session = state.session();
@ -25,6 +42,69 @@ pub fn Header () -> impl IntoView {
} }
}); });
// Save: downloads the current dashboard as a JSON file (backup).
let on_save = {
let state = state.clone();
move |_| export_download(&state)
};
// Load: opens the hidden file picker; the picked file is imported.
let on_load = {
let file_input = file_input.clone();
move |_| {
if let Some(input) = file_input.get() {
let _ = input.click();
}
}
};
// Clear: asks for confirmation, then replaces everything with the
// blank dashboard.
let on_clear = {
let state = state.clone();
let modal = modal.clone();
move |_| {
modal.confirm(
"Очистить всё?",
"Текущее состояние будет заменено пустой кампанией. Это действие нельзя отменить.",
"Очистить",
move || clear_all(&state),
);
}
};
// Import: reads the picked file, applies it, reports errors in a modal.
let on_file = {
let state = state.clone();
let modal = modal.clone();
move |ev: Event| {
let Some(target) = ev.target() else { return };
let input = target.unchecked_into::<web_sys::HtmlInputElement>();
let Some(file) = input.files().and_then(|files| files.item(0)) else {
return;
};
let Ok(reader) = web_sys::FileReader::new() else { return };
let reader_c = reader.clone();
let modal = modal.clone();
let onload = Closure::once(move || {
let text = reader_c
.result()
.ok()
.and_then(|value| value.as_string())
.unwrap_or_default();
match apply_import(&state, &text) {
Ok(()) => {}
Err(err) => modal.error("Ошибка импорта", err.to_string()),
}
});
reader.set_onloadend(Some(onload.as_ref().unchecked_ref()));
onload.forget();
let _ = reader.read_as_text(&file);
input.set_value("");
}
};
view! { view! {
<header> <header>
<Show when=move || !image.get().is_empty()> <Show when=move || !image.get().is_empty()>
@ -46,11 +126,22 @@ pub fn Header () -> impl IntoView {
<button on:click=move |_| adjust_session( 1) title="Следующая сессия" class="adjust" id="session-forward">">"</button> <button on:click=move |_| adjust_session( 1) title="Следующая сессия" class="adjust" id="session-forward">">"</button>
</div> </div>
<div class="controls"> <div class="controls">
<button>Save</button> <button on:click=on_save>"Сохранить"</button>
<button>Load</button> <button on:click=on_load>"Загрузить"</button>
<button>Clear</button> <button on:click=on_clear>"Очистить"</button>
<button
on:click=move |_| history.undo()
disabled=move || undo_depth.get() == 0
title="Отменить (Ctrl+Z)"
>"Отменить"</button>
<button
on:click=move |_| history.redo()
disabled=move || redo_depth.get() == 0
title="Вернуть (Ctrl+Shift+Z или Ctrl+Y)"
>"Вернуть"</button>
</div> </div>
</section> </section>
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
</header> </header>
} }
} }

View File

@ -1,4 +1,4 @@
use leptos::{logging, prelude::*}; use leptos::prelude::*;
use reactive_stores::Field; use reactive_stores::Field;
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* }; use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };

View File

@ -1,7 +1,7 @@
use crate::{prelude::*, utils::BasisPoints}; use crate::{prelude::*, utils::BasisPoints};
use leptos::prelude::*; use leptos::prelude::*;
use reactive_stores::{Field, OptionStoreExt}; use reactive_stores::Field;
/// One inventory row: name, quantity stepper + `Numput`, weight stepper + /// One inventory row: name, quantity stepper + `Numput`, weight stepper +
/// `Numput`, and more/swap/remove buttons. A weight-based tag drives the /// `Numput`, and more/swap/remove buttons. A weight-based tag drives the
@ -14,7 +14,6 @@ pub fn Item (
mut rm: impl FnMut(usize) + 'static, mut rm: impl FnMut(usize) + 'static,
) -> impl IntoView { ) -> impl IntoView {
let qty = move || entry.quantity();
let item = move || entry.item(); let item = move || entry.item();
let empty = String::new(); let empty = String::new();

View File

@ -4,25 +4,35 @@
//! and `numput` implement the inventories page; the page components //! and `numput` implement the inventories page; the page components
//! (`Character`, `Inventories`, `Wiki`) and the item search live in this //! (`Character`, `Inventories`, `Wiki`) and the item search live in this
//! module root. //! module root.
#![allow(dead_code)]
// `ItemSearchField` and `ItemSearchCard` are stubs, not mounted on any page
// yet. rustc flags the macro-generated props field as unread. Remove this
// when ROADMAP phase 3 mounts the search.
use leptos::prelude::*; use leptos::prelude::*;
use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher }; use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher };
use reactive_stores::Field; use reactive_stores::Field;
use crate::prelude::*; use crate::prelude::*;
pub mod character;
pub mod header; pub mod header;
pub mod sidebar; pub mod sidebar;
pub mod nav; pub mod nav;
pub mod inventory; pub mod inventory;
pub mod numput; pub mod numput;
pub mod item; pub mod item;
pub mod modal;
/// Character page (route `/`). Stub: renders a placeholder until /// Character page (route `/`): prepared spells and attunements
/// ROADMAP phase 2. /// (ROADMAP phase 2). The sidebar carries the character editors.
#[component] #[component]
pub fn Character () -> impl IntoView { pub fn Character () -> impl IntoView {
view! { view! {
character <div class="character-page">
<character::prepared_spells::PreparedSpells />
<character::attunements::Attunements />
</div>
} }
} }

106
src/components/modal.rs Normal file
View File

@ -0,0 +1,106 @@
//! In-app modal dialogs: confirmations and error messages.
//!
//! `ModalState` is created in `App` and provided through Leptos context;
//! the header's Save/Load/Clear handlers open dialogs and [`Modal`]
//! renders them. The dialog language is Russian.
use std::sync::Arc;
use leptos::prelude::*;
/// Shared state of the modal overlay.
///
/// `open` decides visibility; `title`/`message`/`cancel_label` and
/// `confirm_label` (None for error dialogs) drive the text; `action` runs
/// when the confirm button is clicked.
#[derive(Clone)]
pub struct ModalState {
open: RwSignal<bool>,
title: RwSignal<String>,
message: RwSignal<String>,
cancel_label: RwSignal<String>,
confirm_label: RwSignal<Option<String>>,
action: RwSignal<Arc<dyn Fn() + Send + Sync>>,
}
impl ModalState {
/// Creates a closed modal with empty content.
pub fn new () -> Self {
Self {
open: RwSignal::new(false),
title: RwSignal::new(String::new()),
message: RwSignal::new(String::new()),
cancel_label: RwSignal::new(String::new()),
confirm_label: RwSignal::new(None),
action: RwSignal::new(Arc::new(|| {})),
}
}
/// Opens a confirmation dialog: Cancel plus the given confirm button.
pub fn confirm (
&self,
title: impl Into<String>,
message: impl Into<String>,
confirm_label: impl Into<String>,
action: impl Fn() + Send + Sync + 'static,
) {
self.title.set(title.into());
self.message.set(message.into());
self.cancel_label.set("Отмена".to_string());
self.confirm_label.set(Some(confirm_label.into()));
self.action.set(Arc::new(action));
self.open.set(true);
}
/// Opens an error dialog with a single Close button.
pub fn error (&self, title: impl Into<String>, message: impl Into<String>) {
self.title.set(title.into());
self.message.set(message.into());
self.cancel_label.set("Закрыть".to_string());
self.confirm_label.set(None);
self.open.set(true);
}
/// Closes the dialog.
pub fn close (&self) {
self.open.set(false);
}
}
/// Renders the modal overlay; empty when no dialog is open.
///
/// Clicking the overlay background closes the dialog; clicks inside the
/// dialog do not. The confirm button runs the action captured in
/// `ModalState` (read at click time, so a late `confirm` wins).
#[component]
pub fn Modal (state: ModalState) -> impl IntoView {
view! {
<Show when=move || state.open.get()>
<div class="modal-overlay" on:click=move |_| state.open.set(false)>
<div
class="modal"
on:click=move |ev: leptos::ev::MouseEvent| ev.stop_propagation()
>
<h3>{move || state.title.get()}</h3>
<p>{move || state.message.get()}</p>
<div class="modal-actions">
<button class="modal-cancel" on:click=move |_| state.open.set(false)>
{move || state.cancel_label.get()}
</button>
<Show when=move || state.confirm_label.get().is_some()>
<button
class="modal-confirm"
on:click=move |_| {
(state.action.get())();
state.open.set(false);
}
>
{move || state.confirm_label.get().unwrap_or_default()}
</button>
</Show>
</div>
</div>
</div>
</Show>
}
}

View File

@ -1,4 +1,3 @@
use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::components::A; use leptos_router::components::A;

View File

@ -1,6 +1,6 @@
use std::{ fmt::Debug, str::FromStr, ops::{ Sub, Add }}; use std::{ fmt::Debug, str::FromStr };
use leptos::{ logging, prelude::*, tachys::html::property::IntoProperty }; use leptos::{ prelude::*, tachys::html::property::IntoProperty };
use num_format::{Locale, ToFormattedString}; use num_format::{Locale, ToFormattedString};
use num_traits::{ SaturatingSub, SaturatingAdd }; use num_traits::{ SaturatingSub, SaturatingAdd };
use crate::prelude::*; use crate::prelude::*;

View File

@ -1,12 +1,12 @@
//! Character sidebar: portrait, names, class/level/XP, HP, hit dice, //! Character sidebar: portrait, names, class/level/XP, HP, hit dice,
//! death saves and spell slots. //! death saves and spell slots.
use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
mod image; mod image;
mod names; mod names;
mod about; mod about;
mod hit_points; mod hit_points;
mod armor_class;
mod hit_dice; mod hit_dice;
mod death_saving_throws; mod death_saving_throws;
mod spell_slots; mod spell_slots;
@ -21,6 +21,7 @@ pub fn Sidebar () -> impl IntoView {
<names::Names /> <names::Names />
<about::About /> <about::About />
<hit_points::HitPoints /> <hit_points::HitPoints />
<armor_class::ArmorClass />
<hit_dice::HitDice /> <hit_dice::HitDice />
<death_saving_throws::DeathSavingThrows /> <death_saving_throws::DeathSavingThrows />
<spell_slots::SpellSlots /> <spell_slots::SpellSlots />

View File

@ -0,0 +1,22 @@
use crate::prelude::*;
use leptos::prelude::*;
/// AC counter: for now is to be set manually
#[component]
pub fn ArmorClass () -> impl IntoView {
let state = expect_context::<Context>();
let player = state.player();
let ac = player.ac();
let adjust_ac = move |ev: Targeted<Event, HtmlInputElement>| {
utils::adjust_checked(ev, ac);
};
view! {
<h6>"ac/кд:"</h6>
<div class="ac">
<input min=0 title="Класс доспеха (вручную)" id="ac" type="number" class="header-input" on:input:target=move |e| adjust_ac(e) prop:value=move || ac.get() />
</div>
}
}

View File

@ -45,7 +45,7 @@ pub fn HitDice () -> impl IntoView {
</select> </select>
</div> </div>
<For <For
each=move || (0..level.get()) each=move || 0..level.get()
key=|slot| slot.clone() key=|slot| slot.clone()
let (slot) let (slot)
> >

View File

@ -2,9 +2,10 @@ use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
/// Current/max HP with a fill bar, plus temp HP with its own bar. /// Current/max HP with a fill bar, plus temp HP with its own bar.
/// All three numbers are editable `type="number"` inputs. /// All three numbers are editable `type="number"` inputs
#[component] #[component]
pub fn HitPoints () -> impl IntoView { pub fn HitPoints() -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
let player = state.player(); let player = state.player();
@ -26,13 +27,10 @@ pub fn HitPoints () -> impl IntoView {
utils::adjust_checked(ev, temp_hp); utils::adjust_checked(ev, temp_hp);
}; };
let pc_hp =
let pc_hp = move |a: u16, b: u16| -> String { move |a: u16, b: u16| -> String { format!("width: {}%", utils::filled_pc(a, b).min(100.)) };
format!("width: {}%", utils::filled_pc(a, b).min(100.))
};
view! { view! {
<h6>hp/оз:</h6> <h6>"hp/оз:"</h6>
<div class="hp"> <div class="hp">
<div class="perm"> <div class="perm">
<div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div> <div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div>

View File

@ -30,9 +30,10 @@ impl Dashboard {
temp_hp: 12, temp_hp: 12,
hp: 1, hp: 1,
max_hp: 61, max_hp: 61,
ac: 16,
balance: Balance { platinum: 12, gold: 200, electrum: 1, silver: 101, copper: 129021021 }, balance: Balance { platinum: 12, gold: 200, electrum: 1, silver: 101, copper: 129021021 },
spell_slots: SpellSlots(core::array::from_fn(|i| SpellSlotLevel { used: 1, total: 9 - i as u8 })), spell_slots: SpellSlots(core::array::from_fn(|i| SpellSlotLevel { used: 1, total: 9 - i as u8 })),
attunements: Attunements([const { None }; 3]), attunements: Attunements(vec![None; 3]),
hit_dice: HitDice { used: 1, kind: 20 }, hit_dice: HitDice { used: 1, kind: 20 },
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 }, death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
prepared: generate_prepared_spells_list(), prepared: generate_prepared_spells_list(),
@ -45,9 +46,7 @@ impl Dashboard {
quest_book: generate_mock_questbook(), quest_book: generate_mock_questbook(),
notes: generate_mock_notebook(), notes: generate_mock_notebook(),
people: generate_mock_contact_book(), people: generate_mock_contact_book(),
settings: Settings { settings: Settings::default(),
}
} }
} }
} }
@ -186,11 +185,22 @@ fn generate_mock_contact_book () -> ContactBook {
} }
/// Demo prepared spells. Currently an empty list — `Spell` is a stub. /// Demo prepared spells: a vacant slot, two known spells, one more
/// vacant slot. The UI can fill and empty slots freely.
fn generate_prepared_spells_list () -> PreparedSpells { fn generate_prepared_spells_list () -> PreparedSpells {
use PreparedSpell::*;
let list = vec![ let list = vec![
PreparedSpell::Vacant,
PreparedSpell::Occupied(Spell {
name: "Огненный шар".to_string(),
level: Some(3),
description: Some("Вспышка пламени в точке, радиус 6 м.".to_string()),
}),
PreparedSpell::Occupied(Spell {
name: "Магическая броня".to_string(),
level: Some(1),
description: None,
}),
PreparedSpell::Vacant,
]; ];
PreparedSpells(list) PreparedSpells(list)

View File

@ -9,12 +9,14 @@
//! //!
//! The model is deliberately "wide": it covers the campaign header, the //! The model is deliberately "wide": it covers the campaign header, the
//! player character, the party's common stash, quests, notes, contacts and //! player character, the party's common stash, quests, notes, contacts and
//! settings. All of it currently comes from hardcoded mock data (the `mock` //! settings. `mock` provides demo data, `Dashboard::blank` an empty slate;
//! module); nothing is persisted yet (see ROADMAP phase 1). //! persistence (localStorage autosave, JSON export/import) lives in
//! `crate::state::persist`.
use std::fmt::Display; use std::fmt::Display;
use chrono::{ NaiveDate, NaiveDateTime }; use chrono::{ NaiveDate, NaiveDateTime };
use reactive_stores::Store; use reactive_stores::Store;
use serde::{ Deserialize, Serialize };
use crate::prelude::*; use crate::prelude::*;
@ -66,7 +68,7 @@ pub struct ItemReferenceMap (pub HashMap<String, BookItem>);
/// party's common inventory, quests, notes, contacts and settings. The /// party's common inventory, quests, notes, contacts and settings. The
/// whole tree derives `Store`, so every nested field is independently /// whole tree derives `Store`, so every nested field is independently
/// reactive. `Dashboard::mock()` provides the initial state. /// reactive. `Dashboard::mock()` provides the initial state.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Dashboard { pub struct Dashboard {
pub campaign: String, pub campaign: String,
pub campaign_image: String, pub campaign_image: String,
@ -80,10 +82,50 @@ pub struct Dashboard {
pub settings: Settings pub settings: Settings
} }
impl Dashboard {
/// An empty dashboard: the blank slate after Clear.
///
/// No campaign name, no players, empty books, zeroed numbers.
pub fn blank () -> Self {
Self {
campaign: String::new(),
campaign_image: String::new(),
session: 0,
date: chrono::Local::now().date_naive(),
player: PlayerData {
name: Name::default(),
class: String::new(),
image: String::new(),
level: 0,
xp: 0,
temp_hp: 0,
hp: 0,
max_hp: 0,
ac: 0,
balance: Balance::default(),
spell_slots: SpellSlots (core::array::from_fn(|_| SpellSlotLevel { used: 0, total: 0 })),
inventory: Inventory::default(),
attunements: Attunements (vec![None; 3]),
prepared: PreparedSpells (Vec::new()),
hit_dice: HitDice { used: 0, kind: 8 },
death_save_throws: DeathSaveThrows { failed: 0, succeeded: 0 },
},
common: CommonData {
balance: Balance::default(),
inventory: Inventory::default(),
},
quest_book: QuestBook (Vec::new()),
notes: NoteBook (Vec::new()),
people: ContactBook (HashMap::new()),
settings: Settings::default(),
}
}
}
/// The player character: identity, class, level, XP, hit points, money, /// The player character: identity, class, level, XP, hit points, money,
/// spell slots, inventory, attunements, prepared spells, hit dice and /// spell slots, inventory, attunements, prepared spells, hit dice and
/// death saves. This is the heart of the character sheet. /// death saves. This is the heart of the character sheet.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct PlayerData { pub struct PlayerData {
pub name: Name, pub name: Name,
pub class: String, pub class: String,
@ -93,6 +135,10 @@ pub struct PlayerData {
pub temp_hp: u16, pub temp_hp: u16,
pub hp: u16, pub hp: u16,
pub max_hp: u16, pub max_hp: u16,
/// Armor class; set manually by the player (no derived calculation
/// yet). Missing in old saved JSON, so default to 0.
#[serde(default)]
pub ac: u8,
pub balance: Balance, pub balance: Balance,
pub spell_slots: SpellSlots, pub spell_slots: SpellSlots,
pub inventory: Inventory, pub inventory: Inventory,
@ -105,7 +151,7 @@ pub struct PlayerData {
/// The party's shared stash: money and items usable by everyone. /// The party's shared stash: money and items usable by everyone.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct CommonData { pub struct CommonData {
pub balance: Balance, pub balance: Balance,
pub inventory: Inventory, pub inventory: Inventory,
@ -113,7 +159,7 @@ pub struct CommonData {
/// Currency counts per denomination, platinum through copper. /// Currency counts per denomination, platinum through copper.
/// Edited with `Numput` on the inventories page. /// Edited with `Numput` on the inventories page.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Balance { pub struct Balance {
pub platinum: u32, pub platinum: u32,
pub gold: u32, pub gold: u32,
@ -126,14 +172,14 @@ pub struct Balance {
/// ///
/// `#[store(key)]` gives every entry a stable reactive key (its `id`), /// `#[store(key)]` gives every entry a stable reactive key (its `id`),
/// which keeps `For`/`ForEnumerate` updates cheap. /// which keeps `For`/`ForEnumerate` updates cheap.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Inventory { pub struct Inventory {
#[store(key: u64 = |row| row.id)] #[store(key: u64 = |row| row.id)]
pub rows: Vec<InventoryEntry> pub rows: Vec<InventoryEntry>
} }
/// One inventory row: a quantity of a single item. /// One inventory row: a quantity of a single item.
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct InventoryEntry { pub struct InventoryEntry {
pub id: u64, pub id: u64,
pub quantity: u32, pub quantity: u32,
@ -155,7 +201,7 @@ impl InventoryEntry {
/// An item inside an inventory: name, weight (basis points), optional /// An item inside an inventory: name, weight (basis points), optional
/// description, and free-form tags (e.g. `type`, `rarity`). /// description, and free-form tags (e.g. `type`, `rarity`).
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct InventoryItem { pub struct InventoryItem {
pub name: String, pub name: String,
pub weight: u32, pub weight: u32,
@ -164,12 +210,12 @@ pub struct InventoryItem {
} }
/// The party's quest list. No UI yet — see ROADMAP phase 4. /// The party's quest list. No UI yet — see ROADMAP phase 4.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct QuestBook (pub Vec<Quest>); pub struct QuestBook (pub Vec<Quest>);
/// One quest: title, locations, dates, description, giver and reward. /// One quest: title, locations, dates, description, giver and reward.
/// Note: there is no completion state yet (ROADMAP phase 4 adds one). /// Note: there is no completion state yet (ROADMAP phase 4 adds one).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Quest { pub struct Quest {
pub title: String, pub title: String,
pub location_taken: Option<String>, pub location_taken: Option<String>,
@ -182,12 +228,12 @@ pub struct Quest {
} }
/// The party's session notes. No UI yet — see ROADMAP phase 5. /// The party's session notes. No UI yet — see ROADMAP phase 5.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct NoteBook (pub Vec<Note>); pub struct NoteBook (pub Vec<Note>);
/// One note: content, in-game date, real-world timestamp, session number /// One note: content, in-game date, real-world timestamp, session number
/// and tags. /// and tags.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Note { pub struct Note {
pub content: String, pub content: String,
pub date_ingame: NaiveDate, pub date_ingame: NaiveDate,
@ -197,43 +243,45 @@ pub struct Note {
} }
/// Spell slots for the nine spell levels (index 0 = 1st level). /// Spell slots for the nine spell levels (index 0 = 1st level).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct SpellSlots (pub [SpellSlotLevel; 9]); pub struct SpellSlots (pub [SpellSlotLevel; 9]);
/// Used/total slots for one spell level. /// Used/total slots for one spell level.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct SpellSlotLevel { pub struct SpellSlotLevel {
pub used: u8, pub used: u8,
pub total: u8, pub total: u8,
} }
/// Three attunement slots (D&D rules allow three attuned items). /// Attunement slots (D&D rules allow three attuned items; the UI can
#[derive(Clone, Debug, Store)] /// add more slots for custom campaigns).
pub struct Attunements (pub [Option<String>; 3]); #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Attunements (pub Vec<Option<String>>);
/// The spell list the character has prepared (or is learning). /// The spell list the character has prepared (or is learning).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct PreparedSpells (pub Vec<PreparedSpell>); pub struct PreparedSpells (pub Vec<PreparedSpell>);
/// One prepared-spell slot: vacant or holding a spell. /// One prepared-spell slot: vacant or holding a spell.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub enum PreparedSpell { pub enum PreparedSpell {
Vacant, Vacant,
Occupied (Spell) Occupied (Spell)
} }
/// A spell. Only the name exists so far; the rest is a `@TODO` /// A spell: the name plus optional level and description.
/// (see ROADMAP phase 2). /// Optional fields keep old saved JSON (name-only spells) parseable.
// @TODO #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
#[derive(Clone, Debug, Store)]
pub struct Spell { pub struct Spell {
pub name: String pub name: String,
pub level: Option<u8>,
pub description: Option<String>,
} }
/// Hit dice: how many of the current die kind are already spent. /// Hit dice: how many of the current die kind are already spent.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct HitDice { pub struct HitDice {
pub used: u8, pub used: u8,
pub kind: u8 pub kind: u8
@ -242,7 +290,7 @@ pub struct HitDice {
/// Death-saving-throw counters: three failures kill, three successes /// Death-saving-throw counters: three failures kill, three successes
/// stabilize. /// stabilize.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct DeathSaveThrows { pub struct DeathSaveThrows {
pub failed: u8, pub failed: u8,
pub succeeded: u8, pub succeeded: u8,
@ -257,11 +305,11 @@ impl DeathSaveThrows {
} }
/// Known NPCs keyed by name. No UI yet — see ROADMAP phase 6. /// Known NPCs keyed by name. No UI yet — see ROADMAP phase 6.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct ContactBook (pub HashMap<String, Contact>); pub struct ContactBook (pub HashMap<String, Contact>);
/// One NPC contact: name, optional image, status, location and notes. /// One NPC contact: name, optional image, status, location and notes.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Contact { pub struct Contact {
pub name: Name, pub name: Name,
pub image: Option<String>, pub image: Option<String>,
@ -271,7 +319,7 @@ pub struct Contact {
} }
/// Life status of a contact, used for filtering. /// Life status of a contact, used for filtering.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub enum ContactStatus { pub enum ContactStatus {
Unknown, Unknown,
DeadBygone, DeadBygone,
@ -280,7 +328,7 @@ pub enum ContactStatus {
} }
/// A person's name: first, last, and an optional alias (nickname). /// A person's name: first, last, and an optional alias (nickname).
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Name { pub struct Name {
pub first: String, pub first: String,
pub last: String, pub last: String,
@ -298,15 +346,28 @@ impl Name {
} }
} }
/// App settings. Empty today; will carry e.g. a persisted-format version. /// App settings: currently only the saved-format version.
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
/// Saved-format version; bumped on breaking JSON changes.
pub version: u32
}
impl Settings {
/// The current saved-format version.
pub const VERSION: u32 = 1;
}
impl Default for Settings {
/// Defaults carry the current format version.
fn default () -> Self {
Self { version: Self::VERSION }
}
} }
/// Which side of the party an inventory belongs to. /// Which side of the party an inventory belongs to.
#[derive(Clone, Copy, Debug, Store)] #[derive(Clone, Copy, Debug, Store, Serialize, Deserialize)]
pub enum InventoryKind { pub enum InventoryKind {
Personal, Personal,
Common Common

View File

@ -3,7 +3,6 @@
//! Leptos 0.8 app with actix-web SSR and client-side hydration. The crate //! Leptos 0.8 app with actix-web SSR and client-side hydration. The crate
//! root declares the modules and the WASM entry point; `main.rs` holds the //! root declares the modules and the WASM entry point; `main.rs` holds the
//! SSR server, `app.rs` the component tree. //! SSR server, `app.rs` the component tree.
#![feature(type_alias_impl_trait)]
pub mod app; pub mod app;
#[allow(unused_imports)] #[allow(unused_imports)]

View File

@ -2,5 +2,9 @@
//! //!
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>` //! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
//! (alias `Context`), created in `App` and shared through Leptos context. //! (alias `Context`), created in `App` and shared through Leptos context.
//! Persistence (localStorage autosave, JSON export/import) lives in
//! [`persist`]; undo/redo over the same store lives in [`undo`].
pub (crate) mod persist;
pub (crate) mod undo;
use crate::prelude::*;

279
src/state/persist.rs Normal file
View File

@ -0,0 +1,279 @@
//! Persistence: localStorage autosave + JSON export/import.
//!
//! Everything here is client-side. The server build (`ssr`) never touches
//! storage: it renders mock data, and the hydrated client swaps in the
//! saved state a moment after mount ([`init_hydrated`]).
//!
//! Flow:
//! - on mount, [`init_hydrated`] loads the saved state (mock stays as the
//! first-visit fallback) and starts the debounced autosave effect,
//! - the header's Save / Load / Clear buttons call [`export_download`],
//! [`apply_import`] and [`clear_all`],
//! - the saved format is the whole `Dashboard` JSON, version-checked by
//! [`parse_dashboard`] (the migration hook).
#[cfg(feature = "hydrate")]
use std::{ cell::Cell, rc::Rc, time::Duration };
use leptos::prelude::*;
use crate::prelude::*;
/// The localStorage key under which the whole dashboard is stored.
#[cfg(feature = "hydrate")]
pub const STORAGE_KEY: &str = "aex.dashboard";
/// Autosave debounce: changes are written this long after the last change.
#[cfg(feature = "hydrate")]
const SAVE_DEBOUNCE: Duration = Duration::from_millis(500);
/// Failure modes when parsing an imported or stored file.
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
/// The file is not valid JSON or not a dashboard.
#[error("Файл повреждён или не является файлом кампании.")]
Parse,
/// The file comes from a newer app version.
#[error(
"Версия файла ({0}) новее, чем поддерживает приложение (текущая: {current}).",
current = Settings::VERSION
)]
NewerVersion(u32),
/// The file comes from an older app version; no migration exists yet.
#[error(
"Версия файла ({0}) устарела; перенос данных пока не реализован (текущая: {current}).",
current = Settings::VERSION
)]
OldVersion(u32),
}
/// Parses and version-checks a dashboard JSON document.
///
/// This is the migration hook: a future format bump adds an arm here that
/// converts the old version into the new one and recurses.
pub fn parse_dashboard(text: &str) -> std::result::Result<Dashboard, ImportError> {
let dashboard: Dashboard = serde_json::from_str(text).map_err(|_| ImportError::Parse)?;
match dashboard.settings.version {
version if version == Settings::VERSION => Ok(dashboard),
version if version > Settings::VERSION => Err(ImportError::NewerVersion(version)),
version => Err(ImportError::OldVersion(version)),
}
}
/// Serializes the current dashboard to pretty JSON.
#[cfg(feature = "hydrate")]
pub fn serialize_dashboard(store: &Context) -> Option<String> {
let guard = store.try_read_untracked()?;
serde_json::to_string_pretty(&*guard).ok()
}
/// Replaces the whole dashboard inside the store.
pub fn replace_state(store: &Context, dashboard: Dashboard) {
if let Some(mut guard) = store.try_write() {
*guard = dashboard;
}
}
/// Parses `text` and applies it: replaces the state and saves it back.
///
/// The current state becomes an undo step first, so the import is
/// undoable. Returns the error for the UI to show; nothing is applied
/// on error.
pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> {
let dashboard = parse_dashboard(text)?;
crate::state::undo::before_replace();
replace_state(store, dashboard);
#[cfg(feature = "hydrate")]
save_to_storage(store);
Ok(())
}
/// Client-only startup: load the saved state after mount, start autosave
/// and undo tracking.
///
/// Called from `App`. The load is deferred out of the hydration render so
/// the SSR markup stays authoritative; the swap happens before the browser
/// draws the first frame. Undo starts after the load (see the callback).
#[cfg(feature = "hydrate")]
pub fn init_hydrated(store: &Context, history: &crate::state::undo::HistoryHandle) {
let store = store.clone();
let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
// Load the saved state (or keep mock) once hydration has settled,
// then start undo tracking: its first captured state must be the
// saved one, not the mock the SSR pass rendered.
let history = history.clone();
let _ = set_timeout_with_handle(
move || {
load_from_storage(&store);
crate::state::undo::start_tracking(&history);
},
Duration::ZERO,
);
// Debounced autosave: any store change restarts the timer.
let store_save = store.clone();
Effect::new(move |_| {
store_save.track();
if let Some(handle) = pending.take() {
handle.clear();
}
let handle = set_timeout_with_handle(
{
let store = store_save.clone();
move || {
save_to_storage(&store);
}
},
SAVE_DEBOUNCE,
);
if let Ok(handle) = handle {
pending.set(Some(handle));
}
});
}
/// Writes the current dashboard to localStorage.
#[cfg(feature = "hydrate")]
pub fn save_to_storage(store: &Context) {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return;
};
let Some(json) = serialize_dashboard(store) else {
return;
};
if let Err(err) = storage.set_item(STORAGE_KEY, &json) {
e!("не удалось сохранить состояние: {err:?}");
}
}
/// Loads the saved dashboard from localStorage and replaces the store.
///
/// Returns `true` when a valid saved state was applied.
#[cfg(feature = "hydrate")]
pub fn load_from_storage(store: &Context) -> bool {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return false;
};
let Some(text) = storage.get_item(STORAGE_KEY).ok().flatten() else {
return false;
};
match parse_dashboard(&text) {
Ok(dashboard) => {
replace_state(store, dashboard);
true
}
Err(err) => {
e!("сохранённое состояние повреждено: {err}");
false
}
}
}
/// Wipes the saved state and replaces the dashboard with the blank slate.
///
/// The current state becomes an undo step first, so Clear is undoable.
/// The blank dashboard is written back to storage so it survives a reload;
/// mock data stays the fallback for the very first visit only.
#[cfg(feature = "hydrate")]
pub fn clear_all (store: &Context) {
crate::state::undo::before_replace();
replace_state(store, Dashboard::blank());
save_to_storage(store);
}
/// SSR stub: there is no browser, nothing to wipe.
#[cfg(not(feature = "hydrate"))]
pub fn clear_all (_store: &Context) {}
/// SSR stub: no browser, nothing to download.
#[cfg(not(feature = "hydrate"))]
pub fn export_download (_store: &Context) {}
/// Downloads the current dashboard as `dashboard.json`.
#[cfg(feature = "hydrate")]
pub fn export_download (store: &Context) {
use wasm_bindgen::JsCast;
let Some(json) = serialize_dashboard(store) else {
return;
};
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let blob = match web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(
&wasm_bindgen::JsValue::from_str(&json),
)) {
Ok(blob) => blob,
Err(_) => return,
};
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
return;
};
let Ok(anchor) = doc.create_element("a") else {
return;
};
let anchor = anchor.unchecked_into::<web_sys::HtmlAnchorElement>();
let _ = anchor.set_attribute("href", &url);
let _ = anchor.set_attribute("download", "dashboard.json");
anchor.click();
// Release the object URL after the download has started.
queue_microtask(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole dashboard survives a JSON round trip with the version intact.
#[test]
fn round_trip_keeps_state() {
let dashboard = Dashboard::mock();
let json = serde_json::to_string(&dashboard).unwrap();
let loaded = parse_dashboard(&json).unwrap();
assert_eq!(loaded.campaign, dashboard.campaign);
assert_eq!(loaded.player.name.first, dashboard.player.name.first);
assert_eq!(loaded.player.inventory.rows.len(), dashboard.player.inventory.rows.len());
assert_eq!(loaded.settings.version, Settings::VERSION);
}
/// Files from a newer app version are rejected with a clear error.
#[test]
fn rejects_newer_version() {
let mut dashboard = Dashboard::mock();
dashboard.settings.version = Settings::VERSION + 1;
let json = serde_json::to_string(&dashboard).unwrap();
assert!(matches!(parse_dashboard(&json), Err(ImportError::NewerVersion(_))));
}
/// Files from an older version are rejected until a migration exists.
#[test]
fn rejects_older_version() {
let mut dashboard = Dashboard::mock();
dashboard.settings.version = Settings::VERSION - 1;
let json = serde_json::to_string(&dashboard).unwrap();
assert!(matches!(parse_dashboard(&json), Err(ImportError::OldVersion(_))));
}
/// Garbage input fails with the parse error.
#[test]
fn rejects_garbage() {
assert!(matches!(parse_dashboard("not json"), Err(ImportError::Parse)));
assert!(matches!(parse_dashboard("[]"), Err(ImportError::Parse)));
}
/// An empty dashboard round-trips too (Clear produces valid files).
#[test]
fn blank_round_trips() {
let json = serde_json::to_string(&Dashboard::blank()).unwrap();
let loaded = parse_dashboard(&json).unwrap();
assert!(loaded.campaign.is_empty());
assert!(loaded.player.inventory.rows.is_empty());
assert_eq!(loaded.settings.version, Settings::VERSION);
}
}

486
src/state/undo.rs Normal file
View File

@ -0,0 +1,486 @@
//! Undo/redo for the whole dashboard (ROADMAP Phase 1.5).
//!
//! Snapshot-based: [`History`] keeps whole `Dashboard` snapshots — the
//! state as it was *before* each edit burst. Snapshots are plain clones
//! (the dashboard is small; the 5e.tools item data lives in `statics`,
//! not in the state), and a cap bounds memory.
//!
//! The tracking effect (started in [`start_tracking`], hydrate only)
//! subscribes to the store root trigger — the same one the autosave
//! effect in `persist` uses — and remembers the previous state on every
//! change. When the burst goes quiet for [`SETTLE_WINDOW`], the pre-burst
//! state becomes a real undo step. Undo before the window fires reverts
//! the whole burst straight away.
//!
//! Wholesale replacements (Clear, import — see `persist`) call
//! [`before_replace`] first, so the replacement itself is undoable.
//! Undo/redo restore via `persist::replace_state`, which fires the
//! autosave effect too — an undone state survives a reload.
//!
//! The `ssr` build provides the same [`HistoryHandle`] so the header
//! renders identically, but both stacks stay empty: the buttons render
//! disabled and clicks are no-ops.
use std::time::Duration;
use leptos::prelude::*;
use crate::prelude::*;
use crate::state::persist::replace_state;
/// Maximum number of undo steps kept per direction.
const HISTORY_CAP: usize = 100;
/// A burst of edits becomes one undo step this long after the last change.
///
/// (Only the hydrate tracking effect arms this timer; the `ssr` build
/// keeps the constant for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
const SETTLE_WINDOW: Duration = Duration::from_secs(1);
/// Pure undo/redo stack pair over whole dashboards.
///
/// The undo stack holds states *before* an edit burst; `pending` holds the
/// pre-burst state until the burst settles. The settle window is enforced
/// by the tracking effect's timer — the clock lives in the browser, not
/// here (the wasm target has no `Instant`).
pub struct History {
undo: Vec<Dashboard>,
redo: Vec<Dashboard>,
cap: usize,
/// The pre-burst state, promoted to a real undo step on [`Self::settle`].
pending: Option<Dashboard>,
/// Set right before a wholesale store replacement (undo/redo/Clear/
/// import); the tracking effect consumes it and starts a fresh burst
/// from the new state.
suppress: bool,
/// Reactive stack depths, for the buttons' disabled states.
undo_depth: RwSignal<usize>,
redo_depth: RwSignal<usize>,
}
impl History {
/// Creates an empty history with the given cap.
///
/// The settle window is not stored here: the tracking effect's
/// timer enforces it.
pub fn new(cap: usize) -> Self {
History {
undo: Vec::new(),
redo: Vec::new(),
cap,
pending: None,
suppress: false,
undo_depth: RwSignal::new(0),
redo_depth: RwSignal::new(0),
}
}
/// Records that the store changed from `before` to something else.
///
/// The first change of a burst sets `pending` to `before`; later
/// changes of the same burst keep it, so one undo step covers the
/// whole burst.
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn note_change(&mut self, before: Dashboard) {
if self.pending.is_none() {
self.pending = Some(before);
// Undo works before the burst settles (it consumes the
// pending state), so the button must enable right away.
self.undo_depth.set(self.undo.len() + 1);
}
}
/// The burst has been quiet for `window`: promote `pending` to a real
/// undo step. No-op when there is no pending burst.
pub fn settle(&mut self) {
if let Some(pending) = self.pending.take() {
self.push_undo(pending);
}
}
/// Undo one step: reverts the current burst if it has not settled,
/// otherwise pops the most recent settled step. `current` moves to
/// the redo stack.
pub fn undo(&mut self, current: Dashboard) -> Option<Dashboard> {
let snapshot = self.pending.take().or_else(|| self.undo.pop())?;
push_capped(&mut self.redo, self.cap, current);
self.undo_depth.set(self.undo.len());
self.redo_depth.set(self.redo.len());
Some(snapshot)
}
/// Redo: pops the redo stack; `current` moves back to the undo stack.
pub fn redo(&mut self, current: Dashboard) -> Option<Dashboard> {
let snapshot = self.redo.pop()?;
push_capped(&mut self.undo, self.cap, current);
self.undo_depth.set(self.undo.len());
self.redo_depth.set(self.redo.len());
Some(snapshot)
}
/// Flush: promotes the pending burst, then marks `current` as an undo
/// step. Called before wholesale replacements (Clear, import) so the
/// replacement itself is undoable — first the replacement, then the
/// burst it interrupted.
pub fn flush(&mut self, current: Dashboard) {
self.settle();
self.push_undo(current);
}
/// Drops the pending burst. Called after a suppressed replacement:
/// the next edit starts a fresh burst from the new state.
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn reset(&mut self) {
if self.pending.take().is_some() {
self.undo_depth.set(self.undo.len());
}
}
/// Flags the next change as a wholesale replacement (no snapshot).
pub fn suppress(&mut self) {
self.suppress = true;
}
/// Reads and clears the replacement flag (the tracking effect's check).
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn consume_suppress(&mut self) -> bool {
std::mem::take(&mut self.suppress)
}
/// Reactive depth of the undo stack.
pub fn undo_depth(&self) -> RwSignal<usize> {
self.undo_depth
}
/// Reactive depth of the redo stack.
pub fn redo_depth(&self) -> RwSignal<usize> {
self.redo_depth
}
/// Pushes a pre-edit snapshot onto the undo stack (capped). A new
/// edit invalidates the redo branch.
fn push_undo(&mut self, snapshot: Dashboard) {
push_capped(&mut self.undo, self.cap, snapshot);
self.undo_depth.set(self.undo.len());
self.redo.clear();
self.redo_depth.set(0);
}
}
/// Pushes `value`, dropping the oldest entry when the stack is at `cap`.
fn push_capped(stack: &mut Vec<Dashboard>, cap: usize, value: Dashboard) {
if stack.len() == cap {
stack.remove(0);
}
stack.push(value);
}
/// The shared undo/redo state, plus the store it operates on.
///
/// All fields are `Copy` handles, so the handle itself is `Copy`: the
/// header buttons and the tracking effect can capture it freely. Created
/// in `App` and injected through context in both build modes (on the
/// server the stacks simply stay empty).
#[derive(Clone, Copy)]
pub struct HistoryHandle {
history: RwSignal<History>,
store: Context,
}
impl HistoryHandle {
/// Creates an empty history around `store`.
pub fn new(store: Context) -> Self {
HistoryHandle {
history: RwSignal::new(History::new(HISTORY_CAP)),
store,
}
}
/// Undoes the last edit burst (or the last settled step).
pub fn undo(&self) {
let Some(current) = current_state(&self.store) else { return };
let mut snapshot = None;
self.history.update(|history| snapshot = history.undo(current));
self.apply(snapshot);
}
/// Re-applies the most recently undone state.
pub fn redo(&self) {
let Some(current) = current_state(&self.store) else { return };
let mut snapshot = None;
self.history.update(|history| snapshot = history.redo(current));
self.apply(snapshot);
}
/// Reactive depth of the undo stack, for the button's disabled state.
pub fn undo_depth(&self) -> RwSignal<usize> {
self.history.with_untracked(|history| history.undo_depth())
}
/// Reactive depth of the redo stack, for the button's disabled state.
pub fn redo_depth(&self) -> RwSignal<usize> {
self.history.with_untracked(|history| history.redo_depth())
}
/// Applies a popped snapshot: suppresses history capture for the
/// resulting store write, then restores.
fn apply(&self, snapshot: Option<Dashboard>) {
if let Some(snapshot) = snapshot {
self.history.update(|history| history.suppress());
replace_state(&self.store, snapshot);
}
}
}
/// Marks the current state as an undo step and suppresses history capture
/// for the next store write.
///
/// Called by `persist` right before a wholesale replacement (Clear,
/// import) so the replacement is undoable. No-op without a handle in
/// context (unit tests, SSR stubs).
pub fn before_replace() {
if let Some(handle) = use_context::<HistoryHandle>() {
flush_for_replace(&handle);
}
}
/// The core of [`before_replace`]: promotes the pending burst, marks the
/// current state as an undo step, and suppresses capture for the next write.
fn flush_for_replace(handle: &HistoryHandle) {
handle.history.update(|history| {
if let Some(current) = current_state(&handle.store) {
history.flush(current);
}
history.suppress();
});
}
/// Clones the current dashboard out of the store, when readable.
fn current_state(store: &Context) -> Option<Dashboard> {
store.try_read_untracked().map(|guard| (*guard).clone())
}
/// Client-only: starts the tracking effect and installs the shortcuts.
///
/// Call after the saved state has been loaded (`persist::init_hydrated`
/// does this), so the first captured state is the saved one, not the mock
/// the SSR pass rendered.
#[cfg(feature = "hydrate")]
pub fn start_tracking(handle: &HistoryHandle) {
use std::{
cell::{ Cell, RefCell },
rc::Rc,
};
let handle = handle.clone();
// The state as of the previous store trigger; `None` until the first change.
let last: Rc<RefCell<Option<Dashboard>>> = Rc::new(RefCell::new(None));
// The settle timer for the current burst, re-armed on every change.
let settle_timer: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
Effect::new(move |_| {
handle.store.track();
let Some(current) = current_state(&handle.store) else { return };
let mut arm = false;
handle.history.update(|history| {
if history.consume_suppress() {
// The store was replaced wholesale (undo/redo/Clear/
// import): start a fresh burst from the new state.
history.reset();
last.borrow_mut().replace(current);
} else if let Some(prev) = last.borrow_mut().replace(current.clone()) {
history.note_change(prev);
arm = true;
}
});
if arm {
// (Re)arm the settle timer: one undo step per quiet burst.
if let Some(timer) = settle_timer.take() {
timer.clear();
}
let armed = handle;
let Ok(timer) = set_timeout_with_handle(
move || armed.history.update(|history| history.settle()),
SETTLE_WINDOW,
) else {
return;
};
settle_timer.set(Some(timer));
}
});
install_shortcuts(&handle);
}
/// Installs Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (and the ⌘ variants) on the
/// window. Key combos inside editable elements are left to the browser's
/// native undo, so text fields keep their own history.
#[cfg(feature = "hydrate")]
fn install_shortcuts(handle: &HistoryHandle) {
use leptos::ev::keydown;
let handle = handle.clone();
window_event_listener(keydown, move |event: leptos::ev::KeyboardEvent| {
if !(event.ctrl_key() || event.meta_key()) {
return;
}
let key = event.key();
let is_undo = key == "z" || key == "Z";
let is_redo = key == "y" || key == "Y" || (is_undo && event.shift_key());
if !(is_undo || is_redo) {
return;
}
if target_is_editable(&event) {
return;
}
event.prevent_default();
if is_undo {
handle.undo();
} else {
handle.redo();
}
});
}
/// True when the event target is a text-editing element (input, textarea,
/// select, contenteditable): the browser owns Ctrl+Z there.
#[cfg(feature = "hydrate")]
fn target_is_editable(event: &web_sys::KeyboardEvent) -> bool {
use wasm_bindgen::JsCast;
let Some(target) = event.target().and_then(|t| t.dyn_into::<web_sys::Element>().ok()) else {
return false;
};
matches!(target.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
|| target
.dyn_into::<web_sys::HtmlElement>()
.is_ok_and(|element| element.is_content_editable())
}
#[cfg(test)]
mod tests {
use super::*;
fn named(name: &str) -> Dashboard {
let mut dashboard = Dashboard::mock();
dashboard.campaign = name.to_string();
dashboard
}
fn campaign(snapshot: Option<Dashboard>) -> Option<String> {
snapshot.map(|d| d.campaign)
}
#[test]
fn one_undo_step_per_settled_burst() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.note_change(named("s1")); // same burst: the first pre-state wins
history.settle();
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn undo_before_settle_reverts_the_whole_burst() {
let mut history = History::new(10);
history.note_change(named("s0"));
// No settle: the burst is still in flight, undo consumes it.
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
// The undone state lands on the redo stack.
assert_eq!(history.redo_depth().get(), 1);
assert_eq!(history.undo_depth().get(), 0);
}
#[test]
fn redo_restores_and_can_be_undone_again() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.undo(named("s2"));
assert_eq!(campaign(history.redo(named("s0"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn new_edit_clears_the_redo_stack() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.undo(named("s2"));
assert_eq!(history.redo_depth().get(), 1);
// A fresh burst after the undo invalidates the redo branch.
history.note_change(named("s2"));
history.settle();
assert_eq!(history.redo_depth().get(), 0);
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
}
#[test]
fn cap_drops_the_oldest_snapshot() {
let mut history = History::new(2);
for state in ["s0", "s1", "s2"] {
history.note_change(named(state));
history.settle();
}
assert_eq!(history.undo_depth().get(), 2);
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s1".to_string()));
assert_eq!(campaign(history.undo(named("s1"))), None); // "s0" was dropped
}
#[test]
fn undo_on_empty_history_is_none() {
let mut history = History::new(10);
assert_eq!(campaign(history.undo(named("s0"))), None);
assert_eq!(campaign(history.redo(named("s0"))), None);
}
#[test]
fn flush_promotes_pending_then_current() {
let mut history = History::new(10);
// A burst in flight: pre-state "s0", current "s2".
history.note_change(named("s0"));
// Wholesale replacement prep: the replacement becomes the first
// undo target, the interrupted burst the second.
history.flush(named("s2"));
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn suppress_flag_is_consumed_once() {
let mut history = History::new(10);
history.suppress();
assert!(history.consume_suppress());
assert!(!history.consume_suppress());
}
#[test]
fn handle_flush_makes_replacements_undoable() {
let store = Store::new(named("base"));
let handle = HistoryHandle::new(store.clone());
flush_for_replace(&handle);
assert_eq!(handle.undo_depth().get(), 1);
replace_state(&store, named("blank"));
handle.undo();
let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap();
assert_eq!(campaign(Some(current)), Some("base".to_string()));
handle.redo();
let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap();
assert_eq!(campaign(Some(current)), Some("blank".to_string()));
}
}

View File

@ -1,35 +1,31 @@
//! Bundled static game data. //! Bundled static game data.
//! //!
//! `ITEMS_REFS` loads the 5e.tools-format JSON files (`data/*.json`, //! `ITEMS_REFS` loads the 5e.tools-format JSON files (`data/*.json`, roughly
//! roughly 5k items) once at first use via `lazy_static` and indexes them //! 5k items) once at first use via `std::sync::LazyLock` and indexes them by
//! by name. `ITEMS_NAMES` is the flat name list used for fuzzy search. //! name. `ITEMS_NAMES` is the flat name list used for fuzzy search.
use std::sync::LazyLock;
use crate::prelude::*; use crate::prelude::*;
use lazy_static::lazy_static;
lazy_static! {
/// All known items, indexed by name, from the bundled 5e.tools JSON files. /// All known items, indexed by name, from the bundled 5e.tools JSON files.
/// ///
/// Built once at startup: entries with the same name are merged (a missing /// Built once at first access: entries with the same name are merged (a
/// weight fills in, `rest` fields concatenate). Note `foundry.json` is /// missing weight fills in, `rest` fields concatenate).
/// currently loaded twice — see `utils::read_items`. pub static ITEMS_REFS: LazyLock<ItemReferenceMap> = LazyLock::new(|| {
pub static ref ITEMS_REFS: ItemReferenceMap = {
let mut map = HashMap::with_capacity(5_000); let mut map = HashMap::with_capacity(5_000);
utils::read_items(include_str!("../../data/items.json"), &mut map); utils::read_items(include_str!("../../data/items.json"), &mut map);
utils::read_items(include_str!("../../data/base.json"), &mut map); utils::read_items(include_str!("../../data/base.json"), &mut map);
utils::read_items(include_str!("../../data/foundry.json"), &mut map); utils::read_items(include_str!("../../data/foundry.json"), &mut map);
utils::read_items(include_str!("../../data/magic.json"), &mut map); utils::read_items(include_str!("../../data/magic.json"), &mut map);
utils::read_items(include_str!("../../data/foundry.json"), &mut map);
info!("Loaded {} items", map.len()); info!("Loaded {} items", map.len());
ItemReferenceMap(HashMap::from_iter(map.into_iter())) ItemReferenceMap(map)
}; });
/// Flat list of item names; the feed for the fuzzy matcher /// Flat list of item names; the feed for the fuzzy matcher
/// (`ItemSearchField`). /// (`ItemSearchField`).
pub static ref ITEMS_NAMES: Vec<&'static String> = { pub static ITEMS_NAMES: LazyLock<Vec<&'static str>> =
ITEMS_REFS.0.keys().collect() LazyLock::new(|| ITEMS_REFS.0.keys().map(String::as_str).collect());
};
}

79
style/_character.scss Normal file
View File

@ -0,0 +1,79 @@
@use 'mixins';
.character-page {
display: flex;
flex-direction: column;
gap: 24px;
padding: 10px 24px;
overflow-y: auto;
}
.prepared-spells, .attunements {
h6 {
font-size: 1.1em;
margin: 0 0 10px;
font-weight: 900;
&::first-letter {
text-decoration: underline crimson;
}
}
.prepared-slot, .attunement-slot {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
input {
flex: 1;
max-width: 340px;
border: none;
color: white;
background: rgba(255, 255, 255, 0.06);
padding: 3px 8px;
&:focus {
outline: 1px solid crimson;
}
}
button {
@include mixins.button;
width: 34px;
height: 30px;
border: 0;
line-height: 1;
}
}
.prepared-slot {
&.occupied {
.spell-name {
font-weight: 900;
min-width: 200px;
}
}
.spell-level, .spell-desc {
font-weight: 300;
opacity: 0.75;
font-size: 0.85em;
}
.spell-level {
min-width: 44px;
}
}
.attuned-item {
font-weight: 900;
min-width: 200px;
}
.add-slot {
@include mixins.button;
margin-top: 10px;
border: 0;
padding: 3px 14px;
}
}

View File

@ -59,6 +59,11 @@ header {
line-height: 1em; line-height: 1em;
margin: 2px 5px; margin: 2px 5px;
@include mixins.button; @include mixins.button;
&:disabled {
opacity: 0.45;
cursor: default;
}
} }
} }

53
style/_modal.scss Normal file
View File

@ -0,0 +1,53 @@
// Modal dialog: dark overlay, centered card, crimson confirm button.
@use 'mixins';
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.75);
}
.modal {
max-width: 480px;
padding: 24px 32px;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8);
h3 {
margin: 0 0 12px;
@include mixins.heading;
font-weight: 900;
}
p {
margin: 0 0 20px;
color: rgba(255, 255, 255, 0.75);
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button.modal-cancel {
@include mixins.button;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.8);
padding: 6px 18px;
cursor: pointer;
}
button.modal-confirm {
@include mixins.button;
padding: 6px 18px;
cursor: pointer;
}

View File

@ -134,6 +134,14 @@ aside {
.val { .val {
z-index: 2; z-index: 2;
} }
.ac {
display: flex;
align-items: center;
gap: 6px;
padding-left: 10px;
border-left: 1px solid rgba(white, 0.15);
}
} }
.hit-dice { .hit-dice {
@ -194,6 +202,14 @@ aside {
} }
} }
.ac {
margin-left: auto;
justify-content: flex-end;
text-align: right;
width: 20%;
background: rgba(green, 0.15);
}
.death-throws { .death-throws {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -3,9 +3,11 @@
@use 'reset'; @use 'reset';
@use 'mixins'; @use 'mixins';
@use 'header'; @use 'header';
@use 'character';
@use 'sidebar'; @use 'sidebar';
@use 'nav'; @use 'nav';
@use 'inventory'; @use 'inventory';
@use 'modal';
@use 'vars'; @use 'vars';