fix: rendering of avatar thumbnails
This commit is contained in:
parent
a2a0fe5510
commit
23630b19b2
8 changed files with 931 additions and 24 deletions
|
|
@ -7,7 +7,7 @@ use axum::Json;
|
|||
use axum::extract::Path;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest, RealmAvatar, ServerAvatar},
|
||||
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest, RealmAvatarWithPaths, ServerAvatarWithPaths},
|
||||
queries::{avatars, realm_avatars, realms, server_avatars},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
|
@ -135,33 +135,35 @@ pub async fn clear_slot(
|
|||
// Avatar Store Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/// List public server avatars.
|
||||
/// List public server avatars with resolved paths.
|
||||
///
|
||||
/// GET /api/server/avatars
|
||||
///
|
||||
/// Returns all public, active server avatars that users can select from.
|
||||
/// Includes resolved asset paths for client-side rendering.
|
||||
pub async fn list_server_avatars(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<ServerAvatar>>, AppError> {
|
||||
let avatars = server_avatars::list_public_server_avatars(&pool).await?;
|
||||
) -> Result<Json<Vec<ServerAvatarWithPaths>>, AppError> {
|
||||
let avatars = server_avatars::list_public_server_avatars_with_paths(&pool).await?;
|
||||
Ok(Json(avatars))
|
||||
}
|
||||
|
||||
/// List public realm avatars.
|
||||
/// List public realm avatars with resolved paths.
|
||||
///
|
||||
/// GET /api/realms/{slug}/avatars
|
||||
///
|
||||
/// Returns all public, active realm avatars for the specified realm.
|
||||
/// Includes resolved asset paths for client-side rendering.
|
||||
pub async fn list_realm_avatars(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Vec<RealmAvatar>>, AppError> {
|
||||
) -> Result<Json<Vec<RealmAvatarWithPaths>>, AppError> {
|
||||
// Get realm
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
let avatars = realm_avatars::list_public_realm_avatars(&pool, realm.id).await?;
|
||||
let avatars = realm_avatars::list_public_realm_avatars_with_paths(&pool, realm.id).await?;
|
||||
Ok(Json(avatars))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
pub mod avatar_canvas;
|
||||
pub mod avatar_editor;
|
||||
pub mod avatar_store;
|
||||
pub mod avatar_thumbnail;
|
||||
pub mod chat;
|
||||
pub mod chat_types;
|
||||
pub mod context_menu;
|
||||
|
|
@ -30,6 +31,7 @@ pub mod ws_client;
|
|||
pub use avatar_canvas::*;
|
||||
pub use avatar_editor::*;
|
||||
pub use avatar_store::*;
|
||||
pub use avatar_thumbnail::*;
|
||||
pub use chat::*;
|
||||
pub use chat_types::*;
|
||||
pub use context_menu::*;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::{RealmAvatar, ServerAvatar};
|
||||
use chattyness_db::models::{RealmAvatarWithPaths, ServerAvatarWithPaths};
|
||||
|
||||
use super::avatar_thumbnail::AvatarThumbnail;
|
||||
use super::modals::{GuestLockedOverlay, Modal};
|
||||
use super::tabs::{Tab, TabBar};
|
||||
|
||||
|
|
@ -40,13 +41,13 @@ pub fn AvatarStorePopup(
|
|||
let (active_tab, set_active_tab) = signal("server");
|
||||
|
||||
// Server avatars state
|
||||
let (server_avatars, set_server_avatars) = signal(Vec::<ServerAvatar>::new());
|
||||
let (server_avatars, set_server_avatars) = signal(Vec::<ServerAvatarWithPaths>::new());
|
||||
let (server_loading, set_server_loading) = signal(false);
|
||||
let (server_error, set_server_error) = signal(Option::<String>::None);
|
||||
let (selected_server_avatar, set_selected_server_avatar) = signal(Option::<Uuid>::None);
|
||||
|
||||
// Realm avatars state
|
||||
let (realm_avatars, set_realm_avatars) = signal(Vec::<RealmAvatar>::new());
|
||||
let (realm_avatars, set_realm_avatars) = signal(Vec::<RealmAvatarWithPaths>::new());
|
||||
let (realm_loading, set_realm_loading) = signal(false);
|
||||
let (realm_error, set_realm_error) = signal(Option::<String>::None);
|
||||
let (selected_realm_avatar, set_selected_realm_avatar) = signal(Option::<Uuid>::None);
|
||||
|
|
@ -88,7 +89,7 @@ pub fn AvatarStorePopup(
|
|||
let response = Request::get("/api/server/avatars").send().await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(data) = resp.json::<Vec<ServerAvatar>>().await {
|
||||
if let Ok(data) = resp.json::<Vec<ServerAvatarWithPaths>>().await {
|
||||
set_server_avatars.set(data);
|
||||
set_server_loaded.set(true);
|
||||
} else {
|
||||
|
|
@ -136,7 +137,7 @@ pub fn AvatarStorePopup(
|
|||
.await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(data) = resp.json::<Vec<RealmAvatar>>().await {
|
||||
if let Ok(data) = resp.json::<Vec<RealmAvatarWithPaths>>().await {
|
||||
set_realm_avatars.set(data);
|
||||
set_realm_loaded.set(true);
|
||||
} else {
|
||||
|
|
@ -298,7 +299,10 @@ pub fn AvatarStorePopup(
|
|||
id: a.id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
thumbnail_path: a.thumbnail_path,
|
||||
skin_layer: a.skin_layer,
|
||||
clothes_layer: a.clothes_layer,
|
||||
accessories_layer: a.accessories_layer,
|
||||
emotion_layer: a.emotion_layer,
|
||||
}).collect())
|
||||
loading=server_loading
|
||||
error=server_error
|
||||
|
|
@ -321,7 +325,10 @@ pub fn AvatarStorePopup(
|
|||
id: a.id,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
thumbnail_path: a.thumbnail_path,
|
||||
skin_layer: a.skin_layer,
|
||||
clothes_layer: a.clothes_layer,
|
||||
accessories_layer: a.accessories_layer,
|
||||
emotion_layer: a.emotion_layer,
|
||||
}).collect())
|
||||
loading=realm_loading
|
||||
error=realm_error
|
||||
|
|
@ -347,13 +354,20 @@ pub fn AvatarStorePopup(
|
|||
}
|
||||
}
|
||||
|
||||
/// Simplified avatar info for the grid.
|
||||
/// Simplified avatar info for the grid with resolved paths for rendering.
|
||||
#[derive(Clone)]
|
||||
struct AvatarInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
/// Asset paths for skin layer positions 0-8
|
||||
skin_layer: [Option<String>; 9],
|
||||
/// Asset paths for clothes layer positions 0-8
|
||||
clothes_layer: [Option<String>; 9],
|
||||
/// Asset paths for accessories layer positions 0-8
|
||||
accessories_layer: [Option<String>; 9],
|
||||
/// Asset paths for emotion layer positions 0-8
|
||||
emotion_layer: [Option<String>; 9],
|
||||
}
|
||||
|
||||
/// Avatars tab content with selection functionality.
|
||||
|
|
@ -415,15 +429,30 @@ fn AvatarsTab(
|
|||
let avatar_id = avatar.id;
|
||||
let avatar_name = avatar.name.clone();
|
||||
let is_selected = move || selected_id.get() == Some(avatar_id);
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p))
|
||||
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
|
||||
|
||||
// Create signals for the layer data
|
||||
let skin_layer = Signal::derive({
|
||||
let layers = avatar.skin_layer.clone();
|
||||
move || layers.clone()
|
||||
});
|
||||
let clothes_layer = Signal::derive({
|
||||
let layers = avatar.clothes_layer.clone();
|
||||
move || layers.clone()
|
||||
});
|
||||
let accessories_layer = Signal::derive({
|
||||
let layers = avatar.accessories_layer.clone();
|
||||
move || layers.clone()
|
||||
});
|
||||
let emotion_layer = Signal::derive({
|
||||
let layers = avatar.emotion_layer.clone();
|
||||
move || layers.clone()
|
||||
});
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || format!(
|
||||
"aspect-square rounded-lg border-2 transition-all p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
|
||||
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
|
||||
if is_selected() {
|
||||
"border-blue-500 bg-blue-900/30"
|
||||
} else {
|
||||
|
|
@ -437,10 +466,12 @@ fn AvatarsTab(
|
|||
aria-selected=is_selected
|
||||
aria-label=avatar_name
|
||||
>
|
||||
<img
|
||||
src=thumbnail_url
|
||||
alt=""
|
||||
class="w-full h-full object-contain rounded"
|
||||
<AvatarThumbnail
|
||||
skin_layer=skin_layer
|
||||
clothes_layer=clothes_layer
|
||||
accessories_layer=accessories_layer
|
||||
emotion_layer=emotion_layer
|
||||
size=72
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
|
|
|||
145
crates/chattyness-user-ui/src/components/avatar_thumbnail.rs
Normal file
145
crates/chattyness-user-ui/src/components/avatar_thumbnail.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! Avatar thumbnail component for rendering avatar previews.
|
||||
//!
|
||||
//! A simplified canvas-based component for rendering avatars in the
|
||||
//! avatar store grid. Uses the same layer compositing as the main
|
||||
//! avatar renderer but at thumbnail size.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::web_sys;
|
||||
|
||||
/// Avatar thumbnail component for the avatar store.
|
||||
///
|
||||
/// Renders a small preview of an avatar using canvas compositing.
|
||||
/// Takes layer paths directly as props.
|
||||
///
|
||||
/// Props:
|
||||
/// - `skin_layer`: Asset paths for skin layer positions 0-8
|
||||
/// - `clothes_layer`: Asset paths for clothes layer positions 0-8
|
||||
/// - `accessories_layer`: Asset paths for accessories layer positions 0-8
|
||||
/// - `emotion_layer`: Asset paths for emotion layer positions 0-8
|
||||
/// - `size`: Optional canvas size in pixels (default: 80)
|
||||
#[component]
|
||||
pub fn AvatarThumbnail(
|
||||
#[prop(into)] skin_layer: Signal<[Option<String>; 9]>,
|
||||
#[prop(into)] clothes_layer: Signal<[Option<String>; 9]>,
|
||||
#[prop(into)] accessories_layer: Signal<[Option<String>; 9]>,
|
||||
#[prop(into)] emotion_layer: Signal<[Option<String>; 9]>,
|
||||
#[prop(default = 80)] size: u32,
|
||||
) -> impl IntoView {
|
||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let cell_size = 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;
|
||||
|
||||
use crate::utils::normalize_asset_path;
|
||||
|
||||
// Image cache for this thumbnail
|
||||
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
||||
Rc::new(RefCell::new(HashMap::new()));
|
||||
|
||||
// Redraw trigger - incremented when images load
|
||||
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
|
||||
|
||||
Effect::new(move |_| {
|
||||
// Subscribe to redraw trigger
|
||||
let _ = redraw_trigger.get();
|
||||
|
||||
let Some(canvas) = canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let skin = skin_layer.get();
|
||||
let clothes = clothes_layer.get();
|
||||
let accessories = accessories_layer.get();
|
||||
let emotion = emotion_layer.get();
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
canvas_el.set_width(size);
|
||||
canvas_el.set_height(size);
|
||||
|
||||
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, size as f64, size as f64);
|
||||
|
||||
// Draw background
|
||||
ctx.set_fill_style_str("#374151");
|
||||
ctx.fill_rect(0.0, 0.0, size as f64, 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 usize) as f64;
|
||||
let y = (row * cell_size as usize) as f64;
|
||||
let sz = 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, sz, sz,
|
||||
);
|
||||
}
|
||||
} 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 -> emotion
|
||||
for (pos, path) in skin.iter().enumerate() {
|
||||
if let Some(p) = path {
|
||||
draw_at_position(p, pos, &image_cache, &ctx);
|
||||
}
|
||||
}
|
||||
|
||||
for (pos, path) in clothes.iter().enumerate() {
|
||||
if let Some(p) = path {
|
||||
draw_at_position(p, pos, &image_cache, &ctx);
|
||||
}
|
||||
}
|
||||
|
||||
for (pos, path) in accessories.iter().enumerate() {
|
||||
if let Some(p) = path {
|
||||
draw_at_position(p, pos, &image_cache, &ctx);
|
||||
}
|
||||
}
|
||||
|
||||
for (pos, path) in emotion.iter().enumerate() {
|
||||
if let Some(p) = path {
|
||||
draw_at_position(p, pos, &image_cache, &ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view! {
|
||||
<canvas
|
||||
node_ref=canvas_ref
|
||||
style=format!("width: {}px; height: {}px;", size, size)
|
||||
class="rounded"
|
||||
/>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue