Rework avatars.

Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
This commit is contained in:
Evan Carroll 2026-01-22 21:04:27 -06:00
parent e4abdb183f
commit 6fb90e42c3
55 changed files with 7392 additions and 512 deletions

View file

@ -3,6 +3,8 @@
#[cfg(feature = "ssr")]
pub mod auth;
#[cfg(feature = "ssr")]
pub mod avatars;
#[cfg(feature = "ssr")]
pub mod config;
#[cfg(feature = "ssr")]
pub mod dashboard;

View file

@ -0,0 +1,127 @@
//! Server avatars management API handlers for admin UI.
use axum::Json;
use axum::extract::State;
use chattyness_db::{
models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest},
queries::server_avatars,
};
use chattyness_error::AppError;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
// =============================================================================
// API Types
// =============================================================================
/// Response for avatar creation.
#[derive(Debug, Serialize)]
pub struct CreateAvatarResponse {
pub id: Uuid,
pub slug: String,
pub name: String,
pub is_public: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<ServerAvatar> for CreateAvatarResponse {
fn from(avatar: ServerAvatar) -> Self {
Self {
id: avatar.id,
slug: avatar.slug,
name: avatar.name,
is_public: avatar.is_public,
created_at: avatar.created_at,
}
}
}
// =============================================================================
// API Handlers
// =============================================================================
/// List all server avatars.
pub async fn list_avatars(
State(pool): State<PgPool>,
) -> Result<Json<Vec<ServerAvatarSummary>>, AppError> {
let avatars = server_avatars::list_all_server_avatars(&pool).await?;
Ok(Json(avatars))
}
/// Create a new server avatar.
pub async fn create_avatar(
State(pool): State<PgPool>,
Json(req): Json<CreateServerAvatarRequest>,
) -> Result<Json<CreateAvatarResponse>, AppError> {
// Validate the request
req.validate()?;
// Check slug availability
let slug = req.slug_or_generate();
let available = server_avatars::is_avatar_slug_available(&pool, &slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Avatar slug '{}' is already taken",
slug
)));
}
// Create the avatar
let avatar = server_avatars::create_server_avatar(&pool, &req, None).await?;
tracing::info!("Created server avatar: {} ({})", avatar.name, avatar.id);
Ok(Json(CreateAvatarResponse::from(avatar)))
}
/// Get a server avatar by ID.
pub async fn get_avatar(
State(pool): State<PgPool>,
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
) -> Result<Json<ServerAvatar>, AppError> {
let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
Ok(Json(avatar))
}
/// Update a server avatar.
pub async fn update_avatar(
State(pool): State<PgPool>,
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
Json(req): Json<UpdateServerAvatarRequest>,
) -> Result<Json<ServerAvatar>, AppError> {
// Validate the request
req.validate()?;
// Check avatar exists
let existing = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
// Update the avatar
let avatar = server_avatars::update_server_avatar(&pool, avatar_id, &req).await?;
tracing::info!("Updated server avatar: {} ({})", existing.name, avatar_id);
Ok(Json(avatar))
}
/// Delete a server avatar.
pub async fn delete_avatar(
State(pool): State<PgPool>,
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
) -> Result<Json<()>, AppError> {
// Get the avatar first to log its name
let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
// Delete from database
server_avatars::delete_server_avatar(&pool, avatar_id).await?;
tracing::info!("Deleted server avatar: {} ({})", avatar.name, avatar_id);
Ok(Json(()))
}

View file

