From 6fb90e42c336d0e9f65d5e76e186408b97469b22f6c223b1b99e630f17c63e34 Mon Sep 17 00:00:00 2001
From: Evan Carroll
Date: Thu, 22 Jan 2026 21:04:27 -0600
Subject: [PATCH 1/8] 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! {
}>
+ {move || {
+ avatars.get().map(|maybe_avatars: Option>| {
+ match maybe_avatars {
+ Some(avatar_list) if !avatar_list.is_empty() => {
+ if view_mode.get() == ViewMode::Table {
+ view! { }.into_any()
+ } else {
+ view! { }.into_any()
+ }
+ }
+ _ => view! {
+
+ }.into_any()
+ }
+ })
+ }}
+
+
+ }
+}
+
+/// Table view for avatars.
+#[component]
+fn AvatarsTable(avatars: Vec) -> impl IntoView {
+ view! {
+
+
+
+
+ | "Thumbnail" |
+ "Name" |
+ "Slug" |
+ "Public" |
+ "Active" |
+ "Created" |
+
+
+
+ {avatars.into_iter().map(|avatar| {
+ let thumbnail_url = avatar.thumbnail_path.clone()
+ .map(|p| format!("/assets/{}", p));
+ view! {
+
+
+ {thumbnail_url.map(|url| view! {
+
+ })}
+ |
+
+
+ {avatar.name}
+
+ |
+ {avatar.slug} |
+
+ {if avatar.is_public {
+ view! { "Public" }.into_any()
+ } else {
+ view! { "Private" }.into_any()
+ }}
+ |
+
+ {if avatar.is_active {
+ view! { "Active" }.into_any()
+ } else {
+ view! { "Inactive" }.into_any()
+ }}
+ |
+ {avatar.created_at} |
+
+ }
+ }).collect_view()}
+
+
+
+ }
+}
+
+/// Grid view for avatars with thumbnails.
+#[component]
+fn AvatarsGrid(avatars: Vec) -> impl IntoView {
+ view! {
+
+ }
+}
diff --git a/crates/chattyness-admin-ui/src/pages/avatars_detail.rs b/crates/chattyness-admin-ui/src/pages/avatars_detail.rs
new file mode 100644
index 0000000..1935e25
--- /dev/null
+++ b/crates/chattyness-admin-ui/src/pages/avatars_detail.rs
@@ -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::(
+ move || !avatar_id().is_empty(),
+ move || format!("{}/avatars/{}", get_api_base(), avatar_id()),
+ );
+
+ view! {
+
+ "Back to Avatars"
+
+
+ "Loading avatar..." }>
+ {move || {
+ avatar.get().map(|maybe_avatar| {
+ match maybe_avatar {
+ Some(a) => view! {
+
+ }.into_any(),
+ None => view! {
+
+ "Avatar not found or you don't have permission to view."
+
+ }.into_any()
+ }
+ })
+ }}
+
+ }
+}
+
+#[component]
+#[allow(unused_variables)]
+fn AvatarDetailView(
+ avatar: AvatarDetail,
+ message: ReadSignal