diff --git a/src/components/character/attunements.rs b/src/components/character/attunements.rs new file mode 100644 index 0000000..65379df --- /dev/null +++ b/src/components/character/attunements.rs @@ -0,0 +1,65 @@ +use reactive_stores::Field; + +use crate::prelude::*; +use leptos::prelude::*; + +/// The three attunement slots: each is either empty (free-text input and +/// "+" button) or holds an item name with a "×" clear button. +#[component] +pub fn Attunements () -> impl IntoView { + let state = expect_context::(); + let attunements: Field = state.player().attunements().into(); + + view! { +
+
"attunements/слоты привязки:"
+ + {slot_row(attunements, idx, slot)} + +
+ } +} + +/// One attunement slot: the free-text entry (empty) or the item itself. +/// A plain function: component tags are not allowed directly inside +/// `For`'s `let(...)` children. +fn slot_row ( + attunements: Field, + idx: usize, + slot: Option, +) -> impl IntoView { + let text_sig = RwSignal::new(String::new()); + + // Binds the typed item name; empty text is ignored. + let bind = move |_| { + let text = text_sig.get_untracked(); + if text.trim().is_empty() { + return; + } + attunements.update(|a| a.0[idx] = Some(text.trim().to_string())); + }; + + let clear = move |_| attunements.update(|a| a.0[idx] = None); + + view! { +
+ {if slot.is_some() { + view! { +
{slot.clone().unwrap_or_default()}
+ + }.into_any() + } else { + view! { + + + }.into_any() + }} +
+ } +} diff --git a/src/components/character/mod.rs b/src/components/character/mod.rs new file mode 100644 index 0000000..b1b834a --- /dev/null +++ b/src/components/character/mod.rs @@ -0,0 +1,8 @@ +//! Character page blocks (route `/`): player card, prepared spells and +//! attunements. The sidebar already carries the detailed editors (names, +//! class/level/XP, HP, hit dice, death saves, spell slots); this module +//! renders the page-level view of that data. + +pub mod attunements; +pub mod player_card; +pub mod prepared_spells; diff --git a/src/components/character/player_card.rs b/src/components/character/player_card.rs new file mode 100644 index 0000000..29129d0 --- /dev/null +++ b/src/components/character/player_card.rs @@ -0,0 +1,45 @@ +use crate::prelude::*; +use leptos::prelude::*; + +/// Read-only player summary: portrait, name, class, level and XP. +/// +/// The sidebar holds the editors for this data (names, about); this card +/// presents it at the top of the character page. +#[component] +pub fn PlayerCard () -> impl IntoView { + let state = expect_context::(); + let player = state.player(); + + let image = player.image(); + let name = player.name(); + let class = player.class(); + let level = player.level(); + let xp = player.xp(); + + // "First «alias» Last" with the optional parts dropped when empty. + let display_name = move || { + let n = name.get(); + let mut s = n.first.clone(); + if n.display_alias() { + s.push_str(&format!(" «{}»", n.alias)); + } + if n.display_last() { + s.push_str(&format!(" {}", n.last)); + } + s + }; + + view! { +
+ + portrait + +
+
{display_name}
+
{move || class.get()}
+
{move || format!("уровень {}", level.get())}
+
{move || format!("{} XP", xp.get())}
+
+
+ } +} diff --git a/src/components/character/prepared_spells.rs b/src/components/character/prepared_spells.rs new file mode 100644 index 0000000..6a26100 --- /dev/null +++ b/src/components/character/prepared_spells.rs @@ -0,0 +1,96 @@ +use reactive_stores::Field; + +use crate::prelude::*; +use leptos::prelude::*; + +/// Prepared spells: a growable list of vacant or occupied slots. +/// +/// A vacant slot offers a free-text input and a "+" button; entering a +/// name fills the slot with a spell. An occupied slot shows the name +/// (plus level and description when set) and a "×" button that empties +/// it back to vacant. "+ слот" appends a new vacant slot. +#[component] +pub fn PreparedSpells () -> impl IntoView { + let state = expect_context::(); + let prepared: Field = state.player().prepared().into(); + + view! { +
+
"prepared spells/подготовленные заклинания:"
+ "vacant".to_string(), + PreparedSpell::Occupied(spell) => spell.name.clone(), + }) + let((idx, slot)) + > + {slot_row(prepared, idx, slot)} + + +
+ } +} + +/// One prepared-spell slot: the free-text entry (vacant) or the spell +/// itself (occupied). A plain function: component tags are not allowed +/// directly inside `For`'s `let(...)` children. +fn slot_row ( + prepared: Field, + idx: usize, + slot: PreparedSpell, +) -> impl IntoView { + let name_sig = RwSignal::new(String::new()); + + // Fills this slot with the typed name; empty text is ignored. + let add = move |_| { + let text = name_sig.get_untracked(); + if text.trim().is_empty() { + return; + } + prepared.update(|p| { + p.0[idx] = PreparedSpell::Occupied(Spell { + name: text.trim().to_string(), + ..Default::default() + }); + }); + }; + + let remove = move |_| prepared.update(|p| p.0[idx] = PreparedSpell::Vacant); + + let vacant = matches!(slot, PreparedSpell::Vacant); + + view! { +
+ {if vacant { + view! { + + + }.into_any() + } else { + let spell = match slot { + PreparedSpell::Occupied(spell) => spell, + PreparedSpell::Vacant => unreachable!(), + }; + view! { +
{spell.name.clone()}
+
{move || spell.level.clone().map(|l| format!("ур. {l}")).unwrap_or_default()}
+
{move || spell.description.clone().unwrap_or_default()}
+ + }.into_any() + }} +
+ } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index b50702e..262476b 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -15,6 +15,7 @@ use reactive_stores::Field; use crate::prelude::*; +pub mod character; pub mod header; pub mod sidebar; pub mod nav; @@ -23,12 +24,17 @@ pub mod numput; pub mod item; pub mod modal; -/// Character page (route `/`). Stub: renders a placeholder until -/// ROADMAP phase 2. +/// Character page (route `/`): player card, prepared spells and +/// attunements (ROADMAP phase 2). The sidebar carries the detailed +/// editors; the HP block with its Numput-style inputs lives there too. #[component] pub fn Character () -> impl IntoView { view! { - character +
+ + + +
} } diff --git a/style/_character.scss b/style/_character.scss new file mode 100644 index 0000000..3f65f4c --- /dev/null +++ b/style/_character.scss @@ -0,0 +1,112 @@ +@use 'mixins'; + +.character-page { + display: flex; + flex-direction: column; + gap: 24px; + padding: 10px 24px; + overflow-y: auto; +} + +.player-card { + display: flex; + align-items: center; + gap: 18px; + + img { + width: 92px; + height: 92px; + object-fit: cover; + border-radius: 6px; + box-shadow: 0 0 0 2px crimson; + } + + .player-meta { + display: flex; + flex-direction: column; + gap: 2px; + } + + .player-name { + font-size: 1.5em; + font-weight: 900; + } + + .player-class, .player-level, .player-xp { + font-weight: 300; + opacity: 0.8; + } +} + +.prepared-spells, .attunements { + h6 { + text-transform: lowercase; + font-size: 1.1em; + margin: 0 0 10px; + font-weight: 900; + &::first-letter { + text-decoration: underline crimson; + } + } + + .prepared-slot, .attunement-slot { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 0; + + input { + flex: 1; + max-width: 340px; + border: none; + color: white; + background: rgba(255, 255, 255, 0.06); + padding: 3px 8px; + + &:focus { + outline: 1px solid crimson; + } + } + + button { + @include mixins.button; + width: 34px; + height: 30px; + border: 0; + line-height: 1; + } + } + + .prepared-slot { + &.occupied { + .spell-name { + font-weight: 900; + min-width: 200px; + } + } + + .spell-level, .spell-desc { + font-weight: 300; + opacity: 0.75; + font-size: 0.85em; + } + + .spell-level { + min-width: 44px; + } + } + + .attuned-item { + font-weight: 900; + min-width: 200px; + } +} + +.prepared-spells { + .add-slot { + @include mixins.button; + margin-top: 10px; + border: 0; + padding: 3px 14px; + } +} diff --git a/style/main.scss b/style/main.scss index 3236259..27ca37b 100644 --- a/style/main.scss +++ b/style/main.scss @@ -3,6 +3,7 @@ @use 'reset'; @use 'mixins'; @use 'header'; +@use 'character'; @use 'sidebar'; @use 'nav'; @use 'inventory';