fix: avatar center to cursor, and cleanup.

Lots of cleanup, went in with this too
This commit is contained in:
Evan Carroll 2026-01-17 22:32:34 -06:00
parent c3320ddcce
commit fe65835f4a
14 changed files with 769 additions and 708 deletions

View file

@ -15,6 +15,7 @@ pub mod modals;
pub mod scene_viewer; pub mod scene_viewer;
pub mod settings; pub mod settings;
pub mod settings_popup; pub mod settings_popup;
pub mod tabs;
pub mod ws_client; pub mod ws_client;
pub use avatar_canvas::*; pub use avatar_canvas::*;
@ -32,4 +33,5 @@ pub use modals::*;
pub use scene_viewer::*; pub use scene_viewer::*;
pub use settings::*; pub use settings::*;
pub use settings_popup::*; pub use settings_popup::*;
pub use tabs::*;
pub use ws_client::*; pub use ws_client::*;

View file

@ -14,6 +14,75 @@ use super::chat_types::{emotion_bubble_colors, ActiveBubble};
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
const BASE_TEXT_SCALE: f64 = 1.4; const BASE_TEXT_SCALE: f64 = 1.4;
/// Content bounds for a 3x3 avatar grid.
/// Tracks which rows/columns contain actual content for centering calculations.
struct ContentBounds {
min_col: usize,
max_col: usize,
min_row: usize,
max_row: usize,
}
impl ContentBounds {
/// Calculate content bounds from 4 layers (9 positions each).
fn from_layers(
skin: &[Option<String>; 9],
clothes: &[Option<String>; 9],
accessories: &[Option<String>; 9],
emotion: &[Option<String>; 9],
) -> Self {
let has_content_at = |pos: usize| -> bool {
skin[pos].is_some()
|| clothes[pos].is_some()
|| accessories[pos].is_some()
|| emotion[pos].is_some()
};
// Columns: 0 (left), 1 (middle), 2 (right)
let left_col = [0, 3, 6].iter().any(|&p| has_content_at(p));
let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p));
let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p));
let min_col = if left_col { 0 } else if mid_col { 1 } else { 2 };
let max_col = if right_col { 2 } else if mid_col { 1 } else { 0 };
// Rows: 0 (top), 1 (middle), 2 (bottom)
let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p));
let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p));
let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p));
let min_row = if top_row { 0 } else if mid_row { 1 } else { 2 };
let max_row = if bot_row { 2 } else if mid_row { 1 } else { 0 };
Self { min_col, max_col, min_row, max_row }
}
/// Content center column (0.0 to 2.0, grid center is 1.0).
fn center_col(&self) -> f64 {
(self.min_col + self.max_col) as f64 / 2.0
}
/// Content center row (0.0 to 2.0, grid center is 1.0).
fn center_row(&self) -> f64 {
(self.min_row + self.max_row) as f64 / 2.0
}
/// X offset from grid center to content center.
fn x_offset(&self, cell_size: f64) -> f64 {
(self.center_col() - 1.0) * cell_size
}
/// Y offset from grid center to content center.
fn y_offset(&self, cell_size: f64) -> f64 {
(self.center_row() - 1.0) * cell_size
}
/// Number of empty rows at the bottom (for name positioning).
fn empty_bottom_rows(&self) -> usize {
2 - self.max_row
}
}
/// Get a unique key for a member (for Leptos For keying). /// Get a unique key for a member (for Leptos For keying).
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) { pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
(m.member.user_id, m.member.guest_session_id) (m.member.user_id, m.member.guest_session_id)
@ -57,39 +126,27 @@ pub fn AvatarCanvas(
let display_name = member.member.display_name.clone(); let display_name = member.member.display_name.clone();
let current_emotion = member.member.current_emotion; let current_emotion = member.member.current_emotion;
// Helper to check if any layer has content at a position // Calculate content bounds for centering on actual content
let has_content_at = |pos: usize| -> bool { let content_bounds = ContentBounds::from_layers(
skin_layer[pos].is_some() &skin_layer,
|| clothes_layer[pos].is_some() &clothes_layer,
|| accessories_layer[pos].is_some() &accessories_layer,
|| emotion_layer[pos].is_some() &emotion_layer,
}; );
// Calculate content bounds for positioning // Get offsets from grid center to content center
// X-axis: which columns have content let x_content_offset = content_bounds.x_offset(prop_size);
let left_col_has_content = [0, 3, 6].iter().any(|&p| has_content_at(p)); let y_content_offset = content_bounds.y_offset(prop_size);
let middle_col_has_content = [1, 4, 7].iter().any(|&p| has_content_at(p)); let empty_bottom_rows = content_bounds.empty_bottom_rows();
let right_col_has_content = [2, 5, 8].iter().any(|&p| has_content_at(p));
let min_col = if left_col_has_content { 0 } else if middle_col_has_content { 1 } else { 2 };
let max_col = if right_col_has_content { 2 } else if middle_col_has_content { 1 } else { 0 };
let content_center_col = (min_col + max_col) as f64 / 2.0;
let x_content_offset = (content_center_col - 1.0) * prop_size;
// Y-axis: which rows have content
let bottom_row_has_content = [6, 7, 8].iter().any(|&p| has_content_at(p));
let middle_row_has_content = [3, 4, 5].iter().any(|&p| has_content_at(p));
let max_row = if bottom_row_has_content { 2 } else if middle_row_has_content { 1 } else { 0 };
let empty_bottom_rows = 2 - max_row;
let y_content_offset = empty_bottom_rows as f64 * prop_size;
// Avatar is a 3x3 grid of props, each prop is prop_size // Avatar is a 3x3 grid of props, each prop is prop_size
let avatar_size = prop_size * 3.0; let avatar_size = prop_size * 3.0;
// Calculate canvas position from scene coordinates, adjusted for content bounds // Calculate canvas position from scene coordinates, adjusted for content bounds
let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset * scale_x; // Both X and Y center the avatar content on the click point
let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size + y_content_offset * scale_y; // Note: x_content_offset and y_content_offset are already in viewport pixels (prop_size includes scale)
let canvas_x = member.member.position_x * scale_x + offset_x - avatar_size / 2.0 - x_content_offset;
let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size / 2.0 - y_content_offset;
// Fixed text dimensions (independent of prop_size/zoom) // Fixed text dimensions (independent of prop_size/zoom)
// Text stays readable regardless of zoom level - only affected by text_em_size slider // Text stays readable regardless of zoom level - only affected by text_em_size slider
@ -289,44 +346,15 @@ pub fn AvatarCanvas(
let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y);
} }
// Helper to check if any layer has content at a position // Calculate content bounds for name positioning
let has_content_at = |pos: usize| -> bool { let content_bounds = ContentBounds::from_layers(
skin_layer_clone[pos].is_some() &skin_layer_clone,
|| clothes_layer_clone[pos].is_some() &clothes_layer_clone,
|| accessories_layer_clone[pos].is_some() &accessories_layer_clone,
|| emotion_layer_clone[pos].is_some() &emotion_layer_clone,
}; );
let name_x = avatar_cx + content_bounds.x_offset(cell_size);
// Calculate empty bottom rows to adjust name Y position let empty_bottom_rows = content_bounds.empty_bottom_rows();
let mut empty_bottom_rows = 0;
// Check row 2 (positions 6, 7, 8)
let row2_has_content = (6..=8).any(&has_content_at);
if !row2_has_content {
empty_bottom_rows += 1;
// Check row 1 (positions 3, 4, 5)
let row1_has_content = (3..=5).any(&has_content_at);
if !row1_has_content {
empty_bottom_rows += 1;
}
}
// Calculate X offset to center name on columns with content
// Column 0 (left): positions 0, 3, 6
// Column 1 (middle): positions 1, 4, 7
// Column 2 (right): positions 2, 5, 8
let left_col_has_content = [0, 3, 6].iter().any(|&p| has_content_at(p));
let middle_col_has_content = [1, 4, 7].iter().any(|&p| has_content_at(p));
let right_col_has_content = [2, 5, 8].iter().any(|&p| has_content_at(p));
// Find leftmost and rightmost columns with content
let min_col = if left_col_has_content { 0 } else if middle_col_has_content { 1 } else { 2 };
let max_col = if right_col_has_content { 2 } else if middle_col_has_content { 1 } else { 0 };
// Calculate center of content columns (grid center is column 1)
let content_center_col = (min_col + max_col) as f64 / 2.0;
let x_offset = (content_center_col - 1.0) * cell_size;
let name_x = avatar_cx + x_offset;
// Draw display name below avatar (with black outline for readability) // Draw display name below avatar (with black outline for readability)
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));

