Compare commits

..

No commits in common. "ae2fa1c723ff0c3d9f71c1692d151e8b1ee1e2c5" and "6b792734b6500daa5b33a4d518265c1a68777f8f" have entirely different histories.

40 changed files with 219 additions and 5448 deletions

4
.gitignore vendored
View File

@ -3,7 +3,9 @@
debug/ debug/
target/ target/
# Cargo.lock is tracked: the app is a binary, builds should be reproducible # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# 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, std::sync::LazyLock for one-time data loading chrono for dates, lazy_static 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 tracked for reproducible builds. mode for `trunk`). `Cargo.lock` is gitignored.
## Directory layout ## Directory layout
@ -48,7 +48,6 @@ 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
``` ```
@ -74,9 +73,10 @@ nothing is persisted. The header's Save/Load/Clear buttons do nothing yet
## Data layer (items) ## Data layer (items)
- `statics::ITEMS_REFS` is a `LazyLock` map of name → `BookItem`, built - `statics::ITEMS_REFS` is a `lazy_static` 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`, `magic.json`), roughly 5k items. `base.json`, `foundry.json` loaded twice — a known duplication — and
`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,10 +100,9 @@ JSON as-is.
## Component tree ## Component tree
```
App App
│ Save (download)/Load (file import)/Clear (wipe), ├─ Header name, image, date steppers, session steppers, Save/Load/Clear (dead)
│ ↩ 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
@ -114,8 +113,7 @@ 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 prepared spells (vacant/occupied slots with ├─ Character stub text
│ 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
@ -128,20 +126,6 @@ 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
@ -165,14 +149,17 @@ 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
- `Wiki` page renders a placeholder - Prepared spells list is empty; `Spell` is a name-only stub; attunements
- `Settings` has only `version` have no UI; `Settings` is empty
- The item "+"/"…" buttons are dead - Save/Load/Clear buttons and the item "+"/"…" buttons are dead
- `foundry.json` was loaded twice (fixed); ~3.9 MB of JSON is bundled - `foundry.json` is loaded twice; ~3.9 MB of JSON is bundled into the WASM
into the WASM and parsed on startup and parsed on startup
- Several unused dependencies (see `MODERNIZE.md`); kept for now by - Several unused dependencies (`leptos-use`, `dotenvy`,
decision `pretty_env_logger`, `seq_macro`, `itertools`, `thiserror`) and 12
- `Cargo.lock` is tracked since 2026-08-04; builds are reproducible compiler warnings
- `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

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@ 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"
@ -32,7 +33,6 @@ 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"
@ -40,12 +40,12 @@ seq-macro = "0.3.6"
csr = ["leptos/csr"] csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"] hydrate = ["leptos/hydrate"]
ssr = [ ssr = [
"dep:actix-files", "dep:actix-files",
"dep:actix-web", "dep:actix-web",
"dep:leptos_actix", "dep:leptos_actix",
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
] ]
# Defines a size-optimized profile for the WASM bundle in release mode # Defines a size-optimized profile for the WASM bundle in release mode
@ -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:3110" site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring # The port to use for automatic reload monitoring
reload-port = 3111 reload-port = 3001
# [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,11 +8,10 @@ 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
@ -36,8 +35,7 @@ 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).
APPLIED 2026-08-04: `src/statics/mod.rs` now uses `LazyLock`; Rewrite `src/statics/mod.rs`:
`lazy_static` removed from `Cargo.toml`. Reference sketch:
```rust ```rust
use std::sync::LazyLock; use std::sync::LazyLock;
@ -71,11 +69,9 @@ 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 existed at the time; `serde_json::Value` works without naming No derives exist today; `serde_json::Value` works without naming `serde`
`serde` in `Cargo.toml`. Phase 1 (persistence) re-added in `Cargo.toml`. Phase 1 (persistence) re-adds
`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

116
README.md
View File

@ -1,78 +1,72 @@
# aex <picture>
<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>
A D&D 5e character sheet and campaign dashboard for a text-based play-by-post # Leptos Starter Template
game. The UI is in Russian.
## What works today 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.
- Campaign header: name, in-game date steppers, session counter, and ## Creating your template repo
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)
State autosaves to localStorage (debounced) and survives reloads; export/ If you don't have `cargo-leptos` installed you can install it with
import round-trips through a JSON file. The phased plan is in
[ROADMAP.md](./ROADMAP.md).
## Tech stack `cargo install cargo-leptos --locked`
- Rust, edition 2024, nightly toolchain (pinned in `rust-toolchain.toml`) Then run
- 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
## Development `cargo leptos new --git leptos-rs/start-actix`
Prerequisites: `cargo-leptos`, dart-sass, and the `wasm32-unknown-unknown` to generate a new project template (you will be prompted to enter a project name).
target. The nightly toolchain installs itself via `rust-toolchain.toml`.
```sh `cd {projectname}`
cargo install cargo-leptos
cargo leptos watch # dev server with hot reload to go to your newly created project.
# open http://127.0.0.1:3000
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):
Other commands:
```sh ```sh
cargo leptos build --release # production build export LEPTOS_OUTPUT_NAME="leptos_start"
cargo leptos serve # dev server without the watcher export LEPTOS_SITE_ROOT="site"
cargo check --no-default-features --features ssr --bin aex export LEPTOS_SITE_PKG_DIR="pkg"
cargo check --no-default-features --features hydrate --lib export LEPTOS_SITE_ADDR="127.0.0.1:3000"
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib export LEPTOS_RELOAD_PORT="3001"
``` ```
Finally, run the server binary.
## Tests ## Notes about CSR and Trunk:
Although it is not recommended, you can also run your project without server integration using the feature `csr` and `trunk serve`:
End-to-end tests live in `end2end/` (Playwright): a smoke spec and a `trunk serve --open --features csr`
persistence spec (reload keeps state; export/import round trip). With the
dev server running:
```sh This may be useful for integrating external tools which require a static site, e.g. `tauri`.
cd end2end
npm install
npx playwright test --project=chromium
```
or let cargo-leptos manage the server: `cargo leptos end2end`. ## Licensing
## Project docs 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.
- [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,108 +54,50 @@ 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.
- [x] rewrite README (currently the stock template) - [ ] rewrite README (currently the stock template)
- [x] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice) - [ ] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice)
- [x] clear compiler warnings (unused imports, unused `qty` in `item.rs`) - [ ] clear compiler warnings (unused imports, unused `qty` in `item.rs`)
- [x] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it - [ ] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it
(done: committed 2026-08-04) - [ ] dependency and idiom cleanup — see `MODERNIZE.md` (drop unused deps,
- [~] 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`)
(done: `lazy_static``LazyLock` only; the rest stays pending) - [ ] decide on the under-consideration unused deps from `MODERNIZE.md`
- [~] 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
(decision: nothing removed; revisit later) - [ ] replace `end2end/tests/example.spec.ts` with a smoke test (app boots, nav renders, no console errors)
- [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.
- [x] derive `serde::Serialize` / `Deserialize` on all entities in `src/entities/mod.rs` - [ ] 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)
- [x] verify `Store` derive and `serde` derive coexist on the same structs (prototype first) - [ ] verify `Store` derive and `serde` derive coexist on the same structs (prototype first)
- [x] add a `version` field to `Settings`; add a migration hook for future format changes - [ ] add a `version` field to `Settings`; add a migration hook for future format changes
- [x] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage` - [ ] 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)
- [x] auto-save on change — debounced effect over the `Store` — plus manual Save button - [ ] auto-save on change — debounced effect over the `Store` — plus manual Save button
- [x] Load button: restore from localStorage with an overwrite confirm - [ ] Load button: restore from localStorage with an overwrite confirm
- [x] Clear button: wipe localStorage with a confirm - [ ] Clear button: wipe localStorage with a confirm
- [x] export: download `dashboard.json` as a blob - [ ] export: download `dashboard.json` as a blob
- [x] import: file input → parse → validate version → replace state - [ ] import: file input → parse → validate version → replace state
- [x] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard" - [ ] 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".
- [x] player card: portrait, name, class, level, XP (data already in sidebar) - [ ] player card: portrait, name, class, level, XP (data already in sidebar)
- [x] HP block: temp / current / max, `Numput`-style editing - [ ] HP block: temp / current / max, `Numput`-style editing
- [x] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry - [ ] 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)
- [x] attunements UI: 3 slots, free text, clear button (model exists) - [ ] 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
@ -214,8 +156,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: add item from search, quest CRUD, notes filter, undo - [ ] Playwright e2e: persistence round-trip, add item from search, quest CRUD,
shortcuts (persistence round trip is covered by `persistence.spec.ts`) notes filter
- [ ] 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.
@ -226,9 +168,7 @@ 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.5 (different components). - Phases 2 and 3 can run in parallel after Phase 1 (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
View File

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

View File

@ -1,120 +0,0 @@
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

@ -0,0 +1,9 @@
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

@ -1,74 +0,0 @@
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

@ -1,57 +0,0 @@
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([]);
});

View File

@ -1,106 +0,0 @@
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,7 +8,6 @@ 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,
@ -20,27 +19,17 @@ use crate::{
/// Root component of the app. /// Root component of the app.
/// ///
/// Sets up meta context, creates the `Store<Dashboard>` (mock data; the /// Sets up meta context, creates the `Store<Dashboard>` (mock data for now)
/// hydrated client swaps in the saved state, see `state::persist`) and /// and injects it as context, then renders the shell: header, sidebar, nav
/// injects it as context, then renders the shell: header, sidebar, nav, /// and the routed page. Routes: `/` character, `/inv` inventories,
/// the routed page and the modal overlay. Routes: `/` character, `/inv` /// `/wiki` wiki; anything else falls to `NotFound`.
/// 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();
let store = Store::new(Dashboard::mock()); provide_context(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
@ -64,7 +53,6 @@ pub fn App() -> impl IntoView {
</Routes> </Routes>
</main> </main>
</div> </div>
<Modal state=modal />
</Router> </Router>
} }
} }

View File

@ -1,72 +0,0 @@
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

@ -1,7 +0,0 @@
//! 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

@ -1,96 +0,0 @@
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,30 +1,13 @@
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, the persistence controls, and undo/redo. /// session steppers, and Save/Load/Clear buttons (not wired yet — see
/// /// 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();
@ -42,69 +25,6 @@ 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()>
@ -126,22 +46,11 @@ 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 on:click=on_save>"Сохранить"</button> <button>Save</button>
<button on:click=on_load>"Загрузить"</button> <button>Load</button>
<button on:click=on_clear>"Очистить"</button> <button>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::prelude::*; use leptos::{logging, 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; use reactive_stores::{Field, OptionStoreExt};
/// 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,6 +14,7 @@ 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,35 +4,25 @@
//! 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 `/`): prepared spells and attunements /// Character page (route `/`). Stub: renders a placeholder until
/// (ROADMAP phase 2). The sidebar carries the character editors. /// ROADMAP phase 2.
#[component] #[component]
pub fn Character () -> impl IntoView { pub fn Character () -> impl IntoView {
view! { view! {
<div class="character-page"> character
<character::prepared_spells::PreparedSpells />
<character::attunements::Attunements />
</div>
} }
} }

View File

@ -1,106 +0,0 @@
//! 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,3 +1,4 @@
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 }; use std::{ fmt::Debug, str::FromStr, ops::{ Sub, Add }};
use leptos::{ prelude::*, tachys::html::property::IntoProperty }; use leptos::{ logging, 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,7 +21,6 @@ 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

@ -1,22 +0,0 @@
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,10 +2,9 @@ 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();
@ -27,10 +26,13 @@ pub fn HitPoints() -> impl IntoView {
utils::adjust_checked(ev, temp_hp); utils::adjust_checked(ev, temp_hp);
}; };
let pc_hp =
move |a: u16, b: u16| -> String { format!("width: {}%", utils::filled_pc(a, b).min(100.)) }; let pc_hp = move |a: u16, b: u16| -> String {
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,10 +30,9 @@ 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(vec![None; 3]), attunements: Attunements([const { 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(),
@ -46,7 +45,9 @@ 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::default(), settings: Settings {
}
} }
} }
} }
@ -185,22 +186,11 @@ fn generate_mock_contact_book () -> ContactBook {
} }
/// Demo prepared spells: a vacant slot, two known spells, one more /// Demo prepared spells. Currently an empty list — `Spell` is a stub.
/// 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,14 +9,12 @@
//! //!
//! 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. `mock` provides demo data, `Dashboard::blank` an empty slate; //! settings. All of it currently comes from hardcoded mock data (the `mock`
//! persistence (localStorage autosave, JSON export/import) lives in //! module); nothing is persisted yet (see ROADMAP phase 1).
//! `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::*;
@ -68,7 +66,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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct Dashboard { pub struct Dashboard {
pub campaign: String, pub campaign: String,
pub campaign_image: String, pub campaign_image: String,
@ -82,50 +80,10 @@ 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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct PlayerData { pub struct PlayerData {
pub name: Name, pub name: Name,
pub class: String, pub class: String,
@ -135,10 +93,6 @@ 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,
@ -151,7 +105,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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct CommonData { pub struct CommonData {
pub balance: Balance, pub balance: Balance,
pub inventory: Inventory, pub inventory: Inventory,
@ -159,7 +113,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, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Store)]
pub struct Balance { pub struct Balance {
pub platinum: u32, pub platinum: u32,
pub gold: u32, pub gold: u32,
@ -172,14 +126,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, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Store)]
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, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct InventoryEntry { pub struct InventoryEntry {
pub id: u64, pub id: u64,
pub quantity: u32, pub quantity: u32,
@ -201,7 +155,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, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct InventoryItem { pub struct InventoryItem {
pub name: String, pub name: String,
pub weight: u32, pub weight: u32,
@ -210,12 +164,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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct Quest { pub struct Quest {
pub title: String, pub title: String,
pub location_taken: Option<String>, pub location_taken: Option<String>,
@ -228,12 +182,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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct Note { pub struct Note {
pub content: String, pub content: String,
pub date_ingame: NaiveDate, pub date_ingame: NaiveDate,
@ -243,45 +197,43 @@ 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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct SpellSlotLevel { pub struct SpellSlotLevel {
pub used: u8, pub used: u8,
pub total: u8, pub total: u8,
} }
/// Attunement slots (D&D rules allow three attuned items; the UI can /// Three attunement slots (D&D rules allow three attuned items).
/// add more slots for custom campaigns). #[derive(Clone, Debug, Store)]
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)] pub struct Attunements (pub [Option<String>; 3]);
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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub enum PreparedSpell { pub enum PreparedSpell {
Vacant, Vacant,
Occupied (Spell) Occupied (Spell)
} }
/// A spell: the name plus optional level and description. /// A spell. Only the name exists so far; the rest is a `@TODO`
/// Optional fields keep old saved JSON (name-only spells) parseable. /// (see ROADMAP phase 2).
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)] // @TODO
#[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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct HitDice { pub struct HitDice {
pub used: u8, pub used: u8,
pub kind: u8 pub kind: u8
@ -290,7 +242,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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct DeathSaveThrows { pub struct DeathSaveThrows {
pub failed: u8, pub failed: u8,
pub succeeded: u8, pub succeeded: u8,
@ -305,11 +257,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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
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, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub struct Contact { pub struct Contact {
pub name: Name, pub name: Name,
pub image: Option<String>, pub image: Option<String>,
@ -319,7 +271,7 @@ pub struct Contact {
} }
/// Life status of a contact, used for filtering. /// Life status of a contact, used for filtering.
#[derive(Clone, Debug, Store, Serialize, Deserialize)] #[derive(Clone, Debug, Store)]
pub enum ContactStatus { pub enum ContactStatus {
Unknown, Unknown,
DeadBygone, DeadBygone,
@ -328,7 +280,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, Serialize, Deserialize, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct Name { pub struct Name {
pub first: String, pub first: String,
pub last: String, pub last: String,
@ -346,28 +298,15 @@ impl Name {
} }
} }
/// App settings: currently only the saved-format version. /// App settings. Empty today; will carry e.g. a persisted-format version.
#[derive(Clone, Debug, Store, Serialize, Deserialize)] #[derive(Clone, Debug, Store, Default)]
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, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Store)]
pub enum InventoryKind { pub enum InventoryKind {
Personal, Personal,
Common Common

View File

@ -3,6 +3,7 @@
//! 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,9 +2,5 @@
//! //!
//! 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::*;

View File

@ -1,279 +0,0 @@
//! 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);
}
}

View File

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

View File

@ -1,79 +0,0 @@
@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,11 +59,6 @@ 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;
}
} }
} }

View File

@ -1,53 +0,0 @@
// 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,14 +134,6 @@ 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 {
@ -201,14 +193,6 @@ aside {
filter: contrast(70%); filter: contrast(70%);
} }
} }
.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;

View File

@ -3,11 +3,9 @@
@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';