document every function, trait and struct in src/

Doc comments on all modules, components, entities, helpers and type
aliases; larger comments on the important entities (Dashboard, PlayerData,
Context, App, Numput, item data). No behavior changes.
This commit is contained in:
yaroslav k 2026-08-04 09:21:25 +03:00
parent da405758ce
commit 257f254b13
23 changed files with 220 additions and 0 deletions

View File

@ -17,6 +17,12 @@ use crate::{
prelude::* 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] #[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.

View File

@ -2,6 +2,9 @@ use crate::prelude::*;
use chrono::TimeDelta; use chrono::TimeDelta;
use leptos::prelude::*; 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] #[component]
pub fn Header () -> impl IntoView { pub fn Header () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -10,10 +13,12 @@ pub fn Header () -> impl IntoView {
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;

View File

@ -2,6 +2,9 @@ use leptos::{logging, prelude::*};
use reactive_stores::Field; use reactive_stores::Field;
use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* }; use crate::{ components::{ item::Item as ItemRow, numput::Numput }, prelude::* };
/// 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()),

View File

@ -3,6 +3,9 @@ use leptos::prelude::*;
use reactive_stores::{Field, OptionStoreExt}; 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] #[component]
pub fn Item ( pub fn Item (
idx: ReadSignal<usize>, idx: ReadSignal<usize>,
@ -16,10 +19,14 @@ pub fn 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();

View File

@ -1,3 +1,9 @@
//! 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 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;
@ -11,6 +17,8 @@ pub mod inventory;
pub mod numput; pub mod numput;
pub mod item; pub mod item;
/// Character page (route `/`). Stub: renders a placeholder until
/// ROADMAP phase 2.
#[component] #[component]
pub fn Character () -> impl IntoView { pub fn Character () -> impl IntoView {
view! { view! {
@ -18,6 +26,8 @@ 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] #[component]
pub fn Inventories () -> impl IntoView { pub fn Inventories () -> impl IntoView {
let state = expect_context::<Context>(); let state = expect_context::<Context>();
@ -40,6 +50,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 +59,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 +103,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()));

View File

@ -2,6 +2,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! {

View File

@ -5,6 +5,11 @@ 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,

View File

@ -1,3 +1,5 @@
//! Character sidebar: portrait, names, class/level/XP, HP, hit dice,
//! death saves and spell slots.
use crate::prelude::*; use crate::prelude::*;
use leptos::prelude::*; use leptos::prelude::*;
@ -9,6 +11,8 @@ 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! {

View File

@ -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);
}; };

View File

@ -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 };

View File

@ -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 {

View File

@ -1,6 +1,8 @@
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>();
@ -10,6 +12,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);
}; };

View File

@ -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>();

View File

@ -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>();

View File

@ -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];

View File