View file

@ -13,6 +13,9 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem};
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::ws_client::WsSenderStorage; use super::ws_client::WsSenderStorage;
#[cfg(feature = "hydrate")]
use crate::utils::normalize_asset_path;
use crate::utils::use_escape_key;
/// Tab selection for the editor /// Tab selection for the editor
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@ -203,16 +206,6 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
} }
} }
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
#[cfg(feature = "hydrate")]
fn normalize_asset_path(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/static/{}", path)
}
}
/// Avatar Editor popup component. /// Avatar Editor popup component.
/// ///
/// Props: /// Props:
@ -312,33 +305,7 @@ pub fn AvatarEditorPopup(
} }
// Handle escape key to close // Handle escape key to close
#[cfg(feature = "hydrate")] use_escape_key(open, on_close.clone());
{
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close_clone = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
on_close_clone.run(());
}
});
if let Some(window) = web_sys::window() {
let _ = window.add_event_listener_with_callback(
"keydown",
closure.as_ref().unchecked_ref(),
);
}
closure.forget();
});
}
// Close context menu when clicking elsewhere // Close context menu when clicking elsewhere
let close_context_menu = move |_| { let close_context_menu = move |_| {

View file

@ -8,6 +8,8 @@ use chattyness_db::models::{InventoryItem, PublicProp};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage; use chattyness_db::ws_messages::ClientMessage;
use super::modals::Modal;
use super::tabs::{Tab, TabBar};
use super::ws_client::WsSender; use super::ws_client::WsSender;
/// Inventory popup component. /// Inventory popup component.
@ -191,36 +193,6 @@ pub fn InventoryPopup(
}); });
} }
// Handle escape key to close
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close_clone = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
on_close_clone.run(());
}
});
if let Some(window) = web_sys::window() {
let _ = window.add_event_listener_with_callback(
"keydown",
closure.as_ref().unchecked_ref(),
);
}
// Intentionally not cleaning up - closure lives for session
closure.forget();
});
}
// Handle drop action via WebSocket // Handle drop action via WebSocket
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
let handle_drop = { let handle_drop = {
@ -250,94 +222,25 @@ pub fn InventoryPopup(
#[cfg(not(feature = "hydrate"))] #[cfg(not(feature = "hydrate"))]
let handle_drop = |_item_id: Uuid| {}; let handle_drop = |_item_id: Uuid| {};
let on_close_backdrop = on_close.clone();
let on_close_button = on_close.clone();
view! { view! {
<Show when=move || open.get()> <Modal
<div open=open
class="fixed inset-0 z-50 flex items-center justify-center" on_close=on_close
role="dialog" title="Inventory"
aria-modal="true" title_id="inventory-modal-title"
aria-labelledby="inventory-modal-title" max_width="max-w-2xl"
class="max-h-[80vh] flex flex-col"
> >
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6 border border-gray-700 max-h-[80vh] flex flex-col">
// Header
<div class="flex items-center justify-between mb-4">
<h2 id="inventory-modal-title" class="text-xl font-bold text-white">
"Inventory"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close inventory"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
// Tab bar // Tab bar
<div class="flex border-b border-gray-700 mb-4" role="tablist"> <TabBar
<button tabs=vec![
type="button" Tab::new("my_inventory", "My Inventory"),
role="tab" Tab::new("server", "Server"),
aria-selected=move || active_tab.get() == "my_inventory" Tab::new("realm", "Realm"),
class=move || format!( ]
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}", active=Signal::derive(move || active_tab.get())
if active_tab.get() == "my_inventory" { on_select=Callback::new(move |id| set_active_tab.set(id))
"text-blue-400 border-blue-400" />
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
on:click=move |_| set_active_tab.set("my_inventory")
>
"My Inventory"
</button>
<button
type="button"
role="tab"
aria-selected=move || active_tab.get() == "server"
class=move || format!(
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
if active_tab.get() == "server" {
"text-blue-400 border-blue-400"
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
on:click=move |_| set_active_tab.set("server")
>
"Server"
</button>
<button
type="button"
role="tab"
aria-selected=move || active_tab.get() == "realm"
class=move || format!(
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
if active_tab.get() == "realm" {
"text-blue-400 border-blue-400"
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
on:click=move |_| set_active_tab.set("realm")
>
"Realm"
</button>
</div>
// Tab content // Tab content
<div class="flex-1 overflow-y-auto min-h-[300px]"> <div class="flex-1 overflow-y-auto min-h-[300px]">
@ -376,9 +279,7 @@ pub fn InventoryPopup(
/> />
</Show> </Show>
</div> </div>
</div> </Modal>
</div>
</Show>
} }
} }

View file

@ -3,8 +3,7 @@
use chattyness_db::models::EmotionState; use chattyness_db::models::EmotionState;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// LocalStorage key for emotion keybindings. use crate::utils::LocalStoragePersist;
const KEYBINDINGS_KEY: &str = "chattyness_emotion_keybindings";
/// Key slot names for the 12 emotion keybindings. /// Key slot names for the 12 emotion keybindings.
/// Maps to e1, e2, ..., e9, e0, eq, ew /// Maps to e1, e2, ..., e9, e0, eq, ew
@ -64,54 +63,17 @@ impl Default for EmotionKeybindings {
} }
} }
// Implement LocalStoragePersist trait for automatic load/save
impl LocalStoragePersist for EmotionKeybindings {
const STORAGE_KEY: &'static str = "chattyness_emotion_keybindings";
/// Ensure slot 0 is always Happy (enforce lock) after loading.
fn post_load(&mut self) {
self.slots[0] = EmotionState::Happy;
}
}
impl EmotionKeybindings { impl EmotionKeybindings {
/// Load keybindings from localStorage, returning defaults if not found or invalid.
#[cfg(feature = "hydrate")]
pub fn load() -> Self {
let Some(window) = web_sys::window() else {
return Self::default();
};
let Ok(Some(storage)) = window.local_storage() else {
return Self::default();
};
let Ok(Some(json)) = storage.get_item(KEYBINDINGS_KEY) else {
return Self::default();
};
let mut loaded: Self = serde_json::from_str(&json).unwrap_or_default();
// Ensure slot 0 is always Happy (enforce lock)
loaded.slots[0] = EmotionState::Happy;
loaded
}
/// Stub for SSR - returns default keybindings.
#[cfg(not(feature = "hydrate"))]
pub fn load() -> Self {
Self::default()
}
/// Save keybindings to localStorage.
#[cfg(feature = "hydrate")]
pub fn save(&self) {
let Some(window) = web_sys::window() else {
return;
};
let Ok(Some(storage)) = window.local_storage() else {
return;
};
if let Ok(json) = serde_json::to_string(self) {
let _ = storage.set_item(KEYBINDINGS_KEY, &json);
}
}
/// Stub for SSR - no-op.
#[cfg(not(feature = "hydrate"))]
pub fn save(&self) {}
/// Get the emotion for a given key (after 'e' was pressed). /// Get the emotion for a given key (after 'e' was pressed).
/// ///
/// Keys: "1"-"9", "0", "q", "w" /// Keys: "1"-"9", "0", "q", "w"

View file

@ -6,6 +6,8 @@ use chattyness_db::models::{EmotionAvailability, EmotionState};
use super::emotion_picker::EmoteListPopup; use super::emotion_picker::EmoteListPopup;
use super::keybindings::EmotionKeybindings; use super::keybindings::EmotionKeybindings;
use super::modals::Modal;
use crate::utils::LocalStoragePersist;
/// Keybindings popup component for customizing emotion hotkeys. /// Keybindings popup component for customizing emotion hotkeys.
/// ///
@ -29,81 +31,14 @@ pub fn KeybindingsPopup(
// Current active tab (for future extensibility) // Current active tab (for future extensibility)
let (active_tab, _set_active_tab) = signal("emotions"); let (active_tab, _set_active_tab) = signal("emotions");
// Handle escape key to close (only when no picker is open)
#[cfg(feature = "hydrate")]
{
use leptos::web_sys;
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close_clone = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
// Only close on Escape if the event target is not the picker input
if ev.key() == "Escape" {
if let Some(target) = ev.target() {
if let Ok(el) = target.dyn_into::<web_sys::HtmlElement>() {
// Check if it's the picker filter input - if so, don't close the main popup
if el.class_list().contains("emotion-picker-filter") {
return;
}
}
}
on_close_clone.run(());
}
});
if let Some(window) = web_sys::window() {
let _ = window
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
}
// Intentionally not cleaning up - closure lives for session
closure.forget();
});
}
let on_close_backdrop = on_close.clone();
let on_close_button = on_close.clone();
view! { view! {
<Show when=move || open.get()> <Modal
<div open=open
class="fixed inset-0 z-50 flex items-center justify-center" on_close=on_close
role="dialog" title="Keybindings"
aria-modal="true" title_id="keybindings-modal-title"
aria-labelledby="keybindings-modal-title" max_width="max-w-2xl"
> >
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6 border border-gray-700">
// Header
<div class="flex items-center justify-between mb-4">
<h2 id="keybindings-modal-title" class="text-xl font-bold text-white">
"Keybindings"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close keybindings"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
// Tab bar // Tab bar
<div class="flex border-b border-gray-700 mb-4" role="tablist"> <div class="flex border-b border-gray-700 mb-4" role="tablist">
<button <button
@ -135,9 +70,7 @@ pub fn KeybindingsPopup(
" for happy)" " for happy)"
</p> </p>
</div> </div>
</div> </Modal>
</div>
</Show>
} }
} }

View file

@ -2,6 +2,122 @@
use leptos::prelude::*; use leptos::prelude::*;
use crate::utils::use_escape_key;
// ============================================================================
// Base Modal Component
// ============================================================================
/// Base modal component that handles common modal patterns.
///
/// Provides:
/// - Backdrop with blur effect
/// - Escape key to close
/// - Click outside to close
/// - Accessible ARIA attributes
/// - Close button in header
///
/// # Example
///
/// ```ignore
/// <Modal
/// open=open_signal
/// on_close=on_close_callback
/// title="My Modal"
/// title_id="my-modal-title"
/// >
/// <p>"Modal content here"</p>
/// </Modal>
/// ```
#[component]
pub fn Modal(
#[prop(into)] open: Signal<bool>,
on_close: Callback<()>,
title: &'static str,
/// Unique ID for the title element (for aria-labelledby).
title_id: &'static str,
/// Maximum width class (e.g., "max-w-md", "max-w-2xl"). Defaults to "max-w-md".
#[prop(default = "max-w-md")]
max_width: &'static str,
/// Whether to show the close button. Defaults to true.
#[prop(default = true)]
show_close_button: bool,
/// Optional extra classes for the modal container.
#[prop(optional)]
class: Option<&'static str>,
children: Children,
) -> impl IntoView {
// Handle escape key
use_escape_key(open, on_close.clone());
let on_close_backdrop = on_close.clone();
let on_close_button = on_close.clone();
let container_class = format!(
"relative bg-gray-800 rounded-lg shadow-2xl {} w-full mx-4 p-6 border border-gray-700 {}",
max_width,
class.unwrap_or("")
);
// Render children once
let content = children();
// Use CSS-based visibility instead of Show for better children handling
let outer_class = move || {
if open.get() {
"fixed inset-0 z-50 flex items-center justify-center"
} else {
"hidden"
}
};
view! {
<div
class=outer_class
role="dialog"
aria-modal="true"
aria-labelledby=title_id
aria-hidden=move || (!open.get()).to_string()
>
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class=container_class>
// Header with title and close button
<div class="flex items-center justify-between mb-4">
<h2 id=title_id class="text-xl font-bold text-white">
{title}
</h2>
{show_close_button.then(|| view! {
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
})}
</div>
// Content
{content}
</div>
</div>
}
}
// ============================================================================
// Specialized Modals
// ============================================================================
/// Confirmation modal for joining a realm. /// Confirmation modal for joining a realm.
#[component] #[component]
pub fn JoinRealmModal( pub fn JoinRealmModal(

View file

@ -20,44 +20,7 @@ use super::chat_types::ActiveBubble;
use super::settings::{ use super::settings::{
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
}; };
use crate::utils::parse_bounds_dimensions;
/// Parse bounds WKT to extract width and height.
///
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
let trimmed = bounds_wkt.trim();
let coords_str = trimmed
.strip_prefix("POLYGON((")
.and_then(|s| s.strip_suffix("))"))?;
let points: Vec<&str> = coords_str.split(',').collect();
if points.len() < 4 {
return None;
}
let mut max_x: f64 = 0.0;
let mut max_y: f64 = 0.0;
for point in points.iter() {
let coords: Vec<&str> = point.trim().split_whitespace().collect();
if coords.len() >= 2 {
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
}
}
}
if max_x > 0.0 && max_y > 0.0 {
Some((max_x as u32, max_y as u32))
} else {
None
}
}
/// Scene viewer component for displaying a realm scene with avatars. /// Scene viewer component for displaying a realm scene with avatars.
/// ///

View file

@ -2,8 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// LocalStorage key for viewer settings. use crate::utils::LocalStoragePersist;
const SETTINGS_KEY: &str = "chattyness_viewer_settings";
/// Reference resolution for enlarged props calculation. /// Reference resolution for enlarged props calculation.
pub const REFERENCE_WIDTH: f64 = 1920.0; pub const REFERENCE_WIDTH: f64 = 1920.0;
@ -99,51 +98,12 @@ impl Default for ViewerSettings {
} }
} }
// Implement LocalStoragePersist trait for automatic load/save
impl LocalStoragePersist for ViewerSettings {
const STORAGE_KEY: &'static str = "chattyness_viewer_settings";
}
impl ViewerSettings { impl ViewerSettings {
/// Load settings from localStorage, returning defaults if not found or invalid.
#[cfg(feature = "hydrate")]
pub fn load() -> Self {
let Some(window) = web_sys::window() else {
return Self::default();
};
let Ok(Some(storage)) = window.local_storage() else {
return Self::default();
};
let Ok(Some(json)) = storage.get_item(SETTINGS_KEY) else {
return Self::default();
};
serde_json::from_str(&json).unwrap_or_default()
}
/// Stub for SSR - returns default settings.
#[cfg(not(feature = "hydrate"))]
pub fn load() -> Self {
Self::default()
}
/// Save settings to localStorage.
#[cfg(feature = "hydrate")]
pub fn save(&self) {
let Some(window) = web_sys::window() else {
return;
};
let Ok(Some(storage)) = window.local_storage() else {
return;
};
if let Ok(json) = serde_json::to_string(self) {
let _ = storage.set_item(SETTINGS_KEY, &json);
}
}
/// Stub for SSR - no-op.
#[cfg(not(feature = "hydrate"))]
pub fn save(&self) {}
/// Calculate the effective prop size based on current settings. /// Calculate the effective prop size based on current settings.
/// ///
/// In pan mode without enlarge, returns base size * zoom level. /// In pan mode without enlarge, returns base size * zoom level.

View file

@ -3,7 +3,9 @@
use leptos::ev::MouseEvent; use leptos::ev::MouseEvent;
use leptos::prelude::*; use leptos::prelude::*;
use super::modals::Modal;
use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP, TEXT_EM_MIN, TEXT_EM_MAX, TEXT_EM_STEP}; use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP, TEXT_EM_MIN, TEXT_EM_MAX, TEXT_EM_STEP};
use crate::utils::LocalStoragePersist;
/// Settings popup component for scene viewer configuration. /// Settings popup component for scene viewer configuration.
/// ///
@ -109,72 +111,13 @@ pub fn SettingsPopup(
}); });
}; };
// Handle escape key to close
#[cfg(feature = "hydrate")]
{
use leptos::web_sys;
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close_clone = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
on_close_clone.run(());
}
});
if let Some(window) = web_sys::window() {
let _ = window
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
}
// Intentionally not cleaning up - closure lives for session
closure.forget();
});
}
let on_close_backdrop = on_close.clone();
let on_close_button = on_close.clone();
view! { view! {
<Show when=move || open.get()> <Modal
<div open=open
class="fixed inset-0 z-50 flex items-center justify-center" on_close=on_close
role="dialog" title="Scene Settings"
aria-modal="true" title_id="settings-modal-title"
aria-labelledby="settings-modal-title"
> >
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
// Header
<div class="flex items-center justify-between mb-6">
<h2 id="settings-modal-title" class="text-xl font-bold text-white">
"Scene Settings"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close settings"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
// Settings toggles // Settings toggles
<div class="space-y-4"> <div class="space-y-4">
// Panning toggle // Panning toggle
@ -289,9 +232,7 @@ pub fn SettingsPopup(
</p> </p>
</Show> </Show>
</div> </div>
</div> </Modal>
</div>
</Show>
} }
} }

