From c3320ddcce0e997e6eb6cea742404448ec38b5b7b1353bba059f342ac7969430 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 17 Jan 2026 01:11:05 -0600 Subject: [PATCH] avatar fixes and implementation to edit --- crates/chattyness-db/src/models.rs | 124 +++ crates/chattyness-db/src/queries/avatars.rs | 79 ++ crates/chattyness-db/src/ws_messages.rs | 15 +- crates/chattyness-user-ui/src/api/avatars.rs | 77 +- crates/chattyness-user-ui/src/api/routes.rs | 4 + .../chattyness-user-ui/src/api/websocket.rs | 31 + crates/chattyness-user-ui/src/components.rs | 2 + .../src/components/avatar_canvas.rs | 167 +++- .../src/components/avatar_editor.rs | 894 ++++++++++++++++++ .../src/components/ws_client.rs | 16 + crates/chattyness-user-ui/src/pages/realm.rs | 45 +- 11 files changed, 1417 insertions(+), 37 deletions(-) create mode 100644 crates/chattyness-user-ui/src/components/avatar_editor.rs diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 7b3eb62..2a34b4e 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -1968,3 +1968,127 @@ impl AvatarWithPaths { } } } + +// ============================================================================= +// Avatar Slot Update Models +// ============================================================================= + +/// Request to assign an inventory item to an avatar slot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignSlotRequest { + /// Inventory item ID to assign to the slot. + pub inventory_item_id: Uuid, + /// Layer type: "skin", "clothes", "accessories", or an emotion name. + pub layer: String, + /// Grid position (0-8). + pub position: u8, +} + +#[cfg(feature = "ssr")] +impl AssignSlotRequest { + /// Validate the assign slot request. + pub fn validate(&self) -> Result<(), AppError> { + if self.position > 8 { + return Err(AppError::Validation( + "Position must be between 0 and 8".to_string(), + )); + } + + // Validate layer name + let valid_layers = [ + "skin", + "clothes", + "accessories", + "neutral", + "happy", + "sad", + "angry", + "surprised", + "thinking", + "laughing", + "crying", + "love", + "confused", + "sleeping", + "wink", + ]; + if !valid_layers.contains(&self.layer.to_lowercase().as_str()) { + return Err(AppError::Validation(format!( + "Invalid layer: {}", + self.layer + ))); + } + + Ok(()) + } + + /// Get the database column name for this slot. + pub fn column_name(&self) -> String { + let layer = self.layer.to_lowercase(); + match layer.as_str() { + "skin" => format!("l_skin_{}", self.position), + "clothes" => format!("l_clothes_{}", self.position), + "accessories" => format!("l_accessories_{}", self.position), + _ => format!("e_{}_{}", layer, self.position), + } + } +} + +/// Request to clear an avatar slot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClearSlotRequest { + /// Layer type: "skin", "clothes", "accessories", or an emotion name. + pub layer: String, + /// Grid position (0-8). + pub position: u8, +} + +#[cfg(feature = "ssr")] +impl ClearSlotRequest { + /// Validate the clear slot request. + pub fn validate(&self) -> Result<(), AppError> { + if self.position > 8 { + return Err(AppError::Validation( + "Position must be between 0 and 8".to_string(), + )); + } + + // Validate layer name + let valid_layers = [ + "skin", + "clothes", + "accessories", + "neutral", + "happy", + "sad", + "angry", + "surprised", + "thinking", + "laughing", + "crying", + "love", + "confused", + "sleeping", + "wink", + ]; + if !valid_layers.contains(&self.layer.to_lowercase().as_str()) { + return Err(AppError::Validation(format!( + "Invalid layer: {}", + self.layer + ))); + } + + Ok(()) + } + + /// Get the database column name for this slot. + pub fn column_name(&self) -> String { + let layer = self.layer.to_lowercase(); + match layer.as_str() { + "skin" => format!("l_skin_{}", self.position), + "clothes" => format!("l_clothes_{}", self.position), + "accessories" => format!("l_accessories_{}", self.position), + _ => format!("e_{}_{}", layer, self.position), + } + } +} diff --git a/crates/chattyness-db/src/queries/avatars.rs b/crates/chattyness-db/src/queries/avatars.rs index de5868c..f13757a 100644 --- a/crates/chattyness-db/src/queries/avatars.rs +++ b/crates/chattyness-db/src/queries/avatars.rs @@ -960,3 +960,82 @@ pub async fn set_emotion_simple<'e>( Ok(()) } + +/// Update an avatar slot by assigning an inventory item to it. +/// +/// The column_name should be one of: +/// - "l_skin_0" through "l_skin_8" +/// - "l_clothes_0" through "l_clothes_8" +/// - "l_accessories_0" through "l_accessories_8" +/// - "e_{emotion}_0" through "e_{emotion}_8" (e.g., "e_happy_4") +pub async fn update_avatar_slot( + conn: &mut PgConnection, + user_id: Uuid, + realm_id: Uuid, + column_name: &str, + inventory_id: Option, +) -> Result<(), AppError> { + // Validate column name format to prevent SQL injection + let valid_prefixes = [ + "l_skin_", + "l_clothes_", + "l_accessories_", + "e_neutral_", + "e_happy_", + "e_sad_", + "e_angry_", + "e_surprised_", + "e_thinking_", + "e_laughing_", + "e_crying_", + "e_love_", + "e_confused_", + "e_sleeping_", + "e_wink_", + ]; + + let is_valid = valid_prefixes + .iter() + .any(|prefix| column_name.starts_with(prefix)) + && column_name + .chars() + .last() + .map(|c| c.is_ascii_digit() && c <= '8') + .unwrap_or(false); + + if !is_valid { + return Err(AppError::Validation(format!( + "Invalid column name: {}", + column_name + ))); + } + + // Build dynamic UPDATE query + // Note: We've validated the column name format above to prevent SQL injection + let query = format!( + r#" + UPDATE auth.avatars + SET {} = $3, updated_at = now() + WHERE id = ( + SELECT avatar_id FROM auth.active_avatars + WHERE user_id = $1 AND realm_id = $2 + ) + "#, + column_name + ); + + let result = sqlx::query(&query) + .bind(user_id) + .bind(realm_id) + .bind(inventory_id) + .execute(&mut *conn) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound( + "No active avatar for this user in this realm".to_string(), + )); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 2f0d458..2102dd1 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp}; +use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp}; /// Client-to-server WebSocket messages. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -45,6 +45,9 @@ pub enum ClientMessage { /// Loose prop ID to pick up. loose_prop_id: Uuid, }, + + /// Request to broadcast avatar appearance to other users. + SyncAvatar, } /// Server-to-client WebSocket messages. @@ -157,4 +160,14 @@ pub enum ServerMessage { /// ID of the expired prop. prop_id: Uuid, }, + + /// A member updated their avatar appearance. + AvatarUpdated { + /// User ID (if authenticated user). + user_id: Option, + /// Guest session ID (if guest). + guest_session_id: Option, + /// Updated avatar render data. + avatar: AvatarRenderData, + }, } diff --git a/crates/chattyness-user-ui/src/api/avatars.rs b/crates/chattyness-user-ui/src/api/avatars.rs index 355b029..514f5bd 100644 --- a/crates/chattyness-user-ui/src/api/avatars.rs +++ b/crates/chattyness-user-ui/src/api/avatars.rs @@ -1,13 +1,13 @@ //! Avatar API handlers for user UI. //! -//! Handles avatar data retrieval. +//! Handles avatar data retrieval and slot updates. //! Note: Emotion switching is handled via WebSocket. use axum::extract::Path; use axum::Json; use chattyness_db::{ - models::AvatarWithPaths, + models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest}, queries::{avatars, realms}, }; use chattyness_error::AppError; @@ -40,3 +40,76 @@ pub async fn get_avatar( Ok(Json(avatar)) } + +/// Assign an inventory item to an avatar slot. +/// +/// PUT /api/realms/{slug}/avatar/slot +/// +/// Assigns the specified inventory item to the given layer and position +/// in the user's active avatar. +pub async fn assign_slot( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(slug): Path, + Json(req): Json, +) -> Result, AppError> { + req.validate()?; + + let mut conn = rls_conn.acquire().await; + + // Get realm + let realm = realms::get_realm_by_slug(&mut *conn, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + // Update the slot + let column_name = req.column_name(); + avatars::update_avatar_slot( + &mut *conn, + user.id, + realm.id, + &column_name, + Some(req.inventory_item_id), + ) + .await?; + + // Return updated avatar + let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm.id) + .await? + .unwrap_or_default(); + + Ok(Json(avatar)) +} + +/// Clear an avatar slot. +/// +/// DELETE /api/realms/{slug}/avatar/slot +/// +/// Removes any inventory item from the given layer and position +/// in the user's active avatar. +pub async fn clear_slot( + rls_conn: RlsConn, + AuthUser(user): AuthUser, + Path(slug): Path, + Json(req): Json, +) -> Result, AppError> { + req.validate()?; + + let mut conn = rls_conn.acquire().await; + + // Get realm + let realm = realms::get_realm_by_slug(&mut *conn, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + // Clear the slot + let column_name = req.column_name(); + avatars::update_avatar_slot(&mut *conn, user.id, realm.id, &column_name, None).await?; + + // Return updated avatar + let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm.id) + .await? + .unwrap_or_default(); + + Ok(Json(avatar)) +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index ffc5240..22d4142 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -51,6 +51,10 @@ pub fn api_router() -> Router { ) // Avatar routes (require authentication) .route("/realms/{slug}/avatar", get(avatars::get_avatar)) + .route( + "/realms/{slug}/avatar/slot", + axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot), + ) // Inventory routes (require authentication) .route("/inventory", get(inventory::get_inventory)) .route( diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index ee3ba15..eea093d 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -466,6 +466,37 @@ async fn handle_socket( } } } + ClientMessage::SyncAvatar => { + // Fetch current avatar from database and broadcast to channel + match avatars::get_avatar_with_paths_conn( + &mut *recv_conn, + user_id, + realm_id, + ) + .await + { + Ok(Some(avatar_with_paths)) => { + let render_data = avatar_with_paths.to_render_data(); + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] User {} syncing avatar to channel", + user_id + ); + let _ = tx.send(ServerMessage::AvatarUpdated { + user_id: Some(user_id), + guest_session_id: None, + avatar: render_data, + }); + } + Ok(None) => { + #[cfg(debug_assertions)] + tracing::warn!("[WS] No avatar found for user {} to sync", user_id); + } + Err(e) => { + tracing::error!("[WS] Avatar sync failed: {:?}", e); + } + } + } } } } diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 63827dd..65b6c5a 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -1,6 +1,7 @@ //! Reusable UI components. pub mod avatar_canvas; +pub mod avatar_editor; pub mod chat; pub mod chat_types; pub mod editor; @@ -17,6 +18,7 @@ pub mod settings_popup; pub mod ws_client; pub use avatar_canvas::*; +pub use avatar_editor::*; pub use chat::*; pub use chat_types::*; pub use editor::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index 6e172d7..f6fb5f0 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -51,13 +51,45 @@ pub fn AvatarCanvas( // Clone data for use in closures let skin_layer = member.avatar.skin_layer.clone(); + let clothes_layer = member.avatar.clothes_layer.clone(); + let accessories_layer = member.avatar.accessories_layer.clone(); let emotion_layer = member.avatar.emotion_layer.clone(); let display_name = member.member.display_name.clone(); let current_emotion = member.member.current_emotion; - // Calculate canvas position from scene coordinates - let canvas_x = member.member.position_x * scale_x + offset_x - prop_size / 2.0; - let canvas_y = member.member.position_y * scale_y + offset_y - prop_size; + // Helper to check if any layer has content at a position + let has_content_at = |pos: usize| -> bool { + skin_layer[pos].is_some() + || clothes_layer[pos].is_some() + || accessories_layer[pos].is_some() + || emotion_layer[pos].is_some() + }; + + // Calculate content bounds for positioning + // X-axis: which columns have content + 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)); + + 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 + let avatar_size = prop_size * 3.0; + + // 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; + let canvas_y = member.member.position_y * scale_y + offset_y - avatar_size + y_content_offset * scale_y; // Fixed text dimensions (independent of prop_size/zoom) // Text stays readable regardless of zoom level - only affected by text_em_size slider @@ -72,8 +104,8 @@ pub fn AvatarCanvas( let fixed_text_width = 200.0 * text_scale; // Canvas must fit both avatar AND fixed-size text - let canvas_width = prop_size.max(fixed_text_width); - let canvas_height = prop_size + fixed_bubble_height + fixed_name_height; + let canvas_width = avatar_size.max(fixed_text_width); + let canvas_height = avatar_size + fixed_bubble_height + fixed_name_height; // Adjust bubble_extra for positioning (used later in avatar_cy calculation) let bubble_extra = fixed_bubble_height; @@ -90,7 +122,7 @@ pub fn AvatarCanvas( pointer-events: auto; \ width: {}px; \ height: {}px;", - canvas_x - (canvas_width - prop_size) / 2.0, + canvas_x - (canvas_width - avatar_size) / 2.0, adjusted_y, z_index, canvas_width, @@ -115,6 +147,8 @@ pub fn AvatarCanvas( // Clone values for the effect let skin_layer_clone = skin_layer.clone(); + let clothes_layer_clone = clothes_layer.clone(); + let accessories_layer_clone = accessories_layer.clone(); let emotion_layer_clone = emotion_layer.clone(); let display_name_clone = display_name.clone(); let active_bubble_clone = active_bubble.clone(); @@ -143,19 +177,7 @@ pub fn AvatarCanvas( // Avatar center position within the canvas let avatar_cx = canvas_width / 2.0; - let avatar_cy = bubble_extra + prop_size / 2.0; - - // Draw placeholder circle - ctx.begin_path(); - let _ = ctx.arc( - avatar_cx, - avatar_cy, - prop_size / 2.0, - 0.0, - std::f64::consts::PI * 2.0, - ); - ctx.set_fill_style_str("#6366f1"); - ctx.fill(); + let avatar_cy = bubble_extra + avatar_size / 2.0; // Helper to load and draw an image // Images are cached; when loaded, triggers a redraw via signal @@ -192,14 +214,58 @@ pub fn AvatarCanvas( } }; - // Draw skin layer (position 4 = center) - if let Some(ref skin_path) = skin_layer_clone[4] { - draw_image(skin_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size); + // Draw all 9 positions of the avatar grid (3x3 layout) + // Grid positions: + // 0 1 2 + // 3 4 5 + // 6 7 8 + // Each cell is full prop_size, grid is 3x3 + let cell_size = prop_size; + let grid_origin_x = avatar_cx - avatar_size / 2.0; + let grid_origin_y = avatar_cy - avatar_size / 2.0; + + // Draw skin layer for all 9 positions + for pos in 0..9 { + if let Some(ref skin_path) = skin_layer_clone[pos] { + let col = pos % 3; + let row = pos / 3; + let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; + let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; + draw_image(skin_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size); + } } - // Draw emotion overlay (position 4 = center) - if let Some(ref emotion_path) = emotion_layer_clone[4] { - draw_image(emotion_path, &image_cache, &ctx, avatar_cx, avatar_cy, prop_size); + // Draw clothes layer for all 9 positions + for pos in 0..9 { + if let Some(ref clothes_path) = clothes_layer_clone[pos] { + let col = pos % 3; + let row = pos / 3; + let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; + let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; + draw_image(clothes_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size); + } + } + + // Draw accessories layer for all 9 positions + for pos in 0..9 { + if let Some(ref accessories_path) = accessories_layer_clone[pos] { + let col = pos % 3; + let row = pos / 3; + let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; + let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; + draw_image(accessories_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size); + } + } + + // Draw emotion overlay for all 9 positions + for pos in 0..9 { + if let Some(ref emotion_path) = emotion_layer_clone[pos] { + let col = pos % 3; + let row = pos / 3; + let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size; + let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size; + draw_image(emotion_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size); + } } // Text scale independent of zoom - only affected by user's text_em_size setting @@ -208,8 +274,8 @@ pub fn AvatarCanvas( // Draw emotion badge if non-neutral if current_emotion > 0 { let badge_size = 16.0 * text_scale; - let badge_x = avatar_cx + prop_size / 2.0 - badge_size / 2.0; - let badge_y = avatar_cy - prop_size / 2.0 - badge_size / 2.0; + let badge_x = avatar_cx + avatar_size / 2.0 - badge_size / 2.0; + let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0; ctx.begin_path(); let _ = ctx.arc(badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0); @@ -223,24 +289,63 @@ pub fn AvatarCanvas( let _ = ctx.fill_text(&format!("{}", current_emotion), badge_x, badge_y); } + // Helper to check if any layer has content at a position + let has_content_at = |pos: usize| -> bool { + skin_layer_clone[pos].is_some() + || clothes_layer_clone[pos].is_some() + || accessories_layer_clone[pos].is_some() + || emotion_layer_clone[pos].is_some() + }; + + // Calculate empty bottom rows to adjust name Y position + 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) ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale)); ctx.set_text_align("center"); ctx.set_text_baseline("alphabetic"); - let name_y = avatar_cy + prop_size / 2.0 + 15.0 * text_scale; + let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size) + 15.0 * text_scale; // Black outline ctx.set_stroke_style_str("#000"); ctx.set_line_width(3.0); - let _ = ctx.stroke_text(&display_name_clone, avatar_cx, name_y); + let _ = ctx.stroke_text(&display_name_clone, name_x, name_y); // White fill ctx.set_fill_style_str("#fff"); - let _ = ctx.fill_text(&display_name_clone, avatar_cx, name_y); + let _ = ctx.fill_text(&display_name_clone, name_x, name_y); // Draw speech bubble if active if let Some(ref bubble) = active_bubble_clone { let current_time = js_sys::Date::now() as i64; if bubble.expires_at >= current_time { - draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - prop_size / 2.0, prop_size, text_em_size); + draw_bubble(&ctx, bubble, avatar_cx, avatar_cy - avatar_size / 2.0, avatar_size, text_em_size); } } }); diff --git a/crates/chattyness-user-ui/src/components/avatar_editor.rs b/crates/chattyness-user-ui/src/components/avatar_editor.rs new file mode 100644 index 0000000..987cfff --- /dev/null +++ b/crates/chattyness-user-ui/src/components/avatar_editor.rs @@ -0,0 +1,894 @@ +//! Avatar Editor popup component. +//! +//! Provides a two-column interface for customizing avatars: +//! - Left side: Navigation (layers/emotions) and 3x3 grid editor +//! - Right side: Inventory props for drag-and-drop + +use leptos::prelude::*; +use leptos::web_sys; +use uuid::Uuid; + +use chattyness_db::models::{AvatarWithPaths, InventoryItem}; +#[cfg(feature = "hydrate")] +use chattyness_db::ws_messages::ClientMessage; + +use super::ws_client::WsSenderStorage; + +/// Tab selection for the editor +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum EditorTab { + BaseLayers, + Emotions, +} + +/// Base layer selection +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum BaseLayer { + #[default] + Skin, + Clothes, + Accessories, +} + +impl BaseLayer { + /// Get display name for the layer + pub fn display_name(&self) -> &'static str { + match self { + BaseLayer::Skin => "Skin", + BaseLayer::Clothes => "Clothes", + BaseLayer::Accessories => "Accessories", + } + } + + /// Get the database column prefix + pub fn column_prefix(&self) -> &'static str { + match self { + BaseLayer::Skin => "l_skin", + BaseLayer::Clothes => "l_clothes", + BaseLayer::Accessories => "l_accessories", + } + } +} + +/// Inventory sub-tab selection +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum InventoryTab { + #[default] + Suggested, + AllProps, +} + +/// Context menu state for clearing slots +#[derive(Clone)] +pub struct ContextMenuState { + pub x: i32, + pub y: i32, + pub position: u8, +} + +/// All 12 emotion display names +const EMOTIONS: [&str; 12] = [ + "Neutral", + "Happy", + "Sad", + "Angry", + "Surprised", + "Thinking", + "Laughing", + "Crying", + "Love", + "Confused", + "Sleeping", + "Wink", +]; + +/// Rendered preview of the full avatar with all layers composited. +/// +/// Shows all 9 positions of each layer stacked: skin -> clothes -> accessories -> current emotion +#[component] +fn RenderedPreview(#[prop(into)] avatar: Signal>) -> impl IntoView { + let canvas_ref = NodeRef::::new(); + let canvas_size = 200; + let cell_size = canvas_size / 3; + + #[cfg(feature = "hydrate")] + { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + use wasm_bindgen::closure::Closure; + use wasm_bindgen::JsCast; + + let image_cache: Rc>> = + Rc::new(RefCell::new(HashMap::new())); + + let (redraw_trigger, set_redraw_trigger) = signal(0u32); + + Effect::new(move |_| { + let _ = redraw_trigger.get(); + let Some(canvas) = canvas_ref.get() else { + return; + }; + + let Some(av) = avatar.get() else { + return; + }; + + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; + canvas_el.set_width(canvas_size as u32); + canvas_el.set_height(canvas_size as u32); + + let Ok(Some(ctx)) = canvas_el.get_context("2d") else { + return; + }; + let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); + + // Clear canvas + ctx.clear_rect(0.0, 0.0, canvas_size as f64, canvas_size as f64); + + // Draw background + ctx.set_fill_style_str("#374151"); + ctx.fill_rect(0.0, 0.0, canvas_size as f64, canvas_size as f64); + + // Helper to load and draw an image at a grid position + let draw_at_position = |path: &str, + pos: usize, + cache: &Rc>>, + ctx: &web_sys::CanvasRenderingContext2d| { + let normalized_path = normalize_asset_path(path); + let mut cache_borrow = cache.borrow_mut(); + let row = pos / 3; + let col = pos % 3; + let x = (col * cell_size) as f64; + let y = (row * cell_size) as f64; + let size = cell_size as f64; + + if let Some(img) = cache_borrow.get(&normalized_path) { + if img.complete() && img.natural_width() > 0 { + let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh( + img, x, y, size, size, + ); + } + } else { + let img = web_sys::HtmlImageElement::new().unwrap(); + let trigger = set_redraw_trigger; + let onload = Closure::once(Box::new(move || { + trigger.update(|v| *v += 1); + }) as Box); + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src(&normalized_path); + cache_borrow.insert(normalized_path, img); + } + }; + + // Draw layers in order: skin -> clothes -> accessories -> current emotion + for (pos, path) in av.skin_layer.iter().enumerate() { + if let Some(p) = path { + draw_at_position(p, pos, &image_cache, &ctx); + } + } + + for (pos, path) in av.clothes_layer.iter().enumerate() { + if let Some(p) = path { + draw_at_position(p, pos, &image_cache, &ctx); + } + } + + for (pos, path) in av.accessories_layer.iter().enumerate() { + if let Some(p) = path { + draw_at_position(p, pos, &image_cache, &ctx); + } + } + + // Draw current emotion layer + let emotion_idx = av.current_emotion as usize; + if emotion_idx < 12 { + for (pos, path) in av.emotions[emotion_idx].iter().enumerate() { + if let Some(p) = path { + draw_at_position(p, pos, &image_cache, &ctx); + } + } + } + }); + } + + view! { +
+ +
+ } +} + +/// 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. +/// +/// Props: +/// - `open`: Signal controlling visibility +/// - `on_close`: Callback when popup should close +/// - `avatar`: Current avatar data with paths +/// - `realm_slug`: Current realm slug for API calls +/// - `on_avatar_update`: Callback when avatar is updated +/// - `ws_sender`: WebSocket sender for broadcasting avatar changes +#[component] +pub fn AvatarEditorPopup( + #[prop(into)] open: Signal, + on_close: Callback<()>, + #[prop(into)] avatar: Signal>, + #[prop(into)] realm_slug: Signal, + on_avatar_update: Callback, + ws_sender: WsSenderStorage, +) -> impl IntoView { + // Tab state + let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers); + + // Base Layer selection + let (selected_layer, set_selected_layer) = signal(BaseLayer::default()); + + // Emotion selection (0-11) + let (selected_emotion, set_selected_emotion) = signal(0usize); + + // Display Rendered toggle + let (display_rendered, set_display_rendered) = signal(false); + + // Inventory sub-tab + let (inventory_tab, set_inventory_tab) = signal(InventoryTab::default()); + + // Inventory state + let (inventory_items, set_inventory_items) = signal(Vec::::new()); + let (inventory_loading, set_inventory_loading) = signal(false); + let (inventory_loaded, set_inventory_loaded) = signal(false); + + // Drag state + let (dragging_item, set_dragging_item) = signal(Option::::None); + let (hover_cell, set_hover_cell) = signal(Option::::None); + + // Context menu for clearing + let (context_menu, set_context_menu) = signal(Option::::None); + + // Saving state + let (saving, set_saving) = signal(false); + + // Helper to get current layer name for API calls + let get_current_layer_name = move || -> String { + match active_tab.get() { + EditorTab::BaseLayers => match selected_layer.get() { + BaseLayer::Skin => "skin".to_string(), + BaseLayer::Clothes => "clothes".to_string(), + BaseLayer::Accessories => "accessories".to_string(), + }, + EditorTab::Emotions => EMOTIONS[selected_emotion.get()].to_lowercase(), + } + }; + + // Fetch inventory when popup opens + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + use leptos::task::spawn_local; + + Effect::new(move |_| { + if !open.get() { + // Reset state when closing + set_inventory_loaded.set(false); + set_context_menu.set(None); + return; + } + + if inventory_loaded.get() { + return; + } + + set_inventory_loading.set(true); + + spawn_local(async move { + let response = Request::get("/api/inventory").send().await; + match response { + Ok(resp) if resp.ok() => { + if let Ok(data) = + resp.json::().await + { + set_inventory_items.set(data.items); + set_inventory_loaded.set(true); + } + } + _ => {} + } + set_inventory_loading.set(false); + }); + }); + } + + // 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::::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 + let close_context_menu = move |_| { + set_context_menu.set(None); + }; + + // Get current layer's grid paths based on selection + let current_grid = Memo::new(move |_| { + let av = avatar.get(); + let Some(av) = av else { + return [None, None, None, None, None, None, None, None, None]; + }; + + match active_tab.get() { + EditorTab::BaseLayers => match selected_layer.get() { + BaseLayer::Skin => av.skin_layer.clone(), + BaseLayer::Clothes => av.clothes_layer.clone(), + BaseLayer::Accessories => av.accessories_layer.clone(), + }, + EditorTab::Emotions => { + let emotion_idx = selected_emotion.get(); + if emotion_idx < 12 { + av.emotions[emotion_idx].clone() + } else { + Default::default() + } + } + } + }); + + // Helper function to filter inventory items based on current selection + let get_filtered_inventory = move || -> Vec { + let items = inventory_items.get(); + if inventory_tab.get() == InventoryTab::AllProps { + return items; + } + + // Filter based on current selection + match active_tab.get() { + EditorTab::BaseLayers => { + let layer = selected_layer.get(); + items + .into_iter() + .filter(|item| { + item.layer + .map(|l| match (l, layer) { + (chattyness_db::models::AvatarLayer::Skin, BaseLayer::Skin) => true, + (chattyness_db::models::AvatarLayer::Clothes, BaseLayer::Clothes) => true, + (chattyness_db::models::AvatarLayer::Accessories, BaseLayer::Accessories) => true, + _ => false, + }) + .unwrap_or(false) + }) + .collect() + } + EditorTab::Emotions => { + // For emotions, we don't have a default_emotion field on InventoryItem + // so we show all items for now (or could filter by some other criteria) + items + } + } + }; + + // Handle layer click - toggle display_rendered if same layer + let on_layer_click = move |layer: BaseLayer| { + if selected_layer.get() == layer { + set_display_rendered.update(|v| *v = !*v); + } else { + set_selected_layer.set(layer); + } + }; + + // Handle emotion click - toggle display_rendered if same emotion + let on_emotion_click = move |emotion_idx: usize| { + if selected_emotion.get() == emotion_idx { + set_display_rendered.update(|v| *v = !*v); + } else { + set_selected_emotion.set(emotion_idx); + } + }; + + let on_close_backdrop = on_close.clone(); + let on_close_button = on_close.clone(); + + view! { + + + + } +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index 58e2108..c59994c 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -304,6 +304,22 @@ fn handle_server_message( // Treat expired props the same as picked up (remove from display) on_prop_picked_up.run(prop_id); } + ServerMessage::AvatarUpdated { + user_id, + guest_session_id, + avatar, + } => { + // Find member and update their avatar layers + if let Some(m) = members_vec.iter_mut().find(|m| { + m.member.user_id == user_id && m.member.guest_session_id == guest_session_id + }) { + m.avatar.skin_layer = avatar.skin_layer.clone(); + m.avatar.clothes_layer = avatar.clothes_layer.clone(); + m.avatar.accessories_layer = avatar.accessories_layer.clone(); + m.avatar.emotion_layer = avatar.emotion_layer.clone(); + } + on_update.run(members_vec.clone()); + } } } diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index fc6992c..5b048ec 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -12,9 +12,9 @@ use leptos_router::hooks::use_params_map; use uuid::Uuid; use crate::components::{ - ActiveBubble, Card, ChatInput, ChatMessage, EmotionKeybindings, InventoryPopup, - KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup, ViewerSettings, - DEFAULT_BUBBLE_TIMEOUT_MS, + ActiveBubble, AvatarEditorPopup, Card, ChatInput, ChatMessage, EmotionKeybindings, + InventoryPopup, KeybindingsPopup, MessageLog, RealmHeader, RealmSceneViewer, SettingsPopup, + ViewerSettings, DEFAULT_BUBBLE_TIMEOUT_MS, }; #[cfg(feature = "hydrate")] use crate::components::use_channel_websocket; @@ -105,6 +105,11 @@ pub fn RealmPage() -> impl IntoView { let keybindings = RwSignal::new(EmotionKeybindings::load()); let (keybindings_open, set_keybindings_open) = signal(false); + // Avatar editor popup state + let (avatar_editor_open, set_avatar_editor_open) = signal(false); + // Store full avatar data for the editor + let (full_avatar, set_full_avatar) = signal(Option::::None); + // Scene dimensions (extracted from bounds_wkt when scene loads) let (scene_dimensions, set_scene_dimensions) = signal((800.0_f64, 600.0_f64)); @@ -193,6 +198,8 @@ pub fn RealmPage() -> impl IntoView { set_emotion_availability.set(Some(avail)); // Get skin layer position 4 (center) for preview set_skin_preview_path.set(avatar.skin_layer[4].clone()); + // Store full avatar for the editor + set_full_avatar.set(Some(avatar)); } } } @@ -462,6 +469,13 @@ pub fn RealmPage() -> impl IntoView { return; } + // Handle 'a' to toggle avatar editor + if key == "a" || key == "A" { + set_avatar_editor_open.update(|v| *v = !*v); + ev.prevent_default(); + return; + } + // Check if 'e' key was pressed if key == "e" || key == "E" { *e_pressed_clone.borrow_mut() = true; @@ -732,6 +746,31 @@ pub fn RealmPage() -> impl IntoView { skin_preview_path=Signal::derive(move || skin_preview_path.get()) on_close=Callback::new(move |_: ()| set_keybindings_open.set(false)) /> + + // Avatar editor popup + { + #[cfg(feature = "hydrate")] + let ws_sender_for_avatar = ws_sender.clone(); + #[cfg(not(feature = "hydrate"))] + let ws_sender_for_avatar: StoredValue, LocalStorage> = StoredValue::new_local(None); + view! { + + } + } } .into_any() }