Compare commits

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

Fixes the build with the current nightly (tachys 0.2.18 no longer
needs the re-gated min_adt_const_params feature).
2026-08-04 06:40:38 +03:00
48 changed files with 6087 additions and 158 deletions

No files matched your search

+1 -3
View File
@@ -3,9 +3,7 @@
debug/
target/
# 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
# Cargo.lock is tracked: the app is a binary, builds should be reproducible
# These are backup files generated by rustfmt
**/*.rs.bk
+178
View File
@@ -0,0 +1,178 @@
# ARCHITECTURE
This document describes how `aex` works today. It is a snapshot, not a design
spec: several pieces are stubs or unwired (see "Current gaps" at the end and
the phased plan in [ROADMAP.md](./ROADMAP.md)).
## What it is
A D&D 5e character sheet and campaign dashboard for one party's play-by-text
game. The UI is in Russian; code and comments are in English. The app tracks
the campaign (name, in-game date, session number), one player character
(identity, class, HP, money, spell slots, inventory, hit dice, death saves),
a party-shared stash, and — in the data model only, no UI yet — quests,
notes, and contacts.
## Stack
- Rust, edition 2024, nightly toolchain (`rust-toolchain.toml`, channel
pinned to `nightly` without a date)
- 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
- 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.
## Directory layout
```
src/
main.rs SSR entry point: actix-web server, favicon
lib.rs crate root, module declarations, WASM hydrate()
app.rs App component: context, shell, routes
prelude.rs crate-wide re-exports and the Context alias
state/ App state (one Store<Dashboard> in context)
entities/ Domain model (Store-derived structs) + mock data
statics/ Bundled item data: ITEMS_REFS, ITEMS_NAMES
utils/ read_items, adjust_checked, BasisPoints trait
components/
header.rs Campaign header (name, date, session, Save/Load/Clear)
nav.rs Route links
sidebar.rs Character sidebar, composes the seven panels below
sidebar/*.rs image, names, about, hit_points, hit_dice,
death_saving_throws, spell_slots
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
```
## State model
The whole dashboard is one reactive tree:
- `entities::Dashboard` is the root struct: campaign fields, `player:
PlayerData`, `common: CommonData`, plus quest/note/contact books and
settings.
- Every entity struct derives `reactive_stores::Store`, so every nested
field is independently reactive (copy-on-write paths).
- `App` creates `Store::new(Dashboard::mock())` and provides it as Leptos
context. Components read it with `expect_context::<Context>()` and grab
field handles, e.g. `let hp = state.player().hp();`.
- Writes go through `field.update(|v| ...)`, `field.set(...)`, or
`field.write()`. All arithmetic is saturating (`saturating_add/sub`,
`checked_add_signed`) so values never underflow.
The state is mock data only — `Dashboard::mock()` runs on every load and
nothing is persisted. The header's Save/Load/Clear buttons do nothing yet
(ROADMAP phase 1).
## Data layer (items)
- `statics::ITEMS_REFS` is a `LazyLock` 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.
- `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
its `rest` fields.
- `ITEMS_NAMES` is the flat name list fed to the fuzzy matcher.
Weights are stored as `u32` basis points (100 = 1.0) in inventory items and
formatted with the `utils::BasisPoints` trait; book items use `f64` from the
JSON as-is.
## Rendering pipeline
1. `main.rs` (feature `ssr`) starts an actix-web server, generates the route
list from `App`, and renders each route server-side through
`leptos_routes`. It serves the WASM/CSS from `/pkg` and static assets
from `/assets`.
2. The WASM bundle (`hydrate` feature) calls `hydrate()` in `lib.rs`, which
mounts `App` with `hydrate_body` over the server-rendered DOM.
3. Routing: `/` → Character page, `/inv` → Inventories, `/wiki` → Wiki,
anything else → `NotFound` (sets HTTP 404 during SSR).
## 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)
├─ Sidebar
│ ├─ Image portrait
│ ├─ Names first / alias «» / last
│ ├─ About class, level ±, XP
│ ├─ HitPoints current/max with bar, temp HP
│ ├─ HitDice die-kind select, per-level clickable dice
│ ├─ DeathSavingThrows fail/success pips + reset
│ └─ 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)
├─ Inventories two Inventory sections (Personal/Common)
│ └─ Inventory balance (5 × Numput), rows (ForEnumerate)
│ └─ Item name, quantity ±, weight ±, more/swap/remove
└─ Wiki stub text
```
The inventories page reads `Context`, splits it into `Field<Inventory>` and
`Field<Balance>` handles, and passes them down. Item rows get move/remove
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
pattern with caller-supplied display and parse functions (used for weights
in basis points).
Item search (`ItemSearchField`) re-runs a nucleo fuzzy match on every
keystroke over `ITEMS_NAMES` and renders `ItemSearchCard`s. It is not
currently mounted on any page.
## Conventions
- UI strings are Russian and hardcoded in the views; ids like `id="hp"`
exist for e2e tests.
- Collections come from `std::collections` via the prelude (`HashMap`,
`HashSet`).
- `For` lists are keyed: inventories use `#[store(key)]` on `row.id`,
sidebar pips key by index.
- Logging goes through the `log` macros re-exported in the prelude
(`info!`, `warn!`, ...).
## Current gaps
- 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
See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.
Generated
+3394
View File
File diff suppressed because it is too large. Load diff
+16 -16
View File
@@ -11,28 +11,28 @@ actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1"
http = { version = "1.3.1", optional = true }
leptos = { version = "0.8.2", features = ["nightly"] }
leptos_meta = { version = "0.8.2" }
leptos_actix = { version = "0.8.2", optional = true }
leptos_router = { version = "0.8.2", features = ["nightly"] }
wasm-bindgen = "=0.2.101"
leptos = { version = "0.8.20", features = ["nightly"] }
leptos_meta = { version = "0.8.6" }
leptos_actix = { version = "0.8.7", optional = true }
leptos_router = { version = "0.8.15", features = ["nightly"] }
wasm-bindgen = "=0.2.126"
anyhow = "1.0.99"
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"
reactive_stores = "0.2.5"
reactive_stores = "0.4.3"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
chrono = { version = "0.4.42", features = ["serde"] }
console_log = "1.0.0"
num-traits = "0.2.19"
num-format = "0.4.4"
js-sys = "=0.3.78"
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:3000"
site-addr = "127.0.0.1:3110"
# The port to use for automatic reload monitoring
reload-port = 3001
reload-port = 3111
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
+134
View File
@@ -0,0 +1,134 @@
# MODERNIZE.md
A list of dependency reductions and idiom cleanups for the current codebase.
Nothing here is applied yet. Review it, then decide which items to do and when
(most fit in ROADMAP Phase 0).
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.
- 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
None of these are referenced in `src/`. Decision: `itertools`, `thiserror`,
`anyhow`, `pretty_env_logger` and `dotenvy` stay for certain. The rest is
under consideration.
| dependency | note |
|---|---|
| `itertools` | **keep for now** (decision). No usage |
| `seq-macro` | under consideration. No usage |
| `leptos-use` | under consideration. No usage |
| `dotenvy` | **keep for now** (decision). No usage. Never wired into `main` |
| `pretty_env_logger` | **keep for now** (decision). No usage. Logging uses the `log` macros |
| `thiserror` | **keep for now** (decision). No usage. No error types yet |
| `http` | under consideration. Optional, enabled by no feature. Code uses `actix_web::http::StatusCode` |
| `console_log` | under consideration. No usage. Only `console_error_panic_hook` is called |
| `anyhow` | **keep for now** (decision). Only `#[macro_use] extern crate anyhow` and prelude aliases. The `Result` alias and `bail!`/`anyhow!` are used nowhere |
| `js-sys` | under consideration. No direct usage. Resolves transitively. Keep the `wasm-bindgen =0.2.126` pin: `hydrate()` uses `#[wasm_bindgen(...)]` |
## 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:
```rust
use std::sync::LazyLock;
static ITEMS_REFS: LazyLock<ItemReferenceMap> = LazyLock::new(|| {
let mut map = HashMap::with_capacity(5_000);
utils::read_items(include_str!("../../data/items.json"), &mut map);
// base.json, foundry.json (twice — duplication bug), magic.json
info!("Loaded {} items", map.len());
ItemReferenceMap(map)
});
static ITEMS_NAMES: LazyLock<Vec<&'static str>> =
LazyLock::new(|| ITEMS_REFS.0.keys().map(String::as_str).collect());
```
Notes:
- `include_str!` needs literal paths, so keep the five explicit calls.
- The current `HashMap::from_iter(map.into_iter())` roundtrip is a no-op.
Wrap the map directly.
- `ITEMS_NAMES` changes from `Vec<&'static String>` to `Vec<&'static str>`.
The matcher accepts both.
2. **`num-format` → local `group_thousands` helper** in `src/utils/mod.rs`.
It exists only for the `ToFormattedString` bound in `Numput`. Drop the
bound and render `f.to_string()` grouped. About 6 lines.
3. **`num-traits` → local saturating trait** in `src/components/numput.rs`.
It exists only for the `SaturatingAdd`/`SaturatingSub` bounds on `Numput`.
Define a small local trait and impl it for `u8`, `u16`, `u32`, `u64`,
`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
`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
to about 13.
## 3. Idiomatic cleanups (no dependency changes)
1. Delete `#![feature(type_alias_impl_trait)]` in `src/lib.rs:6`.
It is unused and warns.
2. Stay on nightly. The code uses only stable features (let-chains, let-else,
edition 2024, `LazyLock`). If the toolchain is ever revisited: drop the
`nightly` features from `leptos`/`leptos_router`, install stable, and run
the three checks. The original build breakage was a nightly regression
(`min_adt_const_params` re-gating in nightly 1.99); stable does not have
that class of failure.
3. Prelude (`src/prelude.rs`):
- Remove the blanket `#![allow(unused_imports)]` and
`#![allow(dead_code)]`. They hide real warnings.
- Remove the single-letter `log` aliases
(`warn as w, log as l, info as i, ...`). They are unused.
- Remove the `anyhow` re-exports and typealiases. Keeping the `anyhow`
dependency does not force keeping these dead re-exports.
- Replace legacy `#[macro_use] extern crate log;` in `src/lib.rs` with
`use log::{info, error};` at the two call sites.
4. `read_items` (`src/utils/mod.rs`): use the `Entry` API instead of
`contains_key` → `get_mut().unwrap()`. Removes the unwrap and the double
lookup.
5. Mock data (`src/entities/mock.rs`):
`HashSet::from_iter(vec![...])` → `HashSet::from([...])`.
The date `unwrap`s are fine: the data is static.
6. Minor: `adjust_checked` drops `-> ()`; `numput.rs` drops the unused
`leptos::{ logging, ... }` import.
## 4. Opinionated (do only on request)
1. `chrono` → `jiff`. Nicer API, actively maintained. Not worth the churn
for this model. If kept: `chrono`'s `serde` feature is unused today —
trim it now, re-add in phase 1.
2. `cargo fmt` normalization. The repo uses `fn foo (` spacing; it may be
deliberate. Ask before running.
3. Clippy pass over the result.
## Verification
After applying items 1-3, run:
```
cargo check --no-default-features --features hydrate --lib
cargo check --no-default-features --features ssr --bin aex
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib
```
+62 -56
View File
@@ -1,72 +1,78 @@
<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>
# aex
# Leptos Starter Template
A D&D 5e character sheet and campaign dashboard for a text-based play-by-post
game. The UI is in Russian.
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool.
## What works today
## Creating your template repo
- Campaign header: name, in-game date steppers, session counter, and
Save / Load / Clear: download `dashboard.json`, import a file back,
wipe to a blank campaign
- Undo/redo: header buttons and Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y over
edit bursts; Clear and import are undoable too
- Character page: prepared spells (free-text slots with
level/description) and attunements (growable slots, clearable)
- Character sidebar: portrait, names, class, level, XP, hit points with
temp HP bar, manual AC, hit dice, death saves, spell slots
- Inventories page (`/inv`): personal and party-common inventories,
currency per denomination, item rows with quantity and weight steppers,
move between inventories, remove
- Bundled 5e.tools item data (~5k items) with fuzzy search components
(search is not mounted on a page yet)
If you don't have `cargo-leptos` installed you can install it with
State autosaves to localStorage (debounced) and survives reloads; export/
import round-trips through a JSON file. The phased plan is in
[ROADMAP.md](./ROADMAP.md).
`cargo install cargo-leptos --locked`
## Tech stack
Then run
- Rust, edition 2024, nightly toolchain (pinned in `rust-toolchain.toml`)
- Leptos 0.8 SSR (actix-web) + client-side hydration
- `reactive_stores` for the reactive state model
- SCSS (`style/main.scss`), compiled by dart-sass
- Playwright for end-to-end tests
`cargo leptos new --git leptos-rs/start-actix`
## Development
to generate a new project template (you will be prompted to enter a project name).
Prerequisites: `cargo-leptos`, dart-sass, and the `wasm32-unknown-unknown`
target. The nightly toolchain installs itself via `rust-toolchain.toml`.
`cd {projectname}`
to go to your newly created project.
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
## Running your project
`cargo leptos watch`
By default, you can access your local project at `http://localhost:3000`
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
leptos_start
site/
```
Set the following environment variables (updating for your project as needed):
```sh
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"
cargo install cargo-leptos
cargo leptos watch # dev server with hot reload
# open http://127.0.0.1:3000
```
Finally, run the server binary.
## 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`:
Other commands:
`trunk serve --open --features csr`
```sh
cargo leptos build --release # production build
cargo leptos serve # dev server without the watcher
cargo check --no-default-features --features ssr --bin aex
cargo check --no-default-features --features hydrate --lib
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib
```
This may be useful for integrating external tools which require a static site, e.g. `tauri`.
## Tests
## Licensing
End-to-end tests live in `end2end/` (Playwright): a smoke spec and a
persistence spec (reload keeps state; export/import round trip). With the
dev server running:
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly.
```sh
cd end2end
npm install
npx playwright test --project=chromium
```
or let cargo-leptos manage the server: `cargo leptos end2end`.
## Project docs
- [ARCHITECTURE.md](./ARCHITECTURE.md) — how the code is organized
- [ROADMAP.md](./ROADMAP.md) — the phased plan to MVP
- [MODERNIZE.md](./MODERNIZE.md) — dependency and idiom cleanup list
## License
Public domain (Unlicense), see [LICENSE](./LICENSE).
+245
View File
@@ -0,0 +1,245 @@
# ROADMAP — aex
A D&D 5e character sheet and campaign dashboard. One page per concern: character,
inventory, quests, notes, contacts, item reference. Built with Leptos 0.8 (SSR +
hydration), actix-web, SCSS.
## MVP definition
The MVP is done when a player can run a real session with the app:
- the app opens with last session's state (survives tab close / reload)
- the player can track HP, spell slots, hit dice, death saves, money, inventory
(add, remove, move items — including from item search)
- the player can record quests, notes, and contacts as the session happens
- the player can look up any item's stats and add it to inventory
- the player can back up the dashboard to a JSON file and restore it
## Current state (Feb 2026)
| Area | Status |
|---|---|
| Header: campaign name, image, in-game date, session steppers | Works |
| Header: Save / Load / Clear buttons | Dead (no persistence) |
| Sidebar: image, names, class/level/XP, HP, hit dice, death saves, spell slots | Works |
| Character page `/` | Stub — renders literal text "character" |
| Inventory page `/inv`: personal + common, money, item rows | Works |
| Item search (nucleo over ~5k bundled 5e.tools items) | Component exists, wired to no page |
| Item "+" (add) and "…" (details) buttons | Dead |
| Quests / Notes / Contacts | Models + mock data exist, zero UI, no routes |
| Prepared spells | Model exists, list is empty; `Spell` is a `@TODO` stub |
| Attunements | Model exists, no UI |
| Settings | Empty struct |
| Persistence | None — hardcoded `Dashboard::mock()` on every load |
| e2e tests | Example Playwright spec only |
| README | Stock Leptos template |
## Non-goals (explicitly out of MVP)
- accounts, multi-user, or sync across devices
- server-side database
- dice rolling, character builder, full rulebook
- multiple campaigns / dashboards switching
- images hosted by the app (remote URLs are fine)
## Effort legend
- S — ~0.5 day
- M — ~1-2 days
- L — ~3-5 days
---
## Phase 0 — Build hygiene (S, ~0.5-1 day)
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,
`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`
(`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)
## 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`
(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`
(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"
- **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
(upgrade the `Spell` stub from `name`-only to name + optional level/description)
- [x] 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
"…" buttons are dead.
- [ ] host `ItemSearchField` on a page (Wiki page is the natural home — see Phase 7)
- [ ] "+" adds the `BookItem` to the personal inventory: merge into a row with the
same name, else new row, quantity 1
- [ ] "…" opens an item detail view: description, rarity, weight, tags, source data
(`BookItem.rest`)
- [ ] free-form add-entry UI: add a row (name/quantity/weight) without search
- [ ] edit a row: name, quantity, weight, description
- [ ] stretch: carrying capacity — sum row weights per inventory, show total vs limit
- **Acceptance:** search "potion" → click + → a row appears in the personal
inventory with the correct weight; state persists.
## Phase 4 — Quests page (M, ~1-2 days)
New route `/quests`. Model and mock data exist; zero UI.
- [ ] add a completion state to `Quest` (the model lacks one today)
- [ ] quest list: title, giver (Name), taken/task locations, dates, reward (Balance,
reuse `Numput`), description
- [ ] add / update / delete quest; toggle complete
- [ ] sort by deadline; highlight overdue
- **Acceptance:** create a quest, reload — it is still there; mark it complete.
## Phase 5 — Notes page (M, ~1-2 days)
New route `/notes`. Model and mock data exist; zero UI.
- [ ] note list: content, in-game date, session, tags; add / edit / delete
- [ ] tag chips; filter by tag or by session
- [ ] quick-add defaults to the header's current session and in-game date
- **Acceptance:** log a note mid-session, filter by tag, reload — still there.
## Phase 6 — Contacts page (M, ~1-2 days)
New route `/contacts`. Model and mock data exist; zero UI.
- [ ] contact cards: name, alias, status, location, notes, optional image
- [ ] status filter: Alive / Dead / DeadBygone / Unknown
- [ ] add / edit / delete contact
- **Acceptance:** find a contact by status filter; edit its notes; persists.
## Phase 7 — Wiki page: item reference (M, ~1-2 days)
`/wiki` currently renders "books". Search infrastructure already exists.
- [ ] host `ItemSearchField` here with results list
- [ ] item detail panel: rarity, weight, full entry text from the JSON `rest` field
- [ ] filters: rarity / category tags; lazy list or pagination — never render 5k rows
- [ ] "Add to inventory" shortcut (reuses the Phase 3 path)
- **Acceptance:** browse or search items, open a detail, add to inventory.
## 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`)
- [ ] 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.
---
## Sequencing notes
- 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 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.
## Known debt and risks
- **Cargo.lock is gitignored** — builds are not reproducible. Decide in Phase 0.
- **serde × Store derive** on the same structs — unverified combination;
prototype before committing to it (Phase 1).
- **~3.9 MB of item JSON** bundled via `include_str!` — parses on startup in the
browser. Fine for MVP; moving to a server function is a post-MVP option.
- **localStorage** has a ~5 MB budget — plenty for text JSON state.
- **Browser APIs on SSR** — localStorage access must be gated to `hydrate`/`csr`;
SSR renders from mock or empty state.
+3
View File
@@ -0,0 +1,3 @@
node_modules
playwright-report
test-results
+120
View File
@@ -0,0 +1,120 @@
import { test, expect, type Page } from "@playwright/test";
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
test.describe("character page", () => {
test("add a spell, undo it, redo it", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const vacant = page.locator(".prepared-slot.vacant").first();
await vacant.locator("input").fill("Щит веры");
await vacant.locator("button[title='Записать заклинание']").click();
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
// Ctrl+Z on the page (focus is on the button, not an input) undoes
// the add; Ctrl+Y redoes it.
await page.keyboard.press("Control+z");
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toHaveCount(0);
await page.keyboard.press("Control+y");
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
expect(errors).toEqual([]);
});
test("remove a spell empties its slot", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const fireball = page.locator(".prepared-slot.occupied", { hasText: "Огненный шар" });
await fireball.locator("button[title='Убрать заклинание']").click();
await expect(page.locator(".spell-name", { hasText: "Огненный шар" })).toHaveCount(0);
await expect(page.locator(".prepared-slot.vacant")).toHaveCount(3);
expect(errors).toEqual([]);
});
test("a prepared spell survives a reload", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const vacant = page.locator(".prepared-slot.vacant").first();
await vacant.locator("input").fill("Щит веры");
await vacant.locator("button[title='Записать заклинание']").click();
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
// Autosave is debounced by 500 ms; give it time before reloading.
await page.waitForTimeout(700);
await page.reload();
await expect(page.locator(".spell-name", { hasText: "Щит веры" })).toBeVisible();
expect(errors).toEqual([]);
});
test("attune an item, clear it, reload keeps the attunement", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const slot = page.locator(".attunement-slot").first();
await slot.locator("input").fill("Посох погоды");
await slot.locator("button[title='Привязать предмет']").click();
await expect(page.locator(".attuned-item")).toHaveText("Посох погоды");
// Reload: the attunement persists.
await page.waitForTimeout(700);
await page.reload();
await expect(page.locator(".attuned-item")).toHaveText("Посох погоды");
// Clear it.
await page.locator("button[title='Снять привязку']").click();
await expect(page.locator(".attuned-item")).toHaveCount(0);
expect(errors).toEqual([]);
});
test("more attunement slots can be added for custom campaigns", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
await expect(page.locator(".attunement-slot")).toHaveCount(3);
await page.locator("button[title='Добавить слот привязки']").click();
await expect(page.locator(".attunement-slot")).toHaveCount(4);
const fourth = page.locator(".attunement-slot").nth(3);
await fourth.locator("input").fill("Волшебный рог");
await fourth.locator("button[title='Привязать предмет']").click();
await expect(page.locator(".attuned-item")).toHaveText("Волшебный рог");
expect(errors).toEqual([]);
});
test("armor class is set manually and persists", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const ac = page.locator("#ac");
await expect(ac).toHaveValue("16");
await ac.fill("18");
// Autosave is debounced by 500 ms; give it time before reloading.
await page.waitForTimeout(700);
await page.reload();
await expect(page.locator("#ac")).toHaveValue("18");
expect(errors).toEqual([]);
});
});
-9
View File
@@ -1,9 +0,0 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
});
+74
View File
@@ -0,0 +1,74 @@
import { test, expect, type Page } from "@playwright/test";
import * as fs from "node:fs";
// Override with BASE_URL (e.g. when the dev server runs on another port).
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
// Collects browser console errors and page errors for the duration of a test.
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
// Autosave is debounced at 500 ms; give it time before reloading.
const DEBOUNCE_MS = 700;
test("campaign name survives a reload", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const name = "Кампания с сохранением";
const input = page.locator("#campaign-name");
await input.fill(name);
await page.waitForTimeout(DEBOUNCE_MS);
await page.reload();
await expect(input).toHaveValue(name);
expect(errors).toEqual([]);
});
test("export/import round trip through the file", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
// Save: the header button downloads dashboard.json with the current state.
const name = "Кампания экспорта";
await page.locator("#campaign-name").fill(name);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "Сохранить" }).click(),
]);
expect(download.suggestedFilename()).toBe("dashboard.json");
const filePath = await download.path();
expect(filePath).not.toBeNull();
const saved = JSON.parse(fs.readFileSync(filePath!, "utf8"));
expect(saved.campaign).toBe(name);
// Clear: confirm the dialog, the dashboard goes blank.
await page.getByRole("button", { name: "Очистить" }).click();
await page.locator(".modal").waitFor();
await page.locator(".modal").getByRole("button", { name: "Очистить" }).click();
await expect(page.locator(".modal")).toBeHidden();
await expect(page.locator("#campaign-name")).toHaveValue("");
// Load: pick the file we just saved, the state comes back.
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Загрузить" }).click(),
]);
await chooser.setFiles(filePath!);
await expect(page.locator("#campaign-name")).toHaveValue(name);
expect(errors).toEqual([]);
});
+57
View File
@@ -0,0 +1,57 @@
import { test, expect, type Page } from "@playwright/test";
// Override with BASE_URL (e.g. when the dev server runs on another port).
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
// Collects browser console errors and page errors for the duration of a test.
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
test("home page: campaign name, version, nav", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
await expect(page.locator("#campaign-name")).toHaveValue("В Поисках Дмитрия Шардалина");
await expect(page.locator(".version")).toContainText("aex");
await expect(page.locator(".main-nav a")).toHaveCount(3);
await expect(page.locator(".main-nav")).toContainText("Персонаж");
await expect(page.locator(".main-nav")).toContainText("Инвентарь");
await expect(page.locator(".main-nav")).toContainText("Информация");
expect(errors).toEqual([]);
});
test("inventories page: personal and common sections", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/inv`);
await expect(page.locator(".inventory-personal")).toHaveCount(1);
await expect(page.locator(".inventory-common")).toHaveCount(1);
// each section has one balance block with five currency inputs
await expect(page.locator(".inventory-personal .balance .numput")).toHaveCount(5);
await expect(page.locator(".inventory-common .balance .numput")).toHaveCount(5);
// and at least one item row each
await expect(page.locator(".inventory-personal .item").first()).toBeVisible();
await expect(page.locator(".inventory-common .item").first()).toBeVisible();
expect(errors).toEqual([]);
});
test("wiki page: stub renders", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/wiki`);
await expect(page.locator("body")).toContainText("books");
expect(errors).toEqual([]);
});
+106
View File
@@ -0,0 +1,106 @@
import { test, expect, type Page } from "@playwright/test";
// Override with BASE_URL (e.g. when the dev server runs on another port).
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
// Collects browser console errors and page errors for the duration of a test.
function trackErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => {
errors.push(String(err));
});
return errors;
}
// Autosave is debounced at 500 ms; give it time before reloading.
const DEBOUNCE_MS = 700;
test("undo and redo a campaign name edit", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const original = await page.locator("#campaign-name").inputValue();
const undo = page.getByRole("button", { name: "Отменить" });
const redo = page.getByRole("button", { name: "Вернуть" });
// Fresh history: both buttons are disabled.
await expect(undo).toBeDisabled();
await expect(redo).toBeDisabled();
// One edit burst; the undo button wakes up immediately (the burst does
// not need to settle first).
const edited = "Имя для отмены";
await page.locator("#campaign-name").fill(edited);
await expect(undo).toBeEnabled();
await undo.click();
await expect(page.locator("#campaign-name")).toHaveValue(original);
await redo.click();
await expect(page.locator("#campaign-name")).toHaveValue(edited);
// Keyboard: Ctrl+Z outside a text field undoes the redo.
await page.locator(".version").click();
await page.keyboard.press("Control+z");
await expect(page.locator("#campaign-name")).toHaveValue(original);
expect(errors).toEqual([]);
});
test("Ctrl+Z inside a text field keeps the browser's native undo", async ({
page,
}) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const input = page.locator("#campaign-name");
await input.fill("Текст набран в поле");
await input.press("Control+z");
// The app-wide handler must not fire while the field has focus: the
// undo button stays enabled (the pending burst is still undoable).
// The field's own value change is the browser's native undo — it
// owns Ctrl+Z inside text fields by design.
await expect(page.getByRole("button", { name: "Отменить" })).toBeEnabled();
expect(errors).toEqual([]);
});
test("undone state survives a reload", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const original = await page.locator("#campaign-name").inputValue();
const edited = "Будет отменено до перезагрузки";
await page.locator("#campaign-name").fill(edited);
await page.getByRole("button", { name: "Отменить" }).click();
await expect(page.locator("#campaign-name")).toHaveValue(original);
// The undo went through the autosave path; wait for the debounce.
await page.waitForTimeout(DEBOUNCE_MS);
await page.reload();
await expect(page.locator("#campaign-name")).toHaveValue(original);
expect(errors).toEqual([]);
});
test("clear is undoable", async ({ page }) => {
const errors = trackErrors(page);
await page.goto(`${BASE}/`);
const original = await page.locator("#campaign-name").inputValue();
await page.getByRole("button", { name: "Очистить" }).click();
await page.locator(".modal").getByRole("button", { name: "Очистить" }).click();
await expect(page.locator("#campaign-name")).toHaveValue("");
// One undo step restores everything the clear replaced.
await page.getByRole("button", { name: "Отменить" }).click();
await expect(page.locator("#campaign-name")).toHaveValue(original);
expect(errors).toEqual([]);
});
+19 -1
View File
@@ -8,6 +8,7 @@ use leptos_router::{
use crate::{
components::{
header::Header,
modal::{ Modal, ModalState },
nav::Nav,
sidebar::Sidebar,
Character,
@@ -17,13 +18,29 @@ use crate::{
prelude::*
};
/// 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`.
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
provide_context(Store::new(Dashboard::mock()));
let store = Store::new(Dashboard::mock());
provide_context(store.clone());
provide_context(ModalState::new());
provide_context(crate::state::undo::HistoryHandle::new(store.clone()));
console_error_panic_hook::set_once();
// 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
@@ -47,6 +64,7 @@ pub fn App() -> impl IntoView {
</Routes>
</main>
</div>
<Modal state=modal />
</Router>
}
}
+72
View File
@@ -0,0 +1,72 @@
use reactive_stores::Field;
use crate::prelude::*;
use leptos::prelude::*;
/// The three attunement slots: each is either empty (free-text input and
/// "+" button) or holds an item name with a "×" clear button.
#[component]
pub fn Attunements () -> impl IntoView {
let state = expect_context::<Context>();
let attunements: Field<Attunements> = state.player().attunements().into();
view! {
<div class="attunements">
<h6>"Атюнменты:"</h6>
<For
// Same state-in-key convention as prepared spells, so a
// slot whose value changed re-renders.
each=move || attunements.get().0.into_iter().enumerate()
key=|(i, s)| format!("{}-{}", i, s.as_deref().unwrap_or(""))
let((idx, slot))
>
{slot_row(attunements, idx, slot)}
</For>
<button
class="add-slot"
title="Добавить слот привязки"
on:click=move |_| attunements.update(|a| a.0.push(None))
>
"+ слот"
</button>
</div>
}
}
/// One attunement slot: the free-text entry (empty) or the item itself.
/// A plain function: component tags are not allowed directly inside
/// `For`'s `let(...)` children.
fn slot_row (
attunements: Field<Attunements>,
idx: usize,
slot: Option<String>,
) -> impl IntoView {
let text_sig = RwSignal::new(String::new());
// Binds the typed item name; empty text is ignored.
let bind = move |_| {
let text = text_sig.get_untracked();
if text.trim().is_empty() {
return;
}
attunements.update(|a| a.0[idx] = Some(text.trim().to_string()));
};
let clear = move |_| attunements.update(|a| a.0[idx] = None);
view! {
<div class="attunement-slot">
{if slot.is_some() {
view! {
<div class="attuned-item">{slot.clone().unwrap_or_default()}</div>
<button title="Снять привязку" on:click=clear>"×"</button>
}.into_any()
} else {
view! {
<input type="text" placeholder="Предмет…" bind:value=text_sig />
<button title="Привязать предмет" on:click=bind>"+"</button>
}.into_any()
}}
</div>
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Character page blocks (route `/`): prepared spells and attunements.
//! The sidebar carries the character editors (names, class/level/XP,
//! HP, hit dice, death saves, spell slots); this module renders the
//! page-level view.
pub mod attunements;
pub mod prepared_spells;
@@ -0,0 +1,96 @@
use reactive_stores::Field;
use crate::prelude::*;
use leptos::prelude::*;
/// Prepared spells: a growable list of vacant or occupied slots.
///
/// A vacant slot offers a free-text input and a "+" button; entering a
/// name fills the slot with a spell. An occupied slot shows the name
/// (plus level and description when set) and a "×" button that empties
/// it back to vacant. "+ слот" appends a new vacant slot.
#[component]
pub fn PreparedSpells () -> impl IntoView {
let state = expect_context::<Context>();
let prepared: Field<PreparedSpells> = state.player().prepared().into();
view! {
<div class="prepared-spells">
<h6>"Заготовленные заклинания:"</h6>
<For
// The key carries the slot state, so a slot whose state
// changed re-renders (same convention as sidebar pips).
each=move || prepared.get().0.into_iter().enumerate()
key=|(i, s)| format!("{}-{}", i, match &s {
PreparedSpell::Vacant => "vacant".to_string(),
PreparedSpell::Occupied(spell) => spell.name.clone(),
})
let((idx, slot))
>
{slot_row(prepared, idx, slot)}
</For>
<button
class="add-slot"
title="Добавить слот заклинания"
on:click=move |_| prepared.update(|p| p.0.push(PreparedSpell::Vacant))
>
"+ слот"
</button>
</div>
}
}
/// One prepared-spell slot: the free-text entry (vacant) or the spell
/// itself (occupied). A plain function: component tags are not allowed
/// directly inside `For`'s `let(...)` children.
fn slot_row (
prepared: Field<PreparedSpells>,
idx: usize,
slot: PreparedSpell,
) -> impl IntoView {
let name_sig = RwSignal::new(String::new());
// Fills this slot with the typed name; empty text is ignored.
let add = move |_| {
let text = name_sig.get_untracked();
if text.trim().is_empty() {
return;
}
prepared.update(|p| {
p.0[idx] = PreparedSpell::Occupied(Spell {
name: text.trim().to_string(),
..Default::default()
});
});
};
let remove = move |_| prepared.update(|p| p.0[idx] = PreparedSpell::Vacant);
let vacant = matches!(slot, PreparedSpell::Vacant);
view! {
<div class=format!("prepared-slot {}", if vacant { "vacant" } else { "occupied" })>
{if vacant {
view! {
<input
type="text"
placeholder="Название заклинания…"
bind:value=name_sig
/>
<button title="Записать заклинание" on:click=add>"+"</button>
}.into_any()
} else {
let spell = match slot {
PreparedSpell::Occupied(spell) => spell,
PreparedSpell::Vacant => unreachable!(),
};
view! {
<div class="spell-name">{spell.name.clone()}</div>
<div class="spell-level">{move || spell.level.clone().map(|l| format!("ур. {l}")).unwrap_or_default()}</div>
<div class="spell-desc">{move || spell.description.clone().unwrap_or_default()}</div>
<button title="Убрать заклинание" on:click=remove>"×"</button>
}.into_any()
}}
</div>
}
}
+100 -4
View File
@@ -1,25 +1,110 @@
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.
#[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();
let image = state.campaign_image();
// Shifts the in-game date by ±1 day (saturating, via checked_add_signed).
let adjust_day = move |adjustment: i64| date.update(|d| {
*d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap();
});
// Shifts the session counter by ±1, guarding against underflow.
let adjust_session = move |adjustment: isize| session.update(|s| {
if let Some(new) = s.checked_add_signed(adjustment) {
*s = new;
}
});
// 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()>
@@ -41,11 +126,22 @@ pub fn Header () -> impl IntoView {
<button on:click=move |_| adjust_session( 1) title="Следующая сессия" class="adjust" id="session-forward">">"</button>
</div>
<div class="controls">
<button>Save</button>
<button>Load</button>
<button>Clear</button>
<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>
</div>
</section>
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
</header>
}
}
+6 -1
View File
@@ -1,7 +1,10 @@
use leptos::{logging, prelude::*};
use leptos::prelude::*;
use reactive_stores::Field;
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };
/// One inventory section: a titled balance block (five `Numput`s) and the
/// item rows. `kind` decides personal/common styling and the move
/// direction.
#[component]
pub fn Inventory (
kind: InventoryKind,
@@ -11,10 +14,12 @@ pub fn Inventory (
let state = expect_context::<Context>();
// Removes the row at `idx` from this inventory.
let rm = move |idx: usize| inventory.update(|v| {
v.rows.remove(idx);
});
// Moves the row at `idx` to the other inventory (personal ↔ common).
let mv = move |idx: usize| {
let (from, to): (Field<Inventory>, Field<Inventory>) = match kind.dst() {
InventoryKind::Personal => (state.common().inventory().into(), state.player().inventory().into()),
+8 -2
View File
@@ -1,8 +1,11 @@
use crate::{prelude::*, utils::BasisPoints};
use leptos::prelude::*;
use reactive_stores::{Field, OptionStoreExt};
use reactive_stores::Field;
/// One inventory row: name, quantity stepper + `Numput`, weight stepper +
/// `Numput`, and more/swap/remove buttons. A weight-based tag drives the
/// CSS class. The "…" button is not wired yet (ROADMAP phase 3).
#[component]
pub fn Item (
idx: ReadSignal<usize>,
@@ -11,15 +14,18 @@ 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();
// CSS class from the item's weight tag ("weight" key).
let class = move || format!("item {}", item().tags().get().get("weight").unwrap_or(&empty));
// Weight shown as a decimal: basis points → "x.yy".
let weight_display = move || item().weight().with(|f| f.to_decimal_string());
// Parses weight input: "+n"/"-n" adjust relative, plain numbers set
// absolutely, anything else restores the current value.
let weight_change = move |ev: Targeted<Event, HtmlInputElement>| {
let old = item().weight().get_untracked();
let new = ev.target().value();
+29 -1
View File
@@ -1,23 +1,43 @@
//! UI components.
//!
//! `header`, `sidebar` and `nav` form the app shell; `inventory`, `item`
//! 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.
#[component]
pub fn Character () -> impl IntoView {
view! {
character
<div class="character-page">
<character::prepared_spells::PreparedSpells />
<character::attunements::Attunements />
</div>
}
}
/// Inventories page (route `/inv`): the player's and the party's
/// inventories side by side, each with money and item rows.
#[component]
pub fn Inventories () -> impl IntoView {
let state = expect_context::<Context>();
@@ -40,6 +60,8 @@ pub fn Inventories () -> impl IntoView {
}
}
/// Wiki page (route `/wiki`). Stub: renders a placeholder until
/// ROADMAP phase 7.
#[component]
pub fn Wiki () -> impl IntoView {
view! {
@@ -47,6 +69,11 @@ pub fn Wiki () -> impl IntoView {
}
}
/// Fuzzy item search over the bundled item names.
///
/// Re-matches on every keystroke (nucleo) and renders result cards. The
/// "+" buttons on the cards are not wired to the inventory yet (ROADMAP
/// phase 3).
#[component]
pub fn ItemSearchField () -> impl IntoView {
let (query, set_query) = signal("".to_owned());
@@ -86,6 +113,7 @@ pub fn ItemSearchField () -> impl IntoView {
}
/// One search result: name, weight, rarity and an (unwired) add button.
#[component]
pub fn ItemSearchCard <'a> (item: &'a BookItem) -> impl IntoView {
let card_class = format!("item-search-card {}", item.rarity.as_ref().unwrap_or(&String::new()));
+106
View File
@@ -0,0 +1,106 @@
//! In-app modal dialogs: confirmations and error messages.
//!
//! `ModalState` is created in `App` and provided through Leptos context;
//! the header's Save/Load/Clear handlers open dialogs and [`Modal`]
//! renders them. The dialog language is Russian.
use std::sync::Arc;
use leptos::prelude::*;
/// Shared state of the modal overlay.
///
/// `open` decides visibility; `title`/`message`/`cancel_label` and
/// `confirm_label` (None for error dialogs) drive the text; `action` runs
/// when the confirm button is clicked.
#[derive(Clone)]
pub struct ModalState {
open: RwSignal<bool>,
title: RwSignal<String>,
message: RwSignal<String>,
cancel_label: RwSignal<String>,
confirm_label: RwSignal<Option<String>>,
action: RwSignal<Arc<dyn Fn() + Send + Sync>>,
}
impl ModalState {
/// Creates a closed modal with empty content.
pub fn new () -> Self {
Self {
open: RwSignal::new(false),
title: RwSignal::new(String::new()),
message: RwSignal::new(String::new()),
cancel_label: RwSignal::new(String::new()),
confirm_label: RwSignal::new(None),
action: RwSignal::new(Arc::new(|| {})),
}
}
/// Opens a confirmation dialog: Cancel plus the given confirm button.
pub fn confirm (
&self,
title: impl Into<String>,
message: impl Into<String>,
confirm_label: impl Into<String>,
action: impl Fn() + Send + Sync + 'static,
) {
self.title.set(title.into());
self.message.set(message.into());
self.cancel_label.set("Отмена".to_string());
self.confirm_label.set(Some(confirm_label.into()));
self.action.set(Arc::new(action));
self.open.set(true);
}
/// Opens an error dialog with a single Close button.
pub fn error (&self, title: impl Into<String>, message: impl Into<String>) {
self.title.set(title.into());
self.message.set(message.into());
self.cancel_label.set("Закрыть".to_string());
self.confirm_label.set(None);
self.open.set(true);
}
/// Closes the dialog.
pub fn close (&self) {
self.open.set(false);
}
}
/// Renders the modal overlay; empty when no dialog is open.
///
/// Clicking the overlay background closes the dialog; clicks inside the
/// dialog do not. The confirm button runs the action captured in
/// `ModalState` (read at click time, so a late `confirm` wins).
#[component]
pub fn Modal (state: ModalState) -> impl IntoView {
view! {
<Show when=move || state.open.get()>
<div class="modal-overlay" on:click=move |_| state.open.set(false)>
<div
class="modal"
on:click=move |ev: leptos::ev::MouseEvent| ev.stop_propagation()
>
<h3>{move || state.title.get()}</h3>
<p>{move || state.message.get()}</p>
<div class="modal-actions">
<button class="modal-cancel" on:click=move |_| state.open.set(false)>
{move || state.cancel_label.get()}
</button>
<Show when=move || state.confirm_label.get().is_some()>
<button
class="modal-confirm"
on:click=move |_| {
(state.action.get())();
state.open.set(false);
}
>
{move || state.confirm_label.get().unwrap_or_default()}
</button>
</Show>
</div>
</div>
</div>
</Show>
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::prelude::*;
use leptos::prelude::*;
use leptos_router::components::A;
/// Top navigation: links to the three routes (character, inventory, wiki).
#[component]
pub fn Nav () -> impl IntoView {
view! {
+12 -2
View File
@@ -1,10 +1,15 @@
use std::{ fmt::Debug, str::FromStr, ops::{ Sub, Add }};
use std::{ fmt::Debug, str::FromStr };
use leptos::{ logging, prelude::*, tachys::html::property::IntoProperty };
use leptos::{ prelude::*, tachys::html::property::IntoProperty };
use num_format::{Locale, ToFormattedString};
use num_traits::{ SaturatingSub, SaturatingAdd };
use crate::prelude::*;
/// Number input bound to a reactive field.
///
/// `+n` / `-n` adjust the current value relatively, a plain number sets it
/// absolutely, and anything else is rejected (the input is restored). The
/// value renders with thousands separators (`num_format`).
#[component]
pub fn Numput <T, U, 'a> (
field: U,
@@ -37,6 +42,11 @@ pub fn Numput <T, U, 'a> (
}
}
/// `Numput` with caller-supplied display and change handlers.
///
/// For fields whose display differs from the stored value (e.g. weights
/// in basis points): `field_with` renders the input, `change` parses the
/// edit.
#[component]
pub fn NumputChange <U, G, 'a> (
mut field_with: U,
+6 -1
View File
@@ -1,14 +1,18 @@
use crate::prelude::*;
//! Character sidebar: portrait, names, class/level/XP, HP, hit dice,
//! death saves and spell slots.
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;
/// Left-hand character panel. Reads `Context` and stacks the sub-panels
/// (image, names, about, HP, hit dice, death saves, spell slots).
#[component]
pub fn Sidebar () -> impl IntoView {
view! {
@@ -17,6 +21,7 @@ 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 />
+3
View File
@@ -1,6 +1,7 @@
use crate::prelude::*;
use leptos::prelude::*;
/// Class (text input), level (+/− steppers) and XP (number input).
#[component]
pub fn About () -> impl IntoView {
let state = expect_context::<Context>();
@@ -10,12 +11,14 @@ pub fn About () -> impl IntoView {
let level = player.level();
let xp = player.xp();
// Steps the level by ±1, guarding against overflow.
let adjust_level = move |adjustment: i8| level.update(|l| {
if let Some(new) = l.checked_add_signed(adjustment) {
*l = new;
}
});
// Applies the XP input with checked parsing (invalid text is reverted).
let adjust_xp = move |ev: Targeted<Event, HtmlInputElement>| {
utils::adjust_checked(ev, xp);
};
+22
View File
@@ -0,0 +1,22 @@
use crate::prelude::*;
use leptos::prelude::*;
/// AC counter: for now is to be set manually
#[component]
pub fn ArmorClass () -> impl IntoView {
let state = expect_context::<Context>();
let player = state.player();
let ac = player.ac();
let adjust_ac = move |ev: Targeted<Event, HtmlInputElement>| {
utils::adjust_checked(ev, ac);
};
view! {
<h6>"ac/кд:"</h6>
<div class="ac">
<input min=0 title="Класс доспеха (вручную)" id="ac" type="number" class="header-input" on:input:target=move |e| adjust_ac(e) prop:value=move || ac.get() />
</div>
}
}
@@ -1,6 +1,8 @@
use crate::prelude::*;
use leptos::prelude::*;
/// Three failure pips and three success pips, plus a reset button.
/// Clicking a pip fills it; clicking the last filled pip empties it.
#[component]
pub fn DeathSavingThrows () -> impl IntoView {
let state = expect_context::<Context>();
@@ -8,6 +10,7 @@ pub fn DeathSavingThrows () -> impl IntoView {
let dt = player.death_save_throws();
// Toggles pip `slot_id` of the fail/success track.
let process_dt_click = move |success: bool, slot_id: u8| {
dt.update(|d| {
let v = if success { &mut d.succeeded } else { &mut d.failed };
+5 -1
View File
@@ -1,6 +1,8 @@
use crate::prelude::*;
use leptos::prelude::*;
/// Hit dice: a die-kind selector (d4 … d20) and one clickable die per
/// character level; clicking toggles spent/unspent.
#[component]
pub fn HitDice () -> impl IntoView {
let state = expect_context::<Context>();
@@ -8,6 +10,8 @@ pub fn HitDice () -> impl IntoView {
let hit_dice = player.hit_dice();
let level = player.level();
// Toggles die `slot_id` spent/unspent; clicking the last spent die
// frees it again.
let process_die_click = move |slot_id: u8| {
hit_dice.update(|d| {
if slot_id < d.used {
@@ -41,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)
>
+9 -7
View File
@@ -1,8 +1,11 @@
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
#[component]
pub fn HitPoints () -> impl IntoView {
pub fn HitPoints() -> impl IntoView {
let state = expect_context::<Context>();
let player = state.player();
@@ -10,6 +13,8 @@ pub fn HitPoints () -> impl IntoView {
let max_hp = player.max_hp();
let temp_hp = player.temp_hp();
// All three inputs parse via `utils::adjust_checked`; invalid text
// restores the previous value.
let adjust_hp = move |ev: Targeted<Event, HtmlInputElement>| {
utils::adjust_checked(ev, hp);
};
@@ -22,13 +27,10 @@ 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>
+1
View File
@@ -1,6 +1,7 @@
use crate::prelude::*;
use leptos::prelude::*;
/// Character portrait; hidden while no image URL is set.
#[component]
pub fn Image () -> impl IntoView {
let state = expect_context::<Context>();
+2
View File
@@ -1,6 +1,8 @@
use crate::prelude::*;
use leptos::prelude::*;
/// First name, alias (in «»), last name — three text inputs bound to the
/// player's name store.
#[component]
pub fn Names () -> impl IntoView {
let state = expect_context::<Context>();
+3
View File
@@ -1,6 +1,8 @@
use crate::prelude::*;
use leptos::prelude::*;
/// Spell slots for all nine levels: a row of clickable pips per level.
/// Clicking toggles a slot used/unused.
#[component]
pub fn SpellSlots () -> impl IntoView {
let state = expect_context::<Context>();
@@ -8,6 +10,7 @@ pub fn SpellSlots () -> impl IntoView {
let slots = player.spell_slots();
// Toggles pip `slot_id` of `level` used/unused.
let process_spell_slot_click = move |level: usize, slot_id: u8| {
slots.update(|s| {
let l = &mut s.0[level];
+31 -6
View File
@@ -1,7 +1,14 @@
//! Hardcoded demo data.
//!
//! `Dashboard::mock()` is the app's only state source until persistence
//! lands (ROADMAP phase 1).
use crate::prelude::*;
use super::*;
impl Dashboard {
/// Builds a fully populated demo dashboard: a level-8 character, two
/// inventories, quests, notes and contacts. Used at startup and as the
/// fallback state.
pub fn mock () -> Self {
let (p_inv, c_inv) = generate_mock_inventory();
@@ -23,9 +30,10 @@ 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([const { None }; 3]),
attunements: Attunements(vec![None; 3]),
hit_dice: HitDice { used: 1, kind: 20 },
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
prepared: generate_prepared_spells_list(),
@@ -38,15 +46,15 @@ impl Dashboard {
quest_book: generate_mock_questbook(),
notes: generate_mock_notebook(),
people: generate_mock_contact_book(),
settings: Settings {
}
settings: Settings::default(),
}
}
}
/// Demo inventories: 10 personal items and 10 common items, each with a
/// weight, description and tags.
fn generate_mock_inventory() -> (Inventory, Inventory) {
let a = vec![
InventoryEntry { id: 1, quantity: 1, item: InventoryItem { name: "Vorpal Sword".to_string(), weight: 400, description: Some("A blade that severs heads with a single swing.".to_string()), tags: HashMap::from([ ("type".to_string(), "weapon".to_string()), ("rarity".to_string(), "legendary".to_string()), ]), }, },
@@ -79,6 +87,8 @@ fn generate_mock_inventory() -> (Inventory, Inventory) {
/// Demo notes: one per session, with in-game dates, real timestamps and
/// tags.
fn generate_mock_notebook () -> NoteBook {
NoteBook(vec![
Note { session: 1, content: "Met Elminster in Waterdeep; hinted at Netherese ruin.".to_string(), date_ingame: NaiveDate::from_ymd_opt(1481, 4, 15).unwrap(), date_real: NaiveDateTime::parse_from_str("2025-09-01 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap(), tags: HashSet::from_iter(vec!["Elminster".to_string(), "Waterdeep".to_string()]) },
@@ -96,6 +106,7 @@ fn generate_mock_notebook () -> NoteBook {
])
}
/// Demo quests with givers, locations, dates and rewards.
fn generate_mock_questbook () -> QuestBook {
QuestBook(vec![
Quest { title: "Slay the Bandit Chief".to_string(), location_taken: Some("Waterdeep".to_string()), location_task: Some("Phandalin".to_string()), date_taken: Some(NaiveDate::from_ymd_opt(1481, 4, 15).unwrap()), date_by: Some(NaiveDate::from_ymd_opt(1481, 5, 15).unwrap()), description: Some("Kill the bandit leader.".to_string()), from: Some(Name { first: "Dagult".to_string(), last: "Neverember".to_string(), alias: "".to_string(), ..Default::default() }), reward: Some(Balance { platinum: 0, gold: 100, electrum: 0, silver: 0, copper: 0 }) },
@@ -111,6 +122,8 @@ fn generate_mock_questbook () -> QuestBook {
])
}
/// Demo contacts: mostly NPCs met by the party, plus a few merchants,
/// living legends and the dead.
fn generate_mock_contact_book () -> ContactBook {
let mut contacts = HashMap::new();
// 40 Characters Met by Party (80%)
@@ -172,10 +185,22 @@ 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.
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)
+158 -28
View File
@@ -1,12 +1,31 @@
//! The domain model of the campaign dashboard.
//!
//! Every type here is a plain data struct that derives
//! [`reactive_stores::Store`], which makes each field independently
//! reactive: components subscribe to single fields and re-render only when
//! those change. The root type is [`Dashboard`], wrapped in a
//! `Store<Dashboard>` and provided to the whole app through Leptos context
//! (see `crate::prelude::Context`).
//!
//! 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`.
use std::fmt::Display;
use chrono::{ NaiveDate, NaiveDateTime };
use reactive_stores::Store;
use serde::{ Deserialize, Serialize };
use crate::prelude::*;
mod mock;
/// A single item as it appears in the bundled 5e.tools JSON files.
///
/// `rest` keeps every other JSON field verbatim, so the raw source data
/// survives lookup (used by future item detail views).
#[derive(Clone, Debug, Store)]
pub struct BookItem {
pub name: String,
@@ -16,6 +35,8 @@ pub struct BookItem {
}
impl BookItem {
/// Parses one 5e.tools JSON object into a `BookItem`.
/// Returns `None` when the object has no string `name` or is not an object.
pub fn from_json (t: Value) -> Option<Self> {
let Value::String(name) = t["name"].clone() else { return None };
let weight = match t["weight"].clone() {
@@ -36,10 +57,18 @@ impl BookItem {
}
}
/// Name → `BookItem` lookup, built once from the bundled JSON (see `statics`).
#[derive(Clone, Debug, Store)]
pub struct ItemReferenceMap (pub HashMap<String, BookItem>);
#[derive(Clone, Debug, Store)]
/// The root application state.
///
/// One `Dashboard` holds everything the UI edits: campaign header data
/// (name, image, session number, in-game date), the player character, the
/// 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)]
pub struct Dashboard {
pub campaign: String,
pub campaign_image: String,
@@ -53,7 +82,50 @@ pub struct Dashboard {
pub settings: Settings
}
#[derive(Clone, Debug, Store)]
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)]
pub struct PlayerData {
pub name: Name,
pub class: String,
@@ -63,6 +135,10 @@ 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,
@@ -74,13 +150,16 @@ pub struct PlayerData {
}
#[derive(Clone, Debug, Store)]
/// The party's shared stash: money and items usable by everyone.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct CommonData {
pub balance: Balance,
pub inventory: Inventory,
}
#[derive(Clone, Debug, Store)]
/// Currency counts per denomination, platinum through copper.
/// Edited with `Numput` on the inventories page.
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Balance {
pub platinum: u32,
pub gold: u32,
@@ -89,13 +168,18 @@ pub struct Balance {
pub copper: u32,
}
#[derive(Clone, Debug, Store)]
/// A named inventory (personal or common): a list of entries.
///
/// `#[store(key)]` gives every entry a stable reactive key (its `id`),
/// which keeps `For`/`ForEnumerate` updates cheap.
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Inventory {
#[store(key: u64 = |row| row.id)]
pub rows: Vec<InventoryEntry>
}
#[derive(Clone, Debug, Store, Default)]
/// One inventory row: a quantity of a single item.
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct InventoryEntry {
pub id: u64,
pub quantity: u32,
@@ -103,6 +187,7 @@ pub struct InventoryEntry {
}
impl InventoryEntry {
/// Returns a stable key string for this entry (quantity + item name).
pub fn key (&self) -> String {
format!("{}-{}", self.quantity, self.item.name)
}
@@ -114,7 +199,9 @@ impl InventoryEntry {
// Book (Item),
// }
#[derive(Clone, Debug, Store, Default)]
/// 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)]
pub struct InventoryItem {
pub name: String,
pub weight: u32,
@@ -122,10 +209,13 @@ pub struct InventoryItem {
pub tags: HashMap<String, String>,
}
#[derive(Clone, Debug, Store)]
/// The party's quest list. No UI yet — see ROADMAP phase 4.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct QuestBook (pub Vec<Quest>);
#[derive(Clone, Debug, Store)]
/// 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)]
pub struct Quest {
pub title: String,
pub location_taken: Option<String>,
@@ -137,10 +227,13 @@ pub struct Quest {
pub reward: Option<Balance>,
}
#[derive(Clone, Debug, Store)]
/// The party's session notes. No UI yet — see ROADMAP phase 5.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct NoteBook (pub Vec<Note>);
#[derive(Clone, Debug, Store)]
/// One note: content, in-game date, real-world timestamp, session number
/// and tags.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Note {
pub content: String,
pub date_ingame: NaiveDate,
@@ -149,59 +242,74 @@ pub struct Note {
pub tags: HashSet<String>,
}
#[derive(Clone, Debug, Store)]
/// Spell slots for the nine spell levels (index 0 = 1st level).
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct SpellSlots (pub [SpellSlotLevel; 9]);
#[derive(Clone, Debug, Store)]
/// Used/total slots for one spell level.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct SpellSlotLevel {
pub used: u8,
pub total: u8,
}
#[derive(Clone, Debug, Store)]
pub struct Attunements (pub [Option<String>; 3]);
/// 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>>);
#[derive(Clone, Debug, Store)]
/// The spell list the character has prepared (or is learning).
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct PreparedSpells (pub Vec<PreparedSpell>);
#[derive(Clone, Debug, Store)]
/// One prepared-spell slot: vacant or holding a spell.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub enum PreparedSpell {
Vacant,
Occupied (Spell)
}
// @TODO
#[derive(Clone, Debug, Store)]
/// 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)]
pub struct Spell {
pub name: String
pub name: String,
pub level: Option<u8>,
pub description: Option<String>,
}
#[derive(Clone, Debug, Store)]
/// Hit dice: how many of the current die kind are already spent.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct HitDice {
pub used: u8,
pub kind: u8
}
#[derive(Clone, Debug, Store)]
/// Death-saving-throw counters: three failures kill, three successes
/// stabilize.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct DeathSaveThrows {
pub failed: u8,
pub succeeded: u8,
}
impl DeathSaveThrows {
/// Clears both counters. Bound to the ⟳ button in the sidebar.
pub fn reset (&mut self) {
self.failed = 0;
self.succeeded = 0;
}
}
#[derive(Clone, Debug, Store)]
/// Known NPCs keyed by name. No UI yet — see ROADMAP phase 6.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct ContactBook (pub HashMap<String, Contact>);
#[derive(Clone, Debug, Store)]
/// One NPC contact: name, optional image, status, location and notes.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub struct Contact {
pub name: Name,
pub image: Option<String>,
@@ -210,7 +318,8 @@ pub struct Contact {
pub notes: Option<String>,
}
#[derive(Clone, Debug, Store)]
/// Life status of a contact, used for filtering.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
pub enum ContactStatus {
Unknown,
DeadBygone,
@@ -218,7 +327,8 @@ pub enum ContactStatus {
Alive,
}
#[derive(Clone, Debug, Store, Default)]
/// A person's name: first, last, and an optional alias (nickname).
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
pub struct Name {
pub first: String,
pub last: String,
@@ -226,26 +336,44 @@ pub struct Name {
}
impl Name {
/// True when an alias is set.
pub fn display_alias (&self) -> bool {
!self.alias.is_empty()
}
/// True when a last name is set.
pub fn display_last (&self) -> bool {
!self.last.is_empty()
}
}
#[derive(Clone, Debug, Store, Default)]
/// App settings: currently only the saved-format version.
#[derive(Clone, Debug, Store, Serialize, Deserialize)]
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 }
}
}
#[derive(Clone, Copy, Debug, Store)]
/// Which side of the party an inventory belongs to.
#[derive(Clone, Copy, Debug, Store, Serialize, Deserialize)]
pub enum InventoryKind {
Personal,
Common
}
/// Russian display name ("Личный" / "Общий").
impl Display for InventoryKind {
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
@@ -256,6 +384,7 @@ impl Display for InventoryKind {
}
impl InventoryKind {
/// The other inventory (for the move-between button).
pub fn dst (&self) -> Self {
match self {
Self::Common => Self::Personal,
@@ -263,6 +392,7 @@ impl InventoryKind {
}
}
/// CSS classes for the inventory section (personal vs common styling).
pub fn class (&self) -> &'static str {
match self {
Self::Common => "inventory inventory-common",
+7 -1
View File
@@ -1,4 +1,8 @@
#![feature(type_alias_impl_trait)]
//! aex — a D&D 5e character sheet and campaign dashboard.
//!
//! 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.
pub mod app;
#[allow(unused_imports)]
@@ -21,6 +25,8 @@ mod components;
#[allow(unused_imports)]
use prelude::*;
/// WASM entry point: hydrates `App` into the server-rendered DOM.
/// Only compiled with the `hydrate` feature.
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
+10
View File
@@ -1,4 +1,9 @@
/// SSR entry point: starts the actix-web server that renders the app.
///
/// Serves the WASM/CSS bundle from `/pkg`, static assets from `/assets`,
/// and renders every route through `leptos_routes`. Only compiled with
/// the `ssr` feature.
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
@@ -58,6 +63,7 @@ async fn main() -> std::io::Result<()> {
.await
}
/// Serves `/favicon.ico` from the site root.
#[cfg(feature = "ssr")]
#[actix_web::get("favicon.ico")]
async fn favicon(
@@ -70,6 +76,8 @@ async fn favicon(
))?)
}
/// No-op `main` for builds without `ssr` or `csr`: the WASM binary is
/// hydrated from `lib.rs` instead.
#[cfg(not(any(feature = "ssr", feature = "csr")))]
pub fn main() {
// no client-side main function
@@ -78,6 +86,8 @@ pub fn main() {
// see optional feature `csr` instead
}
/// Client-only `main` for `trunk serve --features csr`.
/// Not the normal path — prefer `cargo leptos serve`.
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
pub fn main() {
// a client-side main function is required for using `trunk serve`
+10
View File
@@ -1,3 +1,7 @@
//! Crate-wide re-exports.
//!
//! Components and entities use `use crate::prelude::*` to get the common
//! imports: collections, logging, error aliases, `Store`, entities, utils.
#![allow(unused_imports)]
#![allow(dead_code)]
pub use std::collections::{ HashMap, HashSet };
@@ -16,6 +20,12 @@ pub (crate) use crate::{
components::{ self, numput::{ Numput, NumputChange } },
};
/// Crate-wide error type. Alias of `anyhow::Error`.
pub type Error = anyhow::Error;
/// Crate-wide result type. Alias of `anyhow::Result<T>`.
pub type Result <T> = anyhow::Result<T>;
/// The reactive application state: a `Store` wrapping [`crate::entities::Dashboard`].
///
/// Created once in `App` and injected through Leptos context. Components
/// read it with `expect_context::<Context>()` and grab reactive field handles.
pub type Context = Store<Dashboard>;
+10 -1
View File
@@ -1 +1,10 @@
use crate::prelude::*;
//! App state.
//!
//! 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;
+279
View File
@@ -0,0 +1,279 @@
//! Persistence: localStorage autosave + JSON export/import.
//!
//! Everything here is client-side. The server build (`ssr`) never touches
//! storage: it renders mock data, and the hydrated client swaps in the
//! saved state a moment after mount ([`init_hydrated`]).
//!
//! Flow:
//! - on mount, [`init_hydrated`] loads the saved state (mock stays as the
//! first-visit fallback) and starts the debounced autosave effect,
//! - the header's Save / Load / Clear buttons call [`export_download`],
//! [`apply_import`] and [`clear_all`],
//! - the saved format is the whole `Dashboard` JSON, version-checked by
//! [`parse_dashboard`] (the migration hook).
#[cfg(feature = "hydrate")]
use std::{ cell::Cell, rc::Rc, time::Duration };
use leptos::prelude::*;
use crate::prelude::*;
/// The localStorage key under which the whole dashboard is stored.
#[cfg(feature = "hydrate")]
pub const STORAGE_KEY: &str = "aex.dashboard";
/// Autosave debounce: changes are written this long after the last change.
#[cfg(feature = "hydrate")]
const SAVE_DEBOUNCE: Duration = Duration::from_millis(500);
/// Failure modes when parsing an imported or stored file.
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
/// The file is not valid JSON or not a dashboard.
#[error("Файл повреждён или не является файлом кампании.")]
Parse,
/// The file comes from a newer app version.
#[error(
"Версия файла ({0}) новее, чем поддерживает приложение (текущая: {current}).",
current = Settings::VERSION
)]
NewerVersion(u32),
/// The file comes from an older app version; no migration exists yet.
#[error(
"Версия файла ({0}) устарела; перенос данных пока не реализован (текущая: {current}).",
current = Settings::VERSION
)]
OldVersion(u32),
}
/// Parses and version-checks a dashboard JSON document.
///
/// This is the migration hook: a future format bump adds an arm here that
/// converts the old version into the new one and recurses.
pub fn parse_dashboard(text: &str) -> std::result::Result<Dashboard, ImportError> {
let dashboard: Dashboard = serde_json::from_str(text).map_err(|_| ImportError::Parse)?;
match dashboard.settings.version {
version if version == Settings::VERSION => Ok(dashboard),
version if version > Settings::VERSION => Err(ImportError::NewerVersion(version)),
version => Err(ImportError::OldVersion(version)),
}
}
/// Serializes the current dashboard to pretty JSON.
#[cfg(feature = "hydrate")]
pub fn serialize_dashboard(store: &Context) -> Option<String> {
let guard = store.try_read_untracked()?;
serde_json::to_string_pretty(&*guard).ok()
}
/// Replaces the whole dashboard inside the store.
pub fn replace_state(store: &Context, dashboard: Dashboard) {
if let Some(mut guard) = store.try_write() {
*guard = dashboard;
}
}
/// Parses `text` and applies it: replaces the state and saves it back.
///
/// The current state becomes an undo step first, so the import is
/// undoable. Returns the error for the UI to show; nothing is applied
/// on error.
pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> {
let dashboard = parse_dashboard(text)?;
crate::state::undo::before_replace();
replace_state(store, dashboard);
#[cfg(feature = "hydrate")]
save_to_storage(store);
Ok(())
}
/// Client-only startup: load the saved state after mount, start autosave
/// and undo tracking.
///
/// Called from `App`. The load is deferred out of the hydration render so
/// the SSR markup stays authoritative; the swap happens before the browser
/// draws the first frame. Undo starts after the load (see the callback).
#[cfg(feature = "hydrate")]
pub fn init_hydrated(store: &Context, history: &crate::state::undo::HistoryHandle) {
let store = store.clone();
let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
// Load the saved state (or keep mock) once hydration has settled,
// then start undo tracking: its first captured state must be the
// saved one, not the mock the SSR pass rendered.
let history = history.clone();
let _ = set_timeout_with_handle(
move || {
load_from_storage(&store);
crate::state::undo::start_tracking(&history);
},
Duration::ZERO,
);
// Debounced autosave: any store change restarts the timer.
let store_save = store.clone();
Effect::new(move |_| {
store_save.track();
if let Some(handle) = pending.take() {
handle.clear();
}
let handle = set_timeout_with_handle(
{
let store = store_save.clone();
move || {
save_to_storage(&store);
}
},
SAVE_DEBOUNCE,
);
if let Ok(handle) = handle {
pending.set(Some(handle));
}
});
}
/// Writes the current dashboard to localStorage.
#[cfg(feature = "hydrate")]
pub fn save_to_storage(store: &Context) {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return;
};
let Some(json) = serialize_dashboard(store) else {
return;
};
if let Err(err) = storage.set_item(STORAGE_KEY, &json) {
e!("не удалось сохранить состояние: {err:?}");
}
}
/// Loads the saved dashboard from localStorage and replaces the store.
///
/// Returns `true` when a valid saved state was applied.
#[cfg(feature = "hydrate")]
pub fn load_from_storage(store: &Context) -> bool {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return false;
};
let Some(text) = storage.get_item(STORAGE_KEY).ok().flatten() else {
return false;
};
match parse_dashboard(&text) {
Ok(dashboard) => {
replace_state(store, dashboard);
true
}
Err(err) => {
e!("сохранённое состояние повреждено: {err}");
false
}
}
}
/// Wipes the saved state and replaces the dashboard with the blank slate.
///
/// The current state becomes an undo step first, so Clear is undoable.
/// The blank dashboard is written back to storage so it survives a reload;
/// mock data stays the fallback for the very first visit only.
#[cfg(feature = "hydrate")]
pub fn clear_all (store: &Context) {
crate::state::undo::before_replace();
replace_state(store, Dashboard::blank());
save_to_storage(store);
}
/// SSR stub: there is no browser, nothing to wipe.
#[cfg(not(feature = "hydrate"))]
pub fn clear_all (_store: &Context) {}
/// SSR stub: no browser, nothing to download.
#[cfg(not(feature = "hydrate"))]
pub fn export_download (_store: &Context) {}
/// Downloads the current dashboard as `dashboard.json`.
#[cfg(feature = "hydrate")]
pub fn export_download (store: &Context) {
use wasm_bindgen::JsCast;
let Some(json) = serialize_dashboard(store) else {
return;
};
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let blob = match web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(
&wasm_bindgen::JsValue::from_str(&json),
)) {
Ok(blob) => blob,
Err(_) => return,
};
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
return;
};
let Ok(anchor) = doc.create_element("a") else {
return;
};
let anchor = anchor.unchecked_into::<web_sys::HtmlAnchorElement>();
let _ = anchor.set_attribute("href", &url);
let _ = anchor.set_attribute("download", "dashboard.json");
anchor.click();
// Release the object URL after the download has started.
queue_microtask(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole dashboard survives a JSON round trip with the version intact.
#[test]
fn round_trip_keeps_state() {
let dashboard = Dashboard::mock();
let json = serde_json::to_string(&dashboard).unwrap();
let loaded = parse_dashboard(&json).unwrap();
assert_eq!(loaded.campaign, dashboard.campaign);
assert_eq!(loaded.player.name.first, dashboard.player.name.first);
assert_eq!(loaded.player.inventory.rows.len(), dashboard.player.inventory.rows.len());
assert_eq!(loaded.settings.version, Settings::VERSION);
}
/// Files from a newer app version are rejected with a clear error.
#[test]
fn rejects_newer_version() {
let mut dashboard = Dashboard::mock();
dashboard.settings.version = Settings::VERSION + 1;
let json = serde_json::to_string(&dashboard).unwrap();
assert!(matches!(parse_dashboard(&json), Err(ImportError::NewerVersion(_))));
}
/// Files from an older version are rejected until a migration exists.
#[test]
fn rejects_older_version() {
let mut dashboard = Dashboard::mock();
dashboard.settings.version = Settings::VERSION - 1;
let json = serde_json::to_string(&dashboard).unwrap();
assert!(matches!(parse_dashboard(&json), Err(ImportError::OldVersion(_))));
}
/// Garbage input fails with the parse error.
#[test]
fn rejects_garbage() {
assert!(matches!(parse_dashboard("not json"), Err(ImportError::Parse)));
assert!(matches!(parse_dashboard("[]"), Err(ImportError::Parse)));
}
/// An empty dashboard round-trips too (Clear produces valid files).
#[test]
fn blank_round_trips() {
let json = serde_json::to_string(&Dashboard::blank()).unwrap();
let loaded = parse_dashboard(&json).unwrap();
assert!(loaded.campaign.is_empty());
assert!(loaded.player.inventory.rows.is_empty());
assert_eq!(loaded.settings.version, Settings::VERSION);
}
}
+486
View File
@@ -0,0 +1,486 @@
//! Undo/redo for the whole dashboard (ROADMAP Phase 1.5).
//!
//! Snapshot-based: [`History`] keeps whole `Dashboard` snapshots — the
//! state as it was *before* each edit burst. Snapshots are plain clones
//! (the dashboard is small; the 5e.tools item data lives in `statics`,
//! not in the state), and a cap bounds memory.
//!
//! The tracking effect (started in [`start_tracking`], hydrate only)
//! subscribes to the store root trigger — the same one the autosave
//! effect in `persist` uses — and remembers the previous state on every
//! change. When the burst goes quiet for [`SETTLE_WINDOW`], the pre-burst
//! state becomes a real undo step. Undo before the window fires reverts
//! the whole burst straight away.
//!
//! Wholesale replacements (Clear, import — see `persist`) call
//! [`before_replace`] first, so the replacement itself is undoable.
//! Undo/redo restore via `persist::replace_state`, which fires the
//! autosave effect too — an undone state survives a reload.
//!
//! The `ssr` build provides the same [`HistoryHandle`] so the header
//! renders identically, but both stacks stay empty: the buttons render
//! disabled and clicks are no-ops.
use std::time::Duration;
use leptos::prelude::*;
use crate::prelude::*;
use crate::state::persist::replace_state;
/// Maximum number of undo steps kept per direction.
const HISTORY_CAP: usize = 100;
/// A burst of edits becomes one undo step this long after the last change.
///
/// (Only the hydrate tracking effect arms this timer; the `ssr` build
/// keeps the constant for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
const SETTLE_WINDOW: Duration = Duration::from_secs(1);
/// Pure undo/redo stack pair over whole dashboards.
///
/// The undo stack holds states *before* an edit burst; `pending` holds the
/// pre-burst state until the burst settles. The settle window is enforced
/// by the tracking effect's timer — the clock lives in the browser, not
/// here (the wasm target has no `Instant`).
pub struct History {
undo: Vec<Dashboard>,
redo: Vec<Dashboard>,
cap: usize,
/// The pre-burst state, promoted to a real undo step on [`Self::settle`].
pending: Option<Dashboard>,
/// Set right before a wholesale store replacement (undo/redo/Clear/
/// import); the tracking effect consumes it and starts a fresh burst
/// from the new state.
suppress: bool,
/// Reactive stack depths, for the buttons' disabled states.
undo_depth: RwSignal<usize>,
redo_depth: RwSignal<usize>,
}
impl History {
/// Creates an empty history with the given cap.
///
/// The settle window is not stored here: the tracking effect's
/// timer enforces it.
pub fn new(cap: usize) -> Self {
History {
undo: Vec::new(),
redo: Vec::new(),
cap,
pending: None,
suppress: false,
undo_depth: RwSignal::new(0),
redo_depth: RwSignal::new(0),
}
}
/// Records that the store changed from `before` to something else.
///
/// The first change of a burst sets `pending` to `before`; later
/// changes of the same burst keep it, so one undo step covers the
/// whole burst.
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn note_change(&mut self, before: Dashboard) {
if self.pending.is_none() {
self.pending = Some(before);
// Undo works before the burst settles (it consumes the
// pending state), so the button must enable right away.
self.undo_depth.set(self.undo.len() + 1);
}
}
/// The burst has been quiet for `window`: promote `pending` to a real
/// undo step. No-op when there is no pending burst.
pub fn settle(&mut self) {
if let Some(pending) = self.pending.take() {
self.push_undo(pending);
}
}
/// Undo one step: reverts the current burst if it has not settled,
/// otherwise pops the most recent settled step. `current` moves to
/// the redo stack.
pub fn undo(&mut self, current: Dashboard) -> Option<Dashboard> {
let snapshot = self.pending.take().or_else(|| self.undo.pop())?;
push_capped(&mut self.redo, self.cap, current);
self.undo_depth.set(self.undo.len());
self.redo_depth.set(self.redo.len());
Some(snapshot)
}
/// Redo: pops the redo stack; `current` moves back to the undo stack.
pub fn redo(&mut self, current: Dashboard) -> Option<Dashboard> {
let snapshot = self.redo.pop()?;
push_capped(&mut self.undo, self.cap, current);
self.undo_depth.set(self.undo.len());
self.redo_depth.set(self.redo.len());
Some(snapshot)
}
/// Flush: promotes the pending burst, then marks `current` as an undo
/// step. Called before wholesale replacements (Clear, import) so the
/// replacement itself is undoable — first the replacement, then the
/// burst it interrupted.
pub fn flush(&mut self, current: Dashboard) {
self.settle();
self.push_undo(current);
}
/// Drops the pending burst. Called after a suppressed replacement:
/// the next edit starts a fresh burst from the new state.
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn reset(&mut self) {
if self.pending.take().is_some() {
self.undo_depth.set(self.undo.len());
}
}
/// Flags the next change as a wholesale replacement (no snapshot).
pub fn suppress(&mut self) {
self.suppress = true;
}
/// Reads and clears the replacement flag (the tracking effect's check).
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn consume_suppress(&mut self) -> bool {
std::mem::take(&mut self.suppress)
}
/// Reactive depth of the undo stack.
pub fn undo_depth(&self) -> RwSignal<usize> {
self.undo_depth
}
/// Reactive depth of the redo stack.
pub fn redo_depth(&self) -> RwSignal<usize> {
self.redo_depth
}
/// Pushes a pre-edit snapshot onto the undo stack (capped). A new
/// edit invalidates the redo branch.
fn push_undo(&mut self, snapshot: Dashboard) {
push_capped(&mut self.undo, self.cap, snapshot);
self.undo_depth.set(self.undo.len());
self.redo.clear();
self.redo_depth.set(0);
}
}
/// Pushes `value`, dropping the oldest entry when the stack is at `cap`.
fn push_capped(stack: &mut Vec<Dashboard>, cap: usize, value: Dashboard) {
if stack.len() == cap {
stack.remove(0);
}
stack.push(value);
}
/// The shared undo/redo state, plus the store it operates on.
///
/// All fields are `Copy` handles, so the handle itself is `Copy`: the
/// header buttons and the tracking effect can capture it freely. Created
/// in `App` and injected through context in both build modes (on the
/// server the stacks simply stay empty).
#[derive(Clone, Copy)]
pub struct HistoryHandle {
history: RwSignal<History>,
store: Context,
}
impl HistoryHandle {
/// Creates an empty history around `store`.
pub fn new(store: Context) -> Self {
HistoryHandle {
history: RwSignal::new(History::new(HISTORY_CAP)),
store,
}
}
/// Undoes the last edit burst (or the last settled step).
pub fn undo(&self) {
let Some(current) = current_state(&self.store) else { return };
let mut snapshot = None;
self.history.update(|history| snapshot = history.undo(current));
self.apply(snapshot);
}
/// Re-applies the most recently undone state.
pub fn redo(&self) {
let Some(current) = current_state(&self.store) else { return };
let mut snapshot = None;
self.history.update(|history| snapshot = history.redo(current));
self.apply(snapshot);
}
/// Reactive depth of the undo stack, for the button's disabled state.
pub fn undo_depth(&self) -> RwSignal<usize> {
self.history.with_untracked(|history| history.undo_depth())
}
/// Reactive depth of the redo stack, for the button's disabled state.
pub fn redo_depth(&self) -> RwSignal<usize> {
self.history.with_untracked(|history| history.redo_depth())
}
/// Applies a popped snapshot: suppresses history capture for the
/// resulting store write, then restores.
fn apply(&self, snapshot: Option<Dashboard>) {
if let Some(snapshot) = snapshot {
self.history.update(|history| history.suppress());
replace_state(&self.store, snapshot);
}
}
}
/// Marks the current state as an undo step and suppresses history capture
/// for the next store write.
///
/// Called by `persist` right before a wholesale replacement (Clear,
/// import) so the replacement is undoable. No-op without a handle in
/// context (unit tests, SSR stubs).
pub fn before_replace() {
if let Some(handle) = use_context::<HistoryHandle>() {
flush_for_replace(&handle);
}
}
/// The core of [`before_replace`]: promotes the pending burst, marks the
/// current state as an undo step, and suppresses capture for the next write.
fn flush_for_replace(handle: &HistoryHandle) {
handle.history.update(|history| {
if let Some(current) = current_state(&handle.store) {
history.flush(current);
}
history.suppress();
});
}
/// Clones the current dashboard out of the store, when readable.
fn current_state(store: &Context) -> Option<Dashboard> {
store.try_read_untracked().map(|guard| (*guard).clone())
}
/// Client-only: starts the tracking effect and installs the shortcuts.
///
/// Call after the saved state has been loaded (`persist::init_hydrated`
/// does this), so the first captured state is the saved one, not the mock
/// the SSR pass rendered.
#[cfg(feature = "hydrate")]
pub fn start_tracking(handle: &HistoryHandle) {
use std::{
cell::{ Cell, RefCell },
rc::Rc,
};
let handle = handle.clone();
// The state as of the previous store trigger; `None` until the first change.
let last: Rc<RefCell<Option<Dashboard>>> = Rc::new(RefCell::new(None));
// The settle timer for the current burst, re-armed on every change.
let settle_timer: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
Effect::new(move |_| {
handle.store.track();
let Some(current) = current_state(&handle.store) else { return };
let mut arm = false;
handle.history.update(|history| {
if history.consume_suppress() {
// The store was replaced wholesale (undo/redo/Clear/
// import): start a fresh burst from the new state.
history.reset();
last.borrow_mut().replace(current);
} else if let Some(prev) = last.borrow_mut().replace(current.clone()) {
history.note_change(prev);
arm = true;
}
});
if arm {
// (Re)arm the settle timer: one undo step per quiet burst.
if let Some(timer) = settle_timer.take() {
timer.clear();
}
let armed = handle;
let Ok(timer) = set_timeout_with_handle(
move || armed.history.update(|history| history.settle()),
SETTLE_WINDOW,
) else {
return;
};
settle_timer.set(Some(timer));
}
});
install_shortcuts(&handle);
}
/// Installs Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (and the ⌘ variants) on the
/// window. Key combos inside editable elements are left to the browser's
/// native undo, so text fields keep their own history.
#[cfg(feature = "hydrate")]
fn install_shortcuts(handle: &HistoryHandle) {
use leptos::ev::keydown;
let handle = handle.clone();
window_event_listener(keydown, move |event: leptos::ev::KeyboardEvent| {
if !(event.ctrl_key() || event.meta_key()) {
return;
}
let key = event.key();
let is_undo = key == "z" || key == "Z";
let is_redo = key == "y" || key == "Y" || (is_undo && event.shift_key());
if !(is_undo || is_redo) {
return;
}
if target_is_editable(&event) {
return;
}
event.prevent_default();
if is_undo {
handle.undo();
} else {
handle.redo();
}
});
}
/// True when the event target is a text-editing element (input, textarea,
/// select, contenteditable): the browser owns Ctrl+Z there.
#[cfg(feature = "hydrate")]
fn target_is_editable(event: &web_sys::KeyboardEvent) -> bool {
use wasm_bindgen::JsCast;
let Some(target) = event.target().and_then(|t| t.dyn_into::<web_sys::Element>().ok()) else {
return false;
};
matches!(target.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
|| target
.dyn_into::<web_sys::HtmlElement>()
.is_ok_and(|element| element.is_content_editable())
}
#[cfg(test)]
mod tests {
use super::*;
fn named(name: &str) -> Dashboard {
let mut dashboard = Dashboard::mock();
dashboard.campaign = name.to_string();
dashboard
}
fn campaign(snapshot: Option<Dashboard>) -> Option<String> {
snapshot.map(|d| d.campaign)
}
#[test]
fn one_undo_step_per_settled_burst() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.note_change(named("s1")); // same burst: the first pre-state wins
history.settle();
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn undo_before_settle_reverts_the_whole_burst() {
let mut history = History::new(10);
history.note_change(named("s0"));
// No settle: the burst is still in flight, undo consumes it.
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
// The undone state lands on the redo stack.
assert_eq!(history.redo_depth().get(), 1);
assert_eq!(history.undo_depth().get(), 0);
}
#[test]
fn redo_restores_and_can_be_undone_again() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.undo(named("s2"));
assert_eq!(campaign(history.redo(named("s0"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn new_edit_clears_the_redo_stack() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.undo(named("s2"));
assert_eq!(history.redo_depth().get(), 1);
// A fresh burst after the undo invalidates the redo branch.
history.note_change(named("s2"));
history.settle();
assert_eq!(history.redo_depth().get(), 0);
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
}
#[test]
fn cap_drops_the_oldest_snapshot() {
let mut history = History::new(2);
for state in ["s0", "s1", "s2"] {
history.note_change(named(state));
history.settle();
}
assert_eq!(history.undo_depth().get(), 2);
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s1".to_string()));
assert_eq!(campaign(history.undo(named("s1"))), None); // "s0" was dropped
}
#[test]
fn undo_on_empty_history_is_none() {
let mut history = History::new(10);
assert_eq!(campaign(history.undo(named("s0"))), None);
assert_eq!(campaign(history.redo(named("s0"))), None);
}
#[test]
fn flush_promotes_pending_then_current() {
let mut history = History::new(10);
// A burst in flight: pre-state "s0", current "s2".
history.note_change(named("s0"));
// Wholesale replacement prep: the replacement becomes the first
// undo target, the interrupted burst the second.
history.flush(named("s2"));
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn suppress_flag_is_consumed_once() {
let mut history = History::new(10);
history.suppress();
assert!(history.consume_suppress());
assert!(!history.consume_suppress());
}
#[test]
fn handle_flush_makes_replacements_undoable() {
let store = Store::new(named("base"));
let handle = HistoryHandle::new(store.clone());
flush_for_replace(&handle);
assert_eq!(handle.undo_depth().get(), 1);
replace_state(&store, named("blank"));
handle.undo();
let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap();
assert_eq!(campaign(Some(current)), Some("base".to_string()));
handle.redo();
let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap();
assert_eq!(campaign(Some(current)), Some("blank".to_string()));
}
}
+25 -17
View File
@@ -1,23 +1,31 @@
//! 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;
use crate::prelude::*;
use 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);
lazy_static! {
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()))
};
pub static ref ITEMS_NAMES: Vec<&'static String> = {
ITEMS_REFS.0.keys().collect()
};
}
/// 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());
+16
View File
@@ -1,3 +1,5 @@
//! Small shared helpers: item parsing, checked input parsing, percentage
//! math, and the `BasisPoints` display trait.
use crate::prelude::*;
use std::str::FromStr;
use leptos::ev::{ Event, Targeted };
@@ -5,6 +7,11 @@ use leptos::web_sys::HtmlInputElement;
use leptos::reactive::traits::{ Get, Set };
/// Parses a 5e.tools-format JSON array of items into `map`, keyed by name.
///
/// Skips entries without a string `name` or without an object body.
/// Duplicate names merge: the later entry's weight fills a missing one and
/// its `rest` fields are appended. Returns the number of new names added.
pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize {
let Ok(json): std::result::Result<Value, _> = serde_json::from_str(&contents) else { error!("Can't read Value from contents"); return 0 };
let Value::Array(list) = json else { error!("Value isn't Array, skipping"); return 0 };
@@ -24,6 +31,10 @@ pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize
map.len() - start
}
/// Applies a text input's value to a reactive store with checked parsing.
///
/// On a successful parse, sets the store to the value; otherwise restores
/// the input to the store's current value. Used for `type="number"` inputs.
pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -> ()
where
U: Set<Value = T> + Get<Value = T>,
@@ -39,6 +50,8 @@ pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -
}
}
/// Percent of `cur` relative to `max`; callers clamp to 100. Used for
/// HP-bar widths.
pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
let cur: f32 = cur.into();
let max: f32 = max.into();
@@ -46,10 +59,13 @@ pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
}
/// Formats an integer stored in basis points (hundredths) as a decimal
/// string, e.g. `400` → `"4.00"`.
pub trait BasisPoints {
fn to_decimal_string (&self) -> String;
}
/// Weights are stored as `u32` basis points: 100 = 1.0.
impl BasisPoints for u32 {
fn to_decimal_string (&self) -> String {
let int = self / 100;
+79
View File
@@ -0,0 +1,79 @@
@use 'mixins';
.character-page {
display: flex;
flex-direction: column;
gap: 24px;
padding: 10px 24px;
overflow-y: auto;
}
.prepared-spells, .attunements {
h6 {
font-size: 1.1em;
margin: 0 0 10px;
font-weight: 900;
&::first-letter {
text-decoration: underline crimson;
}
}
.prepared-slot, .attunement-slot {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
input {
flex: 1;
max-width: 340px;
border: none;
color: white;
background: rgba(255, 255, 255, 0.06);
padding: 3px 8px;
&:focus {
outline: 1px solid crimson;
}
}
button {
@include mixins.button;
width: 34px;
height: 30px;
border: 0;
line-height: 1;
}
}
.prepared-slot {
&.occupied {
.spell-name {
font-weight: 900;
min-width: 200px;
}
}
.spell-level, .spell-desc {
font-weight: 300;
opacity: 0.75;
font-size: 0.85em;
}
.spell-level {
min-width: 44px;
}
}
.attuned-item {
font-weight: 900;
min-width: 200px;
}
.add-slot {
@include mixins.button;
margin-top: 10px;
border: 0;
padding: 3px 14px;
}
}
+5
View File
@@ -59,6 +59,11 @@ header {
line-height: 1em;
margin: 2px 5px;
@include mixins.button;
&:disabled {
opacity: 0.45;
cursor: default;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// Modal dialog: dark overlay, centered card, crimson confirm button.
@use 'mixins';
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.75);
}
.modal {
max-width: 480px;
padding: 24px 32px;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8);
h3 {
margin: 0 0 12px;
@include mixins.heading;
font-weight: 900;
}
p {
margin: 0 0 20px;
color: rgba(255, 255, 255, 0.75);
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button.modal-cancel {
@include mixins.button;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.8);
padding: 6px 18px;
cursor: pointer;
}
button.modal-confirm {
@include mixins.button;
padding: 6px 18px;
cursor: pointer;
}
+16
View File
@@ -134,6 +134,14 @@ 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 {
@@ -193,6 +201,14 @@ aside {
filter: contrast(70%);
}
}
.ac {
margin-left: auto;
justify-content: flex-end;
text-align: right;
width: 20%;
background: rgba(green, 0.15);
}
.death-throws {
display: flex;
+2
View File
@@ -3,9 +3,11 @@
@use 'reset';
@use 'mixins';
@use 'header';
@use 'character';
@use 'sidebar';
@use 'nav';
@use 'inventory';
@use 'modal';
@use 'vars';