avatar fixes and implementation to edit
This commit is contained in:
parent
acab2f017d
commit
c3320ddcce
11 changed files with 1417 additions and 37 deletions
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
/// Guest session ID (if guest).
|
||||
guest_session_id: Option<Uuid>,
|
||||
/// Updated avatar render data.
|
||||
avatar: AvatarRenderData,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
Json(req): Json<AssignSlotRequest>,
|
||||
) -> Result<Json<AvatarWithPaths>, 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<String>,
|
||||
Json(req): Json<ClearSlotRequest>,
|
||||
) -> Result<Json<AvatarWithPaths>, 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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ pub fn api_router() -> Router<AppState> {
|
|||
)
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
894
crates/chattyness-user-ui/src/components/avatar_editor.rs
Normal file
894
crates/chattyness-user-ui/src/components/avatar_editor.rs
Normal file
|
|
@ -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<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::closure::Closure;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
on_close: Callback<()>,
|
||||
#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>,
|
||||
#[prop(into)] realm_slug: Signal<String>,
|
||||
on_avatar_update: Callback<AvatarWithPaths>,
|
||||
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::<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
|
||||
#[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(),
|
||||
);
|
||||
}
|
||||
|
||||
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<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>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<AvatarWithPaths>::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<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||
view! {
|
||||
<AvatarEditorPopup
|
||||
open=Signal::derive(move || avatar_editor_open.get())
|
||||
on_close=Callback::new(move |_: ()| set_avatar_editor_open.set(false))
|
||||
avatar=Signal::derive(move || full_avatar.get())
|
||||
realm_slug=Signal::derive(move || slug.get())
|
||||
on_avatar_update=Callback::new(move |updated: AvatarWithPaths| {
|
||||
set_full_avatar.set(Some(updated.clone()));
|
||||
// Update emotion availability for the emotion picker
|
||||
let avail = updated.compute_emotion_availability();
|
||||
set_emotion_availability.set(Some(avail));
|
||||
// Update skin preview
|
||||
set_skin_preview_path.set(updated.skin_layer[4].clone());
|
||||
})
|
||||
ws_sender=ws_sender_for_avatar
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue