Compare commits
31
Commits
6c7ba86018
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2fa1c723 | ||
|
|
5e7cd0e709 | ||
|
|
f26f3bdf50 | ||
|
|
294bf430fd | ||
|
|
6d0846d160 | ||
|
|
0fb9329916 | ||
|
|
fddd97d926 | ||
|
|
c825b867d1 | ||
|
|
bb304bed6a | ||
|
|
ff5ec498b2 | ||
|
|
1f3184baf6 | ||
|
|
99ccb0cdc7 | ||
|
|
42b106c793 | ||
|
|
cc57da0d83 | ||
|
|
9c4d95220d | ||
|
|
cc11700621 | ||
|
|
b9a399be64 | ||
|
|
0c2eda7621 | ||
|
|
043977cc1f | ||
|
|
0409d8edad | ||
|
|
f3b0ceb3e7 | ||
|
|
a11e86e839 | ||
|
|
8bdf1c9be3 | ||
|
|
6a07bd668d | ||
|
|
444f046990 | ||
|
|
6b792734b6 | ||
|
|
af60019fa2 | ||
|
|
ec4e771792 | ||
|
|
257f254b13 | ||
|
|
da405758ce | ||
|
|
8eee42c6dd |
No files matched your search
+1
-3
@@ -3,9 +3,7 @@
|
|||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
# Cargo.lock is tracked: the app is a binary, builds should be reproducible
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|||||||
+178
@@ -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
File diff suppressed because it is too large.
Load diff
+16
-16
@@ -11,28 +11,28 @@ actix-files = { version = "0.6", optional = true }
|
|||||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
http = { version = "1.3.1", optional = true }
|
http = { version = "1.3.1", optional = true }
|
||||||
leptos = { version = "0.8.2", features = ["nightly"] }
|
leptos = { version = "0.8.20", features = ["nightly"] }
|
||||||
leptos_meta = { version = "0.8.2" }
|
leptos_meta = { version = "0.8.6" }
|
||||||
leptos_actix = { version = "0.8.2", optional = true }
|
leptos_actix = { version = "0.8.7", optional = true }
|
||||||
leptos_router = { version = "0.8.2", features = ["nightly"] }
|
leptos_router = { version = "0.8.15", features = ["nightly"] }
|
||||||
wasm-bindgen = "=0.2.101"
|
wasm-bindgen = "=0.2.126"
|
||||||
anyhow = "1.0.99"
|
anyhow = "1.0.99"
|
||||||
thiserror = "2.0.16"
|
thiserror = "2.0.16"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
pretty_env_logger = "0.5.0"
|
pretty_env_logger = "0.5.0"
|
||||||
lazy_static = "1.5.0"
|
|
||||||
leptos-use = "0.16.2"
|
leptos-use = "0.16.2"
|
||||||
log = "0.4.28"
|
log = "0.4.28"
|
||||||
nucleo-matcher = "0.3.1"
|
nucleo-matcher = "0.3.1"
|
||||||
reactive_stores = "0.2.5"
|
reactive_stores = "0.4.3"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
console_log = "1.0.0"
|
console_log = "1.0.0"
|
||||||
num-traits = "0.2.19"
|
num-traits = "0.2.19"
|
||||||
num-format = "0.4.4"
|
num-format = "0.4.4"
|
||||||
js-sys = "=0.3.78"
|
js-sys = "=0.3.103"
|
||||||
|
web-sys = "=0.3.103"
|
||||||
seq-macro = "0.3.6"
|
seq-macro = "0.3.6"
|
||||||
|
|
||||||
|
|
||||||
@@ -40,12 +40,12 @@ seq-macro = "0.3.6"
|
|||||||
csr = ["leptos/csr"]
|
csr = ["leptos/csr"]
|
||||||
hydrate = ["leptos/hydrate"]
|
hydrate = ["leptos/hydrate"]
|
||||||
ssr = [
|
ssr = [
|
||||||
"dep:actix-files",
|
"dep:actix-files",
|
||||||
"dep:actix-web",
|
"dep:actix-web",
|
||||||
"dep:leptos_actix",
|
"dep:leptos_actix",
|
||||||
"leptos/ssr",
|
"leptos/ssr",
|
||||||
"leptos_meta/ssr",
|
"leptos_meta/ssr",
|
||||||
"leptos_router/ssr",
|
"leptos_router/ssr",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||||
@@ -72,9 +72,9 @@ style-file = "style/main.scss"
|
|||||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||||
assets-dir = "assets"
|
assets-dir = "assets"
|
||||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||||
site-addr = "127.0.0.1:3000"
|
site-addr = "127.0.0.1:3110"
|
||||||
# The port to use for automatic reload monitoring
|
# The port to use for automatic reload monitoring
|
||||||
reload-port = 3001
|
reload-port = 3111
|
||||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||||
# This binary name can be checked in Powershell with Get-Command npx
|
# This binary name can be checked in Powershell with Get-Command npx
|
||||||
|
|||||||
+134
@@ -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
|
||||||
|
```
|
||||||
@@ -1,72 +1,78 @@
|
|||||||
<picture>
|
# aex
|
||||||
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
|
|
||||||
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
|
|
||||||
</picture>
|
|
||||||
|
|
||||||
# Leptos Starter Template
|
A D&D 5e character sheet and campaign dashboard for a text-based play-by-post
|
||||||
|
game. The UI is in Russian.
|
||||||
|
|
||||||
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool.
|
## What works today
|
||||||
|
|
||||||
## Creating your template repo
|
- Campaign header: name, in-game date steppers, session counter, and
|
||||||
|
Save / Load / Clear: download `dashboard.json`, import a file back,
|
||||||
|
wipe to a blank campaign
|
||||||
|
- Undo/redo: header buttons and Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y over
|
||||||
|
edit bursts; Clear and import are undoable too
|
||||||
|
- Character page: prepared spells (free-text slots with
|
||||||
|
level/description) and attunements (growable slots, clearable)
|
||||||
|
- Character sidebar: portrait, names, class, level, XP, hit points with
|
||||||
|
temp HP bar, manual AC, hit dice, death saves, spell slots
|
||||||
|
- Inventories page (`/inv`): personal and party-common inventories,
|
||||||
|
currency per denomination, item rows with quantity and weight steppers,
|
||||||
|
move between inventories, remove
|
||||||
|
- Bundled 5e.tools item data (~5k items) with fuzzy search components
|
||||||
|
(search is not mounted on a page yet)
|
||||||
|
|
||||||
If you don't have `cargo-leptos` installed you can install it with
|
State autosaves to localStorage (debounced) and survives reloads; export/
|
||||||
|
import round-trips through a JSON file. The phased plan is in
|
||||||
|
[ROADMAP.md](./ROADMAP.md).
|
||||||
|
|
||||||
`cargo install cargo-leptos --locked`
|
## Tech stack
|
||||||
|
|
||||||
Then run
|
- Rust, edition 2024, nightly toolchain (pinned in `rust-toolchain.toml`)
|
||||||
|
- Leptos 0.8 SSR (actix-web) + client-side hydration
|
||||||
|
- `reactive_stores` for the reactive state model
|
||||||
|
- SCSS (`style/main.scss`), compiled by dart-sass
|
||||||
|
- Playwright for end-to-end tests
|
||||||
|
|
||||||
`cargo leptos new --git leptos-rs/start-actix`
|
## Development
|
||||||
|
|
||||||
to generate a new project template (you will be prompted to enter a project name).
|
Prerequisites: `cargo-leptos`, dart-sass, and the `wasm32-unknown-unknown`
|
||||||
|
target. The nightly toolchain installs itself via `rust-toolchain.toml`.
|
||||||
|
|
||||||
`cd {projectname}`
|
|
||||||
|
|
||||||
to go to your newly created project.
|
|
||||||
|
|
||||||
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
|
|
||||||
|
|
||||||
## Running your project
|
|
||||||
|
|
||||||
`cargo leptos watch`
|
|
||||||
By default, you can access your local project at `http://localhost:3000`
|
|
||||||
|
|
||||||
## Installing Additional Tools
|
|
||||||
|
|
||||||
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
|
|
||||||
|
|
||||||
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
|
|
||||||
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
|
|
||||||
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
|
|
||||||
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
|
|
||||||
|
|
||||||
## Executing a Server on a Remote Machine Without the Toolchain
|
|
||||||
After running a `cargo leptos build --release` the minimum files needed are:
|
|
||||||
|
|
||||||
1. The server binary located in `target/server/release`
|
|
||||||
2. The `site` directory and all files within located in `target/site`
|
|
||||||
|
|
||||||
Copy these files to your remote server. The directory structure should be:
|
|
||||||
```text
|
|
||||||
leptos_start
|
|
||||||
site/
|
|
||||||
```
|
|
||||||
Set the following environment variables (updating for your project as needed):
|
|
||||||
```sh
|
```sh
|
||||||
export LEPTOS_OUTPUT_NAME="leptos_start"
|
cargo install cargo-leptos
|
||||||
export LEPTOS_SITE_ROOT="site"
|
cargo leptos watch # dev server with hot reload
|
||||||
export LEPTOS_SITE_PKG_DIR="pkg"
|
# open http://127.0.0.1:3000
|
||||||
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
|
|
||||||
export LEPTOS_RELOAD_PORT="3001"
|
|
||||||
```
|
```
|
||||||
Finally, run the server binary.
|
|
||||||
|
|
||||||
## Notes about CSR and Trunk:
|
Other commands:
|
||||||
Although it is not recommended, you can also run your project without server integration using the feature `csr` and `trunk serve`:
|
|
||||||
|
|
||||||
`trunk serve --open --features csr`
|
```sh
|
||||||
|
cargo leptos build --release # production build
|
||||||
|
cargo leptos serve # dev server without the watcher
|
||||||
|
cargo check --no-default-features --features ssr --bin aex
|
||||||
|
cargo check --no-default-features --features hydrate --lib
|
||||||
|
cargo check --target wasm32-unknown-unknown --no-default-features --features hydrate --lib
|
||||||
|
```
|
||||||
|
|
||||||
This may be useful for integrating external tools which require a static site, e.g. `tauri`.
|
## Tests
|
||||||
|
|
||||||
## Licensing
|
End-to-end tests live in `end2end/` (Playwright): a smoke spec and a
|
||||||
|
persistence spec (reload keeps state; export/import round trip). With the
|
||||||
|
dev server running:
|
||||||
|
|
||||||
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly.
|
```sh
|
||||||
|
cd end2end
|
||||||
|
npm install
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
or let cargo-leptos manage the server: `cargo leptos end2end`.
|
||||||
|
|
||||||
|
## Project docs
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](./ARCHITECTURE.md) — how the code is organized
|
||||||
|
- [ROADMAP.md](./ROADMAP.md) — the phased plan to MVP
|
||||||
|
- [MODERNIZE.md](./MODERNIZE.md) — dependency and idiom cleanup list
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Public domain (Unlicense), see [LICENSE](./LICENSE).
|
||||||
+245
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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!");
|
|
||||||
});
|
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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
@@ -8,6 +8,7 @@ use leptos_router::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
header::Header,
|
header::Header,
|
||||||
|
modal::{ Modal, ModalState },
|
||||||
nav::Nav,
|
nav::Nav,
|
||||||
sidebar::Sidebar,
|
sidebar::Sidebar,
|
||||||
Character,
|
Character,
|
||||||
@@ -17,13 +18,29 @@ use crate::{
|
|||||||
prelude::*
|
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]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||||
provide_meta_context();
|
provide_meta_context();
|
||||||
provide_context(Store::new(Dashboard::mock()));
|
let store = Store::new(Dashboard::mock());
|
||||||
|
provide_context(store.clone());
|
||||||
|
provide_context(ModalState::new());
|
||||||
|
provide_context(crate::state::undo::HistoryHandle::new(store.clone()));
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
|
// Client-only: load the saved state after mount, then autosave + undo.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
crate::state::persist::init_hydrated(&store, &expect_context::<crate::state::undo::HistoryHandle>());
|
||||||
|
|
||||||
|
let modal = expect_context::<ModalState>();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// injects a stylesheet into the document <head>
|
// injects a stylesheet into the document <head>
|
||||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
||||||
@@ -47,6 +64,7 @@ pub fn App() -> impl IntoView {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<Modal state=modal />
|
||||||
</Router>
|
</Router>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,25 +1,110 @@
|
|||||||
use crate::prelude::*;
|
|
||||||
use chrono::TimeDelta;
|
use chrono::TimeDelta;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
use crate::components::modal::ModalState;
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::state::persist::{ apply_import, clear_all, export_download };
|
||||||
|
use crate::state::undo::HistoryHandle;
|
||||||
|
|
||||||
|
/// Campaign header: editable name, campaign image, in-game date and
|
||||||
|
/// 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]
|
#[component]
|
||||||
pub fn Header () -> impl IntoView {
|
pub fn Header () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
|
let modal = expect_context::<ModalState>();
|
||||||
|
let history = expect_context::<HistoryHandle>();
|
||||||
|
let undo_depth = history.undo_depth();
|
||||||
|
let redo_depth = history.redo_depth();
|
||||||
|
let file_input = NodeRef::<leptos::html::Input>::new();
|
||||||
let campaign = state.campaign();
|
let campaign = state.campaign();
|
||||||
let date = state.date();
|
let date = state.date();
|
||||||
let session = state.session();
|
let session = state.session();
|
||||||
let image = state.campaign_image();
|
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| {
|
let adjust_day = move |adjustment: i64| date.update(|d| {
|
||||||
*d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap();
|
*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| {
|
let adjust_session = move |adjustment: isize| session.update(|s| {
|
||||||
if let Some(new) = s.checked_add_signed(adjustment) {
|
if let Some(new) = s.checked_add_signed(adjustment) {
|
||||||
*s = new;
|
*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! {
|
view! {
|
||||||
<header>
|
<header>
|
||||||
<Show when=move || !image.get().is_empty()>
|
<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>
|
<button on:click=move |_| adjust_session( 1) title="Следующая сессия" class="adjust" id="session-forward">">"</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button>Save</button>
|
<button on:click=on_save>"Сохранить"</button>
|
||||||
<button>Load</button>
|
<button on:click=on_load>"Загрузить"</button>
|
||||||
<button>Clear</button>
|
<button on:click=on_clear>"Очистить"</button>
|
||||||
|
<button
|
||||||
|
on:click=move |_| history.undo()
|
||||||
|
disabled=move || undo_depth.get() == 0
|
||||||
|
title="Отменить (Ctrl+Z)"
|
||||||
|
>"Отменить"</button>
|
||||||
|
<button
|
||||||
|
on:click=move |_| history.redo()
|
||||||
|
disabled=move || redo_depth.get() == 0
|
||||||
|
title="Вернуть (Ctrl+Shift+Z или Ctrl+Y)"
|
||||||
|
>"Вернуть"</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<input type="file" accept=".json,application/json" style="display: none" node_ref=file_input on:change=on_file/>
|
||||||
</header>
|
</header>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
use leptos::{logging, prelude::*};
|
use leptos::prelude::*;
|
||||||
use reactive_stores::Field;
|
use reactive_stores::Field;
|
||||||
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };
|
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };
|
||||||
|
|
||||||
|
/// One inventory section: a titled balance block (five `Numput`s) and the
|
||||||
|
/// item rows. `kind` decides personal/common styling and the move
|
||||||
|
/// direction.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Inventory (
|
pub fn Inventory (
|
||||||
kind: InventoryKind,
|
kind: InventoryKind,
|
||||||
@@ -11,10 +14,12 @@ pub fn Inventory (
|
|||||||
|
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
|
|
||||||
|
// Removes the row at `idx` from this inventory.
|
||||||
let rm = move |idx: usize| inventory.update(|v| {
|
let rm = move |idx: usize| inventory.update(|v| {
|
||||||
v.rows.remove(idx);
|
v.rows.remove(idx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Moves the row at `idx` to the other inventory (personal ↔ common).
|
||||||
let mv = move |idx: usize| {
|
let mv = move |idx: usize| {
|
||||||
let (from, to): (Field<Inventory>, Field<Inventory>) = match kind.dst() {
|
let (from, to): (Field<Inventory>, Field<Inventory>) = match kind.dst() {
|
||||||
InventoryKind::Personal => (state.common().inventory().into(), state.player().inventory().into()),
|
InventoryKind::Personal => (state.common().inventory().into(), state.player().inventory().into()),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use crate::{prelude::*, utils::BasisPoints};
|
use crate::{prelude::*, utils::BasisPoints};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
use reactive_stores::{Field, OptionStoreExt};
|
use reactive_stores::Field;
|
||||||
|
|
||||||
|
/// One inventory row: name, quantity stepper + `Numput`, weight stepper +
|
||||||
|
/// `Numput`, and more/swap/remove buttons. A weight-based tag drives the
|
||||||
|
/// CSS class. The "…" button is not wired yet (ROADMAP phase 3).
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Item (
|
pub fn Item (
|
||||||
idx: ReadSignal<usize>,
|
idx: ReadSignal<usize>,
|
||||||
@@ -11,15 +14,18 @@ pub fn Item (
|
|||||||
mut rm: impl FnMut(usize) + 'static,
|
mut rm: impl FnMut(usize) + 'static,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
|
||||||
let qty = move || entry.quantity();
|
|
||||||
let item = move || entry.item();
|
let item = move || entry.item();
|
||||||
|
|
||||||
let empty = String::new();
|
let empty = String::new();
|
||||||
|
|
||||||
|
// CSS class from the item's weight tag ("weight" key).
|
||||||
let class = move || format!("item {}", item().tags().get().get("weight").unwrap_or(&empty));
|
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());
|
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 weight_change = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||||
let old = item().weight().get_untracked();
|
let old = item().weight().get_untracked();
|
||||||
let new = ev.target().value();
|
let new = ev.target().value();
|
||||||
|
|||||||
+29
-1
@@ -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 leptos::prelude::*;
|
||||||
use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher };
|
use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher };
|
||||||
use reactive_stores::Field;
|
use reactive_stores::Field;
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub mod character;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod numput;
|
pub mod numput;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
|
pub mod modal;
|
||||||
|
|
||||||
|
/// Character page (route `/`): prepared spells and attunements
|
||||||
|
/// (ROADMAP phase 2). The sidebar carries the character editors.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Character () -> impl IntoView {
|
pub fn Character () -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
character
|
<div class="character-page">
|
||||||
|
<character::prepared_spells::PreparedSpells />
|
||||||
|
<character::attunements::Attunements />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inventories page (route `/inv`): the player's and the party's
|
||||||
|
/// inventories side by side, each with money and item rows.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Inventories () -> impl IntoView {
|
pub fn Inventories () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
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]
|
#[component]
|
||||||
pub fn Wiki () -> impl IntoView {
|
pub fn Wiki () -> impl IntoView {
|
||||||
view! {
|
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]
|
#[component]
|
||||||
pub fn ItemSearchField () -> impl IntoView {
|
pub fn ItemSearchField () -> impl IntoView {
|
||||||
let (query, set_query) = signal("".to_owned());
|
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]
|
#[component]
|
||||||
pub fn ItemSearchCard <'a> (item: &'a BookItem) -> impl IntoView {
|
pub fn ItemSearchCard <'a> (item: &'a BookItem) -> impl IntoView {
|
||||||
let card_class = format!("item-search-card {}", item.rarity.as_ref().unwrap_or(&String::new()));
|
let card_class = format!("item-search-card {}", item.rarity.as_ref().unwrap_or(&String::new()));
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
use crate::prelude::*;
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::components::A;
|
use leptos_router::components::A;
|
||||||
|
|
||||||
|
/// Top navigation: links to the three routes (character, inventory, wiki).
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Nav () -> impl IntoView {
|
pub fn Nav () -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
|
|||||||
@@ -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_format::{Locale, ToFormattedString};
|
||||||
use num_traits::{ SaturatingSub, SaturatingAdd };
|
use num_traits::{ SaturatingSub, SaturatingAdd };
|
||||||
use crate::prelude::*;
|
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]
|
#[component]
|
||||||
pub fn Numput <T, U, 'a> (
|
pub fn Numput <T, U, 'a> (
|
||||||
field: U,
|
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]
|
#[component]
|
||||||
pub fn NumputChange <U, G, 'a> (
|
pub fn NumputChange <U, G, 'a> (
|
||||||
mut field_with: U,
|
mut field_with: U,
|
||||||
|
|||||||
@@ -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::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
mod image;
|
mod image;
|
||||||
mod names;
|
mod names;
|
||||||
mod about;
|
mod about;
|
||||||
mod hit_points;
|
mod hit_points;
|
||||||
|
mod armor_class;
|
||||||
mod hit_dice;
|
mod hit_dice;
|
||||||
mod death_saving_throws;
|
mod death_saving_throws;
|
||||||
mod spell_slots;
|
mod spell_slots;
|
||||||
|
|
||||||
|
/// Left-hand character panel. Reads `Context` and stacks the sub-panels
|
||||||
|
/// (image, names, about, HP, hit dice, death saves, spell slots).
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar () -> impl IntoView {
|
pub fn Sidebar () -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
@@ -17,6 +21,7 @@ pub fn Sidebar () -> impl IntoView {
|
|||||||
<names::Names />
|
<names::Names />
|
||||||
<about::About />
|
<about::About />
|
||||||
<hit_points::HitPoints />
|
<hit_points::HitPoints />
|
||||||
|
<armor_class::ArmorClass />
|
||||||
<hit_dice::HitDice />
|
<hit_dice::HitDice />
|
||||||
<death_saving_throws::DeathSavingThrows />
|
<death_saving_throws::DeathSavingThrows />
|
||||||
<spell_slots::SpellSlots />
|
<spell_slots::SpellSlots />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Class (text input), level (+/− steppers) and XP (number input).
|
||||||
#[component]
|
#[component]
|
||||||
pub fn About () -> impl IntoView {
|
pub fn About () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
@@ -10,12 +11,14 @@ pub fn About () -> impl IntoView {
|
|||||||
let level = player.level();
|
let level = player.level();
|
||||||
let xp = player.xp();
|
let xp = player.xp();
|
||||||
|
|
||||||
|
// Steps the level by ±1, guarding against overflow.
|
||||||
let adjust_level = move |adjustment: i8| level.update(|l| {
|
let adjust_level = move |adjustment: i8| level.update(|l| {
|
||||||
if let Some(new) = l.checked_add_signed(adjustment) {
|
if let Some(new) = l.checked_add_signed(adjustment) {
|
||||||
*l = new;
|
*l = new;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Applies the XP input with checked parsing (invalid text is reverted).
|
||||||
let adjust_xp = move |ev: Targeted<Event, HtmlInputElement>| {
|
let adjust_xp = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||||
utils::adjust_checked(ev, xp);
|
utils::adjust_checked(ev, xp);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 crate::prelude::*;
|
||||||
use leptos::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]
|
#[component]
|
||||||
pub fn DeathSavingThrows () -> impl IntoView {
|
pub fn DeathSavingThrows () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
@@ -8,6 +10,7 @@ pub fn DeathSavingThrows () -> impl IntoView {
|
|||||||
|
|
||||||
let dt = player.death_save_throws();
|
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| {
|
let process_dt_click = move |success: bool, slot_id: u8| {
|
||||||
dt.update(|d| {
|
dt.update(|d| {
|
||||||
let v = if success { &mut d.succeeded } else { &mut d.failed };
|
let v = if success { &mut d.succeeded } else { &mut d.failed };
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Hit dice: a die-kind selector (d4 … d20) and one clickable die per
|
||||||
|
/// character level; clicking toggles spent/unspent.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HitDice () -> impl IntoView {
|
pub fn HitDice () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
@@ -8,6 +10,8 @@ pub fn HitDice () -> impl IntoView {
|
|||||||
|
|
||||||
let hit_dice = player.hit_dice();
|
let hit_dice = player.hit_dice();
|
||||||
let level = player.level();
|
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| {
|
let process_die_click = move |slot_id: u8| {
|
||||||
hit_dice.update(|d| {
|
hit_dice.update(|d| {
|
||||||
if slot_id < d.used {
|
if slot_id < d.used {
|
||||||
@@ -41,7 +45,7 @@ pub fn HitDice () -> impl IntoView {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<For
|
<For
|
||||||
each=move || (0..level.get())
|
each=move || 0..level.get()
|
||||||
key=|slot| slot.clone()
|
key=|slot| slot.clone()
|
||||||
let (slot)
|
let (slot)
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use leptos::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]
|
#[component]
|
||||||
pub fn HitPoints () -> impl IntoView {
|
pub fn HitPoints() -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
let player = state.player();
|
let player = state.player();
|
||||||
|
|
||||||
@@ -10,6 +13,8 @@ pub fn HitPoints () -> impl IntoView {
|
|||||||
let max_hp = player.max_hp();
|
let max_hp = player.max_hp();
|
||||||
let temp_hp = player.temp_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>| {
|
let adjust_hp = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||||
utils::adjust_checked(ev, hp);
|
utils::adjust_checked(ev, hp);
|
||||||
};
|
};
|
||||||
@@ -22,13 +27,10 @@ pub fn HitPoints () -> impl IntoView {
|
|||||||
utils::adjust_checked(ev, temp_hp);
|
utils::adjust_checked(ev, temp_hp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let pc_hp =
|
||||||
let pc_hp = move |a: u16, b: u16| -> String {
|
move |a: u16, b: u16| -> String { format!("width: {}%", utils::filled_pc(a, b).min(100.)) };
|
||||||
format!("width: {}%", utils::filled_pc(a, b).min(100.))
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<h6>hp/оз:</h6>
|
<h6>"hp/оз:"</h6>
|
||||||
<div class="hp">
|
<div class="hp">
|
||||||
<div class="perm">
|
<div class="perm">
|
||||||
<div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div>
|
<div style=move || pc_hp(hp.get(), max_hp.get()) class="bar"></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Character portrait; hidden while no image URL is set.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Image () -> impl IntoView {
|
pub fn Image () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// First name, alias (in «»), last name — three text inputs bound to the
|
||||||
|
/// player's name store.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Names () -> impl IntoView {
|
pub fn Names () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Spell slots for all nine levels: a row of clickable pips per level.
|
||||||
|
/// Clicking toggles a slot used/unused.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SpellSlots () -> impl IntoView {
|
pub fn SpellSlots () -> impl IntoView {
|
||||||
let state = expect_context::<Context>();
|
let state = expect_context::<Context>();
|
||||||
@@ -8,6 +10,7 @@ pub fn SpellSlots () -> impl IntoView {
|
|||||||
|
|
||||||
let slots = player.spell_slots();
|
let slots = player.spell_slots();
|
||||||
|
|
||||||
|
// Toggles pip `slot_id` of `level` used/unused.
|
||||||
let process_spell_slot_click = move |level: usize, slot_id: u8| {
|
let process_spell_slot_click = move |level: usize, slot_id: u8| {
|
||||||
slots.update(|s| {
|
slots.update(|s| {
|
||||||
let l = &mut s.0[level];
|
let l = &mut s.0[level];
|
||||||
|
|||||||
+31
-6
@@ -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 crate::prelude::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl Dashboard {
|
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 {
|
pub fn mock () -> Self {
|
||||||
let (p_inv, c_inv) = generate_mock_inventory();
|
let (p_inv, c_inv) = generate_mock_inventory();
|
||||||
|
|
||||||
@@ -23,9 +30,10 @@ impl Dashboard {
|
|||||||
temp_hp: 12,
|
temp_hp: 12,
|
||||||
hp: 1,
|
hp: 1,
|
||||||
max_hp: 61,
|
max_hp: 61,
|
||||||
|
ac: 16,
|
||||||
balance: Balance { platinum: 12, gold: 200, electrum: 1, silver: 101, copper: 129021021 },
|
balance: Balance { platinum: 12, gold: 200, electrum: 1, silver: 101, copper: 129021021 },
|
||||||
spell_slots: SpellSlots(core::array::from_fn(|i| SpellSlotLevel { used: 1, total: 9 - i as u8 })),
|
spell_slots: SpellSlots(core::array::from_fn(|i| SpellSlotLevel { used: 1, total: 9 - i as u8 })),
|
||||||
attunements: Attunements([const { None }; 3]),
|
attunements: Attunements(vec![None; 3]),
|
||||||
hit_dice: HitDice { used: 1, kind: 20 },
|
hit_dice: HitDice { used: 1, kind: 20 },
|
||||||
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
|
death_save_throws: DeathSaveThrows { failed: 1, succeeded: 3 },
|
||||||
prepared: generate_prepared_spells_list(),
|
prepared: generate_prepared_spells_list(),
|
||||||
@@ -38,15 +46,15 @@ impl Dashboard {
|
|||||||
quest_book: generate_mock_questbook(),
|
quest_book: generate_mock_questbook(),
|
||||||
notes: generate_mock_notebook(),
|
notes: generate_mock_notebook(),
|
||||||
people: generate_mock_contact_book(),
|
people: generate_mock_contact_book(),
|
||||||
settings: Settings {
|
settings: Settings::default(),
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Demo inventories: 10 personal items and 10 common items, each with a
|
||||||
|
/// weight, description and tags.
|
||||||
fn generate_mock_inventory() -> (Inventory, Inventory) {
|
fn generate_mock_inventory() -> (Inventory, Inventory) {
|
||||||
let a = vec![
|
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()), ]), }, },
|
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 {
|
fn generate_mock_notebook () -> NoteBook {
|
||||||
NoteBook(vec![
|
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()]) },
|
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 {
|
fn generate_mock_questbook () -> QuestBook {
|
||||||
QuestBook(vec![
|
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 }) },
|
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 {
|
fn generate_mock_contact_book () -> ContactBook {
|
||||||
let mut contacts = HashMap::new();
|
let mut contacts = HashMap::new();
|
||||||
// 40 Characters Met by Party (80%)
|
// 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 {
|
fn generate_prepared_spells_list () -> PreparedSpells {
|
||||||
use PreparedSpell::*;
|
|
||||||
let list = vec![
|
let list = vec![
|
||||||
|
PreparedSpell::Vacant,
|
||||||
|
PreparedSpell::Occupied(Spell {
|
||||||
|
name: "Огненный шар".to_string(),
|
||||||
|
level: Some(3),
|
||||||
|
description: Some("Вспышка пламени в точке, радиус 6 м.".to_string()),
|
||||||
|
}),
|
||||||
|
PreparedSpell::Occupied(Spell {
|
||||||
|
name: "Магическая броня".to_string(),
|
||||||
|
level: Some(1),
|
||||||
|
description: None,
|
||||||
|
}),
|
||||||
|
PreparedSpell::Vacant,
|
||||||
];
|
];
|
||||||
|
|
||||||
PreparedSpells(list)
|
PreparedSpells(list)
|
||||||
|
|||||||
+158
-28
@@ -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 std::fmt::Display;
|
||||||
|
|
||||||
use chrono::{ NaiveDate, NaiveDateTime };
|
use chrono::{ NaiveDate, NaiveDateTime };
|
||||||
use reactive_stores::Store;
|
use reactive_stores::Store;
|
||||||
|
use serde::{ Deserialize, Serialize };
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
mod mock;
|
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)]
|
#[derive(Clone, Debug, Store)]
|
||||||
pub struct BookItem {
|
pub struct BookItem {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -16,6 +35,8 @@ pub struct BookItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub fn from_json (t: Value) -> Option<Self> {
|
||||||
let Value::String(name) = t["name"].clone() else { return None };
|
let Value::String(name) = t["name"].clone() else { return None };
|
||||||
let weight = match t["weight"].clone() {
|
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)]
|
#[derive(Clone, Debug, Store)]
|
||||||
pub struct ItemReferenceMap (pub HashMap<String, BookItem>);
|
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 struct Dashboard {
|
||||||
pub campaign: String,
|
pub campaign: String,
|
||||||
pub campaign_image: String,
|
pub campaign_image: String,
|
||||||
@@ -53,7 +82,50 @@ pub struct Dashboard {
|
|||||||
pub settings: Settings
|
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 struct PlayerData {
|
||||||
pub name: Name,
|
pub name: Name,
|
||||||
pub class: String,
|
pub class: String,
|
||||||
@@ -63,6 +135,10 @@ pub struct PlayerData {
|
|||||||
pub temp_hp: u16,
|
pub temp_hp: u16,
|
||||||
pub hp: u16,
|
pub hp: u16,
|
||||||
pub max_hp: u16,
|
pub max_hp: u16,
|
||||||
|
/// Armor class; set manually by the player (no derived calculation
|
||||||
|
/// yet). Missing in old saved JSON, so default to 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ac: u8,
|
||||||
pub balance: Balance,
|
pub balance: Balance,
|
||||||
pub spell_slots: SpellSlots,
|
pub spell_slots: SpellSlots,
|
||||||
pub inventory: Inventory,
|
pub inventory: Inventory,
|
||||||
@@ -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 struct CommonData {
|
||||||
pub balance: Balance,
|
pub balance: Balance,
|
||||||
pub inventory: Inventory,
|
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 struct Balance {
|
||||||
pub platinum: u32,
|
pub platinum: u32,
|
||||||
pub gold: u32,
|
pub gold: u32,
|
||||||
@@ -89,13 +168,18 @@ pub struct Balance {
|
|||||||
pub copper: u32,
|
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 {
|
pub struct Inventory {
|
||||||
#[store(key: u64 = |row| row.id)]
|
#[store(key: u64 = |row| row.id)]
|
||||||
pub rows: Vec<InventoryEntry>
|
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 struct InventoryEntry {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
@@ -103,6 +187,7 @@ pub struct InventoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InventoryEntry {
|
impl InventoryEntry {
|
||||||
|
/// Returns a stable key string for this entry (quantity + item name).
|
||||||
pub fn key (&self) -> String {
|
pub fn key (&self) -> String {
|
||||||
format!("{}-{}", self.quantity, self.item.name)
|
format!("{}-{}", self.quantity, self.item.name)
|
||||||
}
|
}
|
||||||
@@ -114,7 +199,9 @@ impl InventoryEntry {
|
|||||||
// Book (Item),
|
// 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 struct InventoryItem {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub weight: u32,
|
pub weight: u32,
|
||||||
@@ -122,10 +209,13 @@ pub struct InventoryItem {
|
|||||||
pub tags: HashMap<String, String>,
|
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>);
|
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 struct Quest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub location_taken: Option<String>,
|
pub location_taken: Option<String>,
|
||||||
@@ -137,10 +227,13 @@ pub struct Quest {
|
|||||||
pub reward: Option<Balance>,
|
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>);
|
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 struct Note {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub date_ingame: NaiveDate,
|
pub date_ingame: NaiveDate,
|
||||||
@@ -149,59 +242,74 @@ pub struct Note {
|
|||||||
pub tags: HashSet<String>,
|
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]);
|
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 struct SpellSlotLevel {
|
||||||
pub used: u8,
|
pub used: u8,
|
||||||
pub total: u8,
|
pub total: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Store)]
|
/// Attunement slots (D&D rules allow three attuned items; the UI can
|
||||||
pub struct Attunements (pub [Option<String>; 3]);
|
/// 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>);
|
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 {
|
pub enum PreparedSpell {
|
||||||
Vacant,
|
Vacant,
|
||||||
Occupied (Spell)
|
Occupied (Spell)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @TODO
|
/// A spell: the name plus optional level and description.
|
||||||
#[derive(Clone, Debug, Store)]
|
/// Optional fields keep old saved JSON (name-only spells) parseable.
|
||||||
|
#[derive(Clone, Debug, Store, Serialize, Deserialize, Default)]
|
||||||
pub struct Spell {
|
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 struct HitDice {
|
||||||
pub used: u8,
|
pub used: u8,
|
||||||
pub kind: 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 struct DeathSaveThrows {
|
||||||
pub failed: u8,
|
pub failed: u8,
|
||||||
pub succeeded: u8,
|
pub succeeded: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeathSaveThrows {
|
impl DeathSaveThrows {
|
||||||
|
/// Clears both counters. Bound to the ⟳ button in the sidebar.
|
||||||
pub fn reset (&mut self) {
|
pub fn reset (&mut self) {
|
||||||
self.failed = 0;
|
self.failed = 0;
|
||||||
self.succeeded = 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>);
|
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 struct Contact {
|
||||||
pub name: Name,
|
pub name: Name,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
@@ -210,7 +318,8 @@ pub struct Contact {
|
|||||||
pub notes: Option<String>,
|
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 {
|
pub enum ContactStatus {
|
||||||
Unknown,
|
Unknown,
|
||||||
DeadBygone,
|
DeadBygone,
|
||||||
@@ -218,7 +327,8 @@ pub enum ContactStatus {
|
|||||||
Alive,
|
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 struct Name {
|
||||||
pub first: String,
|
pub first: String,
|
||||||
pub last: String,
|
pub last: String,
|
||||||
@@ -226,26 +336,44 @@ pub struct Name {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Name {
|
impl Name {
|
||||||
|
/// True when an alias is set.
|
||||||
pub fn display_alias (&self) -> bool {
|
pub fn display_alias (&self) -> bool {
|
||||||
!self.alias.is_empty()
|
!self.alias.is_empty()
|
||||||
}
|
}
|
||||||
|
/// True when a last name is set.
|
||||||
pub fn display_last (&self) -> bool {
|
pub fn display_last (&self) -> bool {
|
||||||
!self.last.is_empty()
|
!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 {
|
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 {
|
pub enum InventoryKind {
|
||||||
Personal,
|
Personal,
|
||||||
Common
|
Common
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Russian display name ("Личный" / "Общий").
|
||||||
impl Display for InventoryKind {
|
impl Display for InventoryKind {
|
||||||
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{}", match self {
|
write!(f, "{}", match self {
|
||||||
@@ -256,6 +384,7 @@ impl Display for InventoryKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InventoryKind {
|
impl InventoryKind {
|
||||||
|
/// The other inventory (for the move-between button).
|
||||||
pub fn dst (&self) -> Self {
|
pub fn dst (&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::Common => Self::Personal,
|
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 {
|
pub fn class (&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Common => "inventory inventory-common",
|
Self::Common => "inventory inventory-common",
|
||||||
|
|||||||
+7
-1
@@ -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;
|
pub mod app;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
@@ -21,6 +25,8 @@ mod components;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use prelude::*;
|
use prelude::*;
|
||||||
|
|
||||||
|
/// WASM entry point: hydrates `App` into the server-rendered DOM.
|
||||||
|
/// Only compiled with the `hydrate` feature.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
pub fn hydrate() {
|
pub fn hydrate() {
|
||||||
|
|||||||
+10
@@ -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")]
|
#[cfg(feature = "ssr")]
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
@@ -58,6 +63,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serves `/favicon.ico` from the site root.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
#[actix_web::get("favicon.ico")]
|
#[actix_web::get("favicon.ico")]
|
||||||
async fn favicon(
|
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")))]
|
#[cfg(not(any(feature = "ssr", feature = "csr")))]
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// no client-side main function
|
// no client-side main function
|
||||||
@@ -78,6 +86,8 @@ pub fn main() {
|
|||||||
// see optional feature `csr` instead
|
// 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"))]
|
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// a client-side main function is required for using `trunk serve`
|
// a client-side main function is required for using `trunk serve`
|
||||||
|
|||||||
@@ -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(unused_imports)]
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
pub use std::collections::{ HashMap, HashSet };
|
pub use std::collections::{ HashMap, HashSet };
|
||||||
@@ -16,6 +20,12 @@ pub (crate) use crate::{
|
|||||||
components::{ self, numput::{ Numput, NumputChange } },
|
components::{ self, numput::{ Numput, NumputChange } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Crate-wide error type. Alias of `anyhow::Error`.
|
||||||
pub type Error = anyhow::Error;
|
pub type Error = anyhow::Error;
|
||||||
|
/// Crate-wide result type. Alias of `anyhow::Result<T>`.
|
||||||
pub type Result <T> = 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>;
|
pub type Context = Store<Dashboard>;
|
||||||
+10
-1
@@ -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;
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 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! {
|
utils::read_items(include_str!("../../data/items.json"), &mut map);
|
||||||
pub static ref ITEMS_REFS: ItemReferenceMap = {
|
utils::read_items(include_str!("../../data/base.json"), &mut map);
|
||||||
let mut map = HashMap::with_capacity(5_000);
|
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);
|
info!("Loaded {} items", map.len());
|
||||||
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());
|
ItemReferenceMap(map)
|
||||||
|
});
|
||||||
|
|
||||||
ItemReferenceMap(HashMap::from_iter(map.into_iter()))
|
/// Flat list of item names; the feed for the fuzzy matcher
|
||||||
};
|
/// (`ItemSearchField`).
|
||||||
|
pub static ITEMS_NAMES: LazyLock<Vec<&'static str>> =
|
||||||
pub static ref ITEMS_NAMES: Vec<&'static String> = {
|
LazyLock::new(|| ITEMS_REFS.0.keys().map(String::as_str).collect());
|
||||||
ITEMS_REFS.0.keys().collect()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Small shared helpers: item parsing, checked input parsing, percentage
|
||||||
|
//! math, and the `BasisPoints` display trait.
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use leptos::ev::{ Event, Targeted };
|
use leptos::ev::{ Event, Targeted };
|
||||||
@@ -5,6 +7,11 @@ use leptos::web_sys::HtmlInputElement;
|
|||||||
use leptos::reactive::traits::{ Get, Set };
|
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 {
|
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 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 };
|
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
|
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) -> ()
|
pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -> ()
|
||||||
where
|
where
|
||||||
U: Set<Value = T> + Get<Value = T>,
|
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> {
|
pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
|
||||||
let cur: f32 = cur.into();
|
let cur: f32 = cur.into();
|
||||||
let max: f32 = max.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 {
|
pub trait BasisPoints {
|
||||||
fn to_decimal_string (&self) -> String;
|
fn to_decimal_string (&self) -> String;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Weights are stored as `u32` basis points: 100 = 1.0.
|
||||||
impl BasisPoints for u32 {
|
impl BasisPoints for u32 {
|
||||||
fn to_decimal_string (&self) -> String {
|
fn to_decimal_string (&self) -> String {
|
||||||
let int = self / 100;
|
let int = self / 100;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,11 @@ header {
|
|||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
margin: 2px 5px;
|
margin: 2px 5px;
|
||||||
@include mixins.button;
|
@include mixins.button;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -134,6 +134,14 @@ aside {
|
|||||||
.val {
|
.val {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ac {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 1px solid rgba(white, 0.15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hit-dice {
|
.hit-dice {
|
||||||
@@ -193,6 +201,14 @@ aside {
|
|||||||
filter: contrast(70%);
|
filter: contrast(70%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ac {
|
||||||
|
margin-left: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
width: 20%;
|
||||||
|
background: rgba(green, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.death-throws {
|
.death-throws {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
@use 'reset';
|
@use 'reset';
|
||||||
@use 'mixins';
|
@use 'mixins';
|
||||||
@use 'header';
|
@use 'header';
|
||||||
|
@use 'character';
|
||||||
@use 'sidebar';
|
@use 'sidebar';
|
||||||
@use 'nav';
|
@use 'nav';
|
||||||
@use 'inventory';
|
@use 'inventory';
|
||||||
|
@use 'modal';
|
||||||
@use 'vars';
|
@use 'vars';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user