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/
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
**/*.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_actix 0.8.7 + actix-web 4 for the server
- 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)
- Bundled game data in `data/` (5e.tools-format JSON, ~3.9 MB total)
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
@ -48,7 +48,6 @@ src/
inventory.rs One inventory section (rows + money)
item.rs One inventory row
numput.rs Numeric input components (Numput, NumputChange)
character/*.rs Character page: prepared_spells, attunements
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)
- `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`,
`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
other JSON fields verbatim for future detail views.
- Duplicate names merge: the later entry fills a missing weight and appends
@ -100,10 +100,9 @@ JSON as-is.
## Component tree
```
App
│ Save (download)/Load (file import)/Clear (wipe),
│ ↩ Undo/redo (buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y)
├─ Modal overlay dialog: confirm (action) or error (close)
├─ Header name, image, date steppers, session steppers, Save/Load/Clear (dead)
├─ Sidebar
│ ├─ Image portrait
│ ├─ Names first / alias «» / last
@ -114,8 +113,7 @@ App
│ └─ SpellSlots 9 levels of clickable pips
├─ Nav links to the three routes
└─ Routes
├─ Character prepared spells (vacant/occupied slots with
│ free-text entry); attunements (growable slots)
├─ Character stub text
├─ Inventories two Inventory sections (Personal/Common)
│ └─ Inventory balance (5 × Numput), rows (ForEnumerate)
│ └─ 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
"+" 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
current value, a plain number sets absolutely, invalid input is reverted,
and the value renders with thousands separators. `NumputChange` is the same
@ -165,14 +149,17 @@ currently mounted on any page.
## 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
- `Wiki` page renders a placeholder
- `Settings` has only `version`
- The item "+"/"…" buttons are dead
- `foundry.json` was loaded twice (fixed); ~3.9 MB of JSON is bundled
into the WASM and parsed on startup
- Several unused dependencies (see `MODERNIZE.md`); kept for now by
decision
- `Cargo.lock` is tracked since 2026-08-04; builds are reproducible
- Prepared spells list is empty; `Spell` is a name-only stub; attunements
have no UI; `Settings` is empty
- Save/Load/Clear buttons and the item "+"/"…" buttons are dead
- `foundry.json` is loaded twice; ~3.9 MB of JSON is bundled into the WASM
and parsed on startup
- Several unused dependencies (`leptos-use`, `dotenvy`,
`pretty_env_logger`, `seq_macro`, `itertools`, `thiserror`) and 12
compiler warnings
- `Cargo.lock` is gitignored, so builds are not reproducible
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"
dotenvy = "0.15.7"
pretty_env_logger = "0.5.0"
lazy_static = "1.5.0"
leptos-use = "0.16.2"
log = "0.4.28"
nucleo-matcher = "0.3.1"
@ -32,7 +33,6 @@ console_log = "1.0.0"
num-traits = "0.2.19"
num-format = "0.4.4"
js-sys = "=0.3.103"
web-sys = "=0.3.103"
seq-macro = "0.3.6"
@ -40,12 +40,12 @@ seq-macro = "0.3.6"
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:leptos_actix",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:actix-files",
"dep:actix-web",
"dep:leptos_actix",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
# 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.
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.
site-addr = "127.0.0.1:3110"
site-addr = "127.0.0.1:3000"
# 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.
# [Windows] for non-WSL use "npx.cmd playwright test"
# 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/`.
- Toolchain decision: stay on nightly (channel = "nightly"). The stable-Rust
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`,
`pretty_env_logger`, `dotenvy` stay for certain. The rest of section 1
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
@ -36,8 +35,7 @@ under consideration.
## 2. Replace crates with std or small local code
1. **`lazy_static``std::sync::LazyLock`** (stable since 1.80).
APPLIED 2026-08-04: `src/statics/mod.rs` now uses `LazyLock`;
`lazy_static` removed from `Cargo.toml`. Reference sketch:
Rewrite `src/statics/mod.rs`:
```rust
use std::sync::LazyLock;
@ -71,11 +69,9 @@ under consideration.
`usize`. `Numput` keeps its generics.
4. **`serde` (direct) → remove now, re-add in ROADMAP phase 1.**
No derives existed at the time; `serde_json::Value` works without naming
`serde` in `Cargo.toml`. Phase 1 (persistence) re-added
No derives exist today; `serde_json::Value` works without naming `serde`
in `Cargo.toml`. Phase 1 (persistence) re-adds
`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
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
game. The UI is in Russian.
# Leptos Starter Template
## 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
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)
## Creating your template repo
State autosaves to localStorage (debounced) and survives reloads; export/
import round-trips through a JSON file. The phased plan is in
[ROADMAP.md](./ROADMAP.md).
If you don't have `cargo-leptos` installed you can install it with
## Tech stack
`cargo install cargo-leptos --locked`
- Rust, edition 2024, nightly toolchain (pinned in `rust-toolchain.toml`)
- Leptos 0.8 SSR (actix-web) + client-side hydration
- `reactive_stores` for the reactive state model
- SCSS (`style/main.scss`), compiled by dart-sass
- Playwright for end-to-end tests
Then run
## Development
`cargo leptos new --git leptos-rs/start-actix`
Prerequisites: `cargo-leptos`, dart-sass, and the `wasm32-unknown-unknown`
target. The nightly toolchain installs itself via `rust-toolchain.toml`.
to generate a new project template (you will be prompted to enter a project name).
```sh
cargo install cargo-leptos
cargo leptos watch # dev server with hot reload
# open http://127.0.0.1:3000
`cd {projectname}`
to go to your newly created project.
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
## Running your project
`cargo leptos watch`
By default, you can access your local project at `http://localhost:3000`
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
leptos_start
site/
```
Other commands:
Set the following environment variables (updating for your project as needed):
```sh
cargo leptos build --release # production build
cargo leptos serve # dev server without the watcher
cargo check --no-default-features --features ssr --bin aex
cargo check --no-default-features --features hydrate --lib
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib
export LEPTOS_OUTPUT_NAME="leptos_start"
export LEPTOS_SITE_ROOT="site"
export LEPTOS_SITE_PKG_DIR="pkg"
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
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
persistence spec (reload keeps state; export/import round trip). With the
dev server running:
`trunk serve --open --features csr`
```sh
cd end2end
npm install
npx playwright test --project=chromium
```
This may be useful for integrating external tools which require a static site, e.g. `tauri`.
or let cargo-leptos manage the server: `cargo leptos end2end`.
## Licensing
## Project docs
- [ARCHITECTURE.md](./ARCHITECTURE.md) — how the code is organized
- [ROADMAP.md](./ROADMAP.md) — the phased plan to MVP
- [MODERNIZE.md](./MODERNIZE.md) — dependency and idiom cleanup list
## License
Public domain (Unlicense), see [LICENSE](./LICENSE).
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.

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.
- [x] rewrite README (currently the stock template)
- [x] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice)
- [x] clear compiler warnings (unused imports, unused `qty` in `item.rs`)
- [x] 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,
- [ ] rewrite README (currently the stock template)
- [ ] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice)
- [ ] clear compiler warnings (unused imports, unused `qty` in `item.rs`)
- [ ] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it
- [ ] dependency and idiom cleanup — see `MODERNIZE.md` (drop unused deps,
`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
(`itertools`, `thiserror`, `anyhow`, `pretty_env_logger`, `dotenvy`) stay
(decision: nothing removed; revisit later)
- [x] replace `end2end/tests/example.spec.ts` with a smoke test (app boots, nav renders, no console errors)
- [ ] 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)
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,
QuestBook, Quest, NoteBook, Note, ContactBook, Contact, SpellSlots, Attunements,
PreparedSpells, Spell, HitDice, DeathSaveThrows, Name, Settings; enums:
InventoryKind, ContactStatus, PreparedSpell)
- [x] 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
- [x] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage`
- [ ] verify `Store` derive and `serde` derive coexist on the same structs (prototype first)
- [ ] add a `version` field to `Settings`; add a migration hook for future format changes
- [ ] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage`
(feature-gate to `hydrate`/`csr`; SSR renders mock/empty)
- [x] auto-save on change — debounced effect over the `Store` — plus manual Save button
- [x] Load button: restore from localStorage with an overwrite confirm
- [x] Clear button: wipe localStorage with a confirm
- [x] export: download `dashboard.json` as a blob
- [x] import: file input → parse → validate version → replace state
- [x] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard"
- [ ] auto-save on change — debounced effect over the `Store` — plus manual Save button
- [ ] Load button: restore from localStorage with an overwrite confirm
- [ ] Clear button: wipe localStorage with a confirm
- [ ] export: download `dashboard.json` as a blob
- [ ] import: file input → parse → validate version → replace state
- [ ] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard"
- **Acceptance:** close the tab, reopen — state is intact. Export → import into a
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)
`/` currently renders the literal string "character".
- [x] player card: portrait, name, class, level, XP (data already in sidebar)
- [x] HP block: temp / current / max, `Numput`-style editing
- [x] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry
- [ ] player card: portrait, name, class, level, XP (data already in sidebar)
- [ ] HP block: temp / current / max, `Numput`-style editing
- [ ] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry
(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.
**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)
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)
- [ ] responsive SCSS pass (sidebar and header on mobile widths)
- Playwright e2e: add item from search, quest CRUD, notes filter, undo
shortcuts (persistence round trip is covered by `persistence.spec.ts`)
- [ ] Playwright e2e: persistence round-trip, add item from search, quest CRUD,
notes filter
- [ ] rewrite README: usage, dev workflow, deploy
- [ ] release build via cargo-leptos; verify SSR + hydration; pick a deploy target
- **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
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).
- 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 2 and 3 can run in parallel after Phase 1 (different components).
- 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.
- 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::{
components::{
header::Header,
modal::{ Modal, ModalState },
nav::Nav,
sidebar::Sidebar,
Character,
@ -20,27 +19,17 @@ use crate::{
/// Root component of the app.
///
/// Sets up meta context, creates the `Store<Dashboard>` (mock data; the
/// hydrated client swaps in the saved state, see `state::persist`) and
/// injects it as context, then renders the shell: header, sidebar, nav,
/// the routed page and the modal overlay. Routes: `/` character, `/inv`
/// inventories, `/wiki` wiki; anything else falls to `NotFound`.
/// Sets up meta context, creates the `Store<Dashboard>` (mock data for now)
/// and injects it as context, then renders the shell: header, sidebar, nav
/// and the routed page. Routes: `/` character, `/inv` inventories,
/// `/wiki` wiki; anything else falls to `NotFound`.
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
let store = Store::new(Dashboard::mock());
provide_context(store.clone());
provide_context(ModalState::new());
provide_context(crate::state::undo::HistoryHandle::new(store.clone()));
provide_context(Store::new(Dashboard::mock()));
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! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
@ -64,7 +53,6 @@ pub fn App() -> impl IntoView {
</Routes>
</main>
</div>
<Modal state=modal />
</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 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
/// session steppers, the persistence controls, and undo/redo.
///
/// 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.
/// session steppers, and Save/Load/Clear buttons (not wired yet — see
/// ROADMAP phase 1).
#[component]
pub fn Header () -> impl IntoView {
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 date = state.date();
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! {
<header>
<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>
</div>
<div class="controls">
<button on:click=on_save>"Сохранить"</button>
<button on:click=on_load>"Загрузить"</button>
<button on:click=on_clear>"Очистить"</button>
<button
on:click=move |_| history.undo()
disabled=move || undo_depth.get() == 0
title="Отменить (Ctrl+Z)"
>"Отменить"</button>
<button
on:click=move |_| history.redo()
disabled=move || redo_depth.get() == 0
title="Вернуть (Ctrl+Shift+Z или Ctrl+Y)"
>"Вернуть"</button>
<button>Save</button>
<button>Load</button>
<button>Clear</button>
</div>
</section>
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
</header>
}
}

View File

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

View File

@ -1,7 +1,7 @@
use crate::{prelude::*, utils::BasisPoints};
use leptos::prelude::*;
use reactive_stores::Field;
use reactive_stores::{Field, OptionStoreExt};
/// One inventory row: name, quantity stepper + `Numput`, weight stepper +
/// `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,
) -> impl IntoView {
let qty = move || entry.quantity();
let item = move || entry.item();
let empty = String::new();

View File

@ -4,35 +4,25 @@
//! and `numput` implement the inventories page; the page components
//! (`Character`, `Inventories`, `Wiki`) and the item search live in this
//! 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 nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher };
use reactive_stores::Field;
use crate::prelude::*;
pub mod character;
pub mod header;
pub mod sidebar;
pub mod nav;
pub mod inventory;
pub mod numput;
pub mod item;
pub mod modal;
/// Character page (route `/`): prepared spells and attunements
/// (ROADMAP phase 2). The sidebar carries the character editors.
/// Character page (route `/`). Stub: renders a placeholder until
/// ROADMAP phase 2.
#[component]
pub fn Character () -> impl IntoView {
view! {
<div class="character-page">
<character::prepared_spells::PreparedSpells />
<character::attunements::Attunements />
</div>
character
}
}

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_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_traits::{ SaturatingSub, SaturatingAdd };
use crate::prelude::*;

View File

@ -1,12 +1,12 @@
//! Character sidebar: portrait, names, class/level/XP, HP, hit dice,
//! death saves and spell slots.
use crate::prelude::*;
use leptos::prelude::*;
mod image;
mod names;
mod about;
mod hit_points;
mod armor_class;
mod hit_dice;
mod death_saving_throws;
mod spell_slots;
@ -21,7 +21,6 @@ pub fn Sidebar () -> impl IntoView {
<names::Names />
<about::About />
<hit_points::HitPoints />
<armor_class::ArmorClass />
<hit_dice::HitDice />
<death_saving_throws::DeathSavingThrows />
<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>
</div>
<For
each=move || 0..level.get()
each=move || (0..level.get())
key=|slot| slot.clone()
let (slot)
>

View File

@ -2,10 +2,9 @@ use crate::prelude::*;
use leptos::prelude::*;
/// 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]
pub fn HitPoints() -> impl IntoView {
pub fn HitPoints () -> impl IntoView {
let state = expect_context::<Context>();
let player = state.player();
@ -27,10 +26,13 @@ pub fn HitPoints() -> impl IntoView {
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! {
<h6>"hp/оз:"</h6>
<h6>hp/оз:</h6>
<div class="hp">
<div class="perm">
<div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div>

View File

@ -30,10 +30,9 @@ impl Dashboard {
temp_hp: 12,
hp: 1,
max_hp: 61,
ac: 16,
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 })),
attunements: Attunements(vec![None; 3]),
attunements: Attunements([const { None }; 3]),
hit_dice: HitDice { used: 1, kind: 20 },
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
prepared: generate_prepared_spells_list(),
@ -46,7 +45,9 @@ impl Dashboard {
quest_book: generate_mock_questbook(),
notes: generate_mock_notebook(),
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
/// vacant slot. The UI can fill and empty slots freely.
/// Demo prepared spells. Currently an empty list — `Spell` is a stub.
fn generate_prepared_spells_list () -> PreparedSpells {
use PreparedSpell::*;
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)

View File

@ -9,14 +9,12 @@
//!
//! The model is deliberately "wide": it covers the campaign header, the
//! player character, the party's common stash, quests, notes, contacts and
//! settings. `mock` provides demo data, `Dashboard::blank` an empty slate;
//! persistence (localStorage autosave, JSON export/import) lives in
//! `crate::state::persist`.
//! settings. All of it currently comes from hardcoded mock data (the `mock`
//! module); nothing is persisted yet (see ROADMAP phase 1).
use std::fmt::Display;
use chrono::{ NaiveDate, NaiveDateTime };
use reactive_stores::Store;
use serde::{ Deserialize, Serialize };
use crate::prelude::*;
@ -68,7 +66,7 @@ pub struct ItemReferenceMap (pub HashMap<String, BookItem>);
/// party's common inventory, quests, notes, contacts and settings. The
/// whole tree derives `Store`, so every nested field is independently
/// reactive. `Dashboard::mock()` provides the initial state.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct Dashboard {
pub campaign: String,
pub campaign_image: String,
@ -82,50 +80,10 @@ pub struct Dashboard {
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,
/// spell slots, inventory, attunements, prepared spells, hit dice and
/// death saves. This is the heart of the character sheet.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct PlayerData {
pub name: Name,
pub class: String,
@ -135,10 +93,6 @@ pub struct PlayerData {
pub temp_hp: u16,
pub 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 spell_slots: SpellSlots,
pub inventory: Inventory,
@ -151,7 +105,7 @@ pub struct PlayerData {
/// 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 balance: Balance,
pub inventory: Inventory,
@ -159,7 +113,7 @@ pub struct CommonData {
/// Currency counts per denomination, platinum through copper.
/// Edited with `Numput` on the inventories page.
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
#[derive(Clone, Debug, Store)]
pub struct Balance {
pub platinum: u32,
pub gold: u32,
@ -172,14 +126,14 @@ pub struct Balance {
///
/// `#[store(key)]` gives every entry a stable reactive key (its `id`),
/// which keeps `For`/`ForEnumerate` updates cheap.
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
#[derive(Clone, Debug, Store)]
pub struct Inventory {
#[store(key: u64 = |row| row.id)]
pub rows: Vec<InventoryEntry>
}
/// 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 id: u64,
pub quantity: u32,
@ -201,7 +155,7 @@ impl InventoryEntry {
/// An item inside an inventory: name, weight (basis points), optional
/// 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 name: String,
pub weight: u32,
@ -210,12 +164,12 @@ pub struct InventoryItem {
}
/// 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>);
/// One quest: title, locations, dates, description, giver and reward.
/// 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 title: 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.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct NoteBook (pub Vec<Note>);
/// One note: content, in-game date, real-world timestamp, session number
/// and tags.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct Note {
pub content: String,
pub date_ingame: NaiveDate,
@ -243,45 +197,43 @@ pub struct Note {
}
/// 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]);
/// Used/total slots for one spell level.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct SpellSlotLevel {
pub used: u8,
pub total: u8,
}
/// Attunement slots (D&D rules allow three attuned items; the UI can
/// add more slots for custom campaigns).
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Attunements (pub Vec<Option<String>>);
/// Three attunement slots (D&D rules allow three attuned items).
#[derive(Clone, Debug, Store)]
pub struct Attunements (pub [Option<String>; 3]);
/// 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>);
/// One prepared-spell slot: vacant or holding a spell.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub enum PreparedSpell {
Vacant,
Occupied (Spell)
}
/// A spell: the name plus optional level and description.
/// Optional fields keep old saved JSON (name-only spells) parseable.
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
/// A spell. Only the name exists so far; the rest is a `@TODO`
/// (see ROADMAP phase 2).
// @TODO
#[derive(Clone, Debug, Store)]
pub struct Spell {
pub name: String,
pub level: Option<u8>,
pub description: Option<String>,
pub name: String
}
/// 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 used: u8,
pub kind: u8
@ -290,7 +242,7 @@ pub struct HitDice {
/// Death-saving-throw counters: three failures kill, three successes
/// stabilize.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct DeathSaveThrows {
pub failed: u8,
pub succeeded: u8,
@ -305,11 +257,11 @@ impl DeathSaveThrows {
}
/// 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>);
/// One NPC contact: name, optional image, status, location and notes.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub struct Contact {
pub name: Name,
pub image: Option<String>,
@ -319,7 +271,7 @@ pub struct Contact {
}
/// Life status of a contact, used for filtering.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Debug, Store)]
pub enum ContactStatus {
Unknown,
DeadBygone,
@ -328,7 +280,7 @@ pub enum ContactStatus {
}
/// 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 first: String,
pub last: String,
@ -346,28 +298,15 @@ impl Name {
}
}
/// App settings: currently only the saved-format version.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
/// App settings. Empty today; will carry e.g. a persisted-format version.
#[derive(Clone, Debug, Store, Default)]
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.
#[derive(Clone, Copy, Debug, Store, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, Store)]
pub enum InventoryKind {
Personal,
Common

View File

@ -3,6 +3,7 @@
//! 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
//! SSR server, `app.rs` the component tree.
#![feature(type_alias_impl_trait)]
pub mod app;
#[allow(unused_imports)]

View File

@ -2,9 +2,5 @@
//!
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
//! (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.
//!
//! `ITEMS_REFS` loads the 5e.tools-format JSON files (`data/*.json`, roughly
//! 5k items) once at first use via `std::sync::LazyLock` and indexes them by
//! name. `ITEMS_NAMES` is the flat name list used for fuzzy search.
use std::sync::LazyLock;
//! `ITEMS_REFS` loads the 5e.tools-format JSON files (`data/*.json`,
//! roughly 5k items) once at first use via `lazy_static` and indexes them
//! by name. `ITEMS_NAMES` is the flat name list used for fuzzy search.
use crate::prelude::*;
use lazy_static::lazy_static;
lazy_static! {
/// 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
/// missing weight fills in, `rest` fields concatenate).
pub static ITEMS_REFS: LazyLock<ItemReferenceMap> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(5_000);
/// Built once at startup: entries with the same name are merged (a missing
/// weight fills in, `rest` fields concatenate). Note `foundry.json` is
/// currently loaded twice — see `utils::read_items`.
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/base.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/items.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/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
/// (`ItemSearchField`).
pub static ITEMS_NAMES: LazyLock<Vec<&'static str>> =
LazyLock::new(|| ITEMS_REFS.0.keys().map(String::as_str).collect());
pub static ref ITEMS_NAMES: Vec<&'static String> = {
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;
margin: 2px 5px;
@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 {
z-index: 2;
}
.ac {
display: flex;
align-items: center;
gap: 6px;
padding-left: 10px;
border-left: 1px solid rgba(white, 0.15);
}
}
.hit-dice {
@ -202,14 +194,6 @@ aside {
}
}
.ac {
margin-left: auto;
justify-content: flex-end;
text-align: right;
width: 20%;
background: rgba(green, 0.15);
}
.death-throws {
display: flex;
justify-content: center;

View File

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