Compare commits
No commits in common. "6b792734b6500daa5b33a4d518265c1a68777f8f" and "6c7ba8601877cca61020cd8c3ff224ac1a36f942" have entirely different histories.
6b792734b6
...
6c7ba86018
165
ARCHITECTURE.md
165
ARCHITECTURE.md
@ -1,165 +0,0 @@
|
||||
# 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, lazy_static for one-time data loading
|
||||
- SCSS sources in `style/` (compiled by cargo-leptos)
|
||||
- Bundled game data in `data/` (5e.tools-format JSON, ~3.9 MB total)
|
||||
|
||||
Features: `ssr` (server binary), `hydrate` (WASM), `csr` (optional pure-client
|
||||
mode for `trunk`). `Cargo.lock` is gitignored.
|
||||
|
||||
## 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)
|
||||
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 `lazy_static` map of name → `BookItem`, built
|
||||
once by `utils::read_items` over the bundled JSON files (`items.json`,
|
||||
`base.json`, `foundry.json` loaded twice — a known duplication — and
|
||||
`magic.json`), roughly 5k items.
|
||||
- `BookItem` keeps `name`, `weight`, `rarity`, and `rest`, which holds all
|
||||
other JSON fields verbatim for future detail views.
|
||||
- Duplicate names merge: the later entry fills a missing weight and appends
|
||||
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
|
||||
├─ Header name, image, date steppers, session steppers, Save/Load/Clear (dead)
|
||||
├─ 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 stub text
|
||||
├─ 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.
|
||||
|
||||
`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
|
||||
|
||||
- No persistence; state is recreated from `mock()` each load
|
||||
- `Character` and `Wiki` pages render placeholders
|
||||
- Quests, notes, contacts: models and mock data only
|
||||
- Prepared spells list is empty; `Spell` is a name-only stub; attunements
|
||||
have no UI; `Settings` is empty
|
||||
- Save/Load/Clear buttons and the item "+"/"…" buttons are dead
|
||||
- `foundry.json` is loaded twice; ~3.9 MB of JSON is bundled into the WASM
|
||||
and parsed on startup
|
||||
- Several unused dependencies (`leptos-use`, `dotenvy`,
|
||||
`pretty_env_logger`, `seq_macro`, `itertools`, `thiserror`) and 12
|
||||
compiler warnings
|
||||
- `Cargo.lock` is gitignored, so builds are not reproducible
|
||||
|
||||
See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.
|
||||
14
Cargo.toml
14
Cargo.toml
@ -11,11 +11,11 @@ actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
http = { version = "1.3.1", optional = true }
|
||||
leptos = { version = "0.8.20", features = ["nightly"] }
|
||||
leptos_meta = { version = "0.8.6" }
|
||||
leptos_actix = { version = "0.8.7", optional = true }
|
||||
leptos_router = { version = "0.8.15", features = ["nightly"] }
|
||||
wasm-bindgen = "=0.2.126"
|
||||
leptos = { version = "0.8.2", features = ["nightly"] }
|
||||
leptos_meta = { version = "0.8.2" }
|
||||
leptos_actix = { version = "0.8.2", optional = true }
|
||||
leptos_router = { version = "0.8.2", features = ["nightly"] }
|
||||
wasm-bindgen = "=0.2.101"
|
||||
anyhow = "1.0.99"
|
||||
thiserror = "2.0.16"
|
||||
itertools = "0.14.0"
|
||||
@ -25,14 +25,14 @@ lazy_static = "1.5.0"
|
||||
leptos-use = "0.16.2"
|
||||
log = "0.4.28"
|
||||
nucleo-matcher = "0.3.1"
|
||||
reactive_stores = "0.4.3"
|
||||
reactive_stores = "0.2.5"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
console_log = "1.0.0"
|
||||
num-traits = "0.2.19"
|
||||
num-format = "0.4.4"
|
||||
js-sys = "=0.3.103"
|
||||
js-sys = "=0.3.78"
|
||||
seq-macro = "0.3.6"
|
||||
|
||||
|
||||
|
||||
130
MODERNIZE.md
130
MODERNIZE.md
@ -1,130 +0,0 @@
|
||||
# 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.
|
||||
- The item 3.2 note about the double `foundry.json` load stays true.
|
||||
- Dependency decision (2026-08-04): `itertools`, `thiserror`, `anyhow`,
|
||||
`pretty_env_logger`, `dotenvy` stay for certain. The rest of section 1
|
||||
is under consideration.
|
||||
|
||||
## 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).
|
||||
Rewrite `src/statics/mod.rs`:
|
||||
|
||||
```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 exist today; `serde_json::Value` works without naming `serde`
|
||||
in `Cargo.toml`. Phase 1 (persistence) re-adds
|
||||
`serde = { version = "1", features = ["derive"] }`.
|
||||
|
||||
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
|
||||
```
|
||||
185
ROADMAP.md
185
ROADMAP.md
@ -1,185 +0,0 @@
|
||||
# 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.
|
||||
|
||||
- [ ] rewrite README (currently the stock template)
|
||||
- [ ] deduplicate `foundry.json` load in `src/statics/mod.rs` (loaded twice)
|
||||
- [ ] clear compiler warnings (unused imports, unused `qty` in `item.rs`)
|
||||
- [ ] decide Cargo.lock policy — currently gitignored, builds are non-reproducible; commit it
|
||||
- [ ] dependency and idiom cleanup — see `MODERNIZE.md` (drop unused deps,
|
||||
`lazy_static` → `LazyLock`, local replacements for `num-format`/`num-traits`)
|
||||
- [ ] 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
|
||||
- [ ] 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.
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] verify `Store` derive and `serde` derive coexist on the same structs (prototype first)
|
||||
- [ ] add a `version` field to `Settings`; add a migration hook for future format changes
|
||||
- [ ] serialize Dashboard to JSON; save to localStorage via `web_sys::Storage`
|
||||
(feature-gate to `hydrate`/`csr`; SSR renders mock/empty)
|
||||
- [ ] auto-save on change — debounced effect over the `Store` — plus manual Save button
|
||||
- [ ] Load button: restore from localStorage with an overwrite confirm
|
||||
- [ ] Clear button: wipe localStorage with a confirm
|
||||
- [ ] export: download `dashboard.json` as a blob
|
||||
- [ ] import: file input → parse → validate version → replace state
|
||||
- [ ] on startup, hydrate from localStorage; keep `mock()` as the fallback / "New dashboard"
|
||||
- **Acceptance:** close the tab, reopen — state is intact. Export → import into a
|
||||
fresh browser — identical state.
|
||||
|
||||
## Phase 2 — Character page (M, ~1-2 days)
|
||||
|
||||
`/` currently renders the literal string "character".
|
||||
|
||||
- [ ] player card: portrait, name, class, level, XP (data already in sidebar)
|
||||
- [ ] HP block: temp / current / max, `Numput`-style editing
|
||||
- [ ] prepared spells UI: render Vacant/Occupied slots; free-text spell name entry
|
||||
(upgrade the `Spell` stub from `name`-only to name + optional level/description)
|
||||
- [ ] attunements UI: 3 slots, free text, clear button (model exists)
|
||||
- **Acceptance:** player can set prepared spells and attunements; they persist.
|
||||
|
||||
## 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: persistence round-trip, add item from search, quest CRUD,
|
||||
notes filter
|
||||
- [ ] rewrite README: usage, dev workflow, deploy
|
||||
- [ ] release build via cargo-leptos; verify SSR + hydration; pick a deploy target
|
||||
- **Acceptance:** fresh clone → `cargo leptos build` → app runs; e2e green.
|
||||
|
||||
---
|
||||
|
||||
## 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 (different components).
|
||||
- Phases 4-6 are independent of each other and share one CRUD-list pattern —
|
||||
build the pattern once (e.g. a generic list editor) and reuse it.
|
||||
- Phase 7 reuses the Phase 3 add-to-inventory path; keep that path shared.
|
||||
|
||||
## 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.
|
||||
@ -17,12 +17,6 @@ use crate::{
|
||||
prelude::*
|
||||
};
|
||||
|
||||
/// Root component of the app.
|
||||
///
|
||||
/// Sets up meta context, creates the `Store<Dashboard>` (mock data for now)
|
||||
/// and injects it as context, then renders the shell: header, sidebar, nav
|
||||
/// and the routed page. Routes: `/` character, `/inv` inventories,
|
||||
/// `/wiki` wiki; anything else falls to `NotFound`.
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
|
||||
@ -2,9 +2,6 @@ use crate::prelude::*;
|
||||
use chrono::TimeDelta;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Campaign header: editable name, campaign image, in-game date and
|
||||
/// session steppers, and Save/Load/Clear buttons (not wired yet — see
|
||||
/// ROADMAP phase 1).
|
||||
#[component]
|
||||
pub fn Header () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -13,12 +10,10 @@ pub fn Header () -> impl IntoView {
|
||||
let session = state.session();
|
||||
let image = state.campaign_image();
|
||||
|
||||
// Shifts the in-game date by ±1 day (saturating, via checked_add_signed).
|
||||
let adjust_day = move |adjustment: i64| date.update(|d| {
|
||||
*d = d.checked_add_signed(TimeDelta::days(adjustment)).unwrap();
|
||||
});
|
||||
|
||||
// Shifts the session counter by ±1, guarding against underflow.
|
||||
let adjust_session = move |adjustment: isize| session.update(|s| {
|
||||
if let Some(new) = s.checked_add_signed(adjustment) {
|
||||
*s = new;
|
||||
|
||||
@ -2,9 +2,6 @@ use leptos::{logging, prelude::*};
|
||||
use reactive_stores::Field;
|
||||
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };
|
||||
|
||||
/// One inventory section: a titled balance block (five `Numput`s) and the
|
||||
/// item rows. `kind` decides personal/common styling and the move
|
||||
/// direction.
|
||||
#[component]
|
||||
pub fn Inventory (
|
||||
kind: InventoryKind,
|
||||
@ -14,12 +11,10 @@ pub fn Inventory (
|
||||
|
||||
let state = expect_context::<Context>();
|
||||
|
||||
// Removes the row at `idx` from this inventory.
|
||||
let rm = move |idx: usize| inventory.update(|v| {
|
||||
v.rows.remove(idx);
|
||||
});
|
||||
|
||||
// Moves the row at `idx` to the other inventory (personal ↔ common).
|
||||
let mv = move |idx: usize| {
|
||||
let (from, to): (Field<Inventory>, Field<Inventory>) = match kind.dst() {
|
||||
InventoryKind::Personal => (state.common().inventory().into(), state.player().inventory().into()),
|
||||
|
||||
@ -3,9 +3,6 @@ use leptos::prelude::*;
|
||||
|
||||
use reactive_stores::{Field, OptionStoreExt};
|
||||
|
||||
/// One inventory row: name, quantity stepper + `Numput`, weight stepper +
|
||||
/// `Numput`, and more/swap/remove buttons. A weight-based tag drives the
|
||||
/// CSS class. The "…" button is not wired yet (ROADMAP phase 3).
|
||||
#[component]
|
||||
pub fn Item (
|
||||
idx: ReadSignal<usize>,
|
||||
@ -19,14 +16,10 @@ pub fn Item (
|
||||
|
||||
let empty = String::new();
|
||||
|
||||
// CSS class from the item's weight tag ("weight" key).
|
||||
let class = move || format!("item {}", item().tags().get().get("weight").unwrap_or(&empty));
|
||||
|
||||
// Weight shown as a decimal: basis points → "x.yy".
|
||||
let weight_display = move || item().weight().with(|f| f.to_decimal_string());
|
||||
|
||||
// Parses weight input: "+n"/"-n" adjust relative, plain numbers set
|
||||
// absolutely, anything else restores the current value.
|
||||
let weight_change = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||
let old = item().weight().get_untracked();
|
||||
let new = ev.target().value();
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
//! 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.
|
||||
use leptos::prelude::*;
|
||||
use nucleo_matcher::{ pattern::{ CaseMatching, Pattern, Normalization }, Config, Matcher };
|
||||
use reactive_stores::Field;
|
||||
@ -17,8 +11,6 @@ pub mod inventory;
|
||||
pub mod numput;
|
||||
pub mod item;
|
||||
|
||||
/// Character page (route `/`). Stub: renders a placeholder until
|
||||
/// ROADMAP phase 2.
|
||||
#[component]
|
||||
pub fn Character () -> impl IntoView {
|
||||
view! {
|
||||
@ -26,8 +18,6 @@ pub fn Character () -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inventories page (route `/inv`): the player's and the party's
|
||||
/// inventories side by side, each with money and item rows.
|
||||
#[component]
|
||||
pub fn Inventories () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -50,8 +40,6 @@ pub fn Inventories () -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wiki page (route `/wiki`). Stub: renders a placeholder until
|
||||
/// ROADMAP phase 7.
|
||||
#[component]
|
||||
pub fn Wiki () -> impl IntoView {
|
||||
view! {
|
||||
@ -59,11 +47,6 @@ pub fn Wiki () -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuzzy item search over the bundled item names.
|
||||
///
|
||||
/// Re-matches on every keystroke (nucleo) and renders result cards. The
|
||||
/// "+" buttons on the cards are not wired to the inventory yet (ROADMAP
|
||||
/// phase 3).
|
||||
#[component]
|
||||
pub fn ItemSearchField () -> impl IntoView {
|
||||
let (query, set_query) = signal("".to_owned());
|
||||
@ -103,7 +86,6 @@ pub fn ItemSearchField () -> impl IntoView {
|
||||
}
|
||||
|
||||
|
||||
/// One search result: name, weight, rarity and an (unwired) add button.
|
||||
#[component]
|
||||
pub fn ItemSearchCard <'a> (item: &'a BookItem) -> impl IntoView {
|
||||
let card_class = format!("item-search-card {}", item.rarity.as_ref().unwrap_or(&String::new()));
|
||||
|
||||
@ -2,7 +2,6 @@ use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::A;
|
||||
|
||||
/// Top navigation: links to the three routes (character, inventory, wiki).
|
||||
#[component]
|
||||
pub fn Nav () -> impl IntoView {
|
||||
view! {
|
||||
|
||||
@ -5,11 +5,6 @@ use num_format::{Locale, ToFormattedString};
|
||||
use num_traits::{ SaturatingSub, SaturatingAdd };
|
||||
use crate::prelude::*;
|
||||
|
||||
/// Number input bound to a reactive field.
|
||||
///
|
||||
/// `+n` / `-n` adjust the current value relatively, a plain number sets it
|
||||
/// absolutely, and anything else is rejected (the input is restored). The
|
||||
/// value renders with thousands separators (`num_format`).
|
||||
#[component]
|
||||
pub fn Numput <T, U, 'a> (
|
||||
field: U,
|
||||
@ -42,11 +37,6 @@ pub fn Numput <T, U, 'a> (
|
||||
}
|
||||
}
|
||||
|
||||
/// `Numput` with caller-supplied display and change handlers.
|
||||
///
|
||||
/// For fields whose display differs from the stored value (e.g. weights
|
||||
/// in basis points): `field_with` renders the input, `change` parses the
|
||||
/// edit.
|
||||
#[component]
|
||||
pub fn NumputChange <U, G, 'a> (
|
||||
mut field_with: U,
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//! Character sidebar: portrait, names, class/level/XP, HP, hit dice,
|
||||
//! death saves and spell slots.
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
@ -11,8 +9,6 @@ mod hit_dice;
|
||||
mod death_saving_throws;
|
||||
mod spell_slots;
|
||||
|
||||
/// Left-hand character panel. Reads `Context` and stacks the sub-panels
|
||||
/// (image, names, about, HP, hit dice, death saves, spell slots).
|
||||
#[component]
|
||||
pub fn Sidebar () -> impl IntoView {
|
||||
view! {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Class (text input), level (+/− steppers) and XP (number input).
|
||||
#[component]
|
||||
pub fn About () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -11,14 +10,12 @@ pub fn About () -> impl IntoView {
|
||||
let level = player.level();
|
||||
let xp = player.xp();
|
||||
|
||||
// Steps the level by ±1, guarding against overflow.
|
||||
let adjust_level = move |adjustment: i8| level.update(|l| {
|
||||
if let Some(new) = l.checked_add_signed(adjustment) {
|
||||
*l = new;
|
||||
}
|
||||
});
|
||||
|
||||
// Applies the XP input with checked parsing (invalid text is reverted).
|
||||
let adjust_xp = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||
utils::adjust_checked(ev, xp);
|
||||
};
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Three failure pips and three success pips, plus a reset button.
|
||||
/// Clicking a pip fills it; clicking the last filled pip empties it.
|
||||
#[component]
|
||||
pub fn DeathSavingThrows () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -10,7 +8,6 @@ pub fn DeathSavingThrows () -> impl IntoView {
|
||||
|
||||
let dt = player.death_save_throws();
|
||||
|
||||
// Toggles pip `slot_id` of the fail/success track.
|
||||
let process_dt_click = move |success: bool, slot_id: u8| {
|
||||
dt.update(|d| {
|
||||
let v = if success { &mut d.succeeded } else { &mut d.failed };
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Hit dice: a die-kind selector (d4 … d20) and one clickable die per
|
||||
/// character level; clicking toggles spent/unspent.
|
||||
#[component]
|
||||
pub fn HitDice () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -10,8 +8,6 @@ pub fn HitDice () -> impl IntoView {
|
||||
|
||||
let hit_dice = player.hit_dice();
|
||||
let level = player.level();
|
||||
// Toggles die `slot_id` spent/unspent; clicking the last spent die
|
||||
// frees it again.
|
||||
let process_die_click = move |slot_id: u8| {
|
||||
hit_dice.update(|d| {
|
||||
if slot_id < d.used {
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Current/max HP with a fill bar, plus temp HP with its own bar.
|
||||
/// All three numbers are editable `type="number"` inputs.
|
||||
#[component]
|
||||
pub fn HitPoints () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -12,8 +10,6 @@ pub fn HitPoints () -> impl IntoView {
|
||||
let max_hp = player.max_hp();
|
||||
let temp_hp = player.temp_hp();
|
||||
|
||||
// All three inputs parse via `utils::adjust_checked`; invalid text
|
||||
// restores the previous value.
|
||||
let adjust_hp = move |ev: Targeted<Event, HtmlInputElement>| {
|
||||
utils::adjust_checked(ev, hp);
|
||||
};
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Character portrait; hidden while no image URL is set.
|
||||
#[component]
|
||||
pub fn Image () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// First name, alias (in «»), last name — three text inputs bound to the
|
||||
/// player's name store.
|
||||
#[component]
|
||||
pub fn Names () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Spell slots for all nine levels: a row of clickable pips per level.
|
||||
/// Clicking toggles a slot used/unused.
|
||||
#[component]
|
||||
pub fn SpellSlots () -> impl IntoView {
|
||||
let state = expect_context::<Context>();
|
||||
@ -10,7 +8,6 @@ pub fn SpellSlots () -> impl IntoView {
|
||||
|
||||
let slots = player.spell_slots();
|
||||
|
||||
// Toggles pip `slot_id` of `level` used/unused.
|
||||
let process_spell_slot_click = move |level: usize, slot_id: u8| {
|
||||
slots.update(|s| {
|
||||
let l = &mut s.0[level];
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
//! Hardcoded demo data.
|
||||
//!
|
||||
//! `Dashboard::mock()` is the app's only state source until persistence
|
||||
//! lands (ROADMAP phase 1).
|
||||
use crate::prelude::*;
|
||||
use super::*;
|
||||
|
||||
impl Dashboard {
|
||||
/// Builds a fully populated demo dashboard: a level-8 character, two
|
||||
/// inventories, quests, notes and contacts. Used at startup and as the
|
||||
/// fallback state.
|
||||
pub fn mock () -> Self {
|
||||
let (p_inv, c_inv) = generate_mock_inventory();
|
||||
|
||||
@ -54,8 +47,6 @@ impl Dashboard {
|
||||
|
||||
|
||||
|
||||
/// Demo inventories: 10 personal items and 10 common items, each with a
|
||||
/// weight, description and tags.
|
||||
fn generate_mock_inventory() -> (Inventory, Inventory) {
|
||||
let a = vec![
|
||||
InventoryEntry { id: 1, quantity: 1, item: InventoryItem { name: "Vorpal Sword".to_string(), weight: 400, description: Some("A blade that severs heads with a single swing.".to_string()), tags: HashMap::from([ ("type".to_string(), "weapon".to_string()), ("rarity".to_string(), "legendary".to_string()), ]), }, },
|
||||
@ -88,8 +79,6 @@ fn generate_mock_inventory() -> (Inventory, Inventory) {
|
||||
|
||||
|
||||
|
||||
/// Demo notes: one per session, with in-game dates, real timestamps and
|
||||
/// tags.
|
||||
fn generate_mock_notebook () -> NoteBook {
|
||||
NoteBook(vec![
|
||||
Note { session: 1, content: "Met Elminster in Waterdeep; hinted at Netherese ruin.".to_string(), date_ingame: NaiveDate::from_ymd_opt(1481, 4, 15).unwrap(), date_real: NaiveDateTime::parse_from_str("2025-09-01 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap(), tags: HashSet::from_iter(vec!["Elminster".to_string(), "Waterdeep".to_string()]) },
|
||||
@ -107,7 +96,6 @@ fn generate_mock_notebook () -> NoteBook {
|
||||
])
|
||||
}
|
||||
|
||||
/// Demo quests with givers, locations, dates and rewards.
|
||||
fn generate_mock_questbook () -> QuestBook {
|
||||
QuestBook(vec![
|
||||
Quest { title: "Slay the Bandit Chief".to_string(), location_taken: Some("Waterdeep".to_string()), location_task: Some("Phandalin".to_string()), date_taken: Some(NaiveDate::from_ymd_opt(1481, 4, 15).unwrap()), date_by: Some(NaiveDate::from_ymd_opt(1481, 5, 15).unwrap()), description: Some("Kill the bandit leader.".to_string()), from: Some(Name { first: "Dagult".to_string(), last: "Neverember".to_string(), alias: "".to_string(), ..Default::default() }), reward: Some(Balance { platinum: 0, gold: 100, electrum: 0, silver: 0, copper: 0 }) },
|
||||
@ -123,8 +111,6 @@ fn generate_mock_questbook () -> QuestBook {
|
||||
])
|
||||
}
|
||||
|
||||
/// Demo contacts: mostly NPCs met by the party, plus a few merchants,
|
||||
/// living legends and the dead.
|
||||
fn generate_mock_contact_book () -> ContactBook {
|
||||
let mut contacts = HashMap::new();
|
||||
// 40 Characters Met by Party (80%)
|
||||
@ -186,7 +172,6 @@ fn generate_mock_contact_book () -> ContactBook {
|
||||
}
|
||||
|
||||
|
||||
/// Demo prepared spells. Currently an empty list — `Spell` is a stub.
|
||||
fn generate_prepared_spells_list () -> PreparedSpells {
|
||||
use PreparedSpell::*;
|
||||
let list = vec![
|
||||
|
||||
@ -1,16 +1,3 @@
|
||||
//! 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. All of it currently comes from hardcoded mock data (the `mock`
|
||||
//! module); nothing is persisted yet (see ROADMAP phase 1).
|
||||
use std::fmt::Display;
|
||||
|
||||
use chrono::{ NaiveDate, NaiveDateTime };
|
||||
@ -20,10 +7,6 @@ use crate::prelude::*;
|
||||
|
||||
mod mock;
|
||||
|
||||
/// A single item as it appears in the bundled 5e.tools JSON files.
|
||||
///
|
||||
/// `rest` keeps every other JSON field verbatim, so the raw source data
|
||||
/// survives lookup (used by future item detail views).
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct BookItem {
|
||||
pub name: String,
|
||||
@ -33,8 +16,6 @@ pub struct BookItem {
|
||||
}
|
||||
|
||||
impl BookItem {
|
||||
/// Parses one 5e.tools JSON object into a `BookItem`.
|
||||
/// Returns `None` when the object has no string `name` or is not an object.
|
||||
pub fn from_json (t: Value) -> Option<Self> {
|
||||
let Value::String(name) = t["name"].clone() else { return None };
|
||||
let weight = match t["weight"].clone() {
|
||||
@ -55,17 +36,9 @@ impl BookItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Name → `BookItem` lookup, built once from the bundled JSON (see `statics`).
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct ItemReferenceMap (pub HashMap<String, BookItem>);
|
||||
|
||||
/// 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)]
|
||||
pub struct Dashboard {
|
||||
pub campaign: String,
|
||||
@ -80,9 +53,6 @@ pub struct Dashboard {
|
||||
pub settings: Settings
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct PlayerData {
|
||||
pub name: Name,
|
||||
@ -104,15 +74,12 @@ pub struct PlayerData {
|
||||
}
|
||||
|
||||
|
||||
/// The party's shared stash: money and items usable by everyone.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct CommonData {
|
||||
pub balance: Balance,
|
||||
pub inventory: Inventory,
|
||||
}
|
||||
|
||||
/// Currency counts per denomination, platinum through copper.
|
||||
/// Edited with `Numput` on the inventories page.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct Balance {
|
||||
pub platinum: u32,
|
||||
@ -122,17 +89,12 @@ pub struct Balance {
|
||||
pub copper: u32,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct Inventory {
|
||||
#[store(key: u64 = |row| row.id)]
|
||||
pub rows: Vec<InventoryEntry>
|
||||
}
|
||||
|
||||
/// One inventory row: a quantity of a single item.
|
||||
#[derive(Clone, Debug, Store, Default)]
|
||||
pub struct InventoryEntry {
|
||||
pub id: u64,
|
||||
@ -141,7 +103,6 @@ pub struct InventoryEntry {
|
||||
}
|
||||
|
||||
impl InventoryEntry {
|
||||
/// Returns a stable key string for this entry (quantity + item name).
|
||||
pub fn key (&self) -> String {
|
||||
format!("{}-{}", self.quantity, self.item.name)
|
||||
}
|
||||
@ -153,8 +114,6 @@ impl InventoryEntry {
|
||||
// Book (Item),
|
||||
// }
|
||||
|
||||
/// An item inside an inventory: name, weight (basis points), optional
|
||||
/// description, and free-form tags (e.g. `type`, `rarity`).
|
||||
#[derive(Clone, Debug, Store, Default)]
|
||||
pub struct InventoryItem {
|
||||
pub name: String,
|
||||
@ -163,12 +122,9 @@ pub struct InventoryItem {
|
||||
pub tags: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The party's quest list. No UI yet — see ROADMAP phase 4.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct QuestBook (pub Vec<Quest>);
|
||||
|
||||
/// One quest: title, locations, dates, description, giver and reward.
|
||||
/// Note: there is no completion state yet (ROADMAP phase 4 adds one).
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct Quest {
|
||||
pub title: String,
|
||||
@ -181,12 +137,9 @@ pub struct Quest {
|
||||
pub reward: Option<Balance>,
|
||||
}
|
||||
|
||||
/// The party's session notes. No UI yet — see ROADMAP phase 5.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct NoteBook (pub Vec<Note>);
|
||||
|
||||
/// One note: content, in-game date, real-world timestamp, session number
|
||||
/// and tags.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct Note {
|
||||
pub content: String,
|
||||
@ -196,43 +149,35 @@ pub struct Note {
|
||||
pub tags: HashSet<String>,
|
||||
}
|
||||
|
||||
/// Spell slots for the nine spell levels (index 0 = 1st level).
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct SpellSlots (pub [SpellSlotLevel; 9]);
|
||||
|
||||
/// Used/total slots for one spell level.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct SpellSlotLevel {
|
||||
pub used: u8,
|
||||
pub total: u8,
|
||||
}
|
||||
|
||||
/// Three attunement slots (D&D rules allow three attuned items).
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct Attunements (pub [Option<String>; 3]);
|
||||
|
||||
|
||||
/// The spell list the character has prepared (or is learning).
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct PreparedSpells (pub Vec<PreparedSpell>);
|
||||
|
||||
|
||||
/// One prepared-spell slot: vacant or holding a spell.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub enum PreparedSpell {
|
||||
Vacant,
|
||||
Occupied (Spell)
|
||||
}
|
||||
|
||||
/// A spell. Only the name exists so far; the rest is a `@TODO`
|
||||
/// (see ROADMAP phase 2).
|
||||
// @TODO
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct Spell {
|
||||
pub name: String
|
||||
}
|
||||
|
||||
/// Hit dice: how many of the current die kind are already spent.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct HitDice {
|
||||
pub used: u8,
|
||||
@ -240,8 +185,6 @@ pub struct HitDice {
|
||||
}
|
||||
|
||||
|
||||
/// Death-saving-throw counters: three failures kill, three successes
|
||||
/// stabilize.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct DeathSaveThrows {
|
||||
pub failed: u8,
|
||||
@ -249,18 +192,15 @@ pub struct DeathSaveThrows {
|
||||
}
|
||||
|
||||
impl DeathSaveThrows {
|
||||
/// Clears both counters. Bound to the ⟳ button in the sidebar.
|
||||
pub fn reset (&mut self) {
|
||||
self.failed = 0;
|
||||
self.succeeded = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Known NPCs keyed by name. No UI yet — see ROADMAP phase 6.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct ContactBook (pub HashMap<String, Contact>);
|
||||
|
||||
/// One NPC contact: name, optional image, status, location and notes.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub struct Contact {
|
||||
pub name: Name,
|
||||
@ -270,7 +210,6 @@ pub struct Contact {
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Life status of a contact, used for filtering.
|
||||
#[derive(Clone, Debug, Store)]
|
||||
pub enum ContactStatus {
|
||||
Unknown,
|
||||
@ -279,7 +218,6 @@ pub enum ContactStatus {
|
||||
Alive,
|
||||
}
|
||||
|
||||
/// A person's name: first, last, and an optional alias (nickname).
|
||||
#[derive(Clone, Debug, Store, Default)]
|
||||
pub struct Name {
|
||||
pub first: String,
|
||||
@ -288,31 +226,26 @@ pub struct Name {
|
||||
}
|
||||
|
||||
impl Name {
|
||||
/// True when an alias is set.
|
||||
pub fn display_alias (&self) -> bool {
|
||||
!self.alias.is_empty()
|
||||
}
|
||||
/// True when a last name is set.
|
||||
pub fn display_last (&self) -> bool {
|
||||
!self.last.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings. Empty today; will carry e.g. a persisted-format version.
|
||||
#[derive(Clone, Debug, Store, Default)]
|
||||
pub struct Settings {
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Which side of the party an inventory belongs to.
|
||||
#[derive(Clone, Copy, Debug, Store)]
|
||||
pub enum InventoryKind {
|
||||
Personal,
|
||||
Common
|
||||
}
|
||||
|
||||
/// Russian display name ("Личный" / "Общий").
|
||||
impl Display for InventoryKind {
|
||||
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", match self {
|
||||
@ -323,7 +256,6 @@ impl Display for InventoryKind {
|
||||
}
|
||||
|
||||
impl InventoryKind {
|
||||
/// The other inventory (for the move-between button).
|
||||
pub fn dst (&self) -> Self {
|
||||
match self {
|
||||
Self::Common => Self::Personal,
|
||||
@ -331,7 +263,6 @@ impl InventoryKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS classes for the inventory section (personal vs common styling).
|
||||
pub fn class (&self) -> &'static str {
|
||||
match self {
|
||||
Self::Common => "inventory inventory-common",
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
//! 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.
|
||||
#![feature(type_alias_impl_trait)]
|
||||
pub mod app;
|
||||
|
||||
@ -26,8 +21,6 @@ mod components;
|
||||
#[allow(unused_imports)]
|
||||
use prelude::*;
|
||||
|
||||
/// WASM entry point: hydrates `App` into the server-rendered DOM.
|
||||
/// Only compiled with the `hydrate` feature.
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
|
||||
10
src/main.rs
10
src/main.rs
@ -1,9 +1,4 @@
|
||||
|
||||
/// SSR entry point: starts the actix-web server that renders the app.
|
||||
///
|
||||
/// Serves the WASM/CSS bundle from `/pkg`, static assets from `/assets`,
|
||||
/// and renders every route through `leptos_routes`. Only compiled with
|
||||
/// the `ssr` feature.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
@ -63,7 +58,6 @@ async fn main() -> std::io::Result<()> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Serves `/favicon.ico` from the site root.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::get("favicon.ico")]
|
||||
async fn favicon(
|
||||
@ -76,8 +70,6 @@ async fn favicon(
|
||||
))?)
|
||||
}
|
||||
|
||||
/// No-op `main` for builds without `ssr` or `csr`: the WASM binary is
|
||||
/// hydrated from `lib.rs` instead.
|
||||
#[cfg(not(any(feature = "ssr", feature = "csr")))]
|
||||
pub fn main() {
|
||||
// no client-side main function
|
||||
@ -86,8 +78,6 @@ pub fn main() {
|
||||
// see optional feature `csr` instead
|
||||
}
|
||||
|
||||
/// Client-only `main` for `trunk serve --features csr`.
|
||||
/// Not the normal path — prefer `cargo leptos serve`.
|
||||
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
|
||||
pub fn main() {
|
||||
// a client-side main function is required for using `trunk serve`
|
||||
|
||||
@ -1,7 +1,3 @@
|
||||
//! Crate-wide re-exports.
|
||||
//!
|
||||
//! Components and entities use `use crate::prelude::*` to get the common
|
||||
//! imports: collections, logging, error aliases, `Store`, entities, utils.
|
||||
#![allow(unused_imports)]
|
||||
#![allow(dead_code)]
|
||||
pub use std::collections::{ HashMap, HashSet };
|
||||
@ -20,12 +16,6 @@ pub (crate) use crate::{
|
||||
components::{ self, numput::{ Numput, NumputChange } },
|
||||
};
|
||||
|
||||
/// Crate-wide error type. Alias of `anyhow::Error`.
|
||||
pub type Error = anyhow::Error;
|
||||
/// Crate-wide result type. Alias of `anyhow::Result<T>`.
|
||||
pub type Result <T> = anyhow::Result<T>;
|
||||
/// The reactive application state: a `Store` wrapping [`crate::entities::Dashboard`].
|
||||
///
|
||||
/// Created once in `App` and injected through Leptos context. Components
|
||||
/// read it with `expect_context::<Context>()` and grab reactive field handles.
|
||||
pub type Context = Store<Dashboard>;
|
||||
|
||||
@ -1,6 +1 @@
|
||||
//! App state.
|
||||
//!
|
||||
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
|
||||
//! (alias `Context`), created in `App` and shared through Leptos context.
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
@ -1,18 +1,8 @@
|
||||
//! Bundled static game data.
|
||||
//!
|
||||
//! `ITEMS_REFS` loads the 5e.tools-format JSON files (`data/*.json`,
|
||||
//! roughly 5k items) once at first use via `lazy_static` and indexes them
|
||||
//! by name. `ITEMS_NAMES` is the flat name list used for fuzzy search.
|
||||
use crate::prelude::*;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
|
||||
lazy_static! {
|
||||
/// All known items, indexed by name, from the bundled 5e.tools JSON files.
|
||||
///
|
||||
/// Built once at startup: entries with the same name are merged (a missing
|
||||
/// weight fills in, `rest` fields concatenate). Note `foundry.json` is
|
||||
/// currently loaded twice — see `utils::read_items`.
|
||||
pub static ref ITEMS_REFS: ItemReferenceMap = {
|
||||
let mut map = HashMap::with_capacity(5_000);
|
||||
|
||||
@ -27,8 +17,6 @@ lazy_static! {
|
||||
ItemReferenceMap(HashMap::from_iter(map.into_iter()))
|
||||
};
|
||||
|
||||
/// Flat list of item names; the feed for the fuzzy matcher
|
||||
/// (`ItemSearchField`).
|
||||
pub static ref ITEMS_NAMES: Vec<&'static String> = {
|
||||
ITEMS_REFS.0.keys().collect()
|
||||
};
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//! Small shared helpers: item parsing, checked input parsing, percentage
|
||||
//! math, and the `BasisPoints` display trait.
|
||||
use crate::prelude::*;
|
||||
use std::str::FromStr;
|
||||
use leptos::ev::{ Event, Targeted };
|
||||
@ -7,11 +5,6 @@ use leptos::web_sys::HtmlInputElement;
|
||||
use leptos::reactive::traits::{ Get, Set };
|
||||
|
||||
|
||||
/// Parses a 5e.tools-format JSON array of items into `map`, keyed by name.
|
||||
///
|
||||
/// Skips entries without a string `name` or without an object body.
|
||||
/// Duplicate names merge: the later entry's weight fills a missing one and
|
||||
/// its `rest` fields are appended. Returns the number of new names added.
|
||||
pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize {
|
||||
let Ok(json): std::result::Result<Value, _> = serde_json::from_str(&contents) else { error!("Can't read Value from contents"); return 0 };
|
||||
let Value::Array(list) = json else { error!("Value isn't Array, skipping"); return 0 };
|
||||
@ -31,10 +24,6 @@ pub fn read_items (contents: &str, map: &mut HashMap<String, BookItem>) -> usize
|
||||
map.len() - start
|
||||
}
|
||||
|
||||
/// Applies a text input's value to a reactive store with checked parsing.
|
||||
///
|
||||
/// On a successful parse, sets the store to the value; otherwise restores
|
||||
/// the input to the store's current value. Used for `type="number"` inputs.
|
||||
pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -> ()
|
||||
where
|
||||
U: Set<Value = T> + Get<Value = T>,
|
||||
@ -50,8 +39,6 @@ pub fn adjust_checked <T, U> (ev: Targeted<Event, HtmlInputElement>, store: U) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent of `cur` relative to `max`; callers clamp to 100. Used for
|
||||
/// HP-bar widths.
|
||||
pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
|
||||
let cur: f32 = cur.into();
|
||||
let max: f32 = max.into();
|
||||
@ -59,13 +46,10 @@ pub fn filled_pc <T> (cur: T, max: T) -> f32 where T: Into<f32> {
|
||||
}
|
||||
|
||||
|
||||
/// Formats an integer stored in basis points (hundredths) as a decimal
|
||||
/// string, e.g. `400` → `"4.00"`.
|
||||
pub trait BasisPoints {
|
||||
fn to_decimal_string (&self) -> String;
|
||||
}
|
||||
|
||||
/// Weights are stored as `u32` basis points: 100 = 1.0.
|
||||
impl BasisPoints for u32 {
|
||||
fn to_decimal_string (&self) -> String {
|
||||
let int = self / 100;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user