880 lines
42 KiB
Rust
880 lines
42 KiB
Rust
//! 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<Option<AvatarWithPaths>>) -> impl IntoView {
|
|
let canvas_ref = NodeRef::<leptos::html::Canvas>::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<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
|
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<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
|
|
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<dyn FnOnce()>);
|
|
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! {
|
|
<div class="bg-gray-900 p-2 rounded-lg">
|
|
<canvas
|
|
node_ref=canvas_ref
|
|
style=format!("width: {}px; height: {}px;", canvas_size, canvas_size)
|
|
/>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// 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<bool>,
|
|
on_close: Callback<()>,
|
|
#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>,
|
|
#[prop(into)] realm_slug: Signal<String>,
|
|
on_avatar_update: Callback<AvatarWithPaths>,
|
|
ws_sender: WsSenderStorage,
|
|
/// Whether the current user is a guest. Guests see a locked overlay.
|
|
#[prop(optional, into)]
|
|
is_guest: Option<Signal<bool>>,
|
|
) -> 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::<InventoryItem>::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::<Uuid>::None);
|
|
let (hover_cell, set_hover_cell) = signal(Option::<usize>::None);
|
|
|
|
// Context menu for clearing
|
|
let (context_menu, set_context_menu) = signal(Option::<ContextMenuState>::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::<chattyness_db::models::InventoryResponse>()
|
|
.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<InventoryItem> {
|
|
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! {
|
|
<Show when=move || open.get()>
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="avatar-editor-title"
|
|
on:click=close_context_menu
|
|
>
|
|
// 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-4xl w-full mx-4 border border-gray-700 max-h-[85vh] flex flex-col">
|
|
// Header
|
|
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
|
<h2 id="avatar-editor-title" class="text-xl font-bold text-white">
|
|
"Avatar Editor"
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
class="text-gray-400 hover:text-white transition-colors"
|
|
on:click=move |_| on_close_button.run(())
|
|
aria-label="Close avatar editor"
|
|
>
|
|
<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
|
|
<div class="flex border-b border-gray-700 px-4" role="tablist">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected=move || active_tab.get() == EditorTab::BaseLayers
|
|
class=move || format!(
|
|
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
|
|
if active_tab.get() == EditorTab::BaseLayers {
|
|
"text-blue-400 border-blue-400"
|
|
} else {
|
|
"text-gray-400 border-transparent hover:text-gray-300"
|
|
}
|
|
)
|
|
on:click=move |_| set_active_tab.set(EditorTab::BaseLayers)
|
|
>
|
|
"Base Layers"
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected=move || active_tab.get() == EditorTab::Emotions
|
|
class=move || format!(
|
|
"px-4 py-2 font-medium transition-colors border-b-2 -mb-px {}",
|
|
if active_tab.get() == EditorTab::Emotions {
|
|
"text-blue-400 border-blue-400"
|
|
} else {
|
|
"text-gray-400 border-transparent hover:text-gray-300"
|
|
}
|
|
)
|
|
on:click=move |_| set_active_tab.set(EditorTab::Emotions)
|
|
>
|
|
"Emotions"
|
|
</button>
|
|
</div>
|
|
|
|
// Main content - two columns
|
|
<div class="flex-1 flex overflow-hidden p-4 gap-4">
|
|
// Left column - Navigation + Grid
|
|
<div class="flex gap-4 min-w-0">
|
|
// Left navigation
|
|
<div class="w-28 flex flex-col gap-1">
|
|
<Show when=move || active_tab.get() == EditorTab::BaseLayers>
|
|
{[BaseLayer::Skin, BaseLayer::Clothes, BaseLayer::Accessories]
|
|
.into_iter()
|
|
.map(|layer| {
|
|
let is_selected = move || selected_layer.get() == layer;
|
|
view! {
|
|
<button
|
|
type="button"
|
|
class=move || format!(
|
|
"px-3 py-2 text-left text-sm rounded transition-colors {}",
|
|
if is_selected() {
|
|
"bg-blue-600 text-white"
|
|
} else {
|
|
"text-gray-400 hover:bg-gray-700 hover:text-white"
|
|
}
|
|
)
|
|
on:click=move |_| on_layer_click(layer)
|
|
>
|
|
{layer.display_name()}
|
|
</button>
|
|
}
|
|
})
|
|
.collect_view()}
|
|
</Show>
|
|
<Show when=move || active_tab.get() == EditorTab::Emotions>
|
|
<div class="overflow-y-auto max-h-64">
|
|
{EMOTIONS
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, name)| {
|
|
let is_selected = move || selected_emotion.get() == idx;
|
|
let av = avatar.clone();
|
|
let is_available = move || {
|
|
av.get().map(|a| a.emotions_available[idx]).unwrap_or(false)
|
|
};
|
|
view! {
|
|
<button
|
|
type="button"
|
|
class=move || format!(
|
|
"px-3 py-1.5 text-left text-sm rounded transition-colors {}",
|
|
if is_selected() {
|
|
"bg-blue-600 text-white"
|
|
} else if is_available() {
|
|
"text-gray-300 hover:bg-gray-700 hover:text-white"
|
|
} else {
|
|
"text-gray-500 hover:bg-gray-700"
|
|
}
|
|
)
|
|
on:click=move |_| on_emotion_click(idx)
|
|
>
|
|
{*name}
|
|
</button>
|
|
}
|
|
})
|
|
.collect_view()}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
// 3x3 Grid / Rendered Preview
|
|
<div class="flex flex-col items-center">
|
|
// Rendered preview (when display_rendered is true)
|
|
<Show when=move || display_rendered.get()>
|
|
<RenderedPreview avatar=avatar.clone() />
|
|
</Show>
|
|
|
|
// Regular 3x3 grid (when display_rendered is false)
|
|
<Show when=move || !display_rendered.get()>
|
|
<div
|
|
class="grid grid-cols-3 gap-1 bg-gray-900 p-2 rounded-lg"
|
|
role="grid"
|
|
aria-label="Avatar grid"
|
|
>
|
|
{(0..9)
|
|
.map(|pos| {
|
|
let grid = current_grid.clone();
|
|
let path = move || grid.get()[pos].clone();
|
|
let is_hover = move || hover_cell.get() == Some(pos);
|
|
|
|
view! {
|
|
<div
|
|
class=move || format!(
|
|
"w-16 h-16 border-2 rounded flex items-center justify-center transition-all {}",
|
|
if is_hover() {
|
|
"border-blue-400 bg-blue-900/30"
|
|
} else if path().is_some() {
|
|
"border-gray-600 bg-gray-700"
|
|
} else {
|
|
"border-gray-700 border-dashed bg-gray-800"
|
|
}
|
|
)
|
|
on:dragover=move |ev: web_sys::DragEvent| {
|
|
ev.prevent_default();
|
|
set_hover_cell.set(Some(pos));
|
|
}
|
|
on:dragleave=move |_| {
|
|
set_hover_cell.set(None);
|
|
}
|
|
on:drop=move |ev: web_sys::DragEvent| {
|
|
ev.prevent_default();
|
|
set_hover_cell.set(None);
|
|
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
use gloo_net::http::Request;
|
|
use leptos::task::spawn_local;
|
|
|
|
if let Some(item_id) = dragging_item.get() {
|
|
let slug = realm_slug.get();
|
|
let layer = get_current_layer_name();
|
|
let position = pos as u8;
|
|
let on_update = on_avatar_update.clone();
|
|
|
|
set_saving.set(true);
|
|
|
|
spawn_local(async move {
|
|
let body = serde_json::json!({
|
|
"inventory_item_id": item_id,
|
|
"layer": layer,
|
|
"position": position,
|
|
});
|
|
|
|
let result = Request::put(&format!("/api/realms/{}/avatar/slot", slug))
|
|
.header("Content-Type", "application/json")
|
|
.body(body.to_string())
|
|
.unwrap()
|
|
.send()
|
|
.await;
|
|
|
|
if let Ok(resp) = result {
|
|
if resp.ok() {
|
|
if let Ok(avatar) = resp.json::<AvatarWithPaths>().await {
|
|
on_update.run(avatar);
|
|
}
|
|
}
|
|
}
|
|
set_saving.set(false);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
on:contextmenu=move |ev: web_sys::MouseEvent| {
|
|
ev.prevent_default();
|
|
if path().is_some() {
|
|
set_context_menu.set(Some(ContextMenuState {
|
|
x: ev.client_x(),
|
|
y: ev.client_y(),
|
|
position: pos as u8,
|
|
}));
|
|
}
|
|
}
|
|
role="gridcell"
|
|
aria-label=move || format!("Position {}", pos)
|
|
>
|
|
{move || {
|
|
path().map(|p| {
|
|
let asset_path = if p.starts_with('/') {
|
|
p.clone()
|
|
} else {
|
|
format!("/static/{}", p)
|
|
};
|
|
view! {
|
|
<img
|
|
src=asset_path
|
|
alt=""
|
|
class="w-full h-full object-contain"
|
|
/>
|
|
}
|
|
})
|
|
}}
|
|
</div>
|
|
}
|
|
})
|
|
.collect_view()}
|
|
</div>
|
|
</Show>
|
|
|
|
// Display Rendered toggle
|
|
<label class="flex items-center gap-2 mt-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
class="w-4 h-4 rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-blue-500"
|
|
prop:checked=move || display_rendered.get()
|
|
on:change=move |ev: web_sys::Event| {
|
|
use leptos::wasm_bindgen::JsCast;
|
|
let checked = ev
|
|
.target()
|
|
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
|
|
.map(|el| el.checked())
|
|
.unwrap_or(false);
|
|
set_display_rendered.set(checked);
|
|
}
|
|
/>
|
|
<span class="text-sm text-gray-400">"Display Rendered"</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
// Sync button to broadcast avatar changes
|
|
<div class="flex justify-end mt-4">
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded transition-colors font-medium"
|
|
on:click=move |_| {
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
#[cfg(debug_assertions)]
|
|
web_sys::console::log_1(&"[AvatarEditor] Sync button clicked".into());
|
|
ws_sender.with_value(|sender| {
|
|
match sender {
|
|
Some(send_fn) => {
|
|
#[cfg(debug_assertions)]
|
|
web_sys::console::log_1(&"[AvatarEditor] Sending SyncAvatar".into());
|
|
send_fn(ClientMessage::SyncAvatar);
|
|
}
|
|
None => {
|
|
#[cfg(debug_assertions)]
|
|
web_sys::console::error_1(&"[AvatarEditor] ERROR: ws_sender is None!".into());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
>
|
|
"Save & Sync"
|
|
</button>
|
|
</div>
|
|
|
|
// Right column - Inventory
|
|
<div class="flex-1 flex flex-col min-w-0 border-l border-gray-700 pl-4">
|
|
// Inventory sub-tabs
|
|
<div class="flex gap-2 mb-3">
|
|
<button
|
|
type="button"
|
|
class=move || format!(
|
|
"px-3 py-1 text-sm rounded-full transition-colors {}",
|
|
if inventory_tab.get() == InventoryTab::Suggested {
|
|
"bg-blue-600 text-white"
|
|
} else {
|
|
"bg-gray-700 text-gray-400 hover:text-white"
|
|
}
|
|
)
|
|
on:click=move |_| set_inventory_tab.set(InventoryTab::Suggested)
|
|
>
|
|
"Suggested"
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class=move || format!(
|
|
"px-3 py-1 text-sm rounded-full transition-colors {}",
|
|
if inventory_tab.get() == InventoryTab::AllProps {
|
|
"bg-blue-600 text-white"
|
|
} else {
|
|
"bg-gray-700 text-gray-400 hover:text-white"
|
|
}
|
|
)
|
|
on:click=move |_| set_inventory_tab.set(InventoryTab::AllProps)
|
|
>
|
|
"All Props"
|
|
</button>
|
|
</div>
|
|
|
|
// Inventory grid
|
|
<div class="flex-1 overflow-y-auto">
|
|
<Show when=move || inventory_loading.get()>
|
|
<div class="flex items-center justify-center py-8">
|
|
<p class="text-gray-400">"Loading props..."</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when=move || !inventory_loading.get() && get_filtered_inventory().is_empty()>
|
|
<div class="flex flex-col items-center justify-center py-8 text-center">
|
|
<p class="text-gray-400">"No props available"</p>
|
|
<p class="text-gray-500 text-sm mt-1">"Drag props here to customize your avatar"</p>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when=move || !inventory_loading.get() && !get_filtered_inventory().is_empty()>
|
|
<div
|
|
class="grid grid-cols-4 gap-2"
|
|
role="listbox"
|
|
aria-label="Available props"
|
|
>
|
|
<For
|
|
each=move || get_filtered_inventory()
|
|
key=|item| item.id
|
|
children=move |item: InventoryItem| {
|
|
let item_id = item.id;
|
|
let item_name = item.prop_name.clone();
|
|
let asset_path = if item.prop_asset_path.starts_with('/') {
|
|
item.prop_asset_path.clone()
|
|
} else {
|
|
format!("/static/{}", item.prop_asset_path)
|
|
};
|
|
|
|
view! {
|
|
<div
|
|
class="aspect-square rounded-lg border-2 border-gray-600 hover:border-gray-500 bg-gray-700/50 p-1 cursor-grab transition-all"
|
|
draggable="true"
|
|
on:dragstart=move |_ev: web_sys::DragEvent| {
|
|
set_dragging_item.set(Some(item_id));
|
|
}
|
|
on:dragend=move |_| {
|
|
set_dragging_item.set(None);
|
|
set_hover_cell.set(None);
|
|
}
|
|
role="option"
|
|
aria-label=item_name
|
|
>
|
|
<img
|
|
src=asset_path
|
|
alt=""
|
|
class="w-full h-full object-contain pointer-events-none"
|
|
/>
|
|
</div>
|
|
}
|
|
}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
// Guest locked overlay
|
|
<Show when=move || is_guest.get()>
|
|
<GuestLockedOverlay />
|
|
</Show>
|
|
</div>
|
|
|
|
// Context menu
|
|
{move || {
|
|
context_menu.get().map(|menu| {
|
|
let layer_name = match active_tab.get() {
|
|
EditorTab::BaseLayers => selected_layer.get().display_name().to_lowercase(),
|
|
EditorTab::Emotions => EMOTIONS[selected_emotion.get()].to_lowercase(),
|
|
};
|
|
let layer_for_api = get_current_layer_name();
|
|
let position = menu.position;
|
|
|
|
view! {
|
|
<div
|
|
class="fixed bg-gray-900 border border-gray-600 rounded-lg shadow-xl py-1 z-[60]"
|
|
style=move || format!("left: {}px; top: {}px;", menu.x, menu.y)
|
|
>
|
|
<button
|
|
type="button"
|
|
class="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-800 transition-colors"
|
|
on:click=move |_| {
|
|
set_context_menu.set(None);
|
|
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
use gloo_net::http::Request;
|
|
use leptos::task::spawn_local;
|
|
|
|
let slug = realm_slug.get();
|
|
let layer = layer_for_api.clone();
|
|
let on_update = on_avatar_update.clone();
|
|
|
|
set_saving.set(true);
|
|
|
|
spawn_local(async move {
|
|
let body = serde_json::json!({
|
|
"layer": layer,
|
|
"position": position,
|
|
});
|
|
|
|
let result = Request::delete(&format!("/api/realms/{}/avatar/slot", slug))
|
|
.header("Content-Type", "application/json")
|
|
.body(body.to_string())
|
|
.unwrap()
|
|
.send()
|
|
.await;
|
|
|
|
if let Ok(resp) = result {
|
|
if resp.ok() {
|
|
if let Ok(avatar) = resp.json::<AvatarWithPaths>().await {
|
|
on_update.run(avatar);
|
|
}
|
|
}
|
|
}
|
|
set_saving.set(false);
|
|
});
|
|
}
|
|
}
|
|
>
|
|
{format!("Clear prop from {} layer", layer_name)}
|
|
</button>
|
|
</div>
|
|
}
|
|
})
|
|
}}
|
|
</div>
|
|
</Show>
|
|
}
|
|
}
|