diff --git a/.gitignore b/.gitignore index de83574..036760e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .run-dev.lock chattyness-webserver/static/ */target/* +e2e/node_modules +e2e/playwright-report diff --git a/apps/chattyness-app/src/main.rs b/apps/chattyness-app/src/main.rs index fcec49f..4530482 100644 --- a/apps/chattyness-app/src/main.rs +++ b/apps/chattyness-app/src/main.rs @@ -178,6 +178,7 @@ mod server { leptos_options: leptos_options.clone(), ws_state: ws_state.clone(), ws_config: config.websocket.clone(), + signup_config: config.signup.clone(), }; let admin_api_state = chattyness_admin_ui::AdminAppState { pool: pool.clone(), diff --git a/config.toml b/config.toml index 1d30ae2..23e138f 100644 --- a/config.toml +++ b/config.toml @@ -16,3 +16,13 @@ stale_threshold_secs = 120 # Clear all instance_members on server startup (recommended for single-server deployments) clear_on_startup = true + +[signup] +# birthday: "ask" = show field during signup, omit = don't ask +# birthday = "ask" + +# age: "ask" = user selects, "infer" = from birthday, "default_adult", "default_child" +age = "default_adult" + +# gender: "ask" = user selects, "default_neutral", "default_male", "default_female" +gender = "default_neutral" diff --git a/crates/chattyness-admin-ui/src/api.rs b/crates/chattyness-admin-ui/src/api.rs index b457a21..1c6c1db 100644 --- a/crates/chattyness-admin-ui/src/api.rs +++ b/crates/chattyness-admin-ui/src/api.rs @@ -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; diff --git a/crates/chattyness-admin-ui/src/api/avatars.rs b/crates/chattyness-admin-ui/src/api/avatars.rs new file mode 100644 index 0000000..263f518 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/avatars.rs @@ -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, +} + +impl From 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, +) -> Result>, 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, + Json(req): Json, +) -> Result, 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, + axum::extract::Path(avatar_id): axum::extract::Path, +) -> Result, 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, + axum::extract::Path(avatar_id): axum::extract::Path, + Json(req): Json, +) -> Result, 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, + axum::extract::Path(avatar_id): axum::extract::Path, +) -> Result, 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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/realms.rs b/crates/chattyness-admin-ui/src/api/realms.rs index d2f31ff..503d7fd 100644 --- a/crates/chattyness-admin-ui/src/api/realms.rs +++ b/crates/chattyness-admin-ui/src/api/realms.rs @@ -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, +} + +impl From 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, + Path(slug): Path, +) -> Result>, 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, + Path(slug): Path, + Json(req): Json, +) -> Result, 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, + Path((slug, avatar_id)): Path<(String, Uuid)>, +) -> Result, 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, + Path((slug, avatar_id)): Path<(String, Uuid)>, + Json(req): Json, +) -> Result, 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, + Path((slug, avatar_id)): Path<(String, Uuid)>, +) -> Result, 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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs index fda79d0..3d85a34 100644 --- a/crates/chattyness-admin-ui/src/api/routes.rs +++ b/crates/chattyness-admin-ui/src/api/routes.rs @@ -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 { "/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. diff --git a/crates/chattyness-admin-ui/src/components.rs b/crates/chattyness-admin-ui/src/components.rs index 1931959..204760e 100644 --- a/crates/chattyness-admin-ui/src/components.rs +++ b/crates/chattyness-admin-ui/src/components.rs @@ -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! {