@ -1,7 +1,14 @@
//! Hardcoded demo data.
//!
//! `Dashboard::mock()` is the app's only state source until persistence
//! lands (ROADMAP phase 1).
use crate::prelude::*; use 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();
@ -47,6 +54,8 @@ impl Dashboard {
/// 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 +88,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 +107,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 +123,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,6 +186,7 @@ fn generate_mock_contact_book () -> ContactBook {
} }
/// Demo prepared spells. Currently an empty list — `Spell` is a stub.
fn generate_prepared_spells_list () -> PreparedSpells { fn generate_prepared_spells_list () -> PreparedSpells {
use PreparedSpell::*; use PreparedSpell::*;
let list = vec![ let list = vec![

View File

@ -1,3 +1,16 @@
//! 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 std::fmt::Display;
use chrono::{ NaiveDate, NaiveDateTime }; use chrono::{ NaiveDate, NaiveDateTime };
@ -7,6 +20,10 @@ 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 +33,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,9 +55,17 @@ 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>);
/// 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)] #[derive(Clone, Debug, Store)]
pub struct Dashboard { pub struct Dashboard {
pub campaign: String, pub campaign: String,
@ -53,6 +80,9 @@ pub struct Dashboard {
pub settings: Settings 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)] #[derive(Clone, Debug, Store)]
pub struct PlayerData { pub struct PlayerData {
pub name: Name, pub name: Name,
@ -74,12 +104,15 @@ pub struct PlayerData {
} }
/// The party's shared stash: money and items usable by everyone.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct CommonData { pub struct CommonData {
pub balance: Balance, pub balance: Balance,
pub inventory: Inventory, pub inventory: Inventory,
} }
/// Currency counts per denomination, platinum through copper.
/// Edited with `Numput` on the inventories page.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Balance { pub struct Balance {
pub platinum: u32, pub platinum: u32,
@ -89,12 +122,17 @@ pub struct Balance {
pub copper: u32, 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)] #[derive(Clone, Debug, Store)]
pub struct Inventory { pub struct Inventory {
#[store(key: u64 = |row| row.id)] #[store(key: u64 = |row| row.id)]
pub rows: Vec<InventoryEntry> pub rows: Vec<InventoryEntry>
} }
/// One inventory row: a quantity of a single item.
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct InventoryEntry { pub struct InventoryEntry {
pub id: u64, pub id: u64,
@ -103,6 +141,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,6 +153,8 @@ impl InventoryEntry {
// Book (Item), // 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)] #[derive(Clone, Debug, Store, Default)]
pub struct InventoryItem { pub struct InventoryItem {
pub name: String, pub name: String,
@ -122,9 +163,12 @@ pub struct InventoryItem {
pub tags: HashMap<String, String>, pub tags: HashMap<String, String>,
} }
/// The party's quest list. No UI yet — see ROADMAP phase 4.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct QuestBook (pub Vec<Quest>); pub struct QuestBook (pub Vec<Quest>);
/// One quest: title, locations, dates, description, giver and reward.
/// Note: there is no completion state yet (ROADMAP phase 4 adds one).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Quest { pub struct Quest {
pub title: String, pub title: String,
@ -137,9 +181,12 @@ pub struct Quest {
pub reward: Option<Balance>, pub reward: Option<Balance>,
} }
/// The party's session notes. No UI yet — see ROADMAP phase 5.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct NoteBook (pub Vec<Note>); pub struct NoteBook (pub Vec<Note>);
/// One note: content, in-game date, real-world timestamp, session number
/// and tags.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Note { pub struct Note {
pub content: String, pub content: String,
@ -149,35 +196,43 @@ pub struct Note {
pub tags: HashSet<String>, pub tags: HashSet<String>,
} }
/// Spell slots for the nine spell levels (index 0 = 1st level).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct SpellSlots (pub [SpellSlotLevel; 9]); pub struct SpellSlots (pub [SpellSlotLevel; 9]);
/// Used/total slots for one spell level.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct SpellSlotLevel { pub struct SpellSlotLevel {
pub used: u8, pub used: u8,
pub total: u8, pub total: u8,
} }
/// Three attunement slots (D&D rules allow three attuned items).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Attunements (pub [Option<String>; 3]); pub struct Attunements (pub [Option<String>; 3]);
/// The spell list the character has prepared (or is learning).
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct PreparedSpells (pub Vec<PreparedSpell>); pub struct PreparedSpells (pub Vec<PreparedSpell>);
/// One prepared-spell slot: vacant or holding a spell.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub enum PreparedSpell { pub enum PreparedSpell {
Vacant, Vacant,
Occupied (Spell) Occupied (Spell)
} }
/// A spell. Only the name exists so far; the rest is a `@TODO`
/// (see ROADMAP phase 2).
// @TODO // @TODO
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Spell { pub struct Spell {
pub name: String pub name: String
} }
/// Hit dice: how many of the current die kind are already spent.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct HitDice { pub struct HitDice {
pub used: u8, pub used: u8,
@ -185,6 +240,8 @@ pub struct HitDice {
} }
/// Death-saving-throw counters: three failures kill, three successes
/// stabilize.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct DeathSaveThrows { pub struct DeathSaveThrows {
pub failed: u8, pub failed: u8,
@ -192,15 +249,18 @@ pub struct DeathSaveThrows {
} }
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;
} }
} }
/// Known NPCs keyed by name. No UI yet — see ROADMAP phase 6.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct ContactBook (pub HashMap<String, Contact>); pub struct ContactBook (pub HashMap<String, Contact>);
/// One NPC contact: name, optional image, status, location and notes.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub struct Contact { pub struct Contact {
pub name: Name, pub name: Name,
@ -210,6 +270,7 @@ pub struct Contact {
pub notes: Option<String>, pub notes: Option<String>,
} }
/// Life status of a contact, used for filtering.
#[derive(Clone, Debug, Store)] #[derive(Clone, Debug, Store)]
pub enum ContactStatus { pub enum ContactStatus {
Unknown, Unknown,
@ -218,6 +279,7 @@ pub enum ContactStatus {
Alive, Alive,
} }
/// A person's name: first, last, and an optional alias (nickname).
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct Name { pub struct Name {
pub first: String, pub first: String,
@ -226,26 +288,31 @@ 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()
} }
} }
/// App settings. Empty today; will carry e.g. a persisted-format version.
#[derive(Clone, Debug, Store, Default)] #[derive(Clone, Debug, Store, Default)]
pub struct Settings { pub struct Settings {
} }
/// Which side of the party an inventory belongs to.
#[derive(Clone, Copy, Debug, Store)] #[derive(Clone, Copy, Debug, Store)]
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 +323,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 +331,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",

View File

@ -1,3 +1,8 @@
//! 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)] #![feature(type_alias_impl_trait)]
pub mod app; pub mod app;
@ -21,6 +26,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() {

View File

@ -1,4 +1,9 @@
/// SSR entry point: starts the actix-web server that renders the app.
///
/// Serves the WASM/CSS bundle from `/pkg`, static assets from `/assets`,
/// and renders every route through `leptos_routes`. Only compiled with
/// the `ssr` feature.
#[cfg(feature = "ssr")] #[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`

View File

@ -1,3 +1,7 @@
//! Crate-wide re-exports.
//!
//! Components and entities use `use crate::prelude::*` to get the common
//! imports: collections, logging, error aliases, `Store`, entities, utils.
#![allow(unused_imports)] #![allow(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>;

View File

@ -1 +1,6 @@
//! 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::*; use crate::prelude::*;

View File

@ -1,8 +1,18 @@
//! 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 crate::prelude::*;
use lazy_static::lazy_static; use lazy_static::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 = { pub static ref ITEMS_REFS: ItemReferenceMap = {
let mut map = HashMap::with_capacity(5_000); let mut map = HashMap::with_capacity(5_000);
@ -17,6 +27,8 @@ lazy_static! {
ItemReferenceMap(HashMap::from_iter(map.into_iter())) 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> = { pub static ref ITEMS_NAMES: Vec<&'static String> = {
ITEMS_REFS.0.keys().collect() ITEMS_REFS.0.keys().collect()
}; };

View File

@ -1,3 +1,5 @@
//! Small shared helpers: item parsing, checked input parsing, percentage
//! math, and the `BasisPoints` display trait.
use crate::prelude::*; use 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;