View file

@ -0,0 +1,93 @@
//! Tab components for tabbed interfaces.
use leptos::prelude::*;
/// A single tab definition.
#[derive(Clone)]
pub struct Tab {
/// Unique identifier for this tab.
pub id: &'static str,
/// Display label for the tab button.
pub label: &'static str,
}
impl Tab {
/// Create a new tab.
pub const fn new(id: &'static str, label: &'static str) -> Self {
Self { id, label }
}
}
/// Tab bar component for switching between tabs.
///
/// # Example
///
/// ```ignore
/// let (active_tab, set_active_tab) = signal("tab1");
///
/// view! {
/// <TabBar
/// tabs=vec![
/// Tab::new("tab1", "First Tab"),
/// Tab::new("tab2", "Second Tab"),
/// ]
/// active=active_tab
/// on_select=Callback::new(move |id| set_active_tab.set(id))
/// />
///
/// <Show when=move || active_tab.get() == "tab1">
/// <p>"First tab content"</p>
/// </Show>
/// <Show when=move || active_tab.get() == "tab2">
/// <p>"Second tab content"</p>
/// </Show>
/// }
/// ```
#[component]
pub fn TabBar(
/// List of tabs to display.
tabs: Vec<Tab>,
/// Currently active tab ID.
#[prop(into)]
active: Signal<&'static str>,
/// Callback when a tab is selected.
on_select: Callback<&'static str>,
) -> impl IntoView {
view! {
<div class="flex border-b border-gray-700 mb-4" role="tablist">
<For
each=move || tabs.clone()
key=|tab| tab.id
children=move |tab: Tab| {
let tab_id = tab.id;
let on_select = on_select.clone();
let is_active = move || active.get() == tab_id;
view! {
<button
type="button"
role="tab"
aria-selected=is_active
class=move || tab_button_class(is_active())
on:click=move |_| on_select.run(tab_id)
>
{tab.label}
</button>
}
}
/>
</div>
}
}
/// Generate the CSS class for a tab button based on active state.
fn tab_button_class(is_active: bool) -> String {
format!(
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
if is_active {
"text-blue-400 border-blue-400"
} else {
"text-gray-400 border-transparent hover:text-gray-300"
}
)
}

