From 6fb90e42c336d0e9f65d5e76e186408b97469b22f6c223b1b99e630f17c63e34 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Thu, 22 Jan 2026 21:04:27 -0600 Subject: [PATCH] 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}. --- .gitignore | 2 + apps/chattyness-app/src/main.rs | 1 + config.toml | 10 + crates/chattyness-admin-ui/src/api.rs | 2 + crates/chattyness-admin-ui/src/api/avatars.rs | 127 ++ crates/chattyness-admin-ui/src/api/realms.rs | 148 ++- crates/chattyness-admin-ui/src/api/routes.rs | 24 +- crates/chattyness-admin-ui/src/components.rs | 20 + crates/chattyness-admin-ui/src/models.rs | 70 + crates/chattyness-admin-ui/src/pages.rs | 12 + .../chattyness-admin-ui/src/pages/avatars.rs | 188 +++ .../src/pages/avatars_detail.rs | 306 +++++ .../src/pages/avatars_new.rs | 222 ++++ .../src/pages/realm_avatars.rs | 203 +++ .../src/pages/realm_avatars_detail.rs | 332 +++++ .../src/pages/realm_avatars_new.rs | 239 ++++ crates/chattyness-admin-ui/src/routes.rs | 41 +- crates/chattyness-db/src/models.rs | 1129 ++++++++++++++++- crates/chattyness-db/src/queries.rs | 2 + crates/chattyness-db/src/queries/avatars.rs | 591 +++++++-- .../src/queries/channel_members.rs | 13 +- .../src/queries/realm_avatars.rs | 880 +++++++++++++ .../src/queries/server_avatars.rs | 902 +++++++++++++ crates/chattyness-db/src/queries/users.rs | 132 +- crates/chattyness-db/src/ws_messages.rs | 24 +- crates/chattyness-shared/src/config.rs | 75 ++ crates/chattyness-user-ui/src/api/auth.rs | 101 +- crates/chattyness-user-ui/src/api/avatars.rs | 140 +- crates/chattyness-user-ui/src/api/routes.rs | 15 + .../chattyness-user-ui/src/api/websocket.rs | 341 ++++- crates/chattyness-user-ui/src/app.rs | 10 +- crates/chattyness-user-ui/src/components.rs | 2 + .../src/components/avatar_canvas.rs | 4 +- .../src/components/avatar_store.rs | 513 ++++++++ .../chattyness-user-ui/src/components/chat.rs | 6 + .../src/components/hotkey_help.rs | 1 + .../src/components/ws_client.rs | 34 +- crates/chattyness-user-ui/src/pages/realm.rs | 50 +- crates/chattyness-user-ui/src/pages/signup.rs | 3 + db/reinitialize_all_users.sql | 25 - db/reinitialize_user.md | 59 - db/schema/functions/002_user_init.sql | 170 --- db/schema/load.sql | 4 +- db/schema/policies/001_rls.sql | 53 + db/schema/tables/020_auth.sql | 19 +- db/schema/tables/025_server_avatars.sql | 222 ++++ db/schema/tables/030_realm.sql | 9 + db/schema/tables/035_realm_avatars.sql | 288 +++++ db/schema/triggers/002_user_init.sql | 26 - db/schema/types/001_enums.sql | 15 + e2e/playwright-report/index.html | 85 -- e2e/test-results/.last-run.json | 4 - .../ranosh-patio-daytime-background-admin.png | Bin 109388 -> 0 bytes e2e/test-results/ranosh-realm-success.png | Bin 19794 -> 0 bytes stock/load.sh | 10 +- 55 files changed, 7392 insertions(+), 512 deletions(-) create mode 100644 crates/chattyness-admin-ui/src/api/avatars.rs create mode 100644 crates/chattyness-admin-ui/src/pages/avatars.rs create mode 100644 crates/chattyness-admin-ui/src/pages/avatars_detail.rs create mode 100644 crates/chattyness-admin-ui/src/pages/avatars_new.rs create mode 100644 crates/chattyness-admin-ui/src/pages/realm_avatars.rs create mode 100644 crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs create mode 100644 crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs create mode 100644 crates/chattyness-db/src/queries/realm_avatars.rs create mode 100644 crates/chattyness-db/src/queries/server_avatars.rs create mode 100644 crates/chattyness-user-ui/src/components/avatar_store.rs delete mode 100644 db/reinitialize_all_users.sql delete mode 100644 db/reinitialize_user.md delete mode 100644 db/schema/functions/002_user_init.sql create mode 100644 db/schema/tables/025_server_avatars.sql create mode 100644 db/schema/tables/035_realm_avatars.sql delete mode 100644 db/schema/triggers/002_user_init.sql delete mode 100644 e2e/playwright-report/index.html delete mode 100644 e2e/test-results/.last-run.json delete mode 100644 e2e/test-results/ranosh-patio-daytime-background-admin.png delete mode 100644 e2e/test-results/ranosh-realm-success.png 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! {