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::*;
|
||||
|
||||
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
|
||||
<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 'mixins';
|
||||
@use 'header';
|
||||
@use 'character';
|
||||
@use 'sidebar';
|
||||
@use 'nav';
|
||||
@use 'inventory';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user