View file

@ -28,6 +28,7 @@ pub mod auth;
pub mod components; pub mod components;
pub mod pages; pub mod pages;
pub mod routes; pub mod routes;
pub mod utils;
pub use app::{shell, App}; pub use app::{shell, App};
pub use routes::UserRoutes; pub use routes::UserRoutes;

View file

@ -18,6 +18,7 @@ use crate::components::{
}; };
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::components::use_channel_websocket; use crate::components::use_channel_websocket;
use crate::utils::{parse_bounds_dimensions, LocalStoragePersist};
use chattyness_db::models::{ use chattyness_db::models::{
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole, AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, LooseProp, RealmRole,
RealmWithUserRole, Scene, RealmWithUserRole, Scene,
@ -27,44 +28,6 @@ use chattyness_db::ws_messages::ClientMessage;
use crate::components::ws_client::WsSender; use crate::components::ws_client::WsSender;
/// Parse bounds WKT to extract width and height.
///
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
let trimmed = bounds_wkt.trim();
let coords_str = trimmed
.strip_prefix("POLYGON((")
.and_then(|s| s.strip_suffix("))"))?;
let points: Vec<&str> = coords_str.split(',').collect();
if points.len() < 4 {
return None;
}
let mut max_x: f64 = 0.0;
let mut max_y: f64 = 0.0;
for point in points.iter() {
let coords: Vec<&str> = point.trim().split_whitespace().collect();
if coords.len() >= 2 {
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
}
}
}
if max_x > 0.0 && max_y > 0.0 {
Some((max_x as u32, max_y as u32))
} else {
None
}
}
/// Realm landing page component. /// Realm landing page component.
#[component] #[component]
pub fn RealmPage() -> impl IntoView { pub fn RealmPage() -> impl IntoView {

View file

@ -0,0 +1,231 @@
//! Shared utilities for the user UI.
//!
//! This module provides common abstractions to reduce code duplication:
//! - Geometry utilities (parsing WKT bounds)
//! - localStorage persistence trait
//! - Common hooks (escape key handling)
use serde::{de::DeserializeOwned, Serialize};
// ============================================================================
// Geometry Utilities
// ============================================================================
/// Parse bounds WKT to extract width and height.
///
/// Expected format: `"POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"`
///
/// # Examples
///
/// ```
/// use chattyness_user_ui::utils::parse_bounds_dimensions;
///
/// let dims = parse_bounds_dimensions("POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))");
/// assert_eq!(dims, Some((800, 600)));
/// ```
pub fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
let trimmed = bounds_wkt.trim();
let coords_str = trimmed
.strip_prefix("POLYGON((")
.and_then(|s| s.strip_suffix("))"))?;
let points: Vec<&str> = coords_str.split(',').collect();
if points.len() < 4 {
return None;
}
let mut max_x: f64 = 0.0;
let mut max_y: f64 = 0.0;
for point in points.iter() {
let coords: Vec<&str> = point.trim().split_whitespace().collect();
if coords.len() >= 2 {
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
}
}
}
if max_x > 0.0 && max_y > 0.0 {
Some((max_x as u32, max_y as u32))
} else {
None
}
}
/// Normalize an asset path to be absolute, prefixing with `/static/` if needed.
pub fn normalize_asset_path(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/static/{}", path)
}
}
// ============================================================================
// localStorage Persistence
// ============================================================================
/// Trait for types that can be persisted to localStorage.
///
/// Implementors provide a storage key and get `load()` and `save()` methods
/// that handle serialization/deserialization automatically.
///
/// # Example
///
/// ```ignore
/// use chattyness_user_ui::utils::LocalStoragePersist;
///
/// #[derive(Default, Serialize, Deserialize)]
/// struct MySettings {
/// dark_mode: bool,
/// }
///
/// impl LocalStoragePersist for MySettings {
/// const STORAGE_KEY: &'static str = "my_settings";
/// }
///
/// // Now you can use:
/// let settings = MySettings::load();
/// settings.save();
/// ```
pub trait LocalStoragePersist: Sized + Default + Serialize + DeserializeOwned {
/// The localStorage key used for this type.
const STORAGE_KEY: &'static str;
/// Optional post-load hook for validation/normalization.
/// Override this to enforce invariants after loading.
fn post_load(&mut self) {}
/// Load from localStorage, returning default if not found or invalid.
#[cfg(feature = "hydrate")]
fn load() -> Self {
let result: Self = web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|s| s.get_item(Self::STORAGE_KEY).ok().flatten())
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
let mut instance = result;
instance.post_load();
instance
}
/// SSR stub - returns default.
#[cfg(not(feature = "hydrate"))]
fn load() -> Self {
Self::default()
}
/// Save to localStorage.
#[cfg(feature = "hydrate")]
fn save(&self) {
if let (Some(storage), Ok(json)) = (
web_sys::window().and_then(|w| w.local_storage().ok().flatten()),
serde_json::to_string(self),
) {
let _ = storage.set_item(Self::STORAGE_KEY, &json);
}
}
/// SSR stub - no-op.
#[cfg(not(feature = "hydrate"))]
fn save(&self) {}
}
// ============================================================================
// Hooks
// ============================================================================
/// Hook to close a popup when Escape key is pressed.
///
/// Automatically adds and removes the event listener based on the `open` signal.
///
/// # Example
///
/// ```ignore
/// use chattyness_user_ui::utils::use_escape_key;
///
/// #[component]
/// fn MyPopup(open: Signal<bool>, on_close: Callback<()>) -> impl IntoView {
/// use_escape_key(open, on_close);
/// // ...
/// }
/// ```
#[cfg(feature = "hydrate")]
pub fn use_escape_key(
open: leptos::prelude::Signal<bool>,
on_close: leptos::prelude::Callback<()>,
) {
use leptos::prelude::*;
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
on_close.run(());
}
});
if let Some(window) = web_sys::window() {
let _ =
window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
}
// Note: We intentionally don't clean up the listener here.
// In a production app, you'd want to use on_cleanup() or store the closure
// in a signal to properly remove it. For now, closures accumulate but
// early-return when `open` is false.
closure.forget();
});
}
/// SSR stub - no-op.
#[cfg(not(feature = "hydrate"))]
pub fn use_escape_key(
_open: leptos::prelude::Signal<bool>,
_on_close: leptos::prelude::Callback<()>,
) {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_bounds_dimensions_valid() {
let wkt = "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))";
assert_eq!(parse_bounds_dimensions(wkt), Some((800, 600)));
}
#[test]
fn test_parse_bounds_dimensions_large() {
let wkt = "POLYGON((0 0, 1920 0, 1920 1080, 0 1080, 0 0))";
assert_eq!(parse_bounds_dimensions(wkt), Some((1920, 1080)));
}
#[test]
fn test_parse_bounds_dimensions_invalid() {
assert_eq!(parse_bounds_dimensions("invalid"), None);
assert_eq!(parse_bounds_dimensions("POLYGON(())"), None);
assert_eq!(parse_bounds_dimensions(""), None);
}
#[test]
fn test_normalize_asset_path() {
assert_eq!(normalize_asset_path("/images/foo.png"), "/images/foo.png");
assert_eq!(normalize_asset_path("images/foo.png"), "/static/images/foo.png");
assert_eq!(normalize_asset_path("foo.png"), "/static/foo.png");
}
}