fix: avatar center to cursor, and cleanup.
Lots of cleanup, went in with this too
This commit is contained in:
parent
c3320ddcce
commit
fe65835f4a
14 changed files with 769 additions and 708 deletions
|
|
@ -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::*;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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 |_| {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
93
crates/chattyness-user-ui/src/components/tabs.rs
Normal file
93
crates/chattyness-user-ui/src/components/tabs.rs
Normal 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"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
231
crates/chattyness-user-ui/src/utils.rs
Normal file
231
crates/chattyness-user-ui/src/utils.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue