//! 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::modals::GuestLockedOverlay; 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 #[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::JsCast; use wasm_bindgen::closure::Closure; 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! {
} } /// 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 /// - `is_guest`: Whether the current user is a guest (shows locked overlay) #[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, /// Whether the current user is a guest. Guests see a locked overlay. #[prop(optional, into)] is_guest: Option>, ) -> impl IntoView { let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false)); // 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 use_escape_key(open, on_close.clone()); // 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! { } }