character page: player card, prepared spells, attunements
- PlayerCard: read-only portrait, name, class, level, XP (the sidebar holds the editors for this data) - PreparedSpells: growable slot list; a vacant slot has a free-text input and '+' to fill it, an occupied slot shows name/level/ description and '×' to empty it; '+ слот' appends a vacant slot - Attunements: three slots, free-text entry and per-slot clear - rows are plain functions called from For's let(...) children — component tags are not allowed directly inside them (view macro) - Field<...> conversions for the Subfield prop types, state-in-key keys so changed rows re-render (same convention as sidebar pips) - SCSS: _character.scss with card, slot rows, buttons (mixins)
This commit is contained in:
parent
c825b867d1
commit
fddd97d926
65
src/components/character/attunements.rs
Normal file
65
src/components/character/attunements.rs
Normal file
@ -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::<Context>();
|
||||||
|
let attunements: Field<Attunements> = state.player().attunements().into();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="attunements">
|
||||||
|
<h6>"attunements/слоты привязки:"</h6>
|
||||||
|
<For
|
||||||
|
// Same state-in-key convention as prepared spells, so a
|
||||||
|
// slot whose value changed re-renders.
|
||||||
|
each=move || attunements.get().0.into_iter().enumerate()
|
||||||
|
key=|(i, s)| format!("{}-{}", i, s.as_deref().unwrap_or(""))
|
||||||
|
let((idx, slot))
|
||||||
|
>
|
||||||
|
{slot_row(attunements, idx, slot)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One attunement slot: the free-text entry (empty) or the item itself.
|
||||||
|
/// A plain function: component tags are not allowed directly inside
|
||||||
|
/// `For`'s `let(...)` children.
|
||||||
|
fn slot_row (
|
||||||
|
attunements: Field<Attunements>,
|
||||||
|
idx: usize,
|
||||||
|
slot: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let text_sig = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Binds the typed item name; empty text is ignored.
|
||||||
|
let bind = move |_| {
|
||||||
|
let text = text_sig.get_untracked();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attunements.update(|a| a.0[idx] = Some(text.trim().to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let clear = move |_| attunements.update(|a| a.0[idx] = None);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="attunement-slot">
|
||||||
|
{if slot.is_some() {
|
||||||
|
view! {
|
||||||
|
<div class="attuned-item">{slot.clone().unwrap_or_default()}</div>
|
||||||
|
<button title="Снять привязку" on:click=clear>"×"</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<input type="text" placeholder="Предмет…" bind:value=text_sig />
|
||||||
|
<button title="Привязать предмет" on:click=bind>"+"</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/components/character/mod.rs
Normal file
8
src/components/character/mod.rs
Normal file
@ -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;
|
||||||
45
src/components/character/player_card.rs
Normal file
45
src/components/character/player_card.rs
Normal file
@ -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::<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! {
|
||||||
|
<div class="player-card">
|
||||||
|
<Show when=move || !image.get().is_empty()>
|
||||||
|
<img src=move || image.get() alt="portrait" />
|
||||||
|
</Show>
|
||||||
|
<div class="player-meta">
|
||||||
|
<div class="player-name">{display_name}</div>
|
||||||
|
<div class="player-class">{move || class.get()}</div>
|
||||||
|
<div class="player-level">{move || format!("уровень {}", level.get())}</div>
|
||||||
|
<div class="player-xp">{move || format!("{} XP", xp.get())}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/components/character/prepared_spells.rs
Normal file
96
src/components/character/prepared_spells.rs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
use reactive_stores::Field;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Prepared spells: a growable list of vacant or occupied slots.
|
||||||
|
///
|
||||||
|
/// A vacant slot offers a free-text input and a "+" button; entering a
|
||||||
|
/// name fills the slot with a spell. An occupied slot shows the name
|
||||||
|
/// (plus level and description when set) and a "×" button that empties
|
||||||
|
/// it back to vacant. "+ слот" appends a new vacant slot.
|
||||||
|
#[component]
|
||||||
|
pub fn PreparedSpells () -> impl IntoView {
|
||||||
|
let state = expect_context::<Context>();
|
||||||
|
let prepared: Field<PreparedSpells> = state.player().prepared().into();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="prepared-spells">
|
||||||
|
<h6>"prepared spells/подготовленные заклинания:"</h6>
|
||||||
|
<For
|
||||||
|
// The key carries the slot state, so a slot whose state
|
||||||
|
// changed re-renders (same convention as sidebar pips).
|
||||||
|
each=move || prepared.get().0.into_iter().enumerate()
|
||||||
|
key=|(i, s)| format!("{}-{}", i, match &s {
|
||||||
|
PreparedSpell::Vacant => "vacant".to_string(),
|
||||||
|
PreparedSpell::Occupied(spell) => spell.name.clone(),
|
||||||
|
})
|
||||||
|
let((idx, slot))
|
||||||
|
>
|
||||||
|
{slot_row(prepared, idx, slot)}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
class="add-slot"
|
||||||
|
title="Добавить слот заклинания"
|
||||||
|
on:click=move |_| prepared.update(|p| p.0.push(PreparedSpell::Vacant))
|
||||||
|
>
|
||||||
|
"+ слот"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One prepared-spell slot: the free-text entry (vacant) or the spell
|
||||||
|
/// itself (occupied). A plain function: component tags are not allowed
|
||||||
|
/// directly inside `For`'s `let(...)` children.
|
||||||
|
fn slot_row (
|
||||||
|
prepared: Field<PreparedSpells>,
|
||||||
|
idx: usize,
|
||||||
|
slot: PreparedSpell,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let name_sig = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Fills this slot with the typed name; empty text is ignored.
|
||||||
|
let add = move |_| {
|
||||||
|
let text = name_sig.get_untracked();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prepared.update(|p| {
|
||||||
|
p.0[idx] = PreparedSpell::Occupied(Spell {
|
||||||
|
name: text.trim().to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let remove = move |_| prepared.update(|p| p.0[idx] = PreparedSpell::Vacant);
|
||||||
|
|
||||||
|
let vacant = matches!(slot, PreparedSpell::Vacant);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=format!("prepared-slot {}", if vacant { "vacant" } else { "occupied" })>
|
||||||
|
{if vacant {
|
||||||
|
view! {
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Название заклинания…"
|
||||||
|
bind:value=name_sig
|
||||||
|
/>
|
||||||
|
<button title="Записать заклинание" on:click=add>"+"</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
let spell = match slot {
|
||||||
|
PreparedSpell::Occupied(spell) => spell,
|
||||||
|
PreparedSpell::Vacant => unreachable!(),
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<div class="spell-name">{spell.name.clone()}</div>
|
||||||
|
<div class="spell-level">{move || spell.level.clone().map(|l| format!("ур. {l}")).unwrap_or_default()}</div>
|
||||||
|
<div class="spell-desc">{move || spell.description.clone().unwrap_or_default()}</div>
|
||||||
|
<button title="Убрать заклинание" on:click=remove>"×"</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ use reactive_stores::Field;
|
|||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub mod character;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
@ -23,12 +24,17 @@ pub mod numput;
|
|||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
|
||||||
/// Character page (route `/`). Stub: renders a placeholder until
|
/// Character page (route `/`): player card, prepared spells and
|
||||||
/// ROADMAP phase 2.
|
/// attunements (ROADMAP phase 2). The sidebar carries the detailed
|
||||||
|
/// editors; the HP block with its Numput-style inputs lives there too.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Character () -> impl IntoView {
|
pub fn Character () -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
character
|
<div class="character-page">
|
||||||
|
<character::player_card::PlayerCard />
|
||||||
|
<character::prepared_spells::PreparedSpells />
|
||||||
|
<character::attunements::Attunements />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
style/_character.scss
Normal file
112
style/_character.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
@use 'reset';
|
@use 'reset';
|
||||||
@use 'mixins';
|
@use 'mixins';
|
||||||
@use 'header';
|
@use 'header';
|
||||||
|
@use 'character';
|
||||||
@use 'sidebar';
|
@use 'sidebar';
|
||||||
@use 'nav';
|
@use 'nav';
|
||||||
@use 'inventory';
|
@use 'inventory';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user