@ -5,8 +5,11 @@ use axum::{
extract::{Path, Query, State},
};
use chattyness_db::{
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
queries::owner as queries,
models::{
CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary,
RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest,
},
queries::{owner as queries, realm_avatars},
};
use chattyness_error::AppError;
use serde::{Deserialize, Serialize};
@ -131,3 +134,144 @@ pub async fn transfer_ownership(
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
Ok(Json(()))
}
// =============================================================================
// Realm Avatar Handlers
// =============================================================================
/// Response for realm avatar creation.
#[derive(Debug, Serialize)]
pub struct CreateRealmAvatarResponse {
pub id: Uuid,
pub slug: String,
pub name: String,
pub is_public: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<RealmAvatar> for CreateRealmAvatarResponse {
fn from(avatar: RealmAvatar) -> Self {
Self {
id: avatar.id,
slug: avatar.slug,
name: avatar.name,
is_public: avatar.is_public,
created_at: avatar.created_at,
}
}
}
/// List all avatars for a realm.
pub async fn list_realm_avatars(
State(pool): State<PgPool>,
Path(slug): Path<String>,
) -> Result<Json<Vec<RealmAvatarSummary>>, AppError> {
let realm = queries::get_realm_by_slug(&pool, &slug).await?;
let avatars = realm_avatars::list_all_realm_avatars(&pool, realm.id).await?;
Ok(Json(avatars))
}
/// Create a new realm avatar.
pub async fn create_realm_avatar(
State(pool): State<PgPool>,
Path(slug): Path<String>,
Json(req): Json<CreateRealmAvatarRequest>,
) -> Result<Json<CreateRealmAvatarResponse>, AppError> {
// Validate the request
req.validate()?;
// Get realm ID
let realm = queries::get_realm_by_slug(&pool, &slug).await?;
// Check slug availability
let avatar_slug = req.slug_or_generate();
let available = realm_avatars::is_avatar_slug_available(&pool, realm.id, &avatar_slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Avatar slug '{}' is already taken in this realm",
avatar_slug
)));
}
// Create the avatar
let avatar = realm_avatars::create_realm_avatar(&pool, realm.id, &req, None).await?;
tracing::info!(
"Created realm avatar: {} ({}) in realm {}",
avatar.name,
avatar.id,
slug
);
Ok(Json(CreateRealmAvatarResponse::from(avatar)))
}
/// Get a realm avatar by ID.
pub async fn get_realm_avatar(
State(pool): State<PgPool>,
Path((slug, avatar_id)): Path<(String, Uuid)>,
) -> Result<Json<RealmAvatar>, AppError> {
// Verify realm exists
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
Ok(Json(avatar))
}
/// Update a realm avatar.
pub async fn update_realm_avatar(
State(pool): State<PgPool>,
Path((slug, avatar_id)): Path<(String, Uuid)>,
Json(req): Json<UpdateRealmAvatarRequest>,
) -> Result<Json<RealmAvatar>, AppError> {
// Validate the request
req.validate()?;
// Verify realm exists
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
// Check avatar exists
let existing = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
// Update the avatar
let avatar = realm_avatars::update_realm_avatar(&pool, avatar_id, &req).await?;
tracing::info!(
"Updated realm avatar: {} ({}) in realm {}",
existing.name,
avatar_id,
slug
);
Ok(Json(avatar))
}
/// Delete a realm avatar.
pub async fn delete_realm_avatar(
State(pool): State<PgPool>,
Path((slug, avatar_id)): Path<(String, Uuid)>,
) -> Result<Json<()>, AppError> {
// Verify realm exists
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
// Get the avatar first to log its name
let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
// Delete from database
realm_avatars::delete_realm_avatar(&pool, avatar_id).await?;
tracing::info!(
"Deleted realm avatar: {} ({}) from realm {}",
avatar.name,
avatar_id,
slug
);
Ok(Json(()))
}

View file

@ -5,7 +5,7 @@ use axum::{
routing::{delete, get, post, put},
};
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
use crate::app::AdminAppState;
/// Create the admin API router.
@ -85,6 +85,28 @@ pub fn admin_api_router() -> Router<AdminAppState> {
"/props/{prop_id}",
get(props::get_prop).delete(props::delete_prop),
)
// API - Server Avatars
.route(
"/avatars",
get(avatars::list_avatars).post(avatars::create_avatar),
)
.route(
"/avatars/{avatar_id}",
get(avatars::get_avatar)
.put(avatars::update_avatar)
.delete(avatars::delete_avatar),
)
// API - Realm Avatars
.route(
"/realms/{slug}/avatars",
get(realms::list_realm_avatars).post(realms::create_realm_avatar),
)
.route(
"/realms/{slug}/avatars/{avatar_id}",
get(realms::get_realm_avatar)
.put(realms::update_realm_avatar)
.delete(realms::delete_realm_avatar),
)
}
/// Health check endpoint.

View file

@ -177,6 +177,8 @@ fn Sidebar(
let realms_new_href = format!("{}/realms/new", base_path);
let props_href = format!("{}/props", base_path);
let props_new_href = format!("{}/props/new", base_path);
let avatars_href = format!("{}/avatars", base_path);
let avatars_new_href = format!("{}/avatars/new", base_path);
view! {
<nav class="sidebar">
@ -259,6 +261,24 @@ fn Sidebar(
/>
</ul>
</li>
<li class="nav-section">
<span class="nav-section-title">"Avatars"</span>
<ul class="nav-sublist">
<NavItem
href=avatars_href.clone()
label="All Avatars"
active=current_page == "avatars"
sub=true
/>
<NavItem
href=avatars_new_href.clone()
label="Create Avatar"
active=current_page == "avatars_new"
sub=true
/>
</ul>
</li>
}.into_any()
} else {
// Realm admin: show realm-specific options only

View file

@ -256,3 +256,73 @@ pub struct CreatePropResponse {
pub slug: String,
pub asset_path: String,
}
// =============================================================================
// Avatar Models
// =============================================================================
/// Server avatar summary for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AvatarSummary {
pub id: String,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub is_public: bool,
pub is_active: bool,
pub thumbnail_path: Option<String>,
pub created_at: String,
}
/// Server avatar detail from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AvatarDetail {
pub id: String,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub is_public: bool,
pub is_active: bool,
pub thumbnail_path: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Response for avatar creation.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CreateAvatarResponse {
pub id: String,
pub slug: String,
pub name: String,
pub is_public: bool,
pub created_at: String,
}
/// Realm avatar summary for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RealmAvatarSummary {
pub id: String,
pub realm_id: String,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub is_public: bool,
pub is_active: bool,
pub thumbnail_path: Option<String>,
pub created_at: String,
}
/// Realm avatar detail from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RealmAvatarDetail {
pub id: String,
pub realm_id: String,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub is_public: bool,
pub is_active: bool,
pub thumbnail_path: Option<String>,
pub created_at: String,
pub updated_at: String,
}

View file

@ -1,11 +1,17 @@
//! Admin interface Leptos page components.
mod avatars;
mod avatars_detail;
mod avatars_new;
mod config;
mod dashboard;
mod login;
mod props;
mod props_detail;
mod props_new;
mod realm_avatars;
mod realm_avatars_detail;
mod realm_avatars_new;
mod realm_detail;
mod realm_new;
mod realms;
@ -17,12 +23,18 @@ mod user_detail;
mod user_new;
mod users;
pub use avatars::AvatarsPage;
pub use avatars_detail::AvatarsDetailPage;
pub use avatars_new::AvatarsNewPage;
pub use config::ConfigPage;
pub use dashboard::DashboardPage;
pub use login::LoginPage;
pub use props::PropsPage;
pub use props_detail::PropsDetailPage;
pub use props_new::PropsNewPage;
pub use realm_avatars::RealmAvatarsPage;
pub use realm_avatars_detail::RealmAvatarsDetailPage;
pub use realm_avatars_new::RealmAvatarsNewPage;
pub use realm_detail::RealmDetailPage;
pub use realm_new::RealmNewPage;
pub use realms::RealmsPage;

View file

@ -0,0 +1,188 @@
//! Server avatars list page component.
use leptos::prelude::*;
use crate::components::{Card, EmptyState, PageHeader};
use crate::hooks::use_fetch;
use crate::models::AvatarSummary;
/// View mode for avatars listing.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Table,
Grid,
}
/// Server avatars page component with table and grid views.
#[component]
pub fn AvatarsPage() -> impl IntoView {
let (view_mode, set_view_mode) = signal(ViewMode::Table);
let avatars = use_fetch::<Vec<AvatarSummary>>(|| "/api/admin/avatars".to_string());
view! {
<PageHeader title="Server Avatars" subtitle="Manage pre-configured avatar templates">
<div class="flex gap-2">
<button
type="button"
class=move || if view_mode.get() == ViewMode::Table {
"btn btn-primary"
} else {
"btn btn-secondary"
}
on:click=move |_| set_view_mode.set(ViewMode::Table)
>
"Table"
</button>
<button
type="button"
class=move || if view_mode.get() == ViewMode::Grid {
"btn btn-primary"
} else {
"btn btn-secondary"
}
on:click=move |_| set_view_mode.set(ViewMode::Grid)
>
"Grid"
</button>
<a href="/admin/avatars/new" class="btn btn-primary">"Create Avatar"</a>
</div>
</PageHeader>
<Card>
<Suspense fallback=|| view! { <p>"Loading avatars..."</p> }>
{move || {
avatars.get().map(|maybe_avatars: Option<Vec<AvatarSummary>>| {
match maybe_avatars {
Some(avatar_list) if !avatar_list.is_empty() => {
if view_mode.get() == ViewMode::Table {
view! { <AvatarsTable avatars=avatar_list.clone() /> }.into_any()
} else {
view! { <AvatarsGrid avatars=avatar_list.clone() /> }.into_any()
}
}
_ => view! {
<EmptyState
message="No server avatars found."
action_href="/admin/avatars/new"
action_text="Create Avatar"
/>
}.into_any()
}
})
}}
</Suspense>
</Card>
}
}
/// Table view for avatars.
#[component]
fn AvatarsTable(avatars: Vec<AvatarSummary>) -> impl IntoView {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Thumbnail"</th>
<th>"Name"</th>
<th>"Slug"</th>
<th>"Public"</th>
<th>"Active"</th>
<th>"Created"</th>
</tr>
</thead>
<tbody>
{avatars.into_iter().map(|avatar| {
let thumbnail_url = avatar.thumbnail_path.clone()
.map(|p| format!("/assets/{}", p));
view! {
<tr>
<td>
{thumbnail_url.map(|url| view! {
<img
src=url
alt=avatar.name.clone()
class="avatar-thumbnail"
style="width: 48px; height: 48px; object-fit: contain; border-radius: 4px;"
/>
})}
</td>
<td>
<a href=format!("/admin/avatars/{}", avatar.id) class="table-link">
{avatar.name}
</a>
</td>
<td><code>{avatar.slug}</code></td>
<td>
{if avatar.is_public {
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
}}
</td>
<td>
{if avatar.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
}}
</td>
<td>{avatar.created_at}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}
}
/// Grid view for avatars with thumbnails.
#[component]
fn AvatarsGrid(avatars: Vec<AvatarSummary>) -> impl IntoView {
view! {
<div class="avatars-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; padding: 16px;">
{avatars.into_iter().map(|avatar| {
let thumbnail_url = avatar.thumbnail_path.clone()
.map(|p| format!("/assets/{}", p))
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
let avatar_url = format!("/admin/avatars/{}", avatar.id);
let avatar_name_for_title = avatar.name.clone();
let avatar_name_for_alt = avatar.name.clone();
let avatar_name_for_label = avatar.name;
let is_active = avatar.is_active;
let is_public = avatar.is_public;
view! {
<a
href=avatar_url
class="avatars-grid-item"
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 12px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s; position: relative;"
title=avatar_name_for_title
>
<img
src=thumbnail_url
alt=avatar_name_for_alt
style="width: 80px; height: 80px; object-fit: contain; border-radius: 4px;"
/>
<span style="font-size: 0.75rem; margin-top: 8px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
{avatar_name_for_label}
</span>
<div style="display: flex; gap: 4px; margin-top: 4px;">
{if is_public {
view! { <span class="status-badge status-active" style="font-size: 0.6rem; padding: 2px 4px;">"Public"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Private"</span> }.into_any()
}}
{if !is_active {
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Inactive"</span> }.into_any()
} else {
view! {}.into_any()
}}
</div>
</a>
}
}).collect_view()}
</div>
}
}

View file

@ -0,0 +1,306 @@
//! Server avatar detail/edit page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
use crate::hooks::use_fetch_if;
use crate::models::AvatarDetail;
use crate::utils::get_api_base;
/// Server avatar detail page component.
#[component]
pub fn AvatarsDetailPage() -> impl IntoView {
let params = use_params_map();
let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
let (message, set_message) = signal(Option::<(String, bool)>::None);
let avatar = use_fetch_if::<AvatarDetail>(
move || !avatar_id().is_empty(),
move || format!("{}/avatars/{}", get_api_base(), avatar_id()),
);
view! {
<PageHeader title="Avatar Details" subtitle=format!("ID: {}", initial_avatar_id)>
<a href="/admin/avatars" class="btn btn-secondary">"Back to Avatars"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading avatar..."</p> }>
{move || {
avatar.get().map(|maybe_avatar| {
match maybe_avatar {
Some(a) => view! {
<AvatarDetailView avatar=a message=message set_message=set_message />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Avatar not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
#[allow(unused_variables)]
fn AvatarDetailView(
avatar: AvatarDetail,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
) -> impl IntoView {
#[cfg(feature = "hydrate")]
let avatar_id = avatar.id.clone();
let slug_display = avatar.slug.clone();
let (pending, set_pending) = signal(false);
let (delete_pending, set_delete_pending) = signal(false);
// Form state
let (name, set_name) = signal(avatar.name.clone());
let (description, set_description) = signal(avatar.description.clone().unwrap_or_default());
let (is_public, set_is_public) = signal(avatar.is_public);
let (is_active, set_is_active) = signal(avatar.is_active);
let (thumbnail_path, set_thumbnail_path) = signal(
avatar.thumbnail_path.clone().unwrap_or_default(),
);
// Thumbnail preview
let thumbnail_preview = move || {
let path = thumbnail_path.get();
if path.is_empty() {
None
} else {
Some(format!("/assets/{}", path))
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let api_base = get_api_base();
let avatar_id = avatar_id.clone();
let data = serde_json::json!({
"name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"is_public": is_public.get(),
"is_active": is_active.get(),
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
});
spawn_local(async move {
let response = Request::put(&format!("{}/avatars/{}", api_base, avatar_id))
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some(("Avatar updated successfully!".to_string(), true)));
}
Ok(_) => {
set_message.set(Some(("Failed to update avatar".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
#[cfg(feature = "hydrate")]
let avatar_id_for_delete = avatar.id.clone();
let on_delete = move || {
set_delete_pending.set(true);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let api_base = get_api_base();
let avatar_id = avatar_id_for_delete.clone();
spawn_local(async move {
let response = Request::delete(&format!("{}/avatars/{}", api_base, avatar_id))
.send()
.await;
set_delete_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
// Navigate back to avatars list
crate::utils::navigate_to("/admin/avatars");
}
Ok(_) => {
set_message.set(Some(("Failed to delete avatar".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
// Clone values for the view to avoid borrow issues
let avatar_name_for_alt = avatar.name.clone();
let avatar_name_header = avatar.name.clone();
let avatar_desc_header = avatar.description.clone().unwrap_or_default();
let avatar_id_display = avatar.id.clone();
let avatar_created = avatar.created_at.clone();
let avatar_updated = avatar.updated_at.clone();
view! {
<Card>
<div class="avatar-header" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
{move || {
let name_for_alt = avatar_name_for_alt.clone();
thumbnail_preview().map(move |url| view! {
<img
src=url
alt=name_for_alt
style="width: 80px; height: 80px; object-fit: contain; border-radius: 8px; background: var(--bg-secondary, #1e293b);"
/>
})
}}
<div class="avatar-info">
<h2>{avatar_name_header}</h2>
<p class="text-muted">{avatar_desc_header}</p>
</div>
<div class="avatar-badges" style="margin-left: auto; display: flex; gap: 8px;">
{if avatar.is_public {
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
}}
{if avatar.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
}}
</div>
</div>
<DetailGrid>
<DetailItem label="ID">
<code>{avatar_id_display}</code>
</DetailItem>
<DetailItem label="Slug">
<code>{slug_display}</code>
</DetailItem>
<DetailItem label="Created">
{avatar_created}
</DetailItem>
<DetailItem label="Updated">
{avatar_updated}
</DetailItem>
</DetailGrid>
</Card>
<Card title="Edit Avatar Settings">
<form on:submit=on_submit>
<div class="form-group">
<label for="name" class="form-label">"Name"</label>
<input
type="text"
id="name"
required=true
class="form-input"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-group">
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
<input
type="text"
id="thumbnail_path"
class="form-input"
placeholder="avatars/thumbnails/avatar-name.png"
prop:value=move || thumbnail_path.get()
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
/>
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_public.get()
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
/>
"Public"
</label>
<small class="form-help">"Public avatars can be selected by users."</small>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_active.get()
on:change=move |ev| set_is_active.set(event_target_checked(&ev))
/>
"Active"
</label>
<small class="form-help">"Inactive avatars are not shown to users."</small>
</div>
</div>
<MessageAlert message=message />
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
</button>
</div>
</form>
</Card>
<Card title="Danger Zone" class="danger-zone">
<p class="text-warning">"Deleting an avatar is permanent and cannot be undone."</p>
<DeleteConfirmation
message="Are you sure you want to delete this avatar? This action cannot be undone."
button_text="Delete Avatar"
confirm_text="Yes, Delete Avatar"
pending=delete_pending
on_confirm=on_delete
/>
</Card>
}
}

View file

@ -0,0 +1,222 @@
//! Create new server avatar page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, PageHeader};
/// Server avatar new page component.
#[component]
pub fn AvatarsNewPage() -> impl IntoView {
// Form state
let (name, set_name) = signal(String::new());
let (slug, set_slug) = signal(String::new());
let (description, set_description) = signal(String::new());
let (is_public, set_is_public) = signal(true);
let (thumbnail_path, set_thumbnail_path) = signal(String::new());
// UI state
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let (created_id, _set_created_id) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let set_created_id = _set_created_id;
let (slug_auto, set_slug_auto) = signal(true);
let update_name = move |ev: leptos::ev::Event| {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let data = serde_json::json!({
"name": name.get(),
"slug": if slug.get().is_empty() { None::<String> } else { Some(slug.get()) },
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"is_public": is_public.get(),
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
});
spawn_local(async move {
let response = Request::post("/api/admin/avatars")
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
struct CreateResponse {
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
slug: String,
}
if let Ok(result) = resp.json::<CreateResponse>().await {
set_created_id.set(Some(result.id));
set_message.set(Some(("Avatar created successfully!".to_string(), true)));
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to create avatar".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<PageHeader title="Create Server Avatar" subtitle="Add a new pre-configured avatar template">
<a href="/admin/avatars" class="btn btn-secondary">"Back to Avatars"</a>
</PageHeader>
<Card>
<form on:submit=on_submit>
<h3 class="section-title">"Avatar Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">
"Name" <span class="required">"*"</span>
</label>
<input
type="text"
id="name"
required=true
class="form-input"
placeholder="Happy Robot"
prop:value=move || name.get()
on:input=update_name
/>
</div>
<div class="form-group">
<label for="slug" class="form-label">"Slug (URL)"</label>
<input
type="text"
id="slug"
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
class="form-input"
placeholder="happy-robot"
prop:value=move || slug.get()
on:input=move |ev| {
set_slug_auto.set(false);
set_slug.set(event_target_value(&ev));
}
/>
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
</div>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
placeholder="A cheerful robot avatar for friendly chats"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-group">
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
<input
type="text"
id="thumbnail_path"
class="form-input"
placeholder="avatars/thumbnails/happy-robot.png"
prop:value=move || thumbnail_path.get()
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
/>
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
</div>
<h3 class="section-title">"Visibility"</h3>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_public.get()
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
/>
"Public"
</label>
<small class="form-help">"Public avatars can be selected by users. Private avatars are only available to admins."</small>
</div>
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
<Show when=move || created_id.get().is_some()>
{move || {
let id = created_id.get().unwrap_or_default();
view! {
<div class="alert alert-info">
<p>
<a href=format!("/admin/avatars/{}", id)>
"View avatar"
</a>
</p>
</div>
}
}}
</Show>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Creating..." } else { "Create Avatar" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,203 @@
//! Realm avatars list page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, EmptyState, PageHeader};
use crate::hooks::use_fetch_if;
use crate::models::RealmAvatarSummary;
use crate::utils::get_api_base;
/// View mode for avatars listing.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Table,
Grid,
}
/// Realm avatars page component with table and grid views.
#[component]
pub fn RealmAvatarsPage() -> impl IntoView {
let params = use_params_map();
let slug = move || params.get().get("slug").unwrap_or_default();
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
let (view_mode, set_view_mode) = signal(ViewMode::Table);
let avatars = use_fetch_if::<Vec<RealmAvatarSummary>>(
move || !slug().is_empty(),
move || format!("{}/realms/{}/avatars", get_api_base(), slug()),
);
let slug_for_new = initial_slug.clone();
let slug_for_back = initial_slug.clone();
view! {
<PageHeader title="Realm Avatars" subtitle=format!("Manage avatars for /{}", initial_slug)>
<div class="flex gap-2">
<button
type="button"
class=move || if view_mode.get() == ViewMode::Table {
"btn btn-primary"
} else {
"btn btn-secondary"
}
on:click=move |_| set_view_mode.set(ViewMode::Table)
>
"Table"
</button>
<button
type="button"
class=move || if view_mode.get() == ViewMode::Grid {
"btn btn-primary"
} else {
"btn btn-secondary"
}
on:click=move |_| set_view_mode.set(ViewMode::Grid)
>
"Grid"
</button>
<a href=format!("/admin/realms/{}/avatars/new", slug_for_new) class="btn btn-primary">"Create Avatar"</a>
<a href=format!("/admin/realms/{}", slug_for_back) class="btn btn-secondary">"Back to Realm"</a>
</div>
</PageHeader>
<Card>
<Suspense fallback=|| view! { <p>"Loading avatars..."</p> }>
{move || {
let slug = slug();
avatars.get().map(move |maybe_avatars: Option<Vec<RealmAvatarSummary>>| {
match maybe_avatars {
Some(avatar_list) if !avatar_list.is_empty() => {
if view_mode.get() == ViewMode::Table {
view! { <RealmAvatarsTable avatars=avatar_list.clone() realm_slug=slug.clone() /> }.into_any()
} else {
view! { <RealmAvatarsGrid avatars=avatar_list.clone() realm_slug=slug.clone() /> }.into_any()
}
}
_ => view! {
<EmptyState
message="No realm avatars found."
action_href=format!("/admin/realms/{}/avatars/new", slug).leak()
action_text="Create Avatar"
/>
}.into_any()
}
})
}}
</Suspense>
</Card>
}
}
/// Table view for realm avatars.
#[component]
fn RealmAvatarsTable(avatars: Vec<RealmAvatarSummary>, realm_slug: String) -> impl IntoView {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Thumbnail"</th>
<th>"Name"</th>
<th>"Slug"</th>
<th>"Public"</th>
<th>"Active"</th>
<th>"Created"</th>
</tr>
</thead>
<tbody>
{avatars.into_iter().map(|avatar| {
let thumbnail_url = avatar.thumbnail_path.clone()
.map(|p| format!("/assets/{}", p));
let detail_url = format!("/admin/realms/{}/avatars/{}", realm_slug, avatar.id);
view! {
<tr>
<td>
{thumbnail_url.map(|url| view! {
<img
src=url
alt=avatar.name.clone()
class="avatar-thumbnail"
style="width: 48px; height: 48px; object-fit: contain; border-radius: 4px;"
/>
})}
</td>
<td>
<a href=detail_url class="table-link">
{avatar.name}
</a>
</td>
<td><code>{avatar.slug}</code></td>
<td>
{if avatar.is_public {
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
}}
</td>
<td>
{if avatar.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
}}
</td>
<td>{avatar.created_at}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}
}
/// Grid view for realm avatars with thumbnails.
#[component]
fn RealmAvatarsGrid(avatars: Vec<RealmAvatarSummary>, realm_slug: String) -> impl IntoView {
view! {
<div class="avatars-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; padding: 16px;">
{avatars.into_iter().map(|avatar| {
let thumbnail_url = avatar.thumbnail_path.clone()
.map(|p| format!("/assets/{}", p))
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
let avatar_url = format!("/admin/realms/{}/avatars/{}", realm_slug, avatar.id);
let avatar_name_for_title = avatar.name.clone();
let avatar_name_for_alt = avatar.name.clone();
let avatar_name_for_label = avatar.name;
let is_active = avatar.is_active;
let is_public = avatar.is_public;
view! {
<a
href=avatar_url
class="avatars-grid-item"
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 12px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s; position: relative;"
title=avatar_name_for_title
>
<img
src=thumbnail_url
alt=avatar_name_for_alt
style="width: 80px; height: 80px; object-fit: contain; border-radius: 4px;"
/>
<span style="font-size: 0.75rem; margin-top: 8px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
{avatar_name_for_label}
</span>
<div style="display: flex; gap: 4px; margin-top: 4px;">
{if is_public {
view! { <span class="status-badge status-active" style="font-size: 0.6rem; padding: 2px 4px;">"Public"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Private"</span> }.into_any()
}}
{if !is_active {
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Inactive"</span> }.into_any()
} else {
view! {}.into_any()
}}
</div>
</a>
}
}).collect_view()}
</div>
}
}

View file

@ -0,0 +1,332 @@
//! Realm avatar detail/edit page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
use crate::hooks::use_fetch_if;
use crate::models::RealmAvatarDetail;
use crate::utils::get_api_base;
/// Realm avatar detail page component.
#[component]
pub fn RealmAvatarsDetailPage() -> impl IntoView {
let params = use_params_map();
let realm_slug = move || params.get().get("slug").unwrap_or_default();
let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
let (message, set_message) = signal(Option::<(String, bool)>::None);
let avatar = use_fetch_if::<RealmAvatarDetail>(
move || !realm_slug().is_empty() && !avatar_id().is_empty(),
move || format!("{}/realms/{}/avatars/{}", get_api_base(), realm_slug(), avatar_id()),
);
let realm_slug_for_back = initial_realm_slug.clone();
view! {
<PageHeader title="Realm Avatar Details" subtitle=format!("ID: {}", initial_avatar_id)>
<a href=format!("/admin/realms/{}/avatars", realm_slug_for_back) class="btn btn-secondary">"Back to Avatars"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading avatar..."</p> }>
{move || {
let realm_slug = initial_realm_slug.clone();
avatar.get().map(move |maybe_avatar| {
match maybe_avatar {
Some(a) => view! {
<RealmAvatarDetailView
avatar=a
realm_slug=realm_slug.clone()
message=message
set_message=set_message
/>
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Avatar not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
#[allow(unused_variables)]
fn RealmAvatarDetailView(
avatar: RealmAvatarDetail,
realm_slug: String,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
) -> impl IntoView {
#[cfg(feature = "hydrate")]
let avatar_id = avatar.id.clone();
#[cfg(feature = "hydrate")]
let realm_slug_for_api = realm_slug.clone();
let realm_slug_for_delete = realm_slug.clone();
let slug_display = avatar.slug.clone();
let (pending, set_pending) = signal(false);
let (delete_pending, set_delete_pending) = signal(false);
// Form state
let (name, set_name) = signal(avatar.name.clone());
let (description, set_description) = signal(avatar.description.clone().unwrap_or_default());
let (is_public, set_is_public) = signal(avatar.is_public);
let (is_active, set_is_active) = signal(avatar.is_active);
let (thumbnail_path, set_thumbnail_path) = signal(
avatar.thumbnail_path.clone().unwrap_or_default(),
);
// Thumbnail preview
let thumbnail_preview = move || {
let path = thumbnail_path.get();
if path.is_empty() {
None
} else {
Some(format!("/assets/{}", path))
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let api_base = get_api_base();
let avatar_id = avatar_id.clone();
let realm_slug = realm_slug_for_api.clone();
let data = serde_json::json!({
"name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"is_public": is_public.get(),
"is_active": is_active.get(),
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
});
spawn_local(async move {
let response = Request::put(&format!("{}/realms/{}/avatars/{}", api_base, realm_slug, avatar_id))
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some(("Avatar updated successfully!".to_string(), true)));
}
Ok(_) => {
set_message.set(Some(("Failed to update avatar".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
#[cfg(feature = "hydrate")]
let avatar_id_for_delete = avatar.id.clone();
#[cfg(feature = "hydrate")]
let realm_slug_for_delete_api = realm_slug_for_delete.clone();
let on_delete = move || {
set_delete_pending.set(true);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let api_base = get_api_base();
let avatar_id = avatar_id_for_delete.clone();
let realm_slug = realm_slug_for_delete_api.clone();
let nav_slug = realm_slug_for_delete.clone();
spawn_local(async move {
let response = Request::delete(&format!("{}/realms/{}/avatars/{}", api_base, realm_slug, avatar_id))
.send()
.await;
set_delete_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
// Navigate back to avatars list
crate::utils::navigate_to(&format!("/admin/realms/{}/avatars", nav_slug));
}
Ok(_) => {
set_message.set(Some(("Failed to delete avatar".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
// Clone for the view
let avatar_name_header = avatar.name.clone();
let avatar_desc_header = avatar.description.clone().unwrap_or_default();
let avatar_id_display = avatar.id.clone();
let avatar_created = avatar.created_at.clone();
let avatar_updated = avatar.updated_at.clone();
// Additional clone for the closure
let avatar_name_for_alt = avatar_name_header.clone();
view! {
<Card>
<div class="avatar-header" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
{move || {
let name_for_alt = avatar_name_for_alt.clone();
thumbnail_preview().map(move |url| view! {
<img
src=url
alt=name_for_alt
style="width: 80px; height: 80px; object-fit: contain; border-radius: 8px; background: var(--bg-secondary, #1e293b);"
/>
})
}}
<div class="avatar-info">
<h2>{avatar_name_header}</h2>
<p class="text-muted">{avatar_desc_header}</p>
</div>
<div class="avatar-badges" style="margin-left: auto; display: flex; gap: 8px;">
{if avatar.is_public {
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
}}
{if avatar.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
}}
</div>
</div>
<DetailGrid>
<DetailItem label="ID">
<code>{avatar_id_display}</code>
</DetailItem>
<DetailItem label="Slug">
<code>{slug_display}</code>
</DetailItem>
<DetailItem label="Realm">
<a href=format!("/admin/realms/{}", realm_slug) class="table-link">
{realm_slug.clone()}
</a>
</DetailItem>
<DetailItem label="Created">
{avatar_created}
</DetailItem>
<DetailItem label="Updated">
{avatar_updated}
</DetailItem>
</DetailGrid>
</Card>
<Card title="Edit Avatar Settings">
<form on:submit=on_submit>
<div class="form-group">
<label for="name" class="form-label">"Name"</label>
<input
type="text"
id="name"
required=true
class="form-input"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-group">
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
<input
type="text"
id="thumbnail_path"
class="form-input"
placeholder="avatars/thumbnails/avatar-name.png"
prop:value=move || thumbnail_path.get()
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
/>
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_public.get()
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
/>
"Public"
</label>
<small class="form-help">"Public avatars can be selected by users."</small>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_active.get()
on:change=move |ev| set_is_active.set(event_target_checked(&ev))
/>
"Active"
</label>
<small class="form-help">"Inactive avatars are not shown to users."</small>
</div>
</div>
<MessageAlert message=message />
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
</button>
</div>
</form>
</Card>
<Card title="Danger Zone" class="danger-zone">
<p class="text-warning">"Deleting an avatar is permanent and cannot be undone."</p>
<DeleteConfirmation
message="Are you sure you want to delete this avatar? This action cannot be undone."
button_text="Delete Avatar"
confirm_text="Yes, Delete Avatar"
pending=delete_pending
on_confirm=on_delete
/>
</Card>
}
}

View file

@ -0,0 +1,239 @@
//! Create new realm avatar page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, PageHeader};
#[cfg(feature = "hydrate")]
use crate::utils::get_api_base;
/// Realm avatar new page component.
#[component]
pub fn RealmAvatarsNewPage() -> impl IntoView {
let params = use_params_map();
#[allow(unused_variables)]
let slug = move || params.get().get("slug").unwrap_or_default();
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
// Form state
let (name, set_name) = signal(String::new());
let (avatar_slug, set_avatar_slug) = signal(String::new());
let (description, set_description) = signal(String::new());
let (is_public, set_is_public) = signal(true);
let (thumbnail_path, set_thumbnail_path) = signal(String::new());
// UI state
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let (created_id, _set_created_id) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let set_created_id = _set_created_id;
let (slug_auto, set_slug_auto) = signal(true);
let update_name = move |ev: leptos::ev::Event| {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_avatar_slug.set(new_slug);
}
};
let slug_for_back = initial_slug.clone();
let slug_for_view = initial_slug.clone();
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let api_base = get_api_base();
let realm_slug = slug();
let data = serde_json::json!({
"name": name.get(),
"slug": if avatar_slug.get().is_empty() { None::<String> } else { Some(avatar_slug.get()) },
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"is_public": is_public.get(),
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
});
spawn_local(async move {
let response = Request::post(&format!("{}/realms/{}/avatars", api_base, realm_slug))
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
struct CreateResponse {
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
slug: String,
}
if let Ok(result) = resp.json::<CreateResponse>().await {
set_created_id.set(Some(result.id));
set_message.set(Some(("Avatar created successfully!".to_string(), true)));
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to create avatar".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<PageHeader title="Create Realm Avatar" subtitle=format!("Add a new avatar for /{}", initial_slug)>
<a href=format!("/admin/realms/{}/avatars", slug_for_back) class="btn btn-secondary">"Back to Avatars"</a>
</PageHeader>
<Card>
<form on:submit=on_submit>
<h3 class="section-title">"Avatar Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">
"Name" <span class="required">"*"</span>
</label>
<input
type="text"
id="name"
required=true
class="form-input"
placeholder="Happy Robot"
prop:value=move || name.get()
on:input=update_name
/>
</div>
<div class="form-group">
<label for="slug" class="form-label">"Slug (URL)"</label>
<input
type="text"
id="slug"
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
class="form-input"
placeholder="happy-robot"
prop:value=move || avatar_slug.get()
on:input=move |ev| {
set_slug_auto.set(false);
set_avatar_slug.set(event_target_value(&ev));
}
/>
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
</div>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
placeholder="A cheerful robot avatar for friendly chats"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-group">
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
<input
type="text"
id="thumbnail_path"
class="form-input"
placeholder="avatars/thumbnails/happy-robot.png"
prop:value=move || thumbnail_path.get()
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
/>
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
</div>
<h3 class="section-title">"Visibility"</h3>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_public.get()
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
/>
"Public"
</label>
<small class="form-help">"Public avatars can be selected by users. Private avatars are only available to admins."</small>
</div>
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
<Show when=move || created_id.get().is_some()>
{
let realm_slug = slug_for_view.clone();
move || {
let id = created_id.get().unwrap_or_default();
let realm_slug = realm_slug.clone();
view! {
<div class="alert alert-info">
<p>
<a href=format!("/admin/realms/{}/avatars/{}", realm_slug, id)>
"View avatar"
</a>
</p>
</div>
}
}
}
</Show>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Creating..." } else { "Create Avatar" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -15,9 +15,10 @@ use leptos_router::{
use crate::components::{AuthenticatedLayout, LoginLayout};
use crate::pages::{
ConfigPage, DashboardPage, LoginPage, PropsDetailPage, PropsNewPage, PropsPage,
RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage, ScenesPage,
StaffPage, UserDetailPage, UserNewPage, UsersPage,
AvatarsDetailPage, AvatarsNewPage, AvatarsPage, ConfigPage, DashboardPage, LoginPage,
PropsDetailPage, PropsNewPage, PropsPage, RealmAvatarsDetailPage, RealmAvatarsNewPage,
RealmAvatarsPage, RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage,
ScenesPage, StaffPage, UserDetailPage, UserNewPage, UsersPage,
};
/// Admin routes that can be embedded in a parent Router.
@ -91,6 +92,23 @@ pub fn AdminRoutes() -> impl IntoView {
</AuthenticatedLayout>
} />
// Server Avatars
<Route path=StaticSegment("avatars") view=|| view! {
<AuthenticatedLayout current_page="avatars">
<AvatarsPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("avatars"), StaticSegment("new")) view=|| view! {
<AuthenticatedLayout current_page="avatars_new">
<AvatarsNewPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("avatars"), ParamSegment("avatar_id")) view=|| view! {
<AuthenticatedLayout current_page="avatars">
<AvatarsDetailPage />
</AuthenticatedLayout>
} />
// Realms
<Route path=StaticSegment("realms") view=|| view! {
<AuthenticatedLayout current_page="realms">
@ -120,6 +138,23 @@ pub fn AdminRoutes() -> impl IntoView {
</AuthenticatedLayout>
} />
// Realm Avatars (nested under realms)
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("avatars")) view=|| view! {
<AuthenticatedLayout current_page="realm_avatars">
<RealmAvatarsPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("avatars"), StaticSegment("new")) view=|| view! {
<AuthenticatedLayout current_page="realm_avatars_new">
<RealmAvatarsNewPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("avatars"), ParamSegment("avatar_id")) view=|| view! {
<AuthenticatedLayout current_page="realm_avatars">
<RealmAvatarsDetailPage />
</AuthenticatedLayout>
} />
// Realm detail (must come after more specific realm routes)
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=|| view! {
<AuthenticatedLayout current_page="realms">