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:
parent
e4abdb183f
commit
6fb90e42c3
55 changed files with 7392 additions and 512 deletions
|
|
@ -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;
|
||||
|
|
|
|||
127
crates/chattyness-admin-ui/src/api/avatars.rs
Normal file
127
crates/chattyness-admin-ui/src/api/avatars.rs
Normal 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(()))
|
||||
}
|
||||
|
|
@ -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(()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
188
crates/chattyness-admin-ui/src/pages/avatars.rs
Normal file
188
crates/chattyness-admin-ui/src/pages/avatars.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
306
crates/chattyness-admin-ui/src/pages/avatars_detail.rs
Normal file
306
crates/chattyness-admin-ui/src/pages/avatars_detail.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
222
crates/chattyness-admin-ui/src/pages/avatars_new.rs
Normal file
222
crates/chattyness-admin-ui/src/pages/avatars_new.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
203
crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Normal file
203
crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
332
crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs
Normal file
332
crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
239
crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs
Normal file
239
crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue