Compare commits
No commits in common. "9acb688379e9235617bf1ce8f32edb1821c75f99c2a68af86941ac6354e8a71e" and "e4abdb183f7faa12425358a30f7cdfd566868b39440faae5cee0c33ea0306090" have entirely different histories.
9acb688379
...
e4abdb183f
75 changed files with 1548 additions and 9285 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,5 +2,3 @@
|
||||||
.run-dev.lock
|
.run-dev.lock
|
||||||
chattyness-webserver/static/
|
chattyness-webserver/static/
|
||||||
*/target/*
|
*/target/*
|
||||||
e2e/node_modules
|
|
||||||
e2e/playwright-report
|
|
||||||
|
|
|
||||||
|
|
@ -178,22 +178,16 @@ mod server {
|
||||||
leptos_options: leptos_options.clone(),
|
leptos_options: leptos_options.clone(),
|
||||||
ws_state: ws_state.clone(),
|
ws_state: ws_state.clone(),
|
||||||
ws_config: config.websocket.clone(),
|
ws_config: config.websocket.clone(),
|
||||||
signup_config: config.signup.clone(),
|
|
||||||
};
|
};
|
||||||
let admin_api_state = chattyness_admin_ui::AdminAppState {
|
let admin_api_state = chattyness_admin_ui::AdminAppState {
|
||||||
pool: pool.clone(),
|
pool: pool.clone(),
|
||||||
leptos_options: leptos_options.clone(),
|
leptos_options: leptos_options.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create admin connection layer for RLS context on admin API routes
|
|
||||||
let admin_conn_layer =
|
|
||||||
chattyness_admin_ui::auth::AdminConnLayer::new(pool.clone());
|
|
||||||
|
|
||||||
// Build nested API routers with their own state
|
// Build nested API routers with their own state
|
||||||
let user_api_router = chattyness_user_ui::api::api_router().with_state(user_api_state);
|
let user_api_router = chattyness_user_ui::api::api_router().with_state(user_api_state);
|
||||||
let admin_api_router = chattyness_admin_ui::api::admin_api_router()
|
let admin_api_router =
|
||||||
.layer(admin_conn_layer)
|
chattyness_admin_ui::api::admin_api_router().with_state(admin_api_state);
|
||||||
.with_state(admin_api_state);
|
|
||||||
|
|
||||||
// Create RLS layer for row-level security
|
// Create RLS layer for row-level security
|
||||||
let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone());
|
let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone());
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,6 @@ mod server {
|
||||||
// Shared assets directory for uploaded files (realm images, etc.)
|
// Shared assets directory for uploaded files (realm images, etc.)
|
||||||
let assets_dir = Path::new("/srv/chattyness/assets");
|
let assets_dir = Path::new("/srv/chattyness/assets");
|
||||||
|
|
||||||
// Create admin connection layer for RLS context
|
|
||||||
let admin_conn_layer =
|
|
||||||
chattyness_admin_ui::auth::AdminConnLayer::new(pool.clone());
|
|
||||||
|
|
||||||
// Build the app
|
// Build the app
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
// Redirect root to admin
|
// Redirect root to admin
|
||||||
|
|
@ -119,9 +115,7 @@ mod server {
|
||||||
// Nest API routes under /api/admin (matches frontend expectations when UI is at /admin)
|
// Nest API routes under /api/admin (matches frontend expectations when UI is at /admin)
|
||||||
.nest(
|
.nest(
|
||||||
"/api/admin",
|
"/api/admin",
|
||||||
chattyness_admin_ui::api::admin_api_router()
|
chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone()),
|
||||||
.layer(admin_conn_layer)
|
|
||||||
.with_state(app_state.clone()),
|
|
||||||
)
|
)
|
||||||
// Uploaded assets (realm backgrounds, props, etc.) - must come before /static
|
// Uploaded assets (realm backgrounds, props, etc.) - must come before /static
|
||||||
.nest_service("/assets/server", ServeDir::new(assets_dir.join("server")))
|
.nest_service("/assets/server", ServeDir::new(assets_dir.join("server")))
|
||||||
|
|
|
||||||
10
config.toml
10
config.toml
|
|
@ -16,13 +16,3 @@ stale_threshold_secs = 120
|
||||||
|
|
||||||
# Clear all instance_members on server startup (recommended for single-server deployments)
|
# Clear all instance_members on server startup (recommended for single-server deployments)
|
||||||
clear_on_startup = true
|
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"
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ leptos_router = { workspace = true }
|
||||||
axum = { workspace = true, optional = true }
|
axum = { workspace = true, optional = true }
|
||||||
axum-extra = { workspace = true, optional = true }
|
axum-extra = { workspace = true, optional = true }
|
||||||
sqlx = { workspace = true, optional = true }
|
sqlx = { workspace = true, optional = true }
|
||||||
tower = { workspace = true, optional = true }
|
|
||||||
tower-sessions = { workspace = true, optional = true }
|
tower-sessions = { workspace = true, optional = true }
|
||||||
tower-sessions-sqlx-store = { workspace = true, optional = true }
|
tower-sessions-sqlx-store = { workspace = true, optional = true }
|
||||||
argon2 = { workspace = true, optional = true }
|
argon2 = { workspace = true, optional = true }
|
||||||
|
|
@ -53,7 +52,6 @@ ssr = [
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
"dep:axum-extra",
|
"dep:axum-extra",
|
||||||
"dep:sqlx",
|
"dep:sqlx",
|
||||||
"dep:tower",
|
|
||||||
"dep:tracing",
|
"dep:tracing",
|
||||||
"dep:tower-sessions",
|
"dep:tower-sessions",
|
||||||
"dep:tower-sessions-sqlx-store",
|
"dep:tower-sessions-sqlx-store",
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod avatars;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
//! 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;
|
|
||||||
|
|
||||||
use crate::auth::AdminConn;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// 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(
|
|
||||||
admin_conn: AdminConn,
|
|
||||||
Json(req): Json<CreateServerAvatarRequest>,
|
|
||||||
) -> Result<Json<CreateAvatarResponse>, AppError> {
|
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
req.validate()?;
|
|
||||||
|
|
||||||
// Check slug availability
|
|
||||||
let slug = req.slug_or_generate();
|
|
||||||
let available = server_avatars::is_avatar_slug_available(&mut *guard, &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(&mut *guard, &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(
|
|
||||||
admin_conn: AdminConn,
|
|
||||||
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
|
|
||||||
Json(req): Json<UpdateServerAvatarRequest>,
|
|
||||||
) -> Result<Json<ServerAvatar>, AppError> {
|
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
req.validate()?;
|
|
||||||
|
|
||||||
// Check avatar exists
|
|
||||||
let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
|
||||||
|
|
||||||
// Update the avatar
|
|
||||||
let avatar = server_avatars::update_server_avatar(&mut *guard, 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(
|
|
||||||
admin_conn: AdminConn,
|
|
||||||
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
|
|
||||||
) -> Result<Json<()>, AppError> {
|
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Get the avatar first to log its name
|
|
||||||
let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?;
|
|
||||||
|
|
||||||
tracing::info!("Deleted server avatar: {} ({})", avatar.name, avatar_id);
|
|
||||||
|
|
||||||
Ok(Json(()))
|
|
||||||
}
|
|
||||||
|
|
@ -15,8 +15,6 @@ use sqlx::PgPool;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AdminConn;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// API Types
|
// API Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -117,12 +115,10 @@ pub async fn list_props(
|
||||||
/// Query parameters:
|
/// Query parameters:
|
||||||
/// - `force`: If true, update existing prop with same slug instead of returning 409 Conflict.
|
/// - `force`: If true, update existing prop with same slug instead of returning 409 Conflict.
|
||||||
pub async fn create_prop(
|
pub async fn create_prop(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Query(query): Query<CreatePropQuery>,
|
Query(query): Query<CreatePropQuery>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<CreatePropResponse>, AppError> {
|
) -> Result<Json<CreatePropResponse>, AppError> {
|
||||||
let conn = admin_conn.0;
|
|
||||||
|
|
||||||
let mut metadata: Option<CreateServerPropRequest> = None;
|
let mut metadata: Option<CreateServerPropRequest> = None;
|
||||||
let mut file_data: Option<(Vec<u8>, String)> = None; // (bytes, extension)
|
let mut file_data: Option<(Vec<u8>, String)> = None; // (bytes, extension)
|
||||||
|
|
||||||
|
|
@ -186,23 +182,20 @@ pub async fn create_prop(
|
||||||
// Store the file first (SHA256-based, safe to run even if prop exists)
|
// Store the file first (SHA256-based, safe to run even if prop exists)
|
||||||
let asset_path = store_prop_file(&file_bytes, &extension).await?;
|
let asset_path = store_prop_file(&file_bytes, &extension).await?;
|
||||||
|
|
||||||
// Acquire the connection for database operations
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
let prop = if query.force {
|
let prop = if query.force {
|
||||||
// Force mode: upsert (insert or update)
|
// Force mode: upsert (insert or update)
|
||||||
props::upsert_server_prop(&mut *guard, &metadata, &asset_path, None).await?
|
props::upsert_server_prop(&pool, &metadata, &asset_path, None).await?
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: check availability first
|
// Normal mode: check availability first
|
||||||
let slug = metadata.slug_or_generate();
|
let slug = metadata.slug_or_generate();
|
||||||
let available = props::is_prop_slug_available(&mut *guard, &slug).await?;
|
let available = props::is_prop_slug_available(&pool, &slug).await?;
|
||||||
if !available {
|
if !available {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
"Prop slug '{}' is already taken",
|
"Prop slug '{}' is already taken",
|
||||||
slug
|
slug
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
props::create_server_prop(&mut *guard, &metadata, &asset_path, None).await?
|
props::create_server_prop(&pool, &metadata, &asset_path, None).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
let action = if query.force { "Upserted" } else { "Created" };
|
let action = if query.force { "Upserted" } else { "Created" };
|
||||||
|
|
@ -224,19 +217,16 @@ pub async fn get_prop(
|
||||||
|
|
||||||
/// Delete a server prop.
|
/// Delete a server prop.
|
||||||
pub async fn delete_prop(
|
pub async fn delete_prop(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
axum::extract::Path(prop_id): axum::extract::Path<Uuid>,
|
axum::extract::Path(prop_id): axum::extract::Path<Uuid>,
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Get the prop first to get the asset path
|
// Get the prop first to get the asset path
|
||||||
let prop = props::get_server_prop_by_id(&mut *guard, prop_id)
|
let prop = props::get_server_prop_by_id(&pool, prop_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
|
||||||
|
|
||||||
// Delete from database
|
// Delete from database
|
||||||
props::delete_server_prop(&mut *guard, prop_id).await?;
|
props::delete_server_prop(&pool, prop_id).await?;
|
||||||
|
|
||||||
// Try to delete the file (don't fail if file doesn't exist)
|
// Try to delete the file (don't fail if file doesn't exist)
|
||||||
let file_path = PathBuf::from("/srv/chattyness/assets").join(&prop.asset_path);
|
let file_path = PathBuf::from("/srv/chattyness/assets").join(&prop.asset_path);
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,14 @@ use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
};
|
};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{
|
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
|
||||||
CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary,
|
queries::owner as queries,
|
||||||
RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest,
|
|
||||||
},
|
|
||||||
queries::{owner as queries, realm_avatars},
|
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AdminConn;
|
|
||||||
|
|
||||||
/// Create realm response.
|
/// Create realm response.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CreateRealmResponse {
|
pub struct CreateRealmResponse {
|
||||||
|
|
@ -68,9 +63,6 @@ pub async fn get_realm(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new realm.
|
/// Create a new realm.
|
||||||
///
|
|
||||||
/// Note: Uses State(pool) instead of AdminConn because create_realm_with_new_owner
|
|
||||||
/// uses transactions internally that require pool access.
|
|
||||||
pub async fn create_realm(
|
pub async fn create_realm(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(req): Json<OwnerCreateRealmRequest>,
|
Json(req): Json<OwnerCreateRealmRequest>,
|
||||||
|
|
@ -115,8 +107,6 @@ pub async fn create_realm(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a realm.
|
/// Update a realm.
|
||||||
///
|
|
||||||
/// Note: Uses State(pool) because owner queries use &PgPool directly.
|
|
||||||
pub async fn update_realm(
|
pub async fn update_realm(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
|
|
@ -131,9 +121,6 @@ pub async fn update_realm(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer realm ownership.
|
/// Transfer realm ownership.
|
||||||
///
|
|
||||||
/// Note: Uses State(pool) instead of AdminConn because transfer_realm_ownership
|
|
||||||
/// uses transactions internally that require pool access.
|
|
||||||
pub async fn transfer_ownership(
|
pub async fn transfer_ownership(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
|
|
@ -144,157 +131,3 @@ pub async fn transfer_ownership(
|
||||||
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
|
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
|
||||||
Ok(Json(()))
|
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>,
|
|
||||||
admin_conn: AdminConn,
|
|
||||||
Path(slug): Path<String>,
|
|
||||||
Json(req): Json<CreateRealmAvatarRequest>,
|
|
||||||
) -> Result<Json<CreateRealmAvatarResponse>, AppError> {
|
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
req.validate()?;
|
|
||||||
|
|
||||||
// Get realm ID (uses pool because owner queries require it)
|
|
||||||
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(&mut *guard, 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(&mut *guard, 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>,
|
|
||||||
admin_conn: AdminConn,
|
|
||||||
Path((slug, avatar_id)): Path<(String, Uuid)>,
|
|
||||||
Json(req): Json<UpdateRealmAvatarRequest>,
|
|
||||||
) -> Result<Json<RealmAvatar>, AppError> {
|
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Validate the request
|
|
||||||
req.validate()?;
|
|
||||||
|
|
||||||
// Verify realm exists (uses pool because owner queries require it)
|
|
||||||
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
|
||||||
|
|
||||||
// Check avatar exists
|
|
||||||
let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
|
||||||
|
|
||||||
// Update the avatar
|
|
||||||
let avatar = realm_avatars::update_realm_avatar(&mut *guard, 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>,
|
|
||||||
admin_conn: AdminConn,
|
|
||||||
Path((slug, avatar_id)): Path<(String, Uuid)>,
|
|
||||||
) -> Result<Json<()>, AppError> {
|
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Verify realm exists (uses pool because owner queries require it)
|
|
||||||
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(&mut *guard, avatar_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
realm_avatars::delete_realm_avatar(&mut *guard, 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},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
|
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
|
||||||
use crate::app::AdminAppState;
|
use crate::app::AdminAppState;
|
||||||
|
|
||||||
/// Create the admin API router.
|
/// Create the admin API router.
|
||||||
|
|
@ -85,28 +85,6 @@ pub fn admin_api_router() -> Router<AdminAppState> {
|
||||||
"/props/{prop_id}",
|
"/props/{prop_id}",
|
||||||
get(props::get_prop).delete(props::delete_prop),
|
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.
|
/// Health check endpoint.
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ use sqlx::PgPool;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AdminConn;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Image Processing Helpers
|
// Image Processing Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -212,20 +210,17 @@ pub struct CreateSceneResponse {
|
||||||
|
|
||||||
/// Create a new scene in a realm.
|
/// Create a new scene in a realm.
|
||||||
pub async fn create_scene(
|
pub async fn create_scene(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
Json(mut req): Json<CreateSceneRequest>,
|
Json(mut req): Json<CreateSceneRequest>,
|
||||||
) -> Result<Json<CreateSceneResponse>, AppError> {
|
) -> Result<Json<CreateSceneResponse>, AppError> {
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Get the realm
|
// Get the realm
|
||||||
let realm = realms::get_realm_by_slug(&mut *guard, &slug)
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
// Check if slug is available
|
// Check if slug is available
|
||||||
let available = scenes::is_scene_slug_available(&mut *guard, realm.id, &req.slug).await?;
|
let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?;
|
||||||
if !available {
|
if !available {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
"Scene slug '{}' is already taken in this realm",
|
"Scene slug '{}' is already taken in this realm",
|
||||||
|
|
@ -254,7 +249,7 @@ pub async fn create_scene(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let scene = scenes::create_scene_with_id(&mut *guard, scene_id, realm.id, &req).await?;
|
let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?;
|
||||||
Ok(Json(CreateSceneResponse {
|
Ok(Json(CreateSceneResponse {
|
||||||
id: scene.id,
|
id: scene.id,
|
||||||
slug: scene.slug,
|
slug: scene.slug,
|
||||||
|
|
@ -263,15 +258,12 @@ pub async fn create_scene(
|
||||||
|
|
||||||
/// Update a scene.
|
/// Update a scene.
|
||||||
pub async fn update_scene(
|
pub async fn update_scene(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Path(scene_id): Path<Uuid>,
|
Path(scene_id): Path<Uuid>,
|
||||||
Json(mut req): Json<UpdateSceneRequest>,
|
Json(mut req): Json<UpdateSceneRequest>,
|
||||||
) -> Result<Json<Scene>, AppError> {
|
) -> Result<Json<Scene>, AppError> {
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Get the existing scene to get realm_id
|
// Get the existing scene to get realm_id
|
||||||
let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id)
|
let existing_scene = scenes::get_scene_by_id(&pool, scene_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||||
|
|
||||||
|
|
@ -304,17 +296,15 @@ pub async fn update_scene(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let scene = scenes::update_scene(&mut *guard, scene_id, &req).await?;
|
let scene = scenes::update_scene(&pool, scene_id, &req).await?;
|
||||||
Ok(Json(scene))
|
Ok(Json(scene))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a scene.
|
/// Delete a scene.
|
||||||
pub async fn delete_scene(
|
pub async fn delete_scene(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Path(scene_id): Path<Uuid>,
|
Path(scene_id): Path<Uuid>,
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
let conn = admin_conn.0;
|
scenes::delete_scene(&pool, scene_id).await?;
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
scenes::delete_scene(&mut *guard, scene_id).await?;
|
|
||||||
Ok(Json(()))
|
Ok(Json(()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ use serde::Serialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AdminConn;
|
|
||||||
|
|
||||||
/// List all spots for a scene.
|
/// List all spots for a scene.
|
||||||
pub async fn list_spots(
|
pub async fn list_spots(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
|
|
@ -43,16 +41,13 @@ pub struct CreateSpotResponse {
|
||||||
|
|
||||||
/// Create a new spot in a scene.
|
/// Create a new spot in a scene.
|
||||||
pub async fn create_spot(
|
pub async fn create_spot(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Path(scene_id): Path<Uuid>,
|
Path(scene_id): Path<Uuid>,
|
||||||
Json(req): Json<CreateSpotRequest>,
|
Json(req): Json<CreateSpotRequest>,
|
||||||
) -> Result<Json<CreateSpotResponse>, AppError> {
|
) -> Result<Json<CreateSpotResponse>, AppError> {
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// Check if slug is available (if provided)
|
// Check if slug is available (if provided)
|
||||||
if let Some(ref slug) = req.slug {
|
if let Some(ref slug) = req.slug {
|
||||||
let available = spots::is_spot_slug_available(&mut *guard, scene_id, slug).await?;
|
let available = spots::is_spot_slug_available(&pool, scene_id, slug).await?;
|
||||||
if !available {
|
if !available {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
"Spot slug '{}' is already taken in this scene",
|
"Spot slug '{}' is already taken in this scene",
|
||||||
|
|
@ -61,28 +56,25 @@ pub async fn create_spot(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let spot = spots::create_spot(&mut *guard, scene_id, &req).await?;
|
let spot = spots::create_spot(&pool, scene_id, &req).await?;
|
||||||
Ok(Json(CreateSpotResponse { id: spot.id }))
|
Ok(Json(CreateSpotResponse { id: spot.id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a spot.
|
/// Update a spot.
|
||||||
pub async fn update_spot(
|
pub async fn update_spot(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Path(spot_id): Path<Uuid>,
|
Path(spot_id): Path<Uuid>,
|
||||||
Json(req): Json<UpdateSpotRequest>,
|
Json(req): Json<UpdateSpotRequest>,
|
||||||
) -> Result<Json<Spot>, AppError> {
|
) -> Result<Json<Spot>, AppError> {
|
||||||
let conn = admin_conn.0;
|
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
|
|
||||||
// If updating slug, check availability
|
// If updating slug, check availability
|
||||||
if let Some(ref new_slug) = req.slug {
|
if let Some(ref new_slug) = req.slug {
|
||||||
let existing = spots::get_spot_by_id(&mut *guard, spot_id)
|
let existing = spots::get_spot_by_id(&pool, spot_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||||
|
|
||||||
if Some(new_slug.clone()) != existing.slug {
|
if Some(new_slug.clone()) != existing.slug {
|
||||||
let available =
|
let available =
|
||||||
spots::is_spot_slug_available(&mut *guard, existing.scene_id, new_slug).await?;
|
spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
|
||||||
if !available {
|
if !available {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
"Spot slug '{}' is already taken in this scene",
|
"Spot slug '{}' is already taken in this scene",
|
||||||
|
|
@ -92,17 +84,15 @@ pub async fn update_spot(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let spot = spots::update_spot(&mut *guard, spot_id, &req).await?;
|
let spot = spots::update_spot(&pool, spot_id, &req).await?;
|
||||||
Ok(Json(spot))
|
Ok(Json(spot))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a spot.
|
/// Delete a spot.
|
||||||
pub async fn delete_spot(
|
pub async fn delete_spot(
|
||||||
admin_conn: AdminConn,
|
State(pool): State<PgPool>,
|
||||||
Path(spot_id): Path<Uuid>,
|
Path(spot_id): Path<Uuid>,
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
let conn = admin_conn.0;
|
spots::delete_spot(&pool, spot_id).await?;
|
||||||
let mut guard = conn.acquire().await;
|
|
||||||
spots::delete_spot(&mut *guard, spot_id).await?;
|
|
||||||
Ok(Json(()))
|
Ok(Json(()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@
|
||||||
//! - Server staff: Uses chattyness_owner pool (bypasses RLS, full access)
|
//! - Server staff: Uses chattyness_owner pool (bypasses RLS, full access)
|
||||||
//! - Realm admins: Uses chattyness_app pool (RLS enforces permissions)
|
//! - Realm admins: Uses chattyness_app pool (RLS enforces permissions)
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
mod admin_conn;
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub use admin_conn::{AdminConn, AdminConnError, AdminConnGuard, AdminConnLayer, AdminConnection};
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use axum::{
|
use axum::{
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
//! Admin connection extractor with RLS context.
|
|
||||||
//!
|
|
||||||
//! Provides database connections that set RLS context when a user is authenticated.
|
|
||||||
//! Used by admin API handlers to ensure write operations respect RLS policies.
|
|
||||||
//!
|
|
||||||
//! - In owner app: No session, uses plain connection (RLS bypassed by chattyness_owner role)
|
|
||||||
//! - In user app: Session exists, sets current_user_id() for RLS enforcement
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{Request, StatusCode, request::Parts},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgConnection};
|
|
||||||
use std::{
|
|
||||||
future::Future,
|
|
||||||
ops::{Deref, DerefMut},
|
|
||||||
pin::Pin,
|
|
||||||
sync::Arc,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
use tokio::sync::{Mutex, MutexGuard};
|
|
||||||
use tower::{Layer, Service};
|
|
||||||
use tower_sessions::Session;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::{ADMIN_SESSION_STAFF_ID_KEY, SESSION_USER_ID_KEY};
|
|
||||||
use chattyness_error::ErrorResponse;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Admin Connection Wrapper
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
struct AdminConnectionInner {
|
|
||||||
conn: Option<PoolConnection<Postgres>>,
|
|
||||||
pool: PgPool,
|
|
||||||
had_user_context: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for AdminConnectionInner {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(mut conn) = self.conn.take() {
|
|
||||||
// Only clear context if we set it
|
|
||||||
if self.had_user_context {
|
|
||||||
let pool = self.pool.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = sqlx::query("SELECT public.set_current_user_id(NULL)")
|
|
||||||
.execute(&mut *conn)
|
|
||||||
.await;
|
|
||||||
drop(conn);
|
|
||||||
drop(pool);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A database connection with optional RLS user context set.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AdminConnection {
|
|
||||||
inner: Arc<Mutex<AdminConnectionInner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdminConnection {
|
|
||||||
fn new(conn: PoolConnection<Postgres>, pool: PgPool, had_user_context: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(AdminConnectionInner {
|
|
||||||
conn: Some(conn),
|
|
||||||
pool,
|
|
||||||
had_user_context,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Acquire exclusive access to the admin connection.
|
|
||||||
pub async fn acquire(&self) -> AdminConnGuard<'_> {
|
|
||||||
AdminConnGuard {
|
|
||||||
guard: self.inner.lock().await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A guard providing mutable access to the admin database connection.
|
|
||||||
pub struct AdminConnGuard<'a> {
|
|
||||||
guard: MutexGuard<'a, AdminConnectionInner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for AdminConnGuard<'_> {
|
|
||||||
type Target = PgConnection;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
self.guard
|
|
||||||
.conn
|
|
||||||
.as_ref()
|
|
||||||
.expect("AdminConnection already consumed")
|
|
||||||
.deref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DerefMut for AdminConnGuard<'_> {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
self.guard
|
|
||||||
.conn
|
|
||||||
.as_mut()
|
|
||||||
.expect("AdminConnection already consumed")
|
|
||||||
.deref_mut()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Admin Connection Extractor
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Extractor for an admin database connection with RLS context.
|
|
||||||
///
|
|
||||||
/// Usage in handlers:
|
|
||||||
/// ```ignore
|
|
||||||
/// pub async fn create_scene(
|
|
||||||
/// admin_conn: AdminConn,
|
|
||||||
/// Json(req): Json<CreateSceneRequest>,
|
|
||||||
/// ) -> Result<Json<Scene>, AppError> {
|
|
||||||
/// let mut conn = admin_conn.0;
|
|
||||||
/// let mut guard = conn.acquire().await;
|
|
||||||
/// scenes::create_scene(&mut *guard, ...).await
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub struct AdminConn(pub AdminConnection);
|
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for AdminConn
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = AdminConnError;
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
parts
|
|
||||||
.extensions
|
|
||||||
.remove::<AdminConnection>()
|
|
||||||
.map(AdminConn)
|
|
||||||
.ok_or(AdminConnError::NoConnection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for AdminConn {
|
|
||||||
type Target = AdminConnection;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DerefMut for AdminConn {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors related to admin connection handling.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum AdminConnError {
|
|
||||||
NoConnection,
|
|
||||||
DatabaseError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IntoResponse for AdminConnError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let (status, message) = match self {
|
|
||||||
AdminConnError::NoConnection => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"Admin connection not available - is AdminConnLayer middleware configured?",
|
|
||||||
),
|
|
||||||
AdminConnError::DatabaseError(msg) => (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
msg.leak() as &'static str,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = ErrorResponse {
|
|
||||||
error: message.to_string(),
|
|
||||||
code: Some("ADMIN_CONN_ERROR".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
(status, Json(body)).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Admin Connection Middleware Layer
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Layer that provides admin database connections with RLS context per request.
|
|
||||||
///
|
|
||||||
/// This middleware:
|
|
||||||
/// 1. Checks for user_id in session (staff_id or user_id)
|
|
||||||
/// 2. Acquires a connection from the pool
|
|
||||||
/// 3. If user_id exists, calls `set_current_user_id($1)` for RLS
|
|
||||||
/// 4. Inserts the connection into request extensions
|
|
||||||
///
|
|
||||||
/// Usage:
|
|
||||||
/// ```ignore
|
|
||||||
/// let app = Router::new()
|
|
||||||
/// .nest("/api/admin", admin_api_router())
|
|
||||||
/// .layer(AdminConnLayer::new(pool.clone()))
|
|
||||||
/// .layer(session_layer);
|
|
||||||
/// ```
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AdminConnLayer {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdminConnLayer {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S> Layer<S> for AdminConnLayer {
|
|
||||||
type Service = AdminConnMiddleware<S>;
|
|
||||||
|
|
||||||
fn layer(&self, inner: S) -> Self::Service {
|
|
||||||
AdminConnMiddleware {
|
|
||||||
inner,
|
|
||||||
pool: self.pool.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Middleware that sets up admin connections with RLS context per request.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AdminConnMiddleware<S> {
|
|
||||||
inner: S,
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S, B> Service<Request<B>> for AdminConnMiddleware<S>
|
|
||||||
where
|
|
||||||
S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
|
|
||||||
S::Future: Send,
|
|
||||||
B: Send + 'static,
|
|
||||||
{
|
|
||||||
type Response = Response;
|
|
||||||
type Error = S::Error;
|
|
||||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
|
||||||
|
|
||||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
self.inner.poll_ready(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(&mut self, mut request: Request<B>) -> Self::Future {
|
|
||||||
let pool = self.pool.clone();
|
|
||||||
let mut inner = self.inner.clone();
|
|
||||||
|
|
||||||
let session = request.extensions().get::<Session>().cloned();
|
|
||||||
|
|
||||||
Box::pin(async move {
|
|
||||||
let user_id = get_admin_user_id(session).await;
|
|
||||||
|
|
||||||
match acquire_admin_connection(&pool, user_id).await {
|
|
||||||
Ok(admin_conn) => {
|
|
||||||
request.extensions_mut().insert(admin_conn);
|
|
||||||
inner.call(request).await
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to acquire admin connection: {}", e);
|
|
||||||
Ok(AdminConnError::DatabaseError(e.to_string()).into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get user ID from session for RLS context.
|
|
||||||
///
|
|
||||||
/// Checks in order:
|
|
||||||
/// 1. staff_id - server staff member
|
|
||||||
/// 2. user_id - realm admin user
|
|
||||||
///
|
|
||||||
/// Returns None if no session or no user ID (owner app context).
|
|
||||||
async fn get_admin_user_id(session: Option<Session>) -> Option<Uuid> {
|
|
||||||
let Some(session) = session else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try staff_id first (server staff)
|
|
||||||
if let Ok(Some(staff_id)) = session.get::<Uuid>(ADMIN_SESSION_STAFF_ID_KEY).await {
|
|
||||||
return Some(staff_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try user_id (realm admin)
|
|
||||||
if let Ok(Some(user_id)) = session.get::<Uuid>(SESSION_USER_ID_KEY).await {
|
|
||||||
return Some(user_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Acquire a database connection and set RLS context if user_id is provided.
|
|
||||||
async fn acquire_admin_connection(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<AdminConnection, sqlx::Error> {
|
|
||||||
let mut conn = pool.acquire().await?;
|
|
||||||
|
|
||||||
let had_user_context = user_id.is_some();
|
|
||||||
|
|
||||||
if let Some(user_id) = user_id {
|
|
||||||
sqlx::query("SELECT public.set_current_user_id($1)")
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&mut *conn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(AdminConnection::new(conn, pool.clone(), had_user_context))
|
|
||||||
}
|
|
||||||
|
|
@ -177,8 +177,6 @@ fn Sidebar(
|
||||||
let realms_new_href = format!("{}/realms/new", base_path);
|
let realms_new_href = format!("{}/realms/new", base_path);
|
||||||
let props_href = format!("{}/props", base_path);
|
let props_href = format!("{}/props", base_path);
|
||||||
let props_new_href = format!("{}/props/new", 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! {
|
view! {
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
|
|
@ -261,24 +259,6 @@ fn Sidebar(
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</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()
|
}.into_any()
|
||||||
} else {
|
} else {
|
||||||
// Realm admin: show realm-specific options only
|
// Realm admin: show realm-specific options only
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,6 @@ pub struct RealmDetail {
|
||||||
pub privacy: String,
|
pub privacy: String,
|
||||||
pub is_nsfw: bool,
|
pub is_nsfw: bool,
|
||||||
pub allow_guest_access: bool,
|
pub allow_guest_access: bool,
|
||||||
pub allow_user_teleport: bool,
|
|
||||||
pub max_users: i32,
|
pub max_users: i32,
|
||||||
pub theme_color: Option<String>,
|
pub theme_color: Option<String>,
|
||||||
pub owner_id: String,
|
pub owner_id: String,
|
||||||
|
|
@ -257,73 +256,3 @@ pub struct CreatePropResponse {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub asset_path: 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,17 +1,11 @@
|
||||||
//! Admin interface Leptos page components.
|
//! Admin interface Leptos page components.
|
||||||
|
|
||||||
mod avatars;
|
|
||||||
mod avatars_detail;
|
|
||||||
mod avatars_new;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod dashboard;
|
mod dashboard;
|
||||||
mod login;
|
mod login;
|
||||||
mod props;
|
mod props;
|
||||||
mod props_detail;
|
mod props_detail;
|
||||||
mod props_new;
|
mod props_new;
|
||||||
mod realm_avatars;
|
|
||||||
mod realm_avatars_detail;
|
|
||||||
mod realm_avatars_new;
|
|
||||||
mod realm_detail;
|
mod realm_detail;
|
||||||
mod realm_new;
|
mod realm_new;
|
||||||
mod realms;
|
mod realms;
|
||||||
|
|
@ -23,18 +17,12 @@ mod user_detail;
|
||||||
mod user_new;
|
mod user_new;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
pub use avatars::AvatarsPage;
|
|
||||||
pub use avatars_detail::AvatarsDetailPage;
|
|
||||||
pub use avatars_new::AvatarsNewPage;
|
|
||||||
pub use config::ConfigPage;
|
pub use config::ConfigPage;
|
||||||
pub use dashboard::DashboardPage;
|
pub use dashboard::DashboardPage;
|
||||||
pub use login::LoginPage;
|
pub use login::LoginPage;
|
||||||
pub use props::PropsPage;
|
pub use props::PropsPage;
|
||||||
pub use props_detail::PropsDetailPage;
|
pub use props_detail::PropsDetailPage;
|
||||||
pub use props_new::PropsNewPage;
|
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_detail::RealmDetailPage;
|
||||||
pub use realm_new::RealmNewPage;
|
pub use realm_new::RealmNewPage;
|
||||||
pub use realms::RealmsPage;
|
pub use realms::RealmsPage;
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
//! 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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
//! 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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
//! 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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
//! 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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
//! 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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
//! 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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -73,7 +73,6 @@ fn RealmDetailView(
|
||||||
let (max_users, set_max_users) = signal(realm.max_users);
|
let (max_users, set_max_users) = signal(realm.max_users);
|
||||||
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
|
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
|
||||||
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
|
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
|
||||||
let (allow_user_teleport, set_allow_user_teleport) = signal(realm.allow_user_teleport);
|
|
||||||
let (theme_color, set_theme_color) = signal(
|
let (theme_color, set_theme_color) = signal(
|
||||||
realm
|
realm
|
||||||
.theme_color
|
.theme_color
|
||||||
|
|
@ -100,7 +99,6 @@ fn RealmDetailView(
|
||||||
"is_nsfw": is_nsfw.get(),
|
"is_nsfw": is_nsfw.get(),
|
||||||
"max_users": max_users.get(),
|
"max_users": max_users.get(),
|
||||||
"allow_guest_access": allow_guest_access.get(),
|
"allow_guest_access": allow_guest_access.get(),
|
||||||
"allow_user_teleport": allow_user_teleport.get(),
|
|
||||||
"theme_color": theme_color.get()
|
"theme_color": theme_color.get()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -271,17 +269,6 @@ fn RealmDetailView(
|
||||||
"Allow Guest Access"
|
"Allow Guest Access"
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-group">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="form-checkbox"
|
|
||||||
prop:checked=move || allow_user_teleport.get()
|
|
||||||
on:change=move |ev| set_allow_user_teleport.set(event_target_checked(&ev))
|
|
||||||
/>
|
|
||||||
"Allow User Teleport"
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,9 @@ use leptos_router::{
|
||||||
|
|
||||||
use crate::components::{AuthenticatedLayout, LoginLayout};
|
use crate::components::{AuthenticatedLayout, LoginLayout};
|
||||||
use crate::pages::{
|
use crate::pages::{
|
||||||
AvatarsDetailPage, AvatarsNewPage, AvatarsPage, ConfigPage, DashboardPage, LoginPage,
|
ConfigPage, DashboardPage, LoginPage, PropsDetailPage, PropsNewPage, PropsPage,
|
||||||
PropsDetailPage, PropsNewPage, PropsPage, RealmAvatarsDetailPage, RealmAvatarsNewPage,
|
RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage, ScenesPage,
|
||||||
RealmAvatarsPage, RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage,
|
StaffPage, UserDetailPage, UserNewPage, UsersPage,
|
||||||
ScenesPage, StaffPage, UserDetailPage, UserNewPage, UsersPage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Admin routes that can be embedded in a parent Router.
|
/// Admin routes that can be embedded in a parent Router.
|
||||||
|
|
@ -92,23 +91,6 @@ pub fn AdminRoutes() -> impl IntoView {
|
||||||
</AuthenticatedLayout>
|
</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
|
// Realms
|
||||||
<Route path=StaticSegment("realms") view=|| view! {
|
<Route path=StaticSegment("realms") view=|| view! {
|
||||||
<AuthenticatedLayout current_page="realms">
|
<AuthenticatedLayout current_page="realms">
|
||||||
|
|
@ -138,23 +120,6 @@ pub fn AdminRoutes() -> impl IntoView {
|
||||||
</AuthenticatedLayout>
|
</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)
|
// Realm detail (must come after more specific realm routes)
|
||||||
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=|| view! {
|
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=|| view! {
|
||||||
<AuthenticatedLayout current_page="realms">
|
<AuthenticatedLayout current_page="realms">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -62,3 +62,23 @@ pub async fn clear_user_context(pool: &PgPool) -> Result<(), AppError> {
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the current guest session context for Row-Level Security.
|
||||||
|
///
|
||||||
|
/// This should be called for guest users to enable RLS policies
|
||||||
|
/// that depend on the current guest session ID.
|
||||||
|
pub async fn set_guest_context(pool: &PgPool, guest_session_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query("SELECT public.set_current_guest_session_id($1)")
|
||||||
|
.bind(guest_session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current guest session context.
|
||||||
|
pub async fn clear_guest_context(pool: &PgPool) -> Result<(), AppError> {
|
||||||
|
sqlx::query("SELECT public.set_current_guest_session_id(NULL)")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,14 @@
|
||||||
pub mod avatars;
|
pub mod avatars;
|
||||||
pub mod channel_members;
|
pub mod channel_members;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
pub mod guests;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod loose_props;
|
pub mod loose_props;
|
||||||
pub mod memberships;
|
pub mod memberships;
|
||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
pub mod owner;
|
pub mod owner;
|
||||||
pub mod props;
|
pub mod props;
|
||||||
pub mod realm_avatars;
|
|
||||||
pub mod realms;
|
pub mod realms;
|
||||||
pub mod scenes;
|
pub mod scenes;
|
||||||
pub mod server_avatars;
|
|
||||||
pub mod spots;
|
pub mod spots;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,7 @@ pub async fn get_active_avatar<'e>(
|
||||||
) -> Result<Option<ActiveAvatar>, AppError> {
|
) -> Result<Option<ActiveAvatar>, AppError> {
|
||||||
let avatar = sqlx::query_as::<_, ActiveAvatar>(
|
let avatar = sqlx::query_as::<_, ActiveAvatar>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
|
||||||
user_id, realm_id, avatar_id,
|
|
||||||
selected_server_avatar_id, selected_realm_avatar_id,
|
|
||||||
current_emotion, updated_at,
|
|
||||||
forced_avatar_id, forced_avatar_source, forced_by, forced_until
|
|
||||||
FROM auth.active_avatars
|
FROM auth.active_avatars
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -35,54 +31,86 @@ pub async fn get_active_avatar<'e>(
|
||||||
|
|
||||||
/// Set the current emotion for a user in a realm.
|
/// Set the current emotion for a user in a realm.
|
||||||
/// Returns the full emotion layer (9 asset paths) for the new emotion.
|
/// Returns the full emotion layer (9 asset paths) for the new emotion.
|
||||||
///
|
|
||||||
/// This function works with any avatar source:
|
|
||||||
/// - Custom user avatars (auth.avatars)
|
|
||||||
/// - Selected server avatars (server.avatars)
|
|
||||||
/// - Selected realm avatars (realm.avatars)
|
|
||||||
/// - Server default avatars (server.avatars via server.config)
|
|
||||||
/// - Realm default avatars (realm.avatars via realm.realms)
|
|
||||||
///
|
|
||||||
/// Takes both a connection (for RLS-protected update) and a pool (for avatar resolution).
|
|
||||||
pub async fn set_emotion<'e>(
|
pub async fn set_emotion<'e>(
|
||||||
conn: &mut PgConnection,
|
executor: impl PgExecutor<'e>,
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
emotion: EmotionState,
|
emotion: EmotionState,
|
||||||
) -> Result<[Option<String>; 9], AppError> {
|
) -> Result<[Option<String>; 9], AppError> {
|
||||||
// First, update the emotion in active_avatars (uses RLS connection)
|
// Map emotion to column prefix
|
||||||
let update_result = sqlx::query(
|
let emotion_prefix = match emotion {
|
||||||
|
EmotionState::Neutral => "e_neutral",
|
||||||
|
EmotionState::Happy => "e_happy",
|
||||||
|
EmotionState::Sad => "e_sad",
|
||||||
|
EmotionState::Angry => "e_angry",
|
||||||
|
EmotionState::Surprised => "e_surprised",
|
||||||
|
EmotionState::Thinking => "e_thinking",
|
||||||
|
EmotionState::Laughing => "e_laughing",
|
||||||
|
EmotionState::Crying => "e_crying",
|
||||||
|
EmotionState::Love => "e_love",
|
||||||
|
EmotionState::Confused => "e_confused",
|
||||||
|
EmotionState::Sleeping => "e_sleeping",
|
||||||
|
EmotionState::Wink => "e_wink",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the numeric index for the database
|
||||||
|
let emotion_index = emotion.to_index() as i16;
|
||||||
|
|
||||||
|
// Build dynamic query for the specific emotion's 9 positions
|
||||||
|
let query = format!(
|
||||||
r#"
|
r#"
|
||||||
UPDATE auth.active_avatars
|
WITH updated AS (
|
||||||
SET current_emotion = $3::server.emotion_state, updated_at = now()
|
UPDATE auth.active_avatars
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
SET current_emotion = $3, updated_at = now()
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
RETURNING avatar_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_0) as p0,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_1) as p1,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_2) as p2,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_3) as p3,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_4) as p4,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_5) as p5,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_6) as p6,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_7) as p7,
|
||||||
|
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_8) as p8
|
||||||
|
FROM updated u
|
||||||
|
JOIN auth.avatars a ON a.id = u.avatar_id
|
||||||
"#,
|
"#,
|
||||||
)
|
prefix = emotion_prefix
|
||||||
.bind(user_id)
|
);
|
||||||
.bind(realm_id)
|
|
||||||
.bind(emotion.to_string())
|
|
||||||
.execute(&mut *conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if update_result.rows_affected() == 0 {
|
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
|
||||||
return Err(AppError::NotFound(
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(emotion_index)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(row) => Ok([
|
||||||
|
row.p0, row.p1, row.p2, row.p3, row.p4, row.p5, row.p6, row.p7, row.p8,
|
||||||
|
]),
|
||||||
|
None => Err(AppError::NotFound(
|
||||||
"No active avatar for this user in this realm".to_string(),
|
"No active avatar for this user in this realm".to_string(),
|
||||||
));
|
)),
|
||||||
}
|
|
||||||
|
|
||||||
// Now get the effective avatar and return the emotion layer (uses pool for multiple queries)
|
|
||||||
let render_data = get_effective_avatar_render_data(pool, user_id, realm_id).await?;
|
|
||||||
|
|
||||||
match render_data {
|
|
||||||
Some((data, _source)) => Ok(data.emotion_layer),
|
|
||||||
None => {
|
|
||||||
// No avatar found - return empty layer
|
|
||||||
Ok([None, None, None, None, None, None, None, None, None])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row type for emotion layer query.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct EmotionLayerRow {
|
||||||
|
p0: Option<String>,
|
||||||
|
p1: Option<String>,
|
||||||
|
p2: Option<String>,
|
||||||
|
p3: Option<String>,
|
||||||
|
p4: Option<String>,
|
||||||
|
p5: Option<String>,
|
||||||
|
p6: Option<String>,
|
||||||
|
p7: Option<String>,
|
||||||
|
p8: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Get emotion availability for a user's avatar in a realm.
|
/// Get emotion availability for a user's avatar in a realm.
|
||||||
///
|
///
|
||||||
|
|
@ -1441,7 +1469,7 @@ fn collect_uuids(dest: &mut Vec<Uuid>, sources: &[Option<Uuid>]) {
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
struct AvatarWithEmotion {
|
struct AvatarWithEmotion {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub current_emotion: EmotionState,
|
pub current_emotion: i16,
|
||||||
// Content layers
|
// Content layers
|
||||||
pub l_skin_0: Option<Uuid>,
|
pub l_skin_0: Option<Uuid>,
|
||||||
pub l_skin_1: Option<Uuid>,
|
pub l_skin_1: Option<Uuid>,
|
||||||
|
|
@ -1589,18 +1617,22 @@ pub async fn set_emotion_simple<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
emotion: EmotionState,
|
emotion: i16,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
if emotion < 0 || emotion > 11 {
|
||||||
|
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE auth.active_avatars
|
UPDATE auth.active_avatars
|
||||||
SET current_emotion = $3::server.emotion_state, updated_at = now()
|
SET current_emotion = $3, updated_at = now()
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(realm_id)
|
.bind(realm_id)
|
||||||
.bind(emotion.to_string())
|
.bind(emotion)
|
||||||
.execute(executor)
|
.execute(executor)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -1691,472 +1723,3 @@ pub async fn update_avatar_slot(
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data needed to resolve effective avatar for a user.
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
pub struct AvatarResolutionContext {
|
|
||||||
// Active avatar row data
|
|
||||||
pub avatar_id: Option<Uuid>,
|
|
||||||
pub selected_server_avatar_id: Option<Uuid>,
|
|
||||||
pub selected_realm_avatar_id: Option<Uuid>,
|
|
||||||
pub current_emotion: EmotionState,
|
|
||||||
// Forced avatar data
|
|
||||||
pub forced_avatar_id: Option<Uuid>,
|
|
||||||
pub forced_avatar_source: Option<String>,
|
|
||||||
pub forced_until: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
// User preferences
|
|
||||||
pub gender_preference: crate::models::GenderPreference,
|
|
||||||
pub age_category: crate::models::AgeCategory,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Source of the resolved avatar.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AvatarSource {
|
|
||||||
/// User's custom avatar from auth.avatars
|
|
||||||
Custom,
|
|
||||||
/// User-selected realm avatar from avatar store
|
|
||||||
SelectedRealm,
|
|
||||||
/// User-selected server avatar from avatar store
|
|
||||||
SelectedServer,
|
|
||||||
/// Realm default avatar based on gender/age
|
|
||||||
RealmDefault,
|
|
||||||
/// Server default avatar based on gender/age
|
|
||||||
ServerDefault,
|
|
||||||
/// Forced avatar (mod command or scene)
|
|
||||||
Forced,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the effective avatar render data for a user in a realm.
|
|
||||||
///
|
|
||||||
/// This function implements the avatar resolution priority chain:
|
|
||||||
/// 1. Forced avatar (mod command or scene) - highest priority
|
|
||||||
/// 2. User's custom avatar (auth.avatars via avatar_id)
|
|
||||||
/// 3. User-selected realm avatar (selected_realm_avatar_id)
|
|
||||||
/// 4. User-selected server avatar (selected_server_avatar_id)
|
|
||||||
/// 5. Realm default (based on gender+age)
|
|
||||||
/// 6. Server default (based on gender+age) - lowest priority
|
|
||||||
pub async fn get_effective_avatar_render_data<'e>(
|
|
||||||
executor: impl PgExecutor<'e> + Copy,
|
|
||||||
user_id: Uuid,
|
|
||||||
realm_id: Uuid,
|
|
||||||
) -> Result<Option<(crate::models::AvatarRenderData, AvatarSource)>, AppError> {
|
|
||||||
|
|
||||||
// Get the resolution context with all necessary data
|
|
||||||
// Use LEFT JOIN so we can still get user preferences even without an active_avatars entry
|
|
||||||
let ctx = sqlx::query_as::<_, AvatarResolutionContext>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
aa.avatar_id,
|
|
||||||
aa.selected_server_avatar_id,
|
|
||||||
aa.selected_realm_avatar_id,
|
|
||||||
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
|
||||||
aa.forced_avatar_id,
|
|
||||||
aa.forced_avatar_source,
|
|
||||||
aa.forced_until,
|
|
||||||
u.gender_preference,
|
|
||||||
u.age_category
|
|
||||||
FROM auth.users u
|
|
||||||
LEFT JOIN auth.active_avatars aa ON aa.user_id = u.id AND aa.realm_id = $2
|
|
||||||
WHERE u.id = $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.fetch_optional(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(ctx) = ctx else {
|
|
||||||
// User doesn't exist
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Priority 1: Check for forced avatar (not expired)
|
|
||||||
if let Some(forced_id) = ctx.forced_avatar_id {
|
|
||||||
let is_expired = ctx.forced_until.map(|t| t < chrono::Utc::now()).unwrap_or(false);
|
|
||||||
if !is_expired {
|
|
||||||
if let Some(source) = &ctx.forced_avatar_source {
|
|
||||||
match source.as_str() {
|
|
||||||
"server" | "scene" => {
|
|
||||||
// Resolve from server.avatars
|
|
||||||
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, forced_id).await? {
|
|
||||||
let render = super::server_avatars::resolve_server_avatar_to_render_data(
|
|
||||||
executor, &avatar, ctx.current_emotion
|
|
||||||
).await?;
|
|
||||||
return Ok(Some((render, AvatarSource::Forced)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"realm" => {
|
|
||||||
// Resolve from realm.avatars
|
|
||||||
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, forced_id).await? {
|
|
||||||
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
|
|
||||||
executor, &avatar, ctx.current_emotion
|
|
||||||
).await?;
|
|
||||||
return Ok(Some((render, AvatarSource::Forced)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: User's custom avatar
|
|
||||||
if let Some(avatar_id) = ctx.avatar_id {
|
|
||||||
if let Some(render) = resolve_user_avatar_to_render_data(executor, avatar_id, ctx.current_emotion).await? {
|
|
||||||
return Ok(Some((render, AvatarSource::Custom)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: User-selected realm avatar
|
|
||||||
if let Some(realm_avatar_id) = ctx.selected_realm_avatar_id {
|
|
||||||
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, realm_avatar_id).await? {
|
|
||||||
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
|
|
||||||
executor, &avatar, ctx.current_emotion
|
|
||||||
).await?;
|
|
||||||
return Ok(Some((render, AvatarSource::SelectedRealm)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 4: User-selected server avatar
|
|
||||||
if let Some(server_avatar_id) = ctx.selected_server_avatar_id {
|
|
||||||
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, server_avatar_id).await? {
|
|
||||||
let render = super::server_avatars::resolve_server_avatar_to_render_data(
|
|
||||||
executor, &avatar, ctx.current_emotion
|
|
||||||
).await?;
|
|
||||||
return Ok(Some((render, AvatarSource::SelectedServer)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 5: Realm default avatar (based on gender+age)
|
|
||||||
let realm_default_id = get_realm_default_avatar_id(executor, realm_id, ctx.gender_preference, ctx.age_category).await?;
|
|
||||||
if let Some(avatar_id) = realm_default_id {
|
|
||||||
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, avatar_id).await? {
|
|
||||||
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
|
|
||||||
executor, &avatar, ctx.current_emotion
|
|
||||||
).await?;
|
|
||||||
return Ok(Some((render, AvatarSource::RealmDefault)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 6: Server default avatar (based on gender+age)
|
|
||||||
let server_default_id = get_server_default_avatar_id(executor, ctx.gender_preference, ctx.age_category).await?;
|
|
||||||
if let Some(avatar_id) = server_default_id {
|
|
||||||
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, avatar_id).await? {
|
|
||||||
let render = super::server_avatars::resolve_server_avatar_to_render_data(
|
|
||||||
executor, &avatar, ctx.current_emotion
|
|
||||||
).await?;
|
|
||||||
return Ok(Some((render, AvatarSource::ServerDefault)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a user's custom avatar (from auth.avatars) to render data.
|
|
||||||
async fn resolve_user_avatar_to_render_data<'e>(
|
|
||||||
executor: impl PgExecutor<'e> + Copy,
|
|
||||||
avatar_id: Uuid,
|
|
||||||
current_emotion: EmotionState,
|
|
||||||
) -> Result<Option<crate::models::AvatarRenderData>, AppError> {
|
|
||||||
// Get the avatar with inventory joins
|
|
||||||
let avatar = sqlx::query_as::<_, AvatarWithEmotion>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
a.id, $2::server.emotion_state as current_emotion,
|
|
||||||
a.l_skin_0, a.l_skin_1, a.l_skin_2, a.l_skin_3, a.l_skin_4,
|
|
||||||
a.l_skin_5, a.l_skin_6, a.l_skin_7, a.l_skin_8,
|
|
||||||
a.l_clothes_0, a.l_clothes_1, a.l_clothes_2, a.l_clothes_3, a.l_clothes_4,
|
|
||||||
a.l_clothes_5, a.l_clothes_6, a.l_clothes_7, a.l_clothes_8,
|
|
||||||
a.l_accessories_0, a.l_accessories_1, a.l_accessories_2, a.l_accessories_3, a.l_accessories_4,
|
|
||||||
a.l_accessories_5, a.l_accessories_6, a.l_accessories_7, a.l_accessories_8,
|
|
||||||
a.e_neutral_0, a.e_neutral_1, a.e_neutral_2, a.e_neutral_3, a.e_neutral_4,
|
|
||||||
a.e_neutral_5, a.e_neutral_6, a.e_neutral_7, a.e_neutral_8,
|
|
||||||
a.e_happy_0, a.e_happy_1, a.e_happy_2, a.e_happy_3, a.e_happy_4,
|
|
||||||
a.e_happy_5, a.e_happy_6, a.e_happy_7, a.e_happy_8,
|
|
||||||
a.e_sad_0, a.e_sad_1, a.e_sad_2, a.e_sad_3, a.e_sad_4,
|
|
||||||
a.e_sad_5, a.e_sad_6, a.e_sad_7, a.e_sad_8,
|
|
||||||
a.e_angry_0, a.e_angry_1, a.e_angry_2, a.e_angry_3, a.e_angry_4,
|
|
||||||
a.e_angry_5, a.e_angry_6, a.e_angry_7, a.e_angry_8,
|
|
||||||
a.e_surprised_0, a.e_surprised_1, a.e_surprised_2, a.e_surprised_3, a.e_surprised_4,
|
|
||||||
a.e_surprised_5, a.e_surprised_6, a.e_surprised_7, a.e_surprised_8,
|
|
||||||
a.e_thinking_0, a.e_thinking_1, a.e_thinking_2, a.e_thinking_3, a.e_thinking_4,
|
|
||||||
a.e_thinking_5, a.e_thinking_6, a.e_thinking_7, a.e_thinking_8,
|
|
||||||
a.e_laughing_0, a.e_laughing_1, a.e_laughing_2, a.e_laughing_3, a.e_laughing_4,
|
|
||||||
a.e_laughing_5, a.e_laughing_6, a.e_laughing_7, a.e_laughing_8,
|
|
||||||
a.e_crying_0, a.e_crying_1, a.e_crying_2, a.e_crying_3, a.e_crying_4,
|
|
||||||
a.e_crying_5, a.e_crying_6, a.e_crying_7, a.e_crying_8,
|
|
||||||
a.e_love_0, a.e_love_1, a.e_love_2, a.e_love_3, a.e_love_4,
|
|
||||||
a.e_love_5, a.e_love_6, a.e_love_7, a.e_love_8,
|
|
||||||
a.e_confused_0, a.e_confused_1, a.e_confused_2, a.e_confused_3, a.e_confused_4,
|
|
||||||
a.e_confused_5, a.e_confused_6, a.e_confused_7, a.e_confused_8,
|
|
||||||
a.e_sleeping_0, a.e_sleeping_1, a.e_sleeping_2, a.e_sleeping_3, a.e_sleeping_4,
|
|
||||||
a.e_sleeping_5, a.e_sleeping_6, a.e_sleeping_7, a.e_sleeping_8,
|
|
||||||
a.e_wink_0, a.e_wink_1, a.e_wink_2, a.e_wink_3, a.e_wink_4,
|
|
||||||
a.e_wink_5, a.e_wink_6, a.e_wink_7, a.e_wink_8
|
|
||||||
FROM auth.avatars a
|
|
||||||
WHERE a.id = $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(avatar_id)
|
|
||||||
.bind(current_emotion)
|
|
||||||
.fetch_optional(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(avatar) = avatar else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collect all inventory UUIDs
|
|
||||||
let mut uuids: Vec<Uuid> = Vec::new();
|
|
||||||
collect_uuids(
|
|
||||||
&mut uuids,
|
|
||||||
&[
|
|
||||||
avatar.l_skin_0, avatar.l_skin_1, avatar.l_skin_2,
|
|
||||||
avatar.l_skin_3, avatar.l_skin_4, avatar.l_skin_5,
|
|
||||||
avatar.l_skin_6, avatar.l_skin_7, avatar.l_skin_8,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
collect_uuids(
|
|
||||||
&mut uuids,
|
|
||||||
&[
|
|
||||||
avatar.l_clothes_0, avatar.l_clothes_1, avatar.l_clothes_2,
|
|
||||||
avatar.l_clothes_3, avatar.l_clothes_4, avatar.l_clothes_5,
|
|
||||||
avatar.l_clothes_6, avatar.l_clothes_7, avatar.l_clothes_8,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
collect_uuids(
|
|
||||||
&mut uuids,
|
|
||||||
&[
|
|
||||||
avatar.l_accessories_0, avatar.l_accessories_1, avatar.l_accessories_2,
|
|
||||||
avatar.l_accessories_3, avatar.l_accessories_4, avatar.l_accessories_5,
|
|
||||||
avatar.l_accessories_6, avatar.l_accessories_7, avatar.l_accessories_8,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get emotion slots for current emotion
|
|
||||||
let emotion_slots: [Option<Uuid>; 9] = match current_emotion {
|
|
||||||
EmotionState::Neutral => [avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2,
|
|
||||||
avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5,
|
|
||||||
avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8],
|
|
||||||
EmotionState::Happy => [avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2,
|
|
||||||
avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5,
|
|
||||||
avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8],
|
|
||||||
EmotionState::Sad => [avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2,
|
|
||||||
avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5,
|
|
||||||
avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8],
|
|
||||||
EmotionState::Angry => [avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2,
|
|
||||||
avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5,
|
|
||||||
avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8],
|
|
||||||
EmotionState::Surprised => [avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2,
|
|
||||||
avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5,
|
|
||||||
avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8],
|
|
||||||
EmotionState::Thinking => [avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2,
|
|
||||||
avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5,
|
|
||||||
avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8],
|
|
||||||
EmotionState::Laughing => [avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2,
|
|
||||||
avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5,
|
|
||||||
avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8],
|
|
||||||
EmotionState::Crying => [avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2,
|
|
||||||
avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5,
|
|
||||||
avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8],
|
|
||||||
EmotionState::Love => [avatar.e_love_0, avatar.e_love_1, avatar.e_love_2,
|
|
||||||
avatar.e_love_3, avatar.e_love_4, avatar.e_love_5,
|
|
||||||
avatar.e_love_6, avatar.e_love_7, avatar.e_love_8],
|
|
||||||
EmotionState::Confused => [avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2,
|
|
||||||
avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5,
|
|
||||||
avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8],
|
|
||||||
EmotionState::Sleeping => [avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2,
|
|
||||||
avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5,
|
|
||||||
avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8],
|
|
||||||
EmotionState::Wink => [avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2,
|
|
||||||
avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5,
|
|
||||||
avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8],
|
|
||||||
};
|
|
||||||
collect_uuids(&mut uuids, &emotion_slots);
|
|
||||||
|
|
||||||
// Bulk resolve inventory UUIDs to asset paths
|
|
||||||
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
|
|
||||||
HashMap::new()
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, (Uuid, String)>(
|
|
||||||
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
|
|
||||||
)
|
|
||||||
.bind(&uuids)
|
|
||||||
.fetch_all(executor)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
let get_path = |id: Option<Uuid>| -> Option<String> {
|
|
||||||
id.and_then(|id| paths.get(&id).cloned())
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(crate::models::AvatarRenderData {
|
|
||||||
avatar_id,
|
|
||||||
current_emotion,
|
|
||||||
skin_layer: [
|
|
||||||
get_path(avatar.l_skin_0), get_path(avatar.l_skin_1), get_path(avatar.l_skin_2),
|
|
||||||
get_path(avatar.l_skin_3), get_path(avatar.l_skin_4), get_path(avatar.l_skin_5),
|
|
||||||
get_path(avatar.l_skin_6), get_path(avatar.l_skin_7), get_path(avatar.l_skin_8),
|
|
||||||
],
|
|
||||||
clothes_layer: [
|
|
||||||
get_path(avatar.l_clothes_0), get_path(avatar.l_clothes_1), get_path(avatar.l_clothes_2),
|
|
||||||
get_path(avatar.l_clothes_3), get_path(avatar.l_clothes_4), get_path(avatar.l_clothes_5),
|
|
||||||
get_path(avatar.l_clothes_6), get_path(avatar.l_clothes_7), get_path(avatar.l_clothes_8),
|
|
||||||
],
|
|
||||||
accessories_layer: [
|
|
||||||
get_path(avatar.l_accessories_0), get_path(avatar.l_accessories_1), get_path(avatar.l_accessories_2),
|
|
||||||
get_path(avatar.l_accessories_3), get_path(avatar.l_accessories_4), get_path(avatar.l_accessories_5),
|
|
||||||
get_path(avatar.l_accessories_6), get_path(avatar.l_accessories_7), get_path(avatar.l_accessories_8),
|
|
||||||
],
|
|
||||||
emotion_layer: [
|
|
||||||
get_path(emotion_slots[0]), get_path(emotion_slots[1]), get_path(emotion_slots[2]),
|
|
||||||
get_path(emotion_slots[3]), get_path(emotion_slots[4]), get_path(emotion_slots[5]),
|
|
||||||
get_path(emotion_slots[6]), get_path(emotion_slots[7]), get_path(emotion_slots[8]),
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the realm default avatar ID based on gender and age preferences.
|
|
||||||
async fn get_realm_default_avatar_id<'e>(
|
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
realm_id: Uuid,
|
|
||||||
gender: crate::models::GenderPreference,
|
|
||||||
age: crate::models::AgeCategory,
|
|
||||||
) -> Result<Option<Uuid>, AppError> {
|
|
||||||
use crate::models::{AgeCategory, GenderPreference};
|
|
||||||
|
|
||||||
// Build column name based on gender and age
|
|
||||||
let column = match (gender, age) {
|
|
||||||
(GenderPreference::GenderNeutral, AgeCategory::Child) => "default_avatar_neutral_child",
|
|
||||||
(GenderPreference::GenderNeutral, AgeCategory::Adult) => "default_avatar_neutral_adult",
|
|
||||||
(GenderPreference::GenderMale, AgeCategory::Child) => "default_avatar_male_child",
|
|
||||||
(GenderPreference::GenderMale, AgeCategory::Adult) => "default_avatar_male_adult",
|
|
||||||
(GenderPreference::GenderFemale, AgeCategory::Child) => "default_avatar_female_child",
|
|
||||||
(GenderPreference::GenderFemale, AgeCategory::Adult) => "default_avatar_female_adult",
|
|
||||||
};
|
|
||||||
|
|
||||||
let query = format!(
|
|
||||||
"SELECT {} FROM realm.realms WHERE id = $1",
|
|
||||||
column
|
|
||||||
);
|
|
||||||
|
|
||||||
let result: Option<(Option<Uuid>,)> = sqlx::query_as(&query)
|
|
||||||
.bind(realm_id)
|
|
||||||
.fetch_optional(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(result.and_then(|r| r.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the server default avatar ID based on gender and age preferences.
|
|
||||||
async fn get_server_default_avatar_id<'e>(
|
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
gender: crate::models::GenderPreference,
|
|
||||||
age: crate::models::AgeCategory,
|
|
||||||
) -> Result<Option<Uuid>, AppError> {
|
|
||||||
use crate::models::{AgeCategory, GenderPreference};
|
|
||||||
|
|
||||||
// Build column name based on gender and age
|
|
||||||
let column = match (gender, age) {
|
|
||||||
(GenderPreference::GenderNeutral, AgeCategory::Child) => "default_avatar_neutral_child",
|
|
||||||
(GenderPreference::GenderNeutral, AgeCategory::Adult) => "default_avatar_neutral_adult",
|
|
||||||
(GenderPreference::GenderMale, AgeCategory::Child) => "default_avatar_male_child",
|
|
||||||
(GenderPreference::GenderMale, AgeCategory::Adult) => "default_avatar_male_adult",
|
|
||||||
(GenderPreference::GenderFemale, AgeCategory::Child) => "default_avatar_female_child",
|
|
||||||
(GenderPreference::GenderFemale, AgeCategory::Adult) => "default_avatar_female_adult",
|
|
||||||
};
|
|
||||||
|
|
||||||
let query = format!(
|
|
||||||
"SELECT {} FROM server.config WHERE id = '00000000-0000-0000-0000-000000000001'",
|
|
||||||
column
|
|
||||||
);
|
|
||||||
|
|
||||||
let result: Option<(Option<Uuid>,)> = sqlx::query_as(&query)
|
|
||||||
.fetch_optional(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(result.and_then(|r| r.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select a server avatar for a user in a realm.
|
|
||||||
/// This updates the selected_server_avatar_id in active_avatars.
|
|
||||||
/// Uses UPSERT to create the record if it doesn't exist.
|
|
||||||
pub async fn select_server_avatar<'e>(
|
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
user_id: Uuid,
|
|
||||||
realm_id: Uuid,
|
|
||||||
server_avatar_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO auth.active_avatars (user_id, realm_id, selected_server_avatar_id, updated_at)
|
|
||||||
VALUES ($1, $2, $3, now())
|
|
||||||
ON CONFLICT (user_id, realm_id) DO UPDATE
|
|
||||||
SET selected_server_avatar_id = EXCLUDED.selected_server_avatar_id,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.bind(server_avatar_id)
|
|
||||||
.execute(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select a realm avatar for a user in a realm.
|
|
||||||
/// This updates the selected_realm_avatar_id in active_avatars.
|
|
||||||
/// Uses UPSERT to create the record if it doesn't exist.
|
|
||||||
pub async fn select_realm_avatar<'e>(
|
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
user_id: Uuid,
|
|
||||||
realm_id: Uuid,
|
|
||||||
realm_avatar_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO auth.active_avatars (user_id, realm_id, selected_realm_avatar_id, updated_at)
|
|
||||||
VALUES ($1, $2, $3, now())
|
|
||||||
ON CONFLICT (user_id, realm_id) DO UPDATE
|
|
||||||
SET selected_realm_avatar_id = EXCLUDED.selected_realm_avatar_id,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.bind(realm_avatar_id)
|
|
||||||
.execute(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear avatar selection for a user in a realm.
|
|
||||||
/// Clears both selected_server_avatar_id and selected_realm_avatar_id.
|
|
||||||
/// If no record exists, this is a no-op (clearing nothing is success).
|
|
||||||
pub async fn clear_avatar_selection<'e>(
|
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
user_id: Uuid,
|
|
||||||
realm_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
UPDATE auth.active_avatars
|
|
||||||
SET
|
|
||||||
selected_server_avatar_id = NULL,
|
|
||||||
selected_realm_avatar_id = NULL,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.execute(executor)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// No error if record doesn't exist - clearing nothing is success
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ pub async fn join_channel<'e>(
|
||||||
id,
|
id,
|
||||||
instance_id as channel_id,
|
instance_id as channel_id,
|
||||||
user_id,
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
ST_X(position) as position_x,
|
ST_X(position) as position_x,
|
||||||
ST_Y(position) as position_y,
|
ST_Y(position) as position_y,
|
||||||
facing_direction,
|
facing_direction,
|
||||||
|
|
@ -57,8 +58,7 @@ pub async fn join_channel<'e>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure an active avatar exists for a user in a realm.
|
/// Ensure an active avatar exists for a user in a realm.
|
||||||
/// If user has a custom avatar (slot 0), use it. Otherwise, avatar_id is NULL
|
/// Uses the user's default avatar (slot 0) if none exists.
|
||||||
/// and the system will use server/realm default avatars based on user preferences.
|
|
||||||
pub async fn ensure_active_avatar<'e>(
|
pub async fn ensure_active_avatar<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
|
@ -67,9 +67,9 @@ pub async fn ensure_active_avatar<'e>(
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
||||||
SELECT $1, $2,
|
SELECT $1, $2, id, 1
|
||||||
(SELECT id FROM auth.avatars WHERE user_id = $1 AND slot_number = 0),
|
FROM auth.avatars
|
||||||
'happy'::server.emotion_state
|
WHERE user_id = $1 AND slot_number = 0
|
||||||
ON CONFLICT (user_id, realm_id) DO NOTHING
|
ON CONFLICT (user_id, realm_id) DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -168,17 +168,19 @@ pub async fn get_channel_members<'e>(
|
||||||
cm.id,
|
cm.id,
|
||||||
cm.instance_id as channel_id,
|
cm.instance_id as channel_id,
|
||||||
cm.user_id,
|
cm.user_id,
|
||||||
COALESCE(u.display_name, 'Anonymous') as display_name,
|
cm.guest_session_id,
|
||||||
|
COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name,
|
||||||
ST_X(cm.position) as position_x,
|
ST_X(cm.position) as position_x,
|
||||||
ST_Y(cm.position) as position_y,
|
ST_Y(cm.position) as position_y,
|
||||||
cm.facing_direction,
|
cm.facing_direction,
|
||||||
cm.is_moving,
|
cm.is_moving,
|
||||||
cm.is_afk,
|
cm.is_afk,
|
||||||
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||||
cm.joined_at,
|
cm.joined_at,
|
||||||
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
||||||
FROM scene.instance_members cm
|
FROM scene.instance_members cm
|
||||||
LEFT JOIN auth.users u ON cm.user_id = u.id
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||||
|
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
|
||||||
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2
|
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2
|
||||||
WHERE cm.instance_id = $1
|
WHERE cm.instance_id = $1
|
||||||
ORDER BY cm.joined_at ASC
|
ORDER BY cm.joined_at ASC
|
||||||
|
|
@ -205,13 +207,14 @@ pub async fn get_channel_member<'e>(
|
||||||
cm.id,
|
cm.id,
|
||||||
cm.instance_id as channel_id,
|
cm.instance_id as channel_id,
|
||||||
cm.user_id,
|
cm.user_id,
|
||||||
|
cm.guest_session_id,
|
||||||
COALESCE(u.display_name, 'Anonymous') as display_name,
|
COALESCE(u.display_name, 'Anonymous') as display_name,
|
||||||
ST_X(cm.position) as position_x,
|
ST_X(cm.position) as position_x,
|
||||||
ST_Y(cm.position) as position_y,
|
ST_Y(cm.position) as position_y,
|
||||||
cm.facing_direction,
|
cm.facing_direction,
|
||||||
cm.is_moving,
|
cm.is_moving,
|
||||||
cm.is_afk,
|
cm.is_afk,
|
||||||
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||||
cm.joined_at,
|
cm.joined_at,
|
||||||
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
||||||
FROM scene.instance_members cm
|
FROM scene.instance_members cm
|
||||||
|
|
|
||||||
98
crates/chattyness-db/src/queries/guests.rs
Normal file
98
crates/chattyness-db/src/queries/guests.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
//! Guest session database queries.
|
||||||
|
|
||||||
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Guest session record.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct GuestSession {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub guest_name: String,
|
||||||
|
pub current_realm_id: Option<Uuid>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new guest session.
|
||||||
|
///
|
||||||
|
/// Returns the guest session ID.
|
||||||
|
pub async fn create_guest_session(
|
||||||
|
pool: &PgPool,
|
||||||
|
guest_name: &str,
|
||||||
|
realm_id: Uuid,
|
||||||
|
token_hash: &str,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
let (session_id,): (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.guest_sessions (guest_name, token_hash, user_agent, ip_address, current_realm_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4::inet, $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(guest_name)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(ip_address)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a guest session by ID.
|
||||||
|
pub async fn get_guest_session(
|
||||||
|
pool: &PgPool,
|
||||||
|
session_id: Uuid,
|
||||||
|
) -> Result<Option<GuestSession>, AppError> {
|
||||||
|
let session = sqlx::query_as::<_, GuestSession>(
|
||||||
|
r#"
|
||||||
|
SELECT id, guest_name, current_realm_id, expires_at, created_at
|
||||||
|
FROM auth.guest_sessions
|
||||||
|
WHERE id = $1 AND expires_at > now()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(session_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update last activity if session exists
|
||||||
|
if session.is_some() {
|
||||||
|
sqlx::query("UPDATE auth.guest_sessions SET last_activity_at = now() WHERE id = $1")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a guest session.
|
||||||
|
pub async fn delete_guest_session(pool: &PgPool, session_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM auth.guest_sessions WHERE id = $1")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random guest name like "Guest_12345".
|
||||||
|
pub fn generate_guest_name() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let number: u32 = rng.gen_range(10000..100000);
|
||||||
|
format!("Guest_{}", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate guest session expiry (24 hours from now).
|
||||||
|
pub fn guest_session_expiry() -> DateTime<Utc> {
|
||||||
|
Utc::now() + TimeDelta::hours(24)
|
||||||
|
}
|
||||||
|
|
@ -35,30 +35,12 @@ pub async fn get_user_membership(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new membership (join a realm).
|
/// Create a new membership (join a realm).
|
||||||
/// This function is idempotent - if the membership already exists, it returns the existing id.
|
|
||||||
pub async fn create_membership(
|
pub async fn create_membership(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
role: RealmRole,
|
role: RealmRole,
|
||||||
) -> Result<Uuid, AppError> {
|
) -> Result<Uuid, AppError> {
|
||||||
// Check if membership already exists
|
|
||||||
let existing: Option<(Uuid,)> = sqlx::query_as(
|
|
||||||
r#"
|
|
||||||
SELECT id FROM realm.memberships
|
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((id,)) = existing {
|
|
||||||
return Ok(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new membership
|
|
||||||
let (membership_id,): (Uuid,) = sqlx::query_as(
|
let (membership_id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO realm.memberships (realm_id, user_id, role)
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
|
@ -72,7 +54,7 @@ pub async fn create_membership(
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Only increment count for new memberships
|
// Update member count on the realm
|
||||||
sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1")
|
sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1")
|
||||||
.bind(realm_id)
|
.bind(realm_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|
@ -82,30 +64,12 @@ pub async fn create_membership(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new membership using a connection (for RLS support).
|
/// Create a new membership using a connection (for RLS support).
|
||||||
/// This function is idempotent - if the membership already exists, it returns the existing id.
|
|
||||||
pub async fn create_membership_conn(
|
pub async fn create_membership_conn(
|
||||||
conn: &mut sqlx::PgConnection,
|
conn: &mut sqlx::PgConnection,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
role: RealmRole,
|
role: RealmRole,
|
||||||
) -> Result<Uuid, AppError> {
|
) -> Result<Uuid, AppError> {
|
||||||
// Check if membership already exists
|
|
||||||
let existing: Option<(Uuid,)> = sqlx::query_as(
|
|
||||||
r#"
|
|
||||||
SELECT id FROM realm.memberships
|
|
||||||
WHERE user_id = $1 AND realm_id = $2
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(realm_id)
|
|
||||||
.fetch_optional(&mut *conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((id,)) = existing {
|
|
||||||
return Ok(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new membership
|
|
||||||
let (membership_id,): (Uuid,) = sqlx::query_as(
|
let (membership_id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO realm.memberships (realm_id, user_id, role)
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
|
@ -119,7 +83,7 @@ pub async fn create_membership_conn(
|
||||||
.fetch_one(&mut *conn)
|
.fetch_one(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Only increment count for new memberships
|
// Update member count on the realm
|
||||||
sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1")
|
sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1")
|
||||||
.bind(realm_id)
|
.bind(realm_id)
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@
|
||||||
use sqlx::{PgConnection, PgPool};
|
use sqlx::{PgConnection, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{AgeCategory, GenderPreference, StaffMember, User, UserWithAuth};
|
use crate::models::{StaffMember, User, UserWithAuth};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
/// Get a user by their ID.
|
/// Get a user by their ID.
|
||||||
|
|
@ -17,9 +17,6 @@ pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, App
|
||||||
display_name,
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
birthday,
|
|
||||||
gender_preference,
|
|
||||||
age_category,
|
|
||||||
reputation_tier,
|
reputation_tier,
|
||||||
status,
|
status,
|
||||||
email_verified,
|
email_verified,
|
||||||
|
|
@ -48,9 +45,6 @@ pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Optio
|
||||||
display_name,
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
birthday,
|
|
||||||
gender_preference,
|
|
||||||
age_category,
|
|
||||||
reputation_tier,
|
reputation_tier,
|
||||||
status,
|
status,
|
||||||
email_verified,
|
email_verified,
|
||||||
|
|
@ -79,9 +73,6 @@ pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User
|
||||||
display_name,
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
birthday,
|
|
||||||
gender_preference,
|
|
||||||
age_category,
|
|
||||||
reputation_tier,
|
reputation_tier,
|
||||||
status,
|
status,
|
||||||
email_verified,
|
email_verified,
|
||||||
|
|
@ -191,9 +182,6 @@ pub async fn get_user_by_session(
|
||||||
u.display_name,
|
u.display_name,
|
||||||
u.bio,
|
u.bio,
|
||||||
u.avatar_url,
|
u.avatar_url,
|
||||||
u.birthday,
|
|
||||||
u.gender_preference,
|
|
||||||
u.age_category,
|
|
||||||
u.reputation_tier,
|
u.reputation_tier,
|
||||||
u.status,
|
u.status,
|
||||||
u.email_verified,
|
u.email_verified,
|
||||||
|
|
@ -452,20 +440,6 @@ pub async fn create_user_conn(
|
||||||
email: Option<&str>,
|
email: Option<&str>,
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<Uuid, AppError> {
|
|
||||||
create_user_with_preferences_conn(conn, username, email, display_name, password, None, None, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new user with preferences using a connection (for RLS support).
|
|
||||||
pub async fn create_user_with_preferences_conn(
|
|
||||||
conn: &mut sqlx::PgConnection,
|
|
||||||
username: &str,
|
|
||||||
email: Option<&str>,
|
|
||||||
display_name: &str,
|
|
||||||
password: &str,
|
|
||||||
birthday: Option<chrono::NaiveDate>,
|
|
||||||
gender_preference: Option<GenderPreference>,
|
|
||||||
age_category: Option<AgeCategory>,
|
|
||||||
) -> Result<Uuid, AppError> {
|
) -> Result<Uuid, AppError> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2, PasswordHasher,
|
Argon2, PasswordHasher,
|
||||||
|
|
@ -481,11 +455,8 @@ pub async fn create_user_with_preferences_conn(
|
||||||
|
|
||||||
let (user_id,): (Uuid,) = sqlx::query_as(
|
let (user_id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO auth.users (
|
INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status)
|
||||||
username, email, password_hash, display_name, auth_provider, status,
|
VALUES ($1, $2, $3, $4, 'local', 'active')
|
||||||
birthday, gender_preference, age_category
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, 'local', 'active', $5, COALESCE($6, 'gender_neutral'), COALESCE($7, 'adult'))
|
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -493,9 +464,6 @@ pub async fn create_user_with_preferences_conn(
|
||||||
.bind(email)
|
.bind(email)
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.bind(display_name)
|
.bind(display_name)
|
||||||
.bind(birthday)
|
|
||||||
.bind(gender_preference)
|
|
||||||
.bind(age_category)
|
|
||||||
.fetch_one(conn)
|
.fetch_one(conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -622,105 +590,3 @@ pub async fn create_guest_user(pool: &PgPool, guest_name: &str) -> Result<Uuid,
|
||||||
|
|
||||||
Ok(user_id)
|
Ok(user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a user's preferences (birthday, gender_preference, age_category).
|
|
||||||
pub async fn update_user_preferences(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
birthday: Option<chrono::NaiveDate>,
|
|
||||||
gender_preference: Option<crate::models::GenderPreference>,
|
|
||||||
age_category: Option<crate::models::AgeCategory>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// Build dynamic update based on provided fields
|
|
||||||
let mut set_clauses = vec!["updated_at = now()".to_string()];
|
|
||||||
|
|
||||||
if birthday.is_some() {
|
|
||||||
set_clauses.push(format!("birthday = ${}", set_clauses.len() + 1));
|
|
||||||
}
|
|
||||||
if gender_preference.is_some() {
|
|
||||||
set_clauses.push(format!("gender_preference = ${}", set_clauses.len() + 1));
|
|
||||||
}
|
|
||||||
if age_category.is_some() {
|
|
||||||
set_clauses.push(format!("age_category = ${}", set_clauses.len() + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if set_clauses.len() == 1 {
|
|
||||||
// Only updated_at, nothing to update
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = format!(
|
|
||||||
"UPDATE auth.users SET {} WHERE id = $1",
|
|
||||||
set_clauses.join(", ")
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut q = sqlx::query(&query).bind(user_id);
|
|
||||||
if let Some(b) = birthday {
|
|
||||||
q = q.bind(b);
|
|
||||||
}
|
|
||||||
if let Some(g) = gender_preference {
|
|
||||||
q = q.bind(g);
|
|
||||||
}
|
|
||||||
if let Some(a) = age_category {
|
|
||||||
q = q.bind(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
q.execute(pool).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a user's preferences using a connection (for RLS support).
|
|
||||||
pub async fn update_user_preferences_conn(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
birthday: Option<chrono::NaiveDate>,
|
|
||||||
gender_preference: Option<crate::models::GenderPreference>,
|
|
||||||
age_category: Option<crate::models::AgeCategory>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// Build dynamic update based on provided fields
|
|
||||||
let mut set_clauses = vec!["updated_at = now()".to_string()];
|
|
||||||
|
|
||||||
if birthday.is_some() {
|
|
||||||
set_clauses.push(format!("birthday = ${}", set_clauses.len() + 1));
|
|
||||||
}
|
|
||||||
if gender_preference.is_some() {
|
|
||||||
set_clauses.push(format!("gender_preference = ${}", set_clauses.len() + 1));
|
|
||||||
}
|
|
||||||
if age_category.is_some() {
|
|
||||||
set_clauses.push(format!("age_category = ${}", set_clauses.len() + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if set_clauses.len() == 1 {
|
|
||||||
// Only updated_at, nothing to update
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = format!(
|
|
||||||
"UPDATE auth.users SET {} WHERE id = $1",
|
|
||||||
set_clauses.join(", ")
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut q = sqlx::query(&query).bind(user_id);
|
|
||||||
if let Some(b) = birthday {
|
|
||||||
q = q.bind(b);
|
|
||||||
}
|
|
||||||
if let Some(g) = gender_preference {
|
|
||||||
q = q.bind(g);
|
|
||||||
}
|
|
||||||
if let Some(a) = age_category {
|
|
||||||
q = q.bind(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
q.execute(conn).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a random guest name like "Guest_12345".
|
|
||||||
pub fn generate_guest_name() -> String {
|
|
||||||
use rand::Rng;
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let number: u32 = rng.gen_range(10000..100000);
|
|
||||||
format!("Guest_{}", number)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp};
|
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
|
||||||
|
|
||||||
/// Default function for serde that returns true (for is_same_scene field).
|
/// Default function for serde that returns true (for is_same_scene field).
|
||||||
/// Must be pub for serde derive macro to access via full path.
|
/// Must be pub for serde derive macro to access via full path.
|
||||||
|
|
@ -26,8 +26,6 @@ pub mod close_codes {
|
||||||
pub const SCENE_CHANGE: u16 = 4000;
|
pub const SCENE_CHANGE: u16 = 4000;
|
||||||
/// Server timeout (no message received within timeout period).
|
/// Server timeout (no message received within timeout period).
|
||||||
pub const SERVER_TIMEOUT: u16 = 4001;
|
pub const SERVER_TIMEOUT: u16 = 4001;
|
||||||
/// User explicitly logged out.
|
|
||||||
pub const LOGOUT: u16 = 4003;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reason for member disconnect.
|
/// Reason for member disconnect.
|
||||||
|
|
@ -128,16 +126,20 @@ pub enum ServerMessage {
|
||||||
|
|
||||||
/// A member left the channel.
|
/// A member left the channel.
|
||||||
MemberLeft {
|
MemberLeft {
|
||||||
/// User ID of the member who left.
|
/// User ID (if authenticated user).
|
||||||
user_id: Uuid,
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
/// Reason for disconnect.
|
/// Reason for disconnect.
|
||||||
reason: DisconnectReason,
|
reason: DisconnectReason,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// A member updated their position.
|
/// A member updated their position.
|
||||||
PositionUpdated {
|
PositionUpdated {
|
||||||
/// User ID of the member.
|
/// User ID (if authenticated user).
|
||||||
user_id: Uuid,
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
/// New X coordinate.
|
/// New X coordinate.
|
||||||
x: f64,
|
x: f64,
|
||||||
/// New Y coordinate.
|
/// New Y coordinate.
|
||||||
|
|
@ -146,8 +148,10 @@ pub enum ServerMessage {
|
||||||
|
|
||||||
/// A member changed their emotion.
|
/// A member changed their emotion.
|
||||||
EmotionUpdated {
|
EmotionUpdated {
|
||||||
/// User ID of the member.
|
/// User ID (if authenticated user).
|
||||||
user_id: Uuid,
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
/// Emotion name (e.g., "happy", "sad", "neutral").
|
/// Emotion name (e.g., "happy", "sad", "neutral").
|
||||||
emotion: String,
|
emotion: String,
|
||||||
/// Asset paths for all 9 positions of the new emotion layer.
|
/// Asset paths for all 9 positions of the new emotion layer.
|
||||||
|
|
@ -169,8 +173,10 @@ pub enum ServerMessage {
|
||||||
ChatMessageReceived {
|
ChatMessageReceived {
|
||||||
/// Unique message ID.
|
/// Unique message ID.
|
||||||
message_id: Uuid,
|
message_id: Uuid,
|
||||||
/// User ID of sender.
|
/// User ID of sender (if authenticated user).
|
||||||
user_id: Uuid,
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
/// Display name of sender.
|
/// Display name of sender.
|
||||||
display_name: String,
|
display_name: String,
|
||||||
/// Message content.
|
/// Message content.
|
||||||
|
|
@ -211,8 +217,10 @@ pub enum ServerMessage {
|
||||||
PropPickedUp {
|
PropPickedUp {
|
||||||
/// ID of the prop that was picked up.
|
/// ID of the prop that was picked up.
|
||||||
prop_id: Uuid,
|
prop_id: Uuid,
|
||||||
/// User ID who picked it up.
|
/// User ID who picked it up (if authenticated).
|
||||||
picked_up_by_user_id: Uuid,
|
picked_up_by_user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID who picked it up (if guest).
|
||||||
|
picked_up_by_guest_id: Option<Uuid>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// A prop expired and was removed.
|
/// A prop expired and was removed.
|
||||||
|
|
@ -223,8 +231,10 @@ pub enum ServerMessage {
|
||||||
|
|
||||||
/// A member updated their avatar appearance.
|
/// A member updated their avatar appearance.
|
||||||
AvatarUpdated {
|
AvatarUpdated {
|
||||||
/// User ID of the member.
|
/// User ID (if authenticated user).
|
||||||
user_id: Uuid,
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
/// Updated avatar render data.
|
/// Updated avatar render data.
|
||||||
avatar: AvatarRenderData,
|
avatar: AvatarRenderData,
|
||||||
},
|
},
|
||||||
|
|
@ -264,26 +274,4 @@ pub enum ServerMessage {
|
||||||
/// Whether the member is still a guest.
|
/// Whether the member is still a guest.
|
||||||
is_guest: bool,
|
is_guest: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// A user's avatar was forcibly changed (by moderator or scene entry).
|
|
||||||
AvatarForced {
|
|
||||||
/// User ID whose avatar was forced.
|
|
||||||
user_id: Uuid,
|
|
||||||
/// The forced avatar render data.
|
|
||||||
avatar: AvatarRenderData,
|
|
||||||
/// Why the avatar was forced.
|
|
||||||
reason: ForcedAvatarReason,
|
|
||||||
/// Display name of who forced the avatar (if mod command).
|
|
||||||
forced_by: Option<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// A user's forced avatar was cleared (returned to their chosen avatar).
|
|
||||||
AvatarCleared {
|
|
||||||
/// User ID whose forced avatar was cleared.
|
|
||||||
user_id: Uuid,
|
|
||||||
/// The user's original avatar render data (restored).
|
|
||||||
avatar: AvatarRenderData,
|
|
||||||
/// Display name of who cleared the forced avatar (if mod command).
|
|
||||||
cleared_by: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ pub struct AppConfig {
|
||||||
pub websocket: WebSocketConfig,
|
pub websocket: WebSocketConfig,
|
||||||
/// Stale member cleanup configuration.
|
/// Stale member cleanup configuration.
|
||||||
pub cleanup: CleanupConfig,
|
pub cleanup: CleanupConfig,
|
||||||
/// Signup form configuration.
|
|
||||||
pub signup: SignupConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebSocket configuration.
|
/// WebSocket configuration.
|
||||||
|
|
@ -42,62 +40,11 @@ pub struct CleanupConfig {
|
||||||
pub clear_on_startup: bool,
|
pub clear_on_startup: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signup form configuration.
|
|
||||||
/// Controls what fields are shown during user registration.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct SignupConfig {
|
|
||||||
/// Whether to show birthday field during signup.
|
|
||||||
/// None = don't ask, Some("ask") = show field
|
|
||||||
pub birthday: Option<SignupField>,
|
|
||||||
/// How to determine user's age category.
|
|
||||||
pub age: AgeConfig,
|
|
||||||
/// How to determine user's gender preference.
|
|
||||||
pub gender: GenderConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signup field option.
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SignupField {
|
|
||||||
/// Show the field during signup
|
|
||||||
Ask,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Age category configuration.
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AgeConfig {
|
|
||||||
/// Ask user to select their age category
|
|
||||||
Ask,
|
|
||||||
/// Infer age category from birthday (requires birthday = "ask")
|
|
||||||
Infer,
|
|
||||||
/// Default to adult without asking
|
|
||||||
DefaultAdult,
|
|
||||||
/// Default to child without asking
|
|
||||||
DefaultChild,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gender preference configuration.
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum GenderConfig {
|
|
||||||
/// Ask user to select their gender preference
|
|
||||||
Ask,
|
|
||||||
/// Default to neutral without asking
|
|
||||||
DefaultNeutral,
|
|
||||||
/// Default to male without asking
|
|
||||||
DefaultMale,
|
|
||||||
/// Default to female without asking
|
|
||||||
DefaultFemale,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
websocket: WebSocketConfig::default(),
|
websocket: WebSocketConfig::default(),
|
||||||
cleanup: CleanupConfig::default(),
|
cleanup: CleanupConfig::default(),
|
||||||
signup: SignupConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,28 +68,6 @@ impl Default for CleanupConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SignupConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
birthday: None,
|
|
||||||
age: AgeConfig::default(),
|
|
||||||
gender: GenderConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AgeConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::DefaultAdult
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GenderConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::DefaultNeutral
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
/// Load configuration from a TOML file.
|
/// Load configuration from a TOML file.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,14 @@ use tower_sessions::Session;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{
|
models::{
|
||||||
AccountStatus, AgeCategory, AuthenticatedUser, CurrentUserResponse, GenderPreference,
|
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
|
||||||
GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest,
|
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
|
||||||
LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole,
|
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
|
||||||
RealmSummary, RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse,
|
RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary,
|
||||||
UserSummary,
|
|
||||||
},
|
},
|
||||||
queries::{memberships, realms, users},
|
queries::{guests, memberships, realms, users},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
use chattyness_shared::{AgeConfig, GenderConfig, SignupConfig};
|
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
AuthUser, OptionalAuthUser,
|
AuthUser, OptionalAuthUser,
|
||||||
|
|
@ -251,7 +249,6 @@ pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError>
|
||||||
pub async fn signup(
|
pub async fn signup(
|
||||||
rls_conn: crate::auth::RlsConn,
|
rls_conn: crate::auth::RlsConn,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
State(signup_config): State<SignupConfig>,
|
|
||||||
session: Session,
|
session: Session,
|
||||||
Json(req): Json<SignupRequest>,
|
Json(req): Json<SignupRequest>,
|
||||||
) -> Result<Json<SignupResponse>, AppError> {
|
) -> Result<Json<SignupResponse>, AppError> {
|
||||||
|
|
@ -276,29 +273,6 @@ pub async fn signup(
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
|
||||||
|
|
||||||
// Determine gender preference based on config
|
|
||||||
let gender_preference = match signup_config.gender {
|
|
||||||
GenderConfig::Ask => req.gender_preference,
|
|
||||||
GenderConfig::DefaultNeutral => Some(GenderPreference::GenderNeutral),
|
|
||||||
GenderConfig::DefaultMale => Some(GenderPreference::GenderMale),
|
|
||||||
GenderConfig::DefaultFemale => Some(GenderPreference::GenderFemale),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine age category based on config
|
|
||||||
let age_category = match signup_config.age {
|
|
||||||
AgeConfig::Ask => req.age_category,
|
|
||||||
AgeConfig::Infer => {
|
|
||||||
// Infer age from birthday if provided
|
|
||||||
if let Some(birthday) = req.birthday {
|
|
||||||
Some(infer_age_category_from_birthday(birthday))
|
|
||||||
} else {
|
|
||||||
Some(AgeCategory::Adult) // Default to adult if no birthday
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AgeConfig::DefaultAdult => Some(AgeCategory::Adult),
|
|
||||||
AgeConfig::DefaultChild => Some(AgeCategory::Child),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the user using RLS connection
|
// Create the user using RLS connection
|
||||||
let email_opt = req.email.as_ref().and_then(|e| {
|
let email_opt = req.email.as_ref().and_then(|e| {
|
||||||
let trimmed = e.trim();
|
let trimmed = e.trim();
|
||||||
|
|
@ -310,15 +284,12 @@ pub async fn signup(
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut conn = rls_conn.acquire().await;
|
let mut conn = rls_conn.acquire().await;
|
||||||
let user_id = users::create_user_with_preferences_conn(
|
let user_id = users::create_user_conn(
|
||||||
&mut *conn,
|
&mut *conn,
|
||||||
&req.username,
|
&req.username,
|
||||||
email_opt,
|
email_opt,
|
||||||
req.display_name.trim(),
|
req.display_name.trim(),
|
||||||
&req.password,
|
&req.password,
|
||||||
req.birthday,
|
|
||||||
gender_preference,
|
|
||||||
age_category,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
@ -368,7 +339,6 @@ pub async fn signup(
|
||||||
/// Creates a real user account with the 'guest' tag. Guests are regular users
|
/// Creates a real user account with the 'guest' tag. Guests are regular users
|
||||||
/// with limited capabilities (no prop pickup, etc.) that can be reaped after 24 hours.
|
/// with limited capabilities (no prop pickup, etc.) that can be reaped after 24 hours.
|
||||||
pub async fn guest_login(
|
pub async fn guest_login(
|
||||||
rls_conn: crate::auth::RlsConn,
|
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
session: Session,
|
session: Session,
|
||||||
Json(req): Json<GuestLoginRequest>,
|
Json(req): Json<GuestLoginRequest>,
|
||||||
|
|
@ -389,22 +359,11 @@ pub async fn guest_login(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate guest name
|
// Generate guest name
|
||||||
let guest_name = users::generate_guest_name();
|
let guest_name = guests::generate_guest_name();
|
||||||
|
|
||||||
// Create guest user (no password) - trigger creates avatar automatically
|
// Create guest user (no password) - trigger creates avatar automatically
|
||||||
let user_id = users::create_guest_user(&pool, &guest_name).await?;
|
let user_id = users::create_guest_user(&pool, &guest_name).await?;
|
||||||
|
|
||||||
// Set RLS context to the new guest user for membership creation
|
|
||||||
rls_conn
|
|
||||||
.set_user_id(user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
|
||||||
|
|
||||||
// Create membership for the guest so their position can be persisted
|
|
||||||
let mut conn = rls_conn.acquire().await;
|
|
||||||
memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member).await?;
|
|
||||||
drop(conn);
|
|
||||||
|
|
||||||
// Set up tower session (same as regular user login)
|
// Set up tower session (same as regular user login)
|
||||||
session
|
session
|
||||||
.insert(SESSION_USER_ID_KEY, user_id)
|
.insert(SESSION_USER_ID_KEY, user_id)
|
||||||
|
|
@ -571,65 +530,3 @@ pub async fn register_guest(
|
||||||
username: req.username,
|
username: req.username,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request to update user preferences.
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct UpdatePreferencesRequest {
|
|
||||||
#[serde(default)]
|
|
||||||
pub birthday: Option<chrono::NaiveDate>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub gender_preference: Option<chattyness_db::models::GenderPreference>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub age_category: Option<chattyness_db::models::AgeCategory>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response after updating preferences.
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct UpdatePreferencesResponse {
|
|
||||||
pub success: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update user preferences handler.
|
|
||||||
///
|
|
||||||
/// Updates the user's birthday, gender preference, and/or age category.
|
|
||||||
/// These preferences are used for default avatar selection.
|
|
||||||
pub async fn update_preferences(
|
|
||||||
rls_conn: crate::auth::RlsConn,
|
|
||||||
AuthUser(user): AuthUser,
|
|
||||||
Json(req): Json<UpdatePreferencesRequest>,
|
|
||||||
) -> Result<Json<UpdatePreferencesResponse>, AppError> {
|
|
||||||
let mut conn = rls_conn.acquire().await;
|
|
||||||
|
|
||||||
// Update user preferences (requires RLS for auth.users UPDATE policy)
|
|
||||||
users::update_user_preferences_conn(
|
|
||||||
&mut *conn,
|
|
||||||
user.id,
|
|
||||||
req.birthday,
|
|
||||||
req.gender_preference,
|
|
||||||
req.age_category,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(UpdatePreferencesResponse { success: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Infer age category from birthday.
|
|
||||||
///
|
|
||||||
/// Returns `Adult` if 18 years or older, `Child` otherwise.
|
|
||||||
fn infer_age_category_from_birthday(birthday: chrono::NaiveDate) -> AgeCategory {
|
|
||||||
use chrono::Datelike;
|
|
||||||
let today = chrono::Utc::now().date_naive();
|
|
||||||
let age = today.year() - birthday.year();
|
|
||||||
let had_birthday_this_year =
|
|
||||||
(today.month(), today.day()) >= (birthday.month(), birthday.day());
|
|
||||||
let actual_age = if had_birthday_this_year {
|
|
||||||
age
|
|
||||||
} else {
|
|
||||||
age - 1
|
|
||||||
};
|
|
||||||
if actual_age >= 18 {
|
|
||||||
AgeCategory::Adult
|
|
||||||
} else {
|
|
||||||
AgeCategory::Child
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,10 @@ use axum::Json;
|
||||||
use axum::extract::Path;
|
use axum::extract::Path;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest, RealmAvatarWithPaths, ServerAvatarWithPaths},
|
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest},
|
||||||
queries::{avatars, realm_avatars, realms, server_avatars},
|
queries::{avatars, realms},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
use sqlx::PgPool;
|
|
||||||
use axum::extract::State;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::auth::{AuthUser, RlsConn};
|
use crate::auth::{AuthUser, RlsConn};
|
||||||
|
|
||||||
|
|
@ -130,138 +127,3 @@ pub async fn clear_slot(
|
||||||
|
|
||||||
Ok(Json(avatar))
|
Ok(Json(avatar))
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Avatar Store Endpoints
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// List public server avatars with resolved paths.
|
|
||||||
///
|
|
||||||
/// GET /api/server/avatars
|
|
||||||
///
|
|
||||||
/// Returns all public, active server avatars that users can select from.
|
|
||||||
/// Includes resolved asset paths for client-side rendering.
|
|
||||||
pub async fn list_server_avatars(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
) -> Result<Json<Vec<ServerAvatarWithPaths>>, AppError> {
|
|
||||||
let avatars = server_avatars::list_public_server_avatars_with_paths(&pool).await?;
|
|
||||||
Ok(Json(avatars))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List public realm avatars with resolved paths.
|
|
||||||
///
|
|
||||||
/// GET /api/realms/{slug}/avatars
|
|
||||||
///
|
|
||||||
/// Returns all public, active realm avatars for the specified realm.
|
|
||||||
/// Includes resolved asset paths for client-side rendering.
|
|
||||||
pub async fn list_realm_avatars(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
Path(slug): Path<String>,
|
|
||||||
) -> Result<Json<Vec<RealmAvatarWithPaths>>, AppError> {
|
|
||||||
// Get realm
|
|
||||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
|
||||||
|
|
||||||
let avatars = realm_avatars::list_public_realm_avatars_with_paths(&pool, realm.id).await?;
|
|
||||||
Ok(Json(avatars))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request to select an avatar from the store.
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct SelectAvatarRequest {
|
|
||||||
/// The avatar ID to select
|
|
||||||
pub avatar_id: Uuid,
|
|
||||||
/// Source: "server" or "realm"
|
|
||||||
pub source: AvatarSelectionSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Avatar selection source.
|
|
||||||
#[derive(Debug, serde::Deserialize, Clone, Copy)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum AvatarSelectionSource {
|
|
||||||
Server,
|
|
||||||
Realm,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response after selecting an avatar.
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct SelectAvatarResponse {
|
|
||||||
pub success: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select an avatar from the store.
|
|
||||||
///
|
|
||||||
/// POST /api/realms/{slug}/avatar/select
|
|
||||||
///
|
|
||||||
/// Selects a server or realm avatar to use. This has lower priority than
|
|
||||||
/// a custom avatar but higher priority than default avatars.
|
|
||||||
pub async fn select_avatar(
|
|
||||||
rls_conn: RlsConn,
|
|
||||||
AuthUser(user): AuthUser,
|
|
||||||
Path(slug): Path<String>,
|
|
||||||
Json(req): Json<SelectAvatarRequest>,
|
|
||||||
) -> Result<Json<SelectAvatarResponse>, AppError> {
|
|
||||||
let mut conn = rls_conn.acquire().await;
|
|
||||||
|
|
||||||
// Get realm
|
|
||||||
let realm = realms::get_realm_by_slug(&mut *conn, &slug)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
|
||||||
|
|
||||||
// Verify the avatar exists and is public
|
|
||||||
match req.source {
|
|
||||||
AvatarSelectionSource::Server => {
|
|
||||||
let avatar = server_avatars::get_server_avatar_by_id(&mut *conn, req.avatar_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Server avatar not found".to_string()))?;
|
|
||||||
if !avatar.is_public || !avatar.is_active {
|
|
||||||
return Err(AppError::NotFound("Server avatar not available".to_string()));
|
|
||||||
}
|
|
||||||
avatars::select_server_avatar(&mut *conn, user.id, realm.id, req.avatar_id).await?;
|
|
||||||
}
|
|
||||||
AvatarSelectionSource::Realm => {
|
|
||||||
let avatar = realm_avatars::get_realm_avatar_by_id(&mut *conn, req.avatar_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("Realm avatar not found".to_string()))?;
|
|
||||||
if avatar.realm_id != realm.id {
|
|
||||||
return Err(AppError::NotFound("Realm avatar not found in this realm".to_string()));
|
|
||||||
}
|
|
||||||
if !avatar.is_public || !avatar.is_active {
|
|
||||||
return Err(AppError::NotFound("Realm avatar not available".to_string()));
|
|
||||||
}
|
|
||||||
avatars::select_realm_avatar(&mut *conn, user.id, realm.id, req.avatar_id).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(SelectAvatarResponse { success: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear avatar selection response.
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct ClearAvatarSelectionResponse {
|
|
||||||
pub success: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear avatar selection.
|
|
||||||
///
|
|
||||||
/// DELETE /api/realms/{slug}/avatar/selection
|
|
||||||
///
|
|
||||||
/// Clears both server and realm avatar selections, reverting to default
|
|
||||||
/// avatar behavior (custom avatar if available, otherwise defaults).
|
|
||||||
pub async fn clear_avatar_selection(
|
|
||||||
rls_conn: RlsConn,
|
|
||||||
AuthUser(user): AuthUser,
|
|
||||||
Path(slug): Path<String>,
|
|
||||||
) -> Result<Json<ClearAvatarSelectionResponse>, AppError> {
|
|
||||||
let mut conn = rls_conn.acquire().await;
|
|
||||||
|
|
||||||
// Get realm
|
|
||||||
let realm = realms::get_realm_by_slug(&mut *conn, &slug)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
|
||||||
|
|
||||||
avatars::clear_avatar_selection(&mut *conn, user.id, realm.id).await?;
|
|
||||||
|
|
||||||
Ok(Json(ClearAvatarSelectionResponse { success: true }))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,6 @@ pub fn api_router() -> Router<AppState> {
|
||||||
"/auth/register-guest",
|
"/auth/register-guest",
|
||||||
axum::routing::post(auth::register_guest),
|
axum::routing::post(auth::register_guest),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/auth/preferences",
|
|
||||||
axum::routing::put(auth::update_preferences),
|
|
||||||
)
|
|
||||||
// Realm routes (READ-ONLY)
|
// Realm routes (READ-ONLY)
|
||||||
.route("/realms", get(realms::list_realms))
|
.route("/realms", get(realms::list_realms))
|
||||||
.route("/realms/{slug}", get(realms::get_realm))
|
.route("/realms/{slug}", get(realms::get_realm))
|
||||||
|
|
@ -66,17 +62,6 @@ pub fn api_router() -> Router<AppState> {
|
||||||
"/realms/{slug}/avatar/slot",
|
"/realms/{slug}/avatar/slot",
|
||||||
axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot),
|
axum::routing::put(avatars::assign_slot).delete(avatars::clear_slot),
|
||||||
)
|
)
|
||||||
// Avatar store routes
|
|
||||||
.route("/server/avatars", get(avatars::list_server_avatars))
|
|
||||||
.route("/realms/{slug}/avatars", get(avatars::list_realm_avatars))
|
|
||||||
.route(
|
|
||||||
"/realms/{slug}/avatar/select",
|
|
||||||
axum::routing::post(avatars::select_avatar),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/realms/{slug}/avatar/selection",
|
|
||||||
axum::routing::delete(avatars::clear_avatar_selection),
|
|
||||||
)
|
|
||||||
// User inventory routes
|
// User inventory routes
|
||||||
.route("/user/{uuid}/inventory", get(inventory::get_user_inventory))
|
.route("/user/{uuid}/inventory", get(inventory::get_user_inventory))
|
||||||
.route(
|
.route(
|
||||||
|
|
|
||||||
|
|
@ -18,33 +18,14 @@ use tokio::sync::{broadcast, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
|
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
||||||
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
|
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users},
|
||||||
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
use chattyness_shared::WebSocketConfig;
|
use chattyness_shared::WebSocketConfig;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use chrono::Duration as ChronoDuration;
|
|
||||||
|
|
||||||
/// Parse a duration string like "30m", "2h", "7d" into a chrono::Duration.
|
|
||||||
fn parse_duration(s: &str) -> Option<ChronoDuration> {
|
|
||||||
let s = s.trim().to_lowercase();
|
|
||||||
if s.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
|
||||||
let num: i64 = num_str.parse().ok()?;
|
|
||||||
|
|
||||||
match unit {
|
|
||||||
"m" => Some(ChronoDuration::minutes(num)),
|
|
||||||
"h" => Some(ChronoDuration::hours(num)),
|
|
||||||
"d" => Some(ChronoDuration::days(num)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Channel state for broadcasting updates.
|
/// Channel state for broadcasting updates.
|
||||||
pub struct ChannelState {
|
pub struct ChannelState {
|
||||||
|
|
@ -290,22 +271,8 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
tracing::info!("[WS] Channel joined");
|
tracing::info!("[WS] Channel joined");
|
||||||
|
|
||||||
// Check if scene has forced avatar
|
|
||||||
if let Ok(Some(scene_forced)) = realm_avatars::get_scene_forced_avatar(&pool, channel_id).await {
|
|
||||||
tracing::info!("[WS] Scene has forced avatar: {:?}", scene_forced.forced_avatar_id);
|
|
||||||
// Apply scene-forced avatar to user
|
|
||||||
if let Err(e) = realm_avatars::apply_scene_forced_avatar(
|
|
||||||
&pool,
|
|
||||||
user.id,
|
|
||||||
realm_id,
|
|
||||||
scene_forced.forced_avatar_id,
|
|
||||||
).await {
|
|
||||||
tracing::warn!("[WS] Failed to apply scene forced avatar: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get initial state
|
// Get initial state
|
||||||
let members = match get_members_with_avatars(&mut conn, &pool, channel_id, realm_id).await {
|
let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[WS] Failed to get members: {:?}", e);
|
tracing::error!("[WS] Failed to get members: {:?}", e);
|
||||||
|
|
@ -371,13 +338,11 @@ async fn handle_socket(
|
||||||
let member_display_name = member.display_name.clone();
|
let member_display_name = member.display_name.clone();
|
||||||
|
|
||||||
// Broadcast join to others
|
// Broadcast join to others
|
||||||
// Use effective avatar resolution to handle priority chain:
|
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
||||||
// forced > custom > selected realm > selected server > realm default > server default
|
|
||||||
let avatar = avatars::get_effective_avatar_render_data(&pool, user.id, realm_id)
|
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|(render_data, _source)| render_data)
|
.map(|a| a.to_render_data())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let join_msg = ServerMessage::MemberJoined {
|
let join_msg = ServerMessage::MemberJoined {
|
||||||
member: ChannelMemberWithAvatar { member, avatar },
|
member: ChannelMemberWithAvatar { member, avatar },
|
||||||
|
|
@ -385,7 +350,7 @@ async fn handle_socket(
|
||||||
let _ = channel_state.tx.send(join_msg);
|
let _ = channel_state.tx.send(join_msg);
|
||||||
|
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
let mut is_guest = user.is_guest();
|
let is_guest = user.is_guest();
|
||||||
let tx = channel_state.tx.clone();
|
let tx = channel_state.tx.clone();
|
||||||
|
|
||||||
// Acquire a second dedicated connection for the receive task
|
// Acquire a second dedicated connection for the receive task
|
||||||
|
|
@ -472,7 +437,8 @@ async fn handle_socket(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let _ = tx.send(ServerMessage::PositionUpdated {
|
let _ = tx.send(ServerMessage::PositionUpdated {
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
});
|
});
|
||||||
|
|
@ -492,7 +458,6 @@ async fn handle_socket(
|
||||||
};
|
};
|
||||||
let emotion_layer = match avatars::set_emotion(
|
let emotion_layer = match avatars::set_emotion(
|
||||||
&mut *recv_conn,
|
&mut *recv_conn,
|
||||||
&pool,
|
|
||||||
user_id,
|
user_id,
|
||||||
realm_id,
|
realm_id,
|
||||||
emotion_state,
|
emotion_state,
|
||||||
|
|
@ -507,7 +472,8 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = tx.send(ServerMessage::EmotionUpdated {
|
let _ = tx.send(ServerMessage::EmotionUpdated {
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
emotion,
|
emotion,
|
||||||
emotion_layer,
|
emotion_layer,
|
||||||
});
|
});
|
||||||
|
|
@ -571,7 +537,8 @@ async fn handle_socket(
|
||||||
|
|
||||||
let msg = ServerMessage::ChatMessageReceived {
|
let msg = ServerMessage::ChatMessageReceived {
|
||||||
message_id: Uuid::new_v4(),
|
message_id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
display_name: member.display_name.clone(),
|
display_name: member.display_name.clone(),
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
emotion: emotion_name.clone(),
|
emotion: emotion_name.clone(),
|
||||||
|
|
@ -592,7 +559,8 @@ async fn handle_socket(
|
||||||
let sender_msg =
|
let sender_msg =
|
||||||
ServerMessage::ChatMessageReceived {
|
ServerMessage::ChatMessageReceived {
|
||||||
message_id: Uuid::new_v4(),
|
message_id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
display_name: member.display_name.clone(),
|
display_name: member.display_name.clone(),
|
||||||
content,
|
content,
|
||||||
emotion: emotion_name,
|
emotion: emotion_name,
|
||||||
|
|
@ -623,7 +591,8 @@ async fn handle_socket(
|
||||||
// Broadcast: send to all users in the channel
|
// Broadcast: send to all users in the channel
|
||||||
let msg = ServerMessage::ChatMessageReceived {
|
let msg = ServerMessage::ChatMessageReceived {
|
||||||
message_id: Uuid::new_v4(),
|
message_id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
display_name: member.display_name.clone(),
|
display_name: member.display_name.clone(),
|
||||||
content,
|
content,
|
||||||
emotion: emotion_name,
|
emotion: emotion_name,
|
||||||
|
|
@ -728,7 +697,8 @@ async fn handle_socket(
|
||||||
);
|
);
|
||||||
let _ = tx.send(ServerMessage::PropPickedUp {
|
let _ = tx.send(ServerMessage::PropPickedUp {
|
||||||
prop_id: loose_prop_id,
|
prop_id: loose_prop_id,
|
||||||
picked_up_by_user_id: user_id,
|
picked_up_by_user_id: Some(user_id),
|
||||||
|
picked_up_by_guest_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -741,23 +711,24 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClientMessage::SyncAvatar => {
|
ClientMessage::SyncAvatar => {
|
||||||
// Fetch current effective avatar from database and broadcast to channel
|
// Fetch current avatar from database and broadcast to channel
|
||||||
// Uses the priority chain: forced > custom > selected realm > selected server > realm default > server default
|
match avatars::get_avatar_with_paths_conn(
|
||||||
match avatars::get_effective_avatar_render_data(
|
&mut *recv_conn,
|
||||||
&pool,
|
|
||||||
user_id,
|
user_id,
|
||||||
realm_id,
|
realm_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some((render_data, _source))) => {
|
Ok(Some(avatar_with_paths)) => {
|
||||||
|
let render_data = avatar_with_paths.to_render_data();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"[WS] User {} syncing avatar to channel",
|
"[WS] User {} syncing avatar to channel",
|
||||||
user_id
|
user_id
|
||||||
);
|
);
|
||||||
let _ = tx.send(ServerMessage::AvatarUpdated {
|
let _ = tx.send(ServerMessage::AvatarUpdated {
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
avatar: render_data,
|
avatar: render_data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1095,278 +1066,6 @@ async fn handle_socket(
|
||||||
}).await;
|
}).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"dress" => {
|
|
||||||
// /mod dress [nick] [avatar-slug] [duration?]
|
|
||||||
// Duration format: 30m, 2h, 7d (minutes/hours/days)
|
|
||||||
if args.len() < 2 {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: "Usage: /mod dress [nick] [avatar-slug] [duration?]".to_string(),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_nick = &args[0];
|
|
||||||
let avatar_slug = &args[1];
|
|
||||||
let duration_str = args.get(2).map(|s| s.as_str());
|
|
||||||
|
|
||||||
// Parse duration if provided
|
|
||||||
let duration = if let Some(dur_str) = duration_str {
|
|
||||||
match parse_duration(dur_str) {
|
|
||||||
Some(d) => Some(d),
|
|
||||||
None => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: "Invalid duration format. Use: 30m, 2h, 7d".to_string(),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None // Permanent until undressed
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find target user
|
|
||||||
if let Some((target_user_id, target_conn)) = ws_state
|
|
||||||
.find_user_by_display_name(realm_id, target_nick)
|
|
||||||
{
|
|
||||||
// Try realm avatars first, then server avatars
|
|
||||||
let avatar_result = realm_avatars::get_realm_avatar_by_slug(&pool, realm_id, avatar_slug).await;
|
|
||||||
let (avatar_render_data, avatar_id, source) = match avatar_result {
|
|
||||||
Ok(Some(realm_avatar)) => {
|
|
||||||
// Resolve realm avatar to render data
|
|
||||||
match realm_avatars::resolve_realm_avatar_to_render_data(&pool, &realm_avatar, EmotionState::default()).await {
|
|
||||||
Ok(render_data) => (render_data, realm_avatar.id, "realm"),
|
|
||||||
Err(e) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to resolve avatar: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// Try server avatars
|
|
||||||
match server_avatars::get_server_avatar_by_slug(&pool, avatar_slug).await {
|
|
||||||
Ok(Some(server_avatar)) => {
|
|
||||||
match server_avatars::resolve_server_avatar_to_render_data(&pool, &server_avatar, EmotionState::default()).await {
|
|
||||||
Ok(render_data) => (render_data, server_avatar.id, "server"),
|
|
||||||
Err(e) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to resolve avatar: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Avatar '{}' not found", avatar_slug),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to lookup avatar: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to lookup avatar: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Acquire connection and set RLS context for the update
|
|
||||||
let mut rls_conn = match pool.acquire().await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Database connection error: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to set RLS context: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply forced avatar using connection with RLS context
|
|
||||||
let apply_result = if source == "server" {
|
|
||||||
server_avatars::apply_forced_server_avatar(
|
|
||||||
&mut *rls_conn,
|
|
||||||
target_user_id,
|
|
||||||
realm_id,
|
|
||||||
avatar_id,
|
|
||||||
Some(user_id),
|
|
||||||
duration,
|
|
||||||
).await
|
|
||||||
} else {
|
|
||||||
realm_avatars::apply_forced_realm_avatar(
|
|
||||||
&mut *rls_conn,
|
|
||||||
target_user_id,
|
|
||||||
realm_id,
|
|
||||||
avatar_id,
|
|
||||||
Some(user_id),
|
|
||||||
duration,
|
|
||||||
).await
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = apply_result {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to apply forced avatar: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log moderation action
|
|
||||||
let metadata = serde_json::json!({
|
|
||||||
"avatar_slug": avatar_slug,
|
|
||||||
"avatar_id": avatar_id.to_string(),
|
|
||||||
"source": source,
|
|
||||||
"duration": duration_str,
|
|
||||||
});
|
|
||||||
let _ = moderation::log_moderation_action(
|
|
||||||
&pool,
|
|
||||||
realm_id,
|
|
||||||
user_id,
|
|
||||||
ActionType::DressUser,
|
|
||||||
Some(target_user_id),
|
|
||||||
&format!("Forced {} to wear avatar '{}'", target_nick, avatar_slug),
|
|
||||||
metadata,
|
|
||||||
).await;
|
|
||||||
|
|
||||||
// Send AvatarForced to target user
|
|
||||||
let _ = target_conn.direct_tx.send(ServerMessage::AvatarForced {
|
|
||||||
user_id: target_user_id,
|
|
||||||
avatar: avatar_render_data.clone(),
|
|
||||||
reason: ForcedAvatarReason::ModCommand,
|
|
||||||
forced_by: Some(mod_member.display_name.clone()),
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
// Broadcast avatar update to channel
|
|
||||||
let _ = tx.send(ServerMessage::AvatarUpdated {
|
|
||||||
user_id: target_user_id,
|
|
||||||
avatar: avatar_render_data,
|
|
||||||
});
|
|
||||||
|
|
||||||
let duration_msg = match duration_str {
|
|
||||||
Some(d) => format!(" for {}", d),
|
|
||||||
None => " permanently".to_string(),
|
|
||||||
};
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: true,
|
|
||||||
message: format!("Forced {} to wear '{}'{}", target_nick, avatar_slug, duration_msg),
|
|
||||||
}).await;
|
|
||||||
} else {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("User '{}' is not online in this realm", target_nick),
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"undress" => {
|
|
||||||
// /mod undress [nick]
|
|
||||||
if args.is_empty() {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: "Usage: /mod undress [nick]".to_string(),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_nick = &args[0];
|
|
||||||
|
|
||||||
// Find target user
|
|
||||||
if let Some((target_user_id, target_conn)) = ws_state
|
|
||||||
.find_user_by_display_name(realm_id, target_nick)
|
|
||||||
{
|
|
||||||
// Acquire connection and set RLS context for the update
|
|
||||||
let mut rls_conn = match pool.acquire().await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Database connection error: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to set RLS context: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear forced avatar using connection with RLS context
|
|
||||||
if let Err(e) = server_avatars::clear_forced_avatar(&mut *rls_conn, target_user_id, realm_id).await {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("Failed to clear forced avatar: {:?}", e),
|
|
||||||
}).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user's original avatar
|
|
||||||
let original_avatar = match avatars::get_avatar_with_paths(&pool, target_user_id, realm_id).await {
|
|
||||||
Ok(Some(avatar)) => avatar.to_render_data(),
|
|
||||||
Ok(None) => AvatarRenderData::default(),
|
|
||||||
Err(_) => AvatarRenderData::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log moderation action
|
|
||||||
let metadata = serde_json::json!({});
|
|
||||||
let _ = moderation::log_moderation_action(
|
|
||||||
&pool,
|
|
||||||
realm_id,
|
|
||||||
user_id,
|
|
||||||
ActionType::UndressUser,
|
|
||||||
Some(target_user_id),
|
|
||||||
&format!("Cleared forced avatar for {}", target_nick),
|
|
||||||
metadata,
|
|
||||||
).await;
|
|
||||||
|
|
||||||
// Send AvatarCleared to target user
|
|
||||||
let _ = target_conn.direct_tx.send(ServerMessage::AvatarCleared {
|
|
||||||
user_id: target_user_id,
|
|
||||||
avatar: original_avatar.clone(),
|
|
||||||
cleared_by: Some(mod_member.display_name.clone()),
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
// Broadcast avatar update to channel
|
|
||||||
let _ = tx.send(ServerMessage::AvatarUpdated {
|
|
||||||
user_id: target_user_id,
|
|
||||||
avatar: original_avatar,
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: true,
|
|
||||||
message: format!("Cleared forced avatar for {}", target_nick),
|
|
||||||
}).await;
|
|
||||||
} else {
|
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
|
||||||
success: false,
|
|
||||||
message: format!("User '{}' is not online in this realm", target_nick),
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -1379,9 +1078,7 @@ async fn handle_socket(
|
||||||
// Fetch updated user info from database
|
// Fetch updated user info from database
|
||||||
match users::get_user_by_id(&pool, user_id).await {
|
match users::get_user_by_id(&pool, user_id).await {
|
||||||
Ok(Some(updated_user)) => {
|
Ok(Some(updated_user)) => {
|
||||||
// Update the is_guest flag - critical for allowing
|
let is_guest: bool = updated_user.is_guest();
|
||||||
// newly registered users to send whispers
|
|
||||||
is_guest = updated_user.is_guest();
|
|
||||||
let display_name = updated_user.display_name.clone();
|
let display_name = updated_user.display_name.clone();
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -1391,19 +1088,6 @@ async fn handle_socket(
|
||||||
is_guest
|
is_guest
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update WebSocket state with the new display name
|
|
||||||
// This is critical for whispers and mod commands to find
|
|
||||||
// the user by their new name after registration
|
|
||||||
if let Some(conn) = ws_state.get_user(user_id) {
|
|
||||||
ws_state.register_user(
|
|
||||||
user_id,
|
|
||||||
conn.direct_tx.clone(),
|
|
||||||
conn.realm_id,
|
|
||||||
conn.channel_id,
|
|
||||||
display_name.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast identity update to all channel members
|
// Broadcast identity update to all channel members
|
||||||
let _ = tx.send(ServerMessage::MemberIdentityUpdated {
|
let _ = tx.send(ServerMessage::MemberIdentityUpdated {
|
||||||
user_id,
|
user_id,
|
||||||
|
|
@ -1422,15 +1106,10 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Close(close_frame) => {
|
Message::Close(close_frame) => {
|
||||||
// Check close code for scene change or logout
|
// Check close code for scene change
|
||||||
if let Some(CloseFrame { code, .. }) = close_frame {
|
if let Some(CloseFrame { code, .. }) = close_frame {
|
||||||
if code == close_codes::SCENE_CHANGE {
|
if code == close_codes::SCENE_CHANGE {
|
||||||
disconnect_reason = DisconnectReason::SceneChange;
|
disconnect_reason = DisconnectReason::SceneChange;
|
||||||
} else if code == close_codes::LOGOUT {
|
|
||||||
// Explicit logout - treat as graceful disconnect
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing::debug!("[WS] User {} logged out", user_id);
|
|
||||||
disconnect_reason = DisconnectReason::Graceful;
|
|
||||||
} else {
|
} else {
|
||||||
disconnect_reason = DisconnectReason::Graceful;
|
disconnect_reason = DisconnectReason::Graceful;
|
||||||
}
|
}
|
||||||
|
|
@ -1545,7 +1224,8 @@ async fn handle_socket(
|
||||||
|
|
||||||
// Broadcast departure with reason
|
// Broadcast departure with reason
|
||||||
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
||||||
user_id,
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
reason: disconnect_reason,
|
reason: disconnect_reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1553,25 +1233,28 @@ async fn handle_socket(
|
||||||
/// Helper: Get all channel members with their avatar render data.
|
/// Helper: Get all channel members with their avatar render data.
|
||||||
async fn get_members_with_avatars(
|
async fn get_members_with_avatars(
|
||||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||||
pool: &PgPool,
|
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
||||||
// Get members first
|
// Get members first
|
||||||
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
|
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
|
||||||
|
|
||||||
// Fetch avatar data for each member using the effective avatar resolution
|
// Fetch avatar data for each member using full avatar with paths
|
||||||
// This handles the priority chain: forced > custom > selected realm > selected server > realm default > server default
|
// This avoids the CASE statement approach and handles all emotions correctly
|
||||||
let mut result = Vec::with_capacity(members.len());
|
let mut result = Vec::with_capacity(members.len());
|
||||||
for member in members {
|
for member in members {
|
||||||
// All members now have a user_id (guests are regular users with the 'guest' tag)
|
let avatar = if let Some(user_id) = member.user_id {
|
||||||
// Use the effective avatar resolution which handles all priority levels
|
// Get full avatar and convert to render data for current emotion
|
||||||
let avatar = avatars::get_effective_avatar_render_data(pool, member.user_id, realm_id)
|
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|(render_data, _source)| render_data)
|
.map(|a| a.to_render_data())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
// Guest users don't have avatars
|
||||||
|
AvatarRenderData::default()
|
||||||
|
};
|
||||||
result.push(ChannelMemberWithAvatar { member, avatar });
|
result.push(ChannelMemberWithAvatar { member, avatar });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||||
use crate::api::WebSocketState;
|
use crate::api::WebSocketState;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use chattyness_shared::{SignupConfig, WebSocketConfig};
|
use chattyness_shared::WebSocketConfig;
|
||||||
|
|
||||||
/// Application state for the public app.
|
/// Application state for the public app.
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|
@ -23,7 +23,6 @@ pub struct AppState {
|
||||||
pub leptos_options: LeptosOptions,
|
pub leptos_options: LeptosOptions,
|
||||||
pub ws_state: Arc<WebSocketState>,
|
pub ws_state: Arc<WebSocketState>,
|
||||||
pub ws_config: WebSocketConfig,
|
pub ws_config: WebSocketConfig,
|
||||||
pub signup_config: SignupConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|
@ -54,13 +53,6 @@ impl axum::extract::FromRef<AppState> for WebSocketConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
impl axum::extract::FromRef<AppState> for SignupConfig {
|
|
||||||
fn from_ref(state: &AppState) -> Self {
|
|
||||||
state.signup_config.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shell component for SSR.
|
/// Shell component for SSR.
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ use tower::{Layer, Service};
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::session::SESSION_USER_ID_KEY;
|
use super::session::{SESSION_GUEST_ID_KEY, SESSION_USER_ID_KEY};
|
||||||
use chattyness_error::ErrorResponse;
|
use chattyness_error::ErrorResponse;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -39,6 +39,9 @@ impl Drop for RlsConnectionInner {
|
||||||
let _ = sqlx::query("SELECT public.set_current_user_id(NULL)")
|
let _ = sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await;
|
.await;
|
||||||
|
let _ = sqlx::query("SELECT public.set_current_guest_session_id(NULL)")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
drop(pool);
|
drop(pool);
|
||||||
});
|
});
|
||||||
|
|
@ -215,9 +218,9 @@ where
|
||||||
let session = request.extensions().get::<Session>().cloned();
|
let session = request.extensions().get::<Session>().cloned();
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let user_id = get_session_user_id(session).await;
|
let (user_id, guest_session_id) = get_session_ids(session).await;
|
||||||
|
|
||||||
match acquire_rls_connection(&pool, user_id).await {
|
match acquire_rls_connection(&pool, user_id, guest_session_id).await {
|
||||||
Ok(rls_conn) => {
|
Ok(rls_conn) => {
|
||||||
request.extensions_mut().insert(rls_conn);
|
request.extensions_mut().insert(rls_conn);
|
||||||
inner.call(request).await
|
inner.call(request).await
|
||||||
|
|
@ -231,30 +234,43 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the user ID from the session if present.
|
async fn get_session_ids(session: Option<Session>) -> (Option<Uuid>, Option<Uuid>) {
|
||||||
async fn get_session_user_id(session: Option<Session>) -> Option<Uuid> {
|
|
||||||
let Some(session) = session else {
|
let Some(session) = session else {
|
||||||
return None;
|
return (None, None);
|
||||||
};
|
};
|
||||||
|
|
||||||
session
|
let user_id = session
|
||||||
.get::<Uuid>(SESSION_USER_ID_KEY)
|
.get::<Uuid>(SESSION_USER_ID_KEY)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten();
|
||||||
|
let guest_session_id = session
|
||||||
|
.get::<Uuid>(SESSION_GUEST_ID_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
(user_id, guest_session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acquire an RLS connection with the user context set.
|
|
||||||
/// Guests are now regular users with the 'guest' tag, so they use the same user_id path.
|
|
||||||
async fn acquire_rls_connection(
|
async fn acquire_rls_connection(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
) -> Result<RlsConnection, sqlx::Error> {
|
) -> Result<RlsConnection, sqlx::Error> {
|
||||||
let mut conn = pool.acquire().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
|
|
||||||
if let Some(id) = user_id {
|
if user_id.is_some() {
|
||||||
sqlx::query("SELECT public.set_current_user_id($1)")
|
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||||
.bind(id)
|
.bind(user_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
} else if guest_session_id.is_some() {
|
||||||
|
sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("SELECT public.set_current_guest_session_id($1)")
|
||||||
|
.bind(guest_session_id)
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ pub const SESSION_CURRENT_REALM_KEY: &str = "current_realm_id";
|
||||||
/// Session original destination key (for password reset redirect).
|
/// Session original destination key (for password reset redirect).
|
||||||
pub const SESSION_ORIGINAL_DEST_KEY: &str = "original_destination";
|
pub const SESSION_ORIGINAL_DEST_KEY: &str = "original_destination";
|
||||||
|
|
||||||
|
/// Session guest ID key (for guest sessions).
|
||||||
|
pub const SESSION_GUEST_ID_KEY: &str = "guest_id";
|
||||||
|
|
||||||
/// Create the session management layer.
|
/// Create the session management layer.
|
||||||
pub async fn create_session_layer(
|
pub async fn create_session_layer(
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
pub mod avatar_canvas;
|
pub mod avatar_canvas;
|
||||||
pub mod avatar_editor;
|
pub mod avatar_editor;
|
||||||
pub mod avatar_store;
|
|
||||||
pub mod avatar_thumbnail;
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod chat_types;
|
pub mod chat_types;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
|
|
@ -30,8 +28,6 @@ pub mod ws_client;
|
||||||
|
|
||||||
pub use avatar_canvas::*;
|
pub use avatar_canvas::*;
|
||||||
pub use avatar_editor::*;
|
pub use avatar_editor::*;
|
||||||
pub use avatar_store::*;
|
|
||||||
pub use avatar_thumbnail::*;
|
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
pub use chat_types::*;
|
pub use chat_types::*;
|
||||||
pub use context_menu::*;
|
pub use context_menu::*;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||||
|
|
||||||
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
||||||
|
|
||||||
|
|
@ -456,9 +456,8 @@ impl CanvasLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a unique key for a member (for Leptos For keying).
|
/// Get a unique key for a member (for Leptos For keying).
|
||||||
/// Note: Guests are now regular users with the 'guest' tag, so user_id is always present.
|
pub fn member_key(m: &ChannelMemberWithAvatar) -> (Option<Uuid>, Option<Uuid>) {
|
||||||
pub fn member_key(m: &ChannelMemberWithAvatar) -> Uuid {
|
(m.member.user_id, m.member.guest_session_id)
|
||||||
m.member.user_id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Individual avatar canvas component.
|
/// Individual avatar canvas component.
|
||||||
|
|
@ -737,7 +736,7 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Draw emotion badge if non-neutral
|
// Draw emotion badge if non-neutral
|
||||||
let current_emotion = m.member.current_emotion;
|
let current_emotion = m.member.current_emotion;
|
||||||
if current_emotion != EmotionState::Neutral {
|
if current_emotion > 0 {
|
||||||
let badge_size = 16.0 * layout.text_scale;
|
let badge_size = 16.0 * layout.text_scale;
|
||||||
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
|
let badge_x = layout.avatar_cx + layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||||
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;
|
let badge_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||||
|
|
@ -790,7 +789,11 @@ pub fn AvatarCanvas(
|
||||||
// Compute data-member-id reactively
|
// Compute data-member-id reactively
|
||||||
let data_member_id = move || {
|
let data_member_id = move || {
|
||||||
let m = member.get();
|
let m = member.get();
|
||||||
m.member.user_id.to_string()
|
m.member
|
||||||
|
.user_id
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
|
||||||
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ pub fn AvatarEditorPopup(
|
||||||
set_inventory_loading.set(true);
|
set_inventory_loading.set(true);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = Request::get("/api/user/me/inventory").send().await;
|
let response = Request::get("/api/inventory").send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) = resp
|
if let Ok(data) = resp
|
||||||
|
|
|
||||||
|
|
@ -1,544 +0,0 @@
|
||||||
//! Avatar store popup component.
|
|
||||||
//!
|
|
||||||
//! Allows users to browse and select pre-configured server and realm avatars.
|
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use chattyness_db::models::{RealmAvatarWithPaths, ServerAvatarWithPaths};
|
|
||||||
|
|
||||||
use super::avatar_thumbnail::AvatarThumbnail;
|
|
||||||
use super::modals::{GuestLockedOverlay, Modal};
|
|
||||||
use super::tabs::{Tab, TabBar};
|
|
||||||
|
|
||||||
/// Avatar store popup component.
|
|
||||||
///
|
|
||||||
/// Shows a tabbed interface with:
|
|
||||||
/// - Server Avatars: Public server-wide pre-configured avatars
|
|
||||||
/// - Realm Avatars: Public realm-specific pre-configured avatars
|
|
||||||
///
|
|
||||||
/// Props:
|
|
||||||
/// - `open`: Signal controlling visibility
|
|
||||||
/// - `on_close`: Callback when popup should close
|
|
||||||
/// - `realm_slug`: Current realm slug for API calls
|
|
||||||
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
|
|
||||||
/// - `on_avatar_selected`: Callback when an avatar is successfully selected
|
|
||||||
#[component]
|
|
||||||
pub fn AvatarStorePopup(
|
|
||||||
#[prop(into)] open: Signal<bool>,
|
|
||||||
on_close: Callback<()>,
|
|
||||||
#[prop(into)] realm_slug: Signal<String>,
|
|
||||||
/// Whether the current user is a guest. Guests see a locked overlay.
|
|
||||||
#[prop(optional, into)]
|
|
||||||
is_guest: Option<Signal<bool>>,
|
|
||||||
/// Callback fired when an avatar is successfully selected (for refreshing avatar state).
|
|
||||||
#[prop(optional, into)]
|
|
||||||
on_avatar_selected: Option<Callback<()>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
|
||||||
|
|
||||||
// Tab state
|
|
||||||
let (active_tab, set_active_tab) = signal("server");
|
|
||||||
|
|
||||||
// Server avatars state
|
|
||||||
let (server_avatars, set_server_avatars) = signal(Vec::<ServerAvatarWithPaths>::new());
|
|
||||||
let (server_loading, set_server_loading) = signal(false);
|
|
||||||
let (server_error, set_server_error) = signal(Option::<String>::None);
|
|
||||||
let (selected_server_avatar, set_selected_server_avatar) = signal(Option::<Uuid>::None);
|
|
||||||
|
|
||||||
// Realm avatars state
|
|
||||||
let (realm_avatars, set_realm_avatars) = signal(Vec::<RealmAvatarWithPaths>::new());
|
|
||||||
let (realm_loading, set_realm_loading) = signal(false);
|
|
||||||
let (realm_error, set_realm_error) = signal(Option::<String>::None);
|
|
||||||
let (selected_realm_avatar, set_selected_realm_avatar) = signal(Option::<Uuid>::None);
|
|
||||||
|
|
||||||
// Track if tabs have been loaded
|
|
||||||
let (server_loaded, set_server_loaded) = signal(false);
|
|
||||||
let (realm_loaded, set_realm_loaded) = signal(false);
|
|
||||||
|
|
||||||
// Selection state
|
|
||||||
let (selecting, set_selecting) = signal(false);
|
|
||||||
let (selection_error, set_selection_error) = signal(Option::<String>::None);
|
|
||||||
|
|
||||||
// Fetch server avatars when popup opens or tab is selected
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
use gloo_net::http::Request;
|
|
||||||
use leptos::task::spawn_local;
|
|
||||||
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if !open.get() {
|
|
||||||
// Reset state when closing
|
|
||||||
set_selected_server_avatar.set(None);
|
|
||||||
set_selected_realm_avatar.set(None);
|
|
||||||
set_server_loaded.set(false);
|
|
||||||
set_realm_loaded.set(false);
|
|
||||||
set_selection_error.set(None);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only fetch if on server tab and not already loaded
|
|
||||||
if active_tab.get() != "server" || server_loaded.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_server_loading.set(true);
|
|
||||||
set_server_error.set(None);
|
|
||||||
|
|
||||||
spawn_local(async move {
|
|
||||||
let response = Request::get("/api/server/avatars").send().await;
|
|
||||||
match response {
|
|
||||||
Ok(resp) if resp.ok() => {
|
|
||||||
if let Ok(data) = resp.json::<Vec<ServerAvatarWithPaths>>().await {
|
|
||||||
set_server_avatars.set(data);
|
|
||||||
set_server_loaded.set(true);
|
|
||||||
} else {
|
|
||||||
set_server_error.set(Some("Failed to parse server avatars".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(resp) => {
|
|
||||||
set_server_error.set(Some(format!(
|
|
||||||
"Failed to load server avatars: {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
set_server_error.set(Some(format!("Network error: {}", e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_server_loading.set(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch realm avatars when realm tab is selected
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
use gloo_net::http::Request;
|
|
||||||
use leptos::task::spawn_local;
|
|
||||||
|
|
||||||
Effect::new(move |_| {
|
|
||||||
if !open.get() || active_tab.get() != "realm" || realm_loaded.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let slug = realm_slug.get();
|
|
||||||
if slug.is_empty() {
|
|
||||||
set_realm_error.set(Some("No realm selected".to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_realm_loading.set(true);
|
|
||||||
set_realm_error.set(None);
|
|
||||||
|
|
||||||
spawn_local(async move {
|
|
||||||
let response = Request::get(&format!("/api/realms/{}/avatars", slug))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
match response {
|
|
||||||
Ok(resp) if resp.ok() => {
|
|
||||||
if let Ok(data) = resp.json::<Vec<RealmAvatarWithPaths>>().await {
|
|
||||||
set_realm_avatars.set(data);
|
|
||||||
set_realm_loaded.set(true);
|
|
||||||
} else {
|
|
||||||
set_realm_error.set(Some("Failed to parse realm avatars".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(resp) => {
|
|
||||||
set_realm_error.set(Some(format!(
|
|
||||||
"Failed to load realm avatars: {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
set_realm_error.set(Some(format!("Network error: {}", e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_realm_loading.set(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle avatar selection
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
let select_avatar = {
|
|
||||||
let on_avatar_selected = on_avatar_selected.clone();
|
|
||||||
Callback::new(move |(avatar_id, source): (Uuid, &'static str)| {
|
|
||||||
set_selecting.set(true);
|
|
||||||
set_selection_error.set(None);
|
|
||||||
|
|
||||||
let slug = realm_slug.get();
|
|
||||||
let on_avatar_selected = on_avatar_selected.clone();
|
|
||||||
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
use gloo_net::http::Request;
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"avatar_id": avatar_id,
|
|
||||||
"source": source
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = Request::post(&format!("/api/realms/{}/avatar/select", slug))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(body.to_string())
|
|
||||||
.unwrap()
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(resp) if resp.ok() => {
|
|
||||||
// Notify parent of successful selection
|
|
||||||
if let Some(callback) = on_avatar_selected {
|
|
||||||
callback.run(());
|
|
||||||
}
|
|
||||||
set_selection_error.set(None);
|
|
||||||
}
|
|
||||||
Ok(resp) => {
|
|
||||||
if let Ok(error_json) = resp.json::<serde_json::Value>().await {
|
|
||||||
let error_msg = error_json
|
|
||||||
.get("error")
|
|
||||||
.and_then(|e| e.as_str())
|
|
||||||
.unwrap_or("Unknown error");
|
|
||||||
set_selection_error.set(Some(error_msg.to_string()));
|
|
||||||
} else {
|
|
||||||
set_selection_error.set(Some(format!(
|
|
||||||
"Failed to select avatar: {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
set_selection_error.set(Some(format!("Network error: {}", e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_selecting.set(false);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
let select_avatar = Callback::new(|_: (Uuid, &'static str)| {});
|
|
||||||
|
|
||||||
// Handle clear selection
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
let clear_selection = {
|
|
||||||
let on_avatar_selected = on_avatar_selected.clone();
|
|
||||||
Callback::new(move |_: ()| {
|
|
||||||
set_selecting.set(true);
|
|
||||||
set_selection_error.set(None);
|
|
||||||
|
|
||||||
let slug = realm_slug.get();
|
|
||||||
let on_avatar_selected = on_avatar_selected.clone();
|
|
||||||
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
use gloo_net::http::Request;
|
|
||||||
|
|
||||||
let response = Request::delete(&format!("/api/realms/{}/avatar/selection", slug))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match response {
|
|
||||||
Ok(resp) if resp.ok() => {
|
|
||||||
// Notify parent of successful clear
|
|
||||||
if let Some(callback) = on_avatar_selected {
|
|
||||||
callback.run(());
|
|
||||||
}
|
|
||||||
set_selection_error.set(None);
|
|
||||||
}
|
|
||||||
Ok(resp) => {
|
|
||||||
set_selection_error.set(Some(format!(
|
|
||||||
"Failed to clear selection: {}",
|
|
||||||
resp.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
set_selection_error.set(Some(format!("Network error: {}", e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_selecting.set(false);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
|
||||||
let clear_selection = Callback::new(|_: ()| {});
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Modal
|
|
||||||
open=open
|
|
||||||
on_close=on_close
|
|
||||||
title="Avatar Store"
|
|
||||||
title_id="avatar-store-modal-title"
|
|
||||||
max_width="max-w-2xl"
|
|
||||||
class="max-h-[80vh] flex flex-col"
|
|
||||||
>
|
|
||||||
<div class="relative flex-1 flex flex-col">
|
|
||||||
// Tab bar
|
|
||||||
<TabBar
|
|
||||||
tabs=vec![
|
|
||||||
Tab::new("server", "Server Avatars"),
|
|
||||||
Tab::new("realm", "Realm Avatars"),
|
|
||||||
]
|
|
||||||
active=Signal::derive(move || active_tab.get())
|
|
||||||
on_select=Callback::new(move |id| set_active_tab.set(id))
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Selection error message
|
|
||||||
<Show when=move || selection_error.get().is_some()>
|
|
||||||
<div class="bg-red-900/20 border border-red-600 rounded p-3 mx-4 mb-2">
|
|
||||||
<p class="text-red-400 text-sm">{move || selection_error.get().unwrap_or_default()}</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// Tab content
|
|
||||||
<div class="flex-1 overflow-y-auto min-h-[300px]">
|
|
||||||
// Server Avatars tab
|
|
||||||
<Show when=move || active_tab.get() == "server">
|
|
||||||
<AvatarsTab
|
|
||||||
avatars=Signal::derive(move || server_avatars.get().into_iter().map(|a| AvatarInfo {
|
|
||||||
id: a.id,
|
|
||||||
name: a.name,
|
|
||||||
description: a.description,
|
|
||||||
skin_layer: a.skin_layer,
|
|
||||||
clothes_layer: a.clothes_layer,
|
|
||||||
accessories_layer: a.accessories_layer,
|
|
||||||
emotion_layer: a.emotion_layer,
|
|
||||||
}).collect())
|
|
||||||
loading=server_loading
|
|
||||||
error=server_error
|
|
||||||
selected_id=selected_server_avatar
|
|
||||||
set_selected_id=set_selected_server_avatar
|
|
||||||
source="server"
|
|
||||||
tab_name="Server"
|
|
||||||
empty_message="No public server avatars available"
|
|
||||||
is_guest=is_guest
|
|
||||||
selecting=selecting
|
|
||||||
on_select=select_avatar.clone()
|
|
||||||
on_clear=clear_selection.clone()
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// Realm Avatars tab
|
|
||||||
<Show when=move || active_tab.get() == "realm">
|
|
||||||
<AvatarsTab
|
|
||||||
avatars=Signal::derive(move || realm_avatars.get().into_iter().map(|a| AvatarInfo {
|
|
||||||
id: a.id,
|
|
||||||
name: a.name,
|
|
||||||
description: a.description,
|
|
||||||
skin_layer: a.skin_layer,
|
|
||||||
clothes_layer: a.clothes_layer,
|
|
||||||
accessories_layer: a.accessories_layer,
|
|
||||||
emotion_layer: a.emotion_layer,
|
|
||||||
}).collect())
|
|
||||||
loading=realm_loading
|
|
||||||
error=realm_error
|
|
||||||
selected_id=selected_realm_avatar
|
|
||||||
set_selected_id=set_selected_realm_avatar
|
|
||||||
source="realm"
|
|
||||||
tab_name="Realm"
|
|
||||||
empty_message="No public realm avatars available"
|
|
||||||
is_guest=is_guest
|
|
||||||
selecting=selecting
|
|
||||||
on_select=select_avatar.clone()
|
|
||||||
on_clear=clear_selection.clone()
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Guest locked overlay
|
|
||||||
<Show when=move || is_guest.get()>
|
|
||||||
<GuestLockedOverlay />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simplified avatar info for the grid with resolved paths for rendering.
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct AvatarInfo {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
description: Option<String>,
|
|
||||||
/// Asset paths for skin layer positions 0-8
|
|
||||||
skin_layer: [Option<String>; 9],
|
|
||||||
/// Asset paths for clothes layer positions 0-8
|
|
||||||
clothes_layer: [Option<String>; 9],
|
|
||||||
/// Asset paths for accessories layer positions 0-8
|
|
||||||
accessories_layer: [Option<String>; 9],
|
|
||||||
/// Asset paths for emotion layer positions 0-8
|
|
||||||
emotion_layer: [Option<String>; 9],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Avatars tab content with selection functionality.
|
|
||||||
#[component]
|
|
||||||
fn AvatarsTab(
|
|
||||||
#[prop(into)] avatars: Signal<Vec<AvatarInfo>>,
|
|
||||||
#[prop(into)] loading: Signal<bool>,
|
|
||||||
#[prop(into)] error: Signal<Option<String>>,
|
|
||||||
#[prop(into)] selected_id: Signal<Option<Uuid>>,
|
|
||||||
set_selected_id: WriteSignal<Option<Uuid>>,
|
|
||||||
source: &'static str,
|
|
||||||
tab_name: &'static str,
|
|
||||||
empty_message: &'static str,
|
|
||||||
#[prop(into)] is_guest: Signal<bool>,
|
|
||||||
#[prop(into)] selecting: Signal<bool>,
|
|
||||||
on_select: Callback<(Uuid, &'static str)>,
|
|
||||||
on_clear: Callback<()>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
// Loading state
|
|
||||||
<Show when=move || loading.get()>
|
|
||||||
<div class="flex items-center justify-center py-12">
|
|
||||||
<p class="text-gray-400">{format!("Loading {} avatars...", tab_name.to_lowercase())}</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
<Show when=move || error.get().is_some()>
|
|
||||||
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4 mx-4">
|
|
||||||
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// Empty state
|
|
||||||
<Show when=move || !loading.get() && error.get().is_none() && avatars.get().is_empty()>
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
|
|
||||||
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-400">{empty_message}</p>
|
|
||||||
<p class="text-gray-500 text-sm mt-1">"Pre-configured avatars will appear here when available"</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
// Grid of avatars
|
|
||||||
<Show when=move || !loading.get() && !avatars.get().is_empty()>
|
|
||||||
<div class="p-4">
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-4 sm:grid-cols-6 gap-3"
|
|
||||||
role="listbox"
|
|
||||||
aria-label=format!("{} avatars", tab_name)
|
|
||||||
>
|
|
||||||
<For
|
|
||||||
each=move || avatars.get()
|
|
||||||
key=|avatar| avatar.id
|
|
||||||
children=move |avatar: AvatarInfo| {
|
|
||||||
let avatar_id = avatar.id;
|
|
||||||
let avatar_name = avatar.name.clone();
|
|
||||||
let is_selected = move || selected_id.get() == Some(avatar_id);
|
|
||||||
|
|
||||||
// Create signals for the layer data
|
|
||||||
let skin_layer = Signal::derive({
|
|
||||||
let layers = avatar.skin_layer.clone();
|
|
||||||
move || layers.clone()
|
|
||||||
});
|
|
||||||
let clothes_layer = Signal::derive({
|
|
||||||
let layers = avatar.clothes_layer.clone();
|
|
||||||
move || layers.clone()
|
|
||||||
});
|
|
||||||
let accessories_layer = Signal::derive({
|
|
||||||
let layers = avatar.accessories_layer.clone();
|
|
||||||
move || layers.clone()
|
|
||||||
});
|
|
||||||
let emotion_layer = Signal::derive({
|
|
||||||
let layers = avatar.emotion_layer.clone();
|
|
||||||
move || layers.clone()
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class=move || format!(
|
|
||||||
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
|
|
||||||
if is_selected() {
|
|
||||||
"border-blue-500 bg-blue-900/30"
|
|
||||||
} else {
|
|
||||||
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
on:click=move |_| {
|
|
||||||
set_selected_id.set(Some(avatar_id));
|
|
||||||
}
|
|
||||||
role="option"
|
|
||||||
aria-selected=is_selected
|
|
||||||
aria-label=avatar_name
|
|
||||||
>
|
|
||||||
<AvatarThumbnail
|
|
||||||
skin_layer=skin_layer
|
|
||||||
clothes_layer=clothes_layer
|
|
||||||
accessories_layer=accessories_layer
|
|
||||||
emotion_layer=emotion_layer
|
|
||||||
size=72
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Selected avatar details and actions
|
|
||||||
{move || {
|
|
||||||
let avatar_id = selected_id.get()?;
|
|
||||||
let avatar = avatars.get().into_iter().find(|a| a.id == avatar_id)?;
|
|
||||||
let guest = is_guest.get();
|
|
||||||
let is_selecting = selecting.get();
|
|
||||||
|
|
||||||
let (button_text, button_class, button_disabled, button_title) = if guest {
|
|
||||||
("Sign in to Select", "bg-gray-600 text-gray-400 cursor-not-allowed", true, "Guests cannot select avatars")
|
|
||||||
} else if is_selecting {
|
|
||||||
("Selecting...", "bg-blue-600 text-white opacity-50", true, "")
|
|
||||||
} else {
|
|
||||||
("Select This Avatar", "bg-blue-600 hover:bg-blue-700 text-white", false, "Use this pre-configured avatar")
|
|
||||||
};
|
|
||||||
|
|
||||||
let avatar_name = avatar.name.clone();
|
|
||||||
let avatar_description = avatar.description.clone();
|
|
||||||
let on_select = on_select.clone();
|
|
||||||
let on_clear = on_clear.clone();
|
|
||||||
|
|
||||||
Some(view! {
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="text-white font-medium truncate">{avatar_name}</h3>
|
|
||||||
{avatar_description.map(|desc| view! {
|
|
||||||
<p class="text-gray-400 text-sm mt-1 line-clamp-2">{desc}</p>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors text-sm disabled:opacity-50"
|
|
||||||
on:click=move |_| {
|
|
||||||
on_clear.run(());
|
|
||||||
}
|
|
||||||
disabled=is_selecting || guest
|
|
||||||
title="Clear selection and use custom/default avatar"
|
|
||||||
>
|
|
||||||
"Clear"
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class=format!("px-4 py-2 rounded-lg transition-colors {}", button_class)
|
|
||||||
on:click=move |_| {
|
|
||||||
if !button_disabled {
|
|
||||||
on_select.run((avatar_id, source));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
disabled=button_disabled
|
|
||||||
title=button_title
|
|
||||||
>
|
|
||||||
{button_text}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
//! Avatar thumbnail component for rendering avatar previews.
|
|
||||||
//!
|
|
||||||
//! A simplified canvas-based component for rendering avatars in the
|
|
||||||
//! avatar store grid. Uses the same layer compositing as the main
|
|
||||||
//! avatar renderer but at thumbnail size.
|
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos::web_sys;
|
|
||||||
|
|
||||||
/// Avatar thumbnail component for the avatar store.
|
|
||||||
///
|
|
||||||
/// Renders a small preview of an avatar using canvas compositing.
|
|
||||||
/// Takes layer paths directly as props.
|
|
||||||
///
|
|
||||||
/// Props:
|
|
||||||
/// - `skin_layer`: Asset paths for skin layer positions 0-8
|
|
||||||
/// - `clothes_layer`: Asset paths for clothes layer positions 0-8
|
|
||||||
/// - `accessories_layer`: Asset paths for accessories layer positions 0-8
|
|
||||||
/// - `emotion_layer`: Asset paths for emotion layer positions 0-8
|
|
||||||
/// - `size`: Optional canvas size in pixels (default: 80)
|
|
||||||
#[component]
|
|
||||||
pub fn AvatarThumbnail(
|
|
||||||
#[prop(into)] skin_layer: Signal<[Option<String>; 9]>,
|
|
||||||
#[prop(into)] clothes_layer: Signal<[Option<String>; 9]>,
|
|
||||||
#[prop(into)] accessories_layer: Signal<[Option<String>; 9]>,
|
|
||||||
#[prop(into)] emotion_layer: Signal<[Option<String>; 9]>,
|
|
||||||
#[prop(default = 80)] size: u32,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
|
||||||
let cell_size = size / 3;
|
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use wasm_bindgen::closure::Closure;
|
|
||||||
|
|
||||||
use crate::utils::normalize_asset_path;
|
|
||||||
|
|
||||||
// Image cache for this thumbnail
|
|
||||||
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
|
||||||
Rc::new(RefCell::new(HashMap::new()));
|
|
||||||
|
|
||||||
// Redraw trigger - incremented when images load
|
|
||||||
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
|
|
||||||
|
|
||||||
Effect::new(move |_| {
|
|
||||||
// Subscribe to redraw trigger
|
|
||||||
let _ = redraw_trigger.get();
|
|
||||||
|
|
||||||
let Some(canvas) = canvas_ref.get() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let skin = skin_layer.get();
|
|
||||||
let clothes = clothes_layer.get();
|
|
||||||
let accessories = accessories_layer.get();
|
|
||||||
let emotion = emotion_layer.get();
|
|
||||||
|
|
||||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
|
||||||
canvas_el.set_width(size);
|
|
||||||
canvas_el.set_height(size);
|
|
||||||
|
|
||||||
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
ctx.clear_rect(0.0, 0.0, size as f64, size as f64);
|
|
||||||
|
|
||||||
// Draw background
|
|
||||||
ctx.set_fill_style_str("#374151");
|
|
||||||
ctx.fill_rect(0.0, 0.0, size as f64, size as f64);
|
|
||||||
|
|
||||||
// Helper to load and draw an image at a grid position
|
|
||||||
let draw_at_position =
|
|
||||||
|path: &str,
|
|
||||||
pos: usize,
|
|
||||||
cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
|
|
||||||
ctx: &web_sys::CanvasRenderingContext2d| {
|
|
||||||
let normalized_path = normalize_asset_path(path);
|
|
||||||
let mut cache_borrow = cache.borrow_mut();
|
|
||||||
let row = pos / 3;
|
|
||||||
let col = pos % 3;
|
|
||||||
let x = (col * cell_size as usize) as f64;
|
|
||||||
let y = (row * cell_size as usize) as f64;
|
|
||||||
let sz = cell_size as f64;
|
|
||||||
|
|
||||||
if let Some(img) = cache_borrow.get(&normalized_path) {
|
|
||||||
if img.complete() && img.natural_width() > 0 {
|
|
||||||
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
|
|
||||||
img, x, y, sz, sz,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
|
||||||
let trigger = set_redraw_trigger;
|
|
||||||
let onload = Closure::once(Box::new(move || {
|
|
||||||
trigger.update(|v| *v += 1);
|
|
||||||
}) as Box<dyn FnOnce()>);
|
|
||||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
|
||||||
onload.forget();
|
|
||||||
img.set_src(&normalized_path);
|
|
||||||
cache_borrow.insert(normalized_path, img);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Draw layers in order: skin -> clothes -> accessories -> emotion
|
|
||||||
for (pos, path) in skin.iter().enumerate() {
|
|
||||||
if let Some(p) = path {
|
|
||||||
draw_at_position(p, pos, &image_cache, &ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (pos, path) in clothes.iter().enumerate() {
|
|
||||||
if let Some(p) = path {
|
|
||||||
draw_at_position(p, pos, &image_cache, &ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (pos, path) in accessories.iter().enumerate() {
|
|
||||||
if let Some(p) = path {
|
|
||||||
draw_at_position(p, pos, &image_cache, &ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (pos, path) in emotion.iter().enumerate() {
|
|
||||||
if let Some(p) = path {
|
|
||||||
draw_at_position(p, pos, &image_cache, &ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<canvas
|
|
||||||
node_ref=canvas_ref
|
|
||||||
style=format!("width: {}px; height: {}px;", size, size)
|
|
||||||
class="rounded"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -903,12 +903,6 @@ pub fn ChatInput(
|
||||||
<span class="text-purple-600">" | "</span>
|
<span class="text-purple-600">" | "</span>
|
||||||
<span class="text-purple-300">"teleport"</span>
|
<span class="text-purple-300">"teleport"</span>
|
||||||
<span class="text-purple-500">" [nick] [slug]"</span>
|
<span class="text-purple-500">" [nick] [slug]"</span>
|
||||||
<span class="text-purple-600">" | "</span>
|
|
||||||
<span class="text-purple-300">"dress"</span>
|
|
||||||
<span class="text-purple-500">" [nick] [avatar] [dur?]"</span>
|
|
||||||
<span class="text-purple-600">" | "</span>
|
|
||||||
<span class="text-purple-300">"undress"</span>
|
|
||||||
<span class="text-purple-500">" [nick]"</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,11 @@ pub const MAX_MESSAGE_LOG_SIZE: usize = 2000;
|
||||||
pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000;
|
pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000;
|
||||||
|
|
||||||
/// A chat message for display and logging.
|
/// A chat message for display and logging.
|
||||||
/// Note: Guests are now regular users with the 'guest' tag, so all messages have a user_id.
|
|
||||||
/// System messages use Uuid::nil() as the user_id.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
pub message_id: Uuid,
|
pub message_id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Option<Uuid>,
|
||||||
|
pub guest_session_id: Option<Uuid>,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// Emotion name (e.g., "happy", "sad", "neutral").
|
/// Emotion name (e.g., "happy", "sad", "neutral").
|
||||||
|
|
@ -71,8 +70,15 @@ impl MessageLog {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the latest message from a specific user.
|
/// Get the latest message from a specific user.
|
||||||
pub fn latest_from_user(&self, user_id: Uuid) -> Option<&ChatMessage> {
|
pub fn latest_from_user(
|
||||||
self.messages.iter().rev().find(|m| m.user_id == user_id)
|
&self,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
guest_id: Option<Uuid>,
|
||||||
|
) -> Option<&ChatMessage> {
|
||||||
|
self.messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.user_id == user_id && m.guest_session_id == guest_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all messages.
|
/// Get all messages.
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ pub fn HotkeyHelp(
|
||||||
<HotkeyRow key="i" description="Inventory" />
|
<HotkeyRow key="i" description="Inventory" />
|
||||||
<HotkeyRow key="k" description="Keybindings" />
|
<HotkeyRow key="k" description="Keybindings" />
|
||||||
<HotkeyRow key="a" description="Avatar editor" />
|
<HotkeyRow key="a" description="Avatar editor" />
|
||||||
<HotkeyRow key="t" description="Avatar store" />
|
|
||||||
<HotkeyRow key="l" description="Message log" />
|
<HotkeyRow key="l" description="Message log" />
|
||||||
|
|
||||||
// Emotions
|
// Emotions
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub fn RealmSceneViewer(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
realm_slug: String,
|
realm_slug: String,
|
||||||
#[prop(into)] members: Signal<Vec<ChannelMemberWithAvatar>>,
|
#[prop(into)] members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||||
#[prop(into)] active_bubbles: Signal<HashMap<Uuid, ActiveBubble>>,
|
#[prop(into)] active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
|
||||||
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
||||||
#[prop(into)] on_move: Callback<(f64, f64)>,
|
#[prop(into)] on_move: Callback<(f64, f64)>,
|
||||||
#[prop(into)] on_prop_click: Callback<Uuid>,
|
#[prop(into)] on_prop_click: Callback<Uuid>,
|
||||||
|
|
@ -51,9 +51,11 @@ pub fn RealmSceneViewer(
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
fading_members: Option<Signal<Vec<FadingMember>>>,
|
fading_members: Option<Signal<Vec<FadingMember>>>,
|
||||||
/// Current user's user_id (for context menu filtering).
|
/// Current user's user_id (for context menu filtering).
|
||||||
/// Note: Guests are now regular users with the 'guest' tag.
|
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
current_user_id: Option<Signal<Option<Uuid>>>,
|
current_user_id: Option<Signal<Option<Uuid>>>,
|
||||||
|
/// Current user's guest_session_id (for context menu filtering).
|
||||||
|
#[prop(optional, into)]
|
||||||
|
current_guest_session_id: Option<Signal<Option<Uuid>>>,
|
||||||
/// Whether the current user is a guest (guests cannot use context menu).
|
/// Whether the current user is a guest (guests cannot use context menu).
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
is_guest: Option<Signal<bool>>,
|
is_guest: Option<Signal<bool>>,
|
||||||
|
|
@ -181,6 +183,7 @@ pub fn RealmSceneViewer(
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_overlay_contextmenu = {
|
let on_overlay_contextmenu = {
|
||||||
let current_user_id = current_user_id.clone();
|
let current_user_id = current_user_id.clone();
|
||||||
|
let current_guest_session_id = current_guest_session_id.clone();
|
||||||
move |ev: web_sys::MouseEvent| {
|
move |ev: web_sys::MouseEvent| {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
|
@ -191,6 +194,7 @@ pub fn RealmSceneViewer(
|
||||||
|
|
||||||
// Get current user identity for filtering
|
// Get current user identity for filtering
|
||||||
let my_user_id = current_user_id.map(|s| s.get()).flatten();
|
let my_user_id = current_user_id.map(|s| s.get()).flatten();
|
||||||
|
let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten();
|
||||||
|
|
||||||
// Get click position
|
// Get click position
|
||||||
let client_x = ev.client_x() as f64;
|
let client_x = ev.client_x() as f64;
|
||||||
|
|
@ -211,17 +215,22 @@ pub fn RealmSceneViewer(
|
||||||
if let Some(member_id_str) = canvas.get_attribute("data-member-id") {
|
if let Some(member_id_str) = canvas.get_attribute("data-member-id") {
|
||||||
// Check if click hits a non-transparent pixel
|
// Check if click hits a non-transparent pixel
|
||||||
if hit_test_canvas(&canvas, client_x, client_y) {
|
if hit_test_canvas(&canvas, client_x, client_y) {
|
||||||
// Parse the member ID (now always user_id since guests are users)
|
// Parse the member ID to determine if it's a user_id or guest_session_id
|
||||||
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
|
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
|
||||||
// Check if this is the current user's avatar
|
// Check if this is the current user's avatar
|
||||||
let is_current_user = my_user_id == Some(member_id);
|
let is_current_user = my_user_id == Some(member_id)
|
||||||
|
|| my_guest_session_id == Some(member_id);
|
||||||
|
|
||||||
if !is_current_user {
|
if !is_current_user {
|
||||||
// Find the display name for this member
|
// Find the display name for this member
|
||||||
let display_name = members
|
let display_name = members
|
||||||
.get()
|
.get()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|m| m.member.user_id == member_id)
|
.find(|m| {
|
||||||
|
m.member.user_id == Some(member_id)
|
||||||
|
|| m.member.guest_session_id
|
||||||
|
== Some(member_id)
|
||||||
|
})
|
||||||
.map(|m| m.member.display_name.clone());
|
.map(|m| m.member.display_name.clone());
|
||||||
|
|
||||||
if let Some(name) = display_name {
|
if let Some(name) = display_name {
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,12 @@ pub type WsSender = Box<dyn Fn(ClientMessage)>;
|
||||||
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;
|
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;
|
||||||
|
|
||||||
/// Information about the current channel member (received on Welcome).
|
/// Information about the current channel member (received on Welcome).
|
||||||
/// Note: Guests are now regular users with the 'guest' tag, so all members have a user_id.
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ChannelMemberInfo {
|
pub struct ChannelMemberInfo {
|
||||||
/// The user's user_id.
|
/// The user's user_id (if authenticated user).
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: Option<uuid::Uuid>,
|
||||||
|
/// The user's guest_session_id (if guest).
|
||||||
|
pub guest_session_id: Option<uuid::Uuid>,
|
||||||
/// The user's display name.
|
/// The user's display name.
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
/// Whether this user is a guest (has the 'guest' tag).
|
/// Whether this user is a guest (has the 'guest' tag).
|
||||||
|
|
@ -98,12 +99,6 @@ pub struct SummonInfo {
|
||||||
pub summoned_by: String,
|
pub summoned_by: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close function type for WebSocket (takes close code and reason).
|
|
||||||
pub type WsCloser = Box<dyn Fn(u16, String)>;
|
|
||||||
|
|
||||||
/// Local stored value type for the closer (non-Send, WASM-compatible).
|
|
||||||
pub type WsCloserStorage = StoredValue<Option<WsCloser>, LocalStorage>;
|
|
||||||
|
|
||||||
/// Result of a moderator command.
|
/// Result of a moderator command.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ModCommandResultInfo {
|
pub struct ModCommandResultInfo {
|
||||||
|
|
@ -124,92 +119,48 @@ pub struct MemberIdentityInfo {
|
||||||
pub is_guest: bool,
|
pub is_guest: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consolidated WebSocket event enum.
|
|
||||||
///
|
|
||||||
/// All WebSocket events are routed through this enum for a cleaner API.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum WsEvent {
|
|
||||||
/// Members list updated.
|
|
||||||
MembersUpdated(Vec<ChannelMemberWithAvatar>),
|
|
||||||
/// Chat message received.
|
|
||||||
ChatMessage(ChatMessage),
|
|
||||||
/// Loose props synchronized (initial list).
|
|
||||||
LoosePropsSync(Vec<LooseProp>),
|
|
||||||
/// A prop was dropped.
|
|
||||||
PropDropped(LooseProp),
|
|
||||||
/// A prop was picked up (by prop ID).
|
|
||||||
PropPickedUp(uuid::Uuid),
|
|
||||||
/// A member started fading out (timeout disconnect).
|
|
||||||
MemberFading(FadingMember),
|
|
||||||
/// Welcome message received with current user info.
|
|
||||||
Welcome(ChannelMemberInfo),
|
|
||||||
/// Error from server.
|
|
||||||
Error(WsError),
|
|
||||||
/// Teleport approved - navigate to new scene.
|
|
||||||
TeleportApproved(TeleportInfo),
|
|
||||||
/// Summoned by moderator.
|
|
||||||
Summoned(SummonInfo),
|
|
||||||
/// Moderator command result.
|
|
||||||
ModCommandResult(ModCommandResultInfo),
|
|
||||||
/// Member identity updated (e.g., guest → user).
|
|
||||||
MemberIdentityUpdated(MemberIdentityInfo),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consolidated internal state to reduce Rc<RefCell<>> proliferation.
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
struct WsInternalState {
|
|
||||||
ws: Option<web_sys::WebSocket>,
|
|
||||||
members: Vec<ChannelMemberWithAvatar>,
|
|
||||||
current_user_id: Option<uuid::Uuid>,
|
|
||||||
is_intentional_close: bool,
|
|
||||||
heartbeat_handle: Option<gloo_timers::callback::Interval>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
impl Default for WsInternalState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
ws: None,
|
|
||||||
members: Vec::new(),
|
|
||||||
current_user_id: None,
|
|
||||||
is_intentional_close: false,
|
|
||||||
heartbeat_handle: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hook to manage WebSocket connection for a channel.
|
/// Hook to manage WebSocket connection for a channel.
|
||||||
///
|
///
|
||||||
/// Returns a tuple of:
|
/// Returns a tuple of:
|
||||||
/// - `Signal<WsState>` - The current connection state
|
/// - `Signal<WsState>` - The current connection state
|
||||||
/// - `WsSenderStorage` - A stored sender function to send messages
|
/// - `WsSenderStorage` - A stored sender function to send messages
|
||||||
/// - `WsCloserStorage` - A stored close function to close the WebSocket with a specific code
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
pub fn use_channel_websocket(
|
pub fn use_channel_websocket(
|
||||||
realm_slug: Signal<String>,
|
realm_slug: Signal<String>,
|
||||||
channel_id: Signal<Option<uuid::Uuid>>,
|
channel_id: Signal<Option<uuid::Uuid>>,
|
||||||
reconnect_trigger: RwSignal<u32>,
|
reconnect_trigger: RwSignal<u32>,
|
||||||
on_event: Callback<WsEvent>,
|
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
|
on_chat_message: Callback<ChatMessage>,
|
||||||
|
on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||||
|
on_prop_dropped: Callback<LooseProp>,
|
||||||
|
on_prop_picked_up: Callback<uuid::Uuid>,
|
||||||
|
on_member_fading: Callback<FadingMember>,
|
||||||
|
on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
|
on_error: Option<Callback<WsError>>,
|
||||||
|
on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
|
on_summoned: Option<Callback<SummonInfo>>,
|
||||||
|
on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
||||||
|
on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
|
||||||
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use wasm_bindgen::{JsCast, closure::Closure};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||||
|
|
||||||
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
|
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
|
||||||
|
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
|
||||||
let state: Rc<RefCell<WsInternalState>> = Rc::new(RefCell::new(WsInternalState::default()));
|
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
// Flag to prevent accessing disposed reactive values after component unmount
|
// Track current user's ID to ignore self MemberLeft during reconnection
|
||||||
let is_disposed: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
|
let current_user_id: Rc<RefCell<Option<uuid::Uuid>>> = Rc::new(RefCell::new(None));
|
||||||
|
// Flag to track intentional closes (teleport, scene change) - guarantees local state
|
||||||
|
// even if close code doesn't arrive correctly due to browser/server quirks
|
||||||
|
let is_intentional_close: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||||
|
|
||||||
// Create a stored sender function (using new_local for WASM single-threaded environment)
|
// Create a stored sender function (using new_local for WASM single-threaded environment)
|
||||||
let state_for_send = state.clone();
|
let ws_ref_for_send = ws_ref.clone();
|
||||||
let sender: WsSenderStorage =
|
let sender: WsSenderStorage =
|
||||||
StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| {
|
StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| {
|
||||||
let state = state_for_send.borrow();
|
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
|
||||||
if let Some(ws) = state.ws.as_ref() {
|
|
||||||
if ws.ready_state() == WebSocket::OPEN {
|
if ws.ready_state() == WebSocket::OPEN {
|
||||||
if let Ok(json) = serde_json::to_string(&msg) {
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
@ -220,18 +171,10 @@ pub fn use_channel_websocket(
|
||||||
}
|
}
|
||||||
})));
|
})));
|
||||||
|
|
||||||
// Clone for closer callback (must be done before Effect captures state)
|
|
||||||
let state_for_close = state.clone();
|
|
||||||
|
|
||||||
// Set disposed flag on cleanup to prevent accessing disposed reactive values
|
|
||||||
let is_disposed_for_cleanup = is_disposed.clone();
|
|
||||||
on_cleanup(move || {
|
|
||||||
is_disposed_for_cleanup.store(true, Ordering::Relaxed);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Effect to manage WebSocket lifecycle
|
// Effect to manage WebSocket lifecycle
|
||||||
let state_for_effect = state.clone();
|
let ws_ref_clone = ws_ref.clone();
|
||||||
let is_disposed_for_effect = is_disposed.clone();
|
let members_clone = members.clone();
|
||||||
|
let is_intentional_close_for_cleanup = is_intentional_close.clone();
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
let slug = realm_slug.get();
|
let slug = realm_slug.get();
|
||||||
|
|
@ -240,20 +183,15 @@ pub fn use_channel_websocket(
|
||||||
let _trigger = reconnect_trigger.get();
|
let _trigger = reconnect_trigger.get();
|
||||||
|
|
||||||
// Cleanup previous connection
|
// Cleanup previous connection
|
||||||
{
|
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
|
||||||
let mut state = state_for_effect.borrow_mut();
|
#[cfg(debug_assertions)]
|
||||||
if let Some(old_ws) = state.ws.take() {
|
web_sys::console::log_1(
|
||||||
#[cfg(debug_assertions)]
|
&format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(),
|
||||||
web_sys::console::log_1(
|
);
|
||||||
&format!("[WS] Closing old connection, readyState={}", old_ws.ready_state()).into(),
|
// Set flag BEFORE closing - guarantees local state even if close code doesn't arrive
|
||||||
);
|
*is_intentional_close_for_cleanup.borrow_mut() = true;
|
||||||
// Set flag BEFORE closing - guarantees local state even if close code doesn't arrive
|
// Close with SCENE_CHANGE code so onclose handler knows this was intentional
|
||||||
state.is_intentional_close = true;
|
let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
|
||||||
// Cancel existing heartbeat
|
|
||||||
state.heartbeat_handle = None;
|
|
||||||
// Close with SCENE_CHANGE code so onclose handler knows this was intentional
|
|
||||||
let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(ch_id) = ch_id else {
|
let Some(ch_id) = ch_id else {
|
||||||
|
|
@ -306,14 +244,26 @@ pub fn use_channel_websocket(
|
||||||
onopen.forget();
|
onopen.forget();
|
||||||
|
|
||||||
// onmessage
|
// onmessage
|
||||||
let state_for_msg = state_for_effect.clone();
|
let members_for_msg = members_clone.clone();
|
||||||
let on_event_for_msg = on_event.clone();
|
let on_members_update_clone = on_members_update.clone();
|
||||||
let is_disposed_for_msg = is_disposed_for_effect.clone();
|
let on_chat_message_clone = on_chat_message.clone();
|
||||||
|
let on_loose_props_sync_clone = on_loose_props_sync.clone();
|
||||||
|
let on_prop_dropped_clone = on_prop_dropped.clone();
|
||||||
|
let on_prop_picked_up_clone = on_prop_picked_up.clone();
|
||||||
|
let on_member_fading_clone = on_member_fading.clone();
|
||||||
|
let on_welcome_clone = on_welcome.clone();
|
||||||
|
let on_error_clone = on_error.clone();
|
||||||
|
let on_teleport_approved_clone = on_teleport_approved.clone();
|
||||||
|
let on_summoned_clone = on_summoned.clone();
|
||||||
|
let on_mod_command_result_clone = on_mod_command_result.clone();
|
||||||
|
let on_member_identity_updated_clone = on_member_identity_updated.clone();
|
||||||
|
// For starting heartbeat on Welcome
|
||||||
|
let ws_ref_for_heartbeat = ws_ref.clone();
|
||||||
|
let heartbeat_started: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||||
|
let heartbeat_started_clone = heartbeat_started.clone();
|
||||||
|
// For tracking current user ID to ignore self MemberLeft during reconnection
|
||||||
|
let current_user_id_for_msg = current_user_id.clone();
|
||||||
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
||||||
// Skip if component has been disposed
|
|
||||||
if is_disposed_for_msg.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
||||||
let text: String = text.into();
|
let text: String = text.into();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
@ -328,13 +278,12 @@ pub fn use_channel_websocket(
|
||||||
} = msg
|
} = msg
|
||||||
{
|
{
|
||||||
// Track current user ID for MemberLeft filtering
|
// Track current user ID for MemberLeft filtering
|
||||||
state_for_msg.borrow_mut().current_user_id = Some(member.user_id);
|
*current_user_id_for_msg.borrow_mut() = member.user_id;
|
||||||
|
|
||||||
// Start heartbeat if not already running
|
if !*heartbeat_started_clone.borrow() {
|
||||||
let needs_heartbeat = state_for_msg.borrow().heartbeat_handle.is_none();
|
*heartbeat_started_clone.borrow_mut() = true;
|
||||||
if needs_heartbeat {
|
|
||||||
let ping_interval_ms = config.ping_interval_secs * 1000;
|
let ping_interval_ms = config.ping_interval_secs * 1000;
|
||||||
let state_for_ping = state_for_msg.clone();
|
let ws_ref_ping = ws_ref_for_heartbeat.clone();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
web_sys::console::log_1(
|
web_sys::console::log_1(
|
||||||
&format!(
|
&format!(
|
||||||
|
|
@ -346,8 +295,7 @@ pub fn use_channel_websocket(
|
||||||
let heartbeat = gloo_timers::callback::Interval::new(
|
let heartbeat = gloo_timers::callback::Interval::new(
|
||||||
ping_interval_ms as u32,
|
ping_interval_ms as u32,
|
||||||
move || {
|
move || {
|
||||||
let state = state_for_ping.borrow();
|
if let Some(ws) = ws_ref_ping.borrow().as_ref() {
|
||||||
if let Some(ws) = state.ws.as_ref() {
|
|
||||||
if ws.ready_state() == WebSocket::OPEN {
|
if ws.ready_state() == WebSocket::OPEN {
|
||||||
if let Ok(json) =
|
if let Ok(json) =
|
||||||
serde_json::to_string(&ClientMessage::Ping)
|
serde_json::to_string(&ClientMessage::Ping)
|
||||||
|
|
@ -358,17 +306,35 @@ pub fn use_channel_websocket(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
state_for_msg.borrow_mut().heartbeat_handle = Some(heartbeat);
|
std::mem::forget(heartbeat);
|
||||||
}
|
}
|
||||||
// Call on_welcome callback with current user info
|
// Call on_welcome callback with current user info
|
||||||
let info = ChannelMemberInfo {
|
if let Some(ref callback) = on_welcome_clone {
|
||||||
user_id: member.user_id,
|
let info = ChannelMemberInfo {
|
||||||
display_name: member.display_name.clone(),
|
user_id: member.user_id,
|
||||||
is_guest: member.is_guest,
|
guest_session_id: member.guest_session_id,
|
||||||
};
|
display_name: member.display_name.clone(),
|
||||||
on_event_for_msg.run(WsEvent::Welcome(info));
|
is_guest: member.is_guest,
|
||||||
|
};
|
||||||
|
callback.run(info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handle_server_message(msg, &state_for_msg, &on_event_for_msg);
|
handle_server_message(
|
||||||
|
msg,
|
||||||
|
&members_for_msg,
|
||||||
|
&on_members_update_clone,
|
||||||
|
&on_chat_message_clone,
|
||||||
|
&on_loose_props_sync_clone,
|
||||||
|
&on_prop_dropped_clone,
|
||||||
|
&on_prop_picked_up_clone,
|
||||||
|
&on_member_fading_clone,
|
||||||
|
&on_error_clone,
|
||||||
|
&on_teleport_approved_clone,
|
||||||
|
&on_summoned_clone,
|
||||||
|
&on_mod_command_result_clone,
|
||||||
|
&on_member_identity_updated_clone,
|
||||||
|
¤t_user_id_for_msg,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(MessageEvent)>);
|
}) as Box<dyn FnMut(MessageEvent)>);
|
||||||
|
|
@ -379,12 +345,7 @@ pub fn use_channel_websocket(
|
||||||
let set_ws_state_err = set_ws_state;
|
let set_ws_state_err = set_ws_state;
|
||||||
let ws_state_for_err = ws_state;
|
let ws_state_for_err = ws_state;
|
||||||
let reconnect_trigger_for_error = reconnect_trigger;
|
let reconnect_trigger_for_error = reconnect_trigger;
|
||||||
let is_disposed_for_err = is_disposed_for_effect.clone();
|
|
||||||
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
|
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
|
||||||
// Skip if component has been disposed
|
|
||||||
if is_disposed_for_err.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
|
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
|
||||||
|
|
||||||
|
|
@ -425,27 +386,16 @@ pub fn use_channel_websocket(
|
||||||
onerror.forget();
|
onerror.forget();
|
||||||
|
|
||||||
// onclose
|
// onclose
|
||||||
let state_for_close = state_for_effect.clone();
|
|
||||||
let set_ws_state_close = set_ws_state;
|
let set_ws_state_close = set_ws_state;
|
||||||
let reconnect_trigger_for_close = reconnect_trigger;
|
let reconnect_trigger_for_close = reconnect_trigger;
|
||||||
let is_disposed_for_close = is_disposed_for_effect.clone();
|
let is_intentional_close_for_onclose = is_intentional_close.clone();
|
||||||
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
|
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
|
||||||
// Skip if component has been disposed
|
|
||||||
if is_disposed_for_close.load(Ordering::Relaxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let code = e.code();
|
let code = e.code();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
web_sys::console::log_1(
|
web_sys::console::log_1(
|
||||||
&format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(),
|
&format!("[WS] Closed: code={}, reason={}", code, e.reason()).into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel heartbeat on close
|
|
||||||
state_for_close.borrow_mut().heartbeat_handle = None;
|
|
||||||
|
|
||||||
// Check if this was an intentional close
|
|
||||||
let is_intentional = state_for_close.borrow().is_intentional_close;
|
|
||||||
|
|
||||||
// Handle based on close code with defense-in-depth using flag
|
// Handle based on close code with defense-in-depth using flag
|
||||||
if code == close_codes::SERVER_TIMEOUT {
|
if code == close_codes::SERVER_TIMEOUT {
|
||||||
// Server timeout - attempt silent reconnection (highest priority)
|
// Server timeout - attempt silent reconnection (highest priority)
|
||||||
|
|
@ -458,16 +408,13 @@ pub fn use_channel_websocket(
|
||||||
reconnect_trigger.update(|v| *v = v.wrapping_add(1));
|
reconnect_trigger.update(|v| *v = v.wrapping_add(1));
|
||||||
})
|
})
|
||||||
.forget();
|
.forget();
|
||||||
} else if code == close_codes::SCENE_CHANGE
|
} else if code == close_codes::SCENE_CHANGE || *is_intentional_close_for_onclose.borrow() {
|
||||||
|| code == close_codes::LOGOUT
|
// Intentional close (scene change/teleport) - don't show disconnection
|
||||||
|| is_intentional
|
|
||||||
{
|
|
||||||
// Intentional close (scene change/teleport/logout) - don't show disconnection
|
|
||||||
// Check both code AND flag for defense-in-depth (flag is guaranteed local state)
|
// Check both code AND flag for defense-in-depth (flag is guaranteed local state)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into());
|
web_sys::console::log_1(&"[WS] Intentional close, not setting Disconnected".into());
|
||||||
// Reset the flag for future connections
|
// Reset the flag for future connections
|
||||||
state_for_close.borrow_mut().is_intentional_close = false;
|
*is_intentional_close_for_onclose.borrow_mut() = false;
|
||||||
} else {
|
} else {
|
||||||
// Other close codes - treat as disconnection
|
// Other close codes - treat as disconnection
|
||||||
set_ws_state_close.set(WsState::Disconnected);
|
set_ws_state_close.set(WsState::Disconnected);
|
||||||
|
|
@ -476,148 +423,153 @@ pub fn use_channel_websocket(
|
||||||
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
|
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
|
||||||
onclose.forget();
|
onclose.forget();
|
||||||
|
|
||||||
state_for_effect.borrow_mut().ws = Some(ws);
|
*ws_ref_clone.borrow_mut() = Some(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create closer function for explicit WebSocket closure (e.g., logout)
|
(Signal::derive(move || ws_state.get()), sender)
|
||||||
let closer: WsCloserStorage = StoredValue::new_local(Some(Box::new(move |code: u16, reason: String| {
|
|
||||||
let mut state = state_for_close.borrow_mut();
|
|
||||||
// Set intentional close flag BEFORE closing
|
|
||||||
state.is_intentional_close = true;
|
|
||||||
// Cancel heartbeat
|
|
||||||
state.heartbeat_handle = None;
|
|
||||||
// Get the WebSocket (if any) and close it
|
|
||||||
if let Some(ws) = state.ws.as_ref() {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("[WS] Closing with code={}, reason={}", code, reason).into(),
|
|
||||||
);
|
|
||||||
let _ = ws.close_with_code_and_reason(code, &reason);
|
|
||||||
}
|
|
||||||
})));
|
|
||||||
|
|
||||||
(Signal::derive(move || ws_state.get()), sender, closer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a message received from the server.
|
/// Handle a message received from the server.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
fn handle_server_message(
|
fn handle_server_message(
|
||||||
msg: ServerMessage,
|
msg: ServerMessage,
|
||||||
state: &std::rc::Rc<std::cell::RefCell<WsInternalState>>,
|
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
||||||
on_event: &Callback<WsEvent>,
|
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
||||||
|
on_chat_message: &Callback<ChatMessage>,
|
||||||
|
on_loose_props_sync: &Callback<Vec<LooseProp>>,
|
||||||
|
on_prop_dropped: &Callback<LooseProp>,
|
||||||
|
on_prop_picked_up: &Callback<uuid::Uuid>,
|
||||||
|
on_member_fading: &Callback<FadingMember>,
|
||||||
|
on_error: &Option<Callback<WsError>>,
|
||||||
|
on_teleport_approved: &Option<Callback<TeleportInfo>>,
|
||||||
|
on_summoned: &Option<Callback<SummonInfo>>,
|
||||||
|
on_mod_command_result: &Option<Callback<ModCommandResultInfo>>,
|
||||||
|
on_member_identity_updated: &Option<Callback<MemberIdentityInfo>>,
|
||||||
|
current_user_id: &std::rc::Rc<std::cell::RefCell<Option<uuid::Uuid>>>,
|
||||||
) {
|
) {
|
||||||
// Process message and collect any events to emit AFTER releasing the borrow
|
let mut members_vec = members.borrow_mut();
|
||||||
enum PostAction {
|
|
||||||
None,
|
|
||||||
UpdateMembers(Vec<ChannelMemberWithAvatar>),
|
|
||||||
UpdateMembersAndFade(Vec<ChannelMemberWithAvatar>, FadingMember),
|
|
||||||
UpdateMembersAndIdentity(Vec<ChannelMemberWithAvatar>, MemberIdentityInfo),
|
|
||||||
ChatMessage(ChatMessage),
|
|
||||||
LoosePropsSync(Vec<LooseProp>),
|
|
||||||
PropDropped(LooseProp),
|
|
||||||
PropPickedUp(uuid::Uuid),
|
|
||||||
Error(WsError),
|
|
||||||
TeleportApproved(TeleportInfo),
|
|
||||||
Summoned(SummonInfo),
|
|
||||||
ModCommandResult(ModCommandResultInfo),
|
|
||||||
}
|
|
||||||
|
|
||||||
let action = {
|
match msg {
|
||||||
let mut state = state.borrow_mut();
|
ServerMessage::Welcome {
|
||||||
let own_user_id = state.current_user_id;
|
member: _,
|
||||||
|
members: initial_members,
|
||||||
match msg {
|
config: _, // Config is handled in the caller for heartbeat setup
|
||||||
ServerMessage::Welcome {
|
} => {
|
||||||
member: _,
|
*members_vec = initial_members;
|
||||||
members: initial_members,
|
on_update.run(members_vec.clone());
|
||||||
config: _, // Config is handled in the caller for heartbeat setup
|
}
|
||||||
} => {
|
ServerMessage::MemberJoined { member } => {
|
||||||
state.members = initial_members;
|
// Remove if exists (rejoin case), then add
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
members_vec.retain(|m| {
|
||||||
|
m.member.user_id != member.member.user_id
|
||||||
|
|| m.member.guest_session_id != member.member.guest_session_id
|
||||||
|
});
|
||||||
|
members_vec.push(member);
|
||||||
|
on_update.run(members_vec.clone());
|
||||||
|
}
|
||||||
|
ServerMessage::MemberLeft {
|
||||||
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
// Check if this is our own MemberLeft due to timeout - ignore it during reconnection
|
||||||
|
// so we don't see our own avatar fade out
|
||||||
|
let own_user_id = *current_user_id.borrow();
|
||||||
|
let is_self = own_user_id.is_some() && user_id == own_user_id;
|
||||||
|
if is_self && reason == DisconnectReason::Timeout {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"[WS] Ignoring self MemberLeft during reconnection".into(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
ServerMessage::MemberJoined { member } => {
|
|
||||||
// Remove if exists (rejoin case), then add
|
|
||||||
state.members.retain(|m| m.member.user_id != member.member.user_id);
|
|
||||||
state.members.push(member);
|
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
|
||||||
}
|
|
||||||
ServerMessage::MemberLeft { user_id, reason } => {
|
|
||||||
// Check if this is our own MemberLeft due to timeout - ignore it during reconnection
|
|
||||||
// so we don't see our own avatar fade out
|
|
||||||
let is_self = own_user_id.is_some_and(|id| user_id == id);
|
|
||||||
if is_self && reason == DisconnectReason::Timeout {
|
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&"[WS] Ignoring self MemberLeft during reconnection".into(),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the member before removing
|
// Find the member before removing
|
||||||
let leaving_member = state.members
|
let leaving_member = members_vec
|
||||||
.iter()
|
.iter()
|
||||||
.find(|m| m.member.user_id == user_id)
|
.find(|m| {
|
||||||
.cloned();
|
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
|
||||||
// Always remove from active members list
|
// Always remove from active members list
|
||||||
state.members.retain(|m| m.member.user_id != user_id);
|
members_vec.retain(|m| {
|
||||||
let updated = state.members.clone();
|
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
|
||||||
|
});
|
||||||
|
on_update.run(members_vec.clone());
|
||||||
|
|
||||||
// For timeout disconnects, trigger fading animation
|
// For timeout disconnects, trigger fading animation
|
||||||
if reason == DisconnectReason::Timeout {
|
if reason == DisconnectReason::Timeout {
|
||||||
if let Some(member) = leaving_member {
|
if let Some(member) = leaving_member {
|
||||||
let fading = FadingMember {
|
let fading = FadingMember {
|
||||||
member,
|
member,
|
||||||
fade_start: js_sys::Date::now() as i64,
|
fade_start: js_sys::Date::now() as i64,
|
||||||
fade_duration: FADE_DURATION_MS,
|
fade_duration: FADE_DURATION_MS,
|
||||||
};
|
};
|
||||||
PostAction::UpdateMembersAndFade(updated, fading)
|
on_member_fading.run(fading);
|
||||||
} else {
|
|
||||||
PostAction::UpdateMembers(updated)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PostAction::UpdateMembers(updated)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ServerMessage::PositionUpdated { user_id, x, y } => {
|
}
|
||||||
if let Some(m) = state.members
|
ServerMessage::PositionUpdated {
|
||||||
.iter_mut()
|
user_id,
|
||||||
.find(|m| m.member.user_id == user_id)
|
guest_session_id,
|
||||||
{
|
x,
|
||||||
m.member.position_x = x;
|
y,
|
||||||
m.member.position_y = y;
|
} => {
|
||||||
}
|
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||||
|
}) {
|
||||||
|
m.member.position_x = x;
|
||||||
|
m.member.position_y = y;
|
||||||
}
|
}
|
||||||
ServerMessage::EmotionUpdated {
|
on_update.run(members_vec.clone());
|
||||||
user_id,
|
}
|
||||||
emotion,
|
ServerMessage::EmotionUpdated {
|
||||||
emotion_layer,
|
user_id,
|
||||||
} => {
|
guest_session_id,
|
||||||
if let Some(m) = state.members
|
emotion,
|
||||||
.iter_mut()
|
emotion_layer,
|
||||||
.find(|m| m.member.user_id == user_id)
|
} => {
|
||||||
{
|
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||||
// Parse emotion name to EmotionState
|
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||||
m.member.current_emotion = emotion
|
}) {
|
||||||
.parse::<EmotionState>()
|
// Convert emotion name to index for internal state
|
||||||
.unwrap_or_default();
|
m.member.current_emotion = emotion
|
||||||
m.avatar.emotion_layer = emotion_layer;
|
.parse::<EmotionState>()
|
||||||
}
|
.map(|e| e.to_index() as i16)
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
.unwrap_or(0);
|
||||||
|
m.avatar.emotion_layer = emotion_layer;
|
||||||
}
|
}
|
||||||
ServerMessage::Pong => {
|
on_update.run(members_vec.clone());
|
||||||
// Heartbeat acknowledged - nothing to do
|
}
|
||||||
PostAction::None
|
ServerMessage::Pong => {
|
||||||
|
// Heartbeat acknowledged - nothing to do
|
||||||
|
}
|
||||||
|
ServerMessage::Error { code, message } => {
|
||||||
|
// Always log errors to console (not just debug mode)
|
||||||
|
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
||||||
|
// Call error callback if provided
|
||||||
|
if let Some(callback) = on_error {
|
||||||
|
callback.run(WsError { code, message });
|
||||||
}
|
}
|
||||||
ServerMessage::Error { code, message } => {
|
}
|
||||||
// Always log errors to console (not just debug mode)
|
ServerMessage::ChatMessageReceived {
|
||||||
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
message_id,
|
||||||
PostAction::Error(WsError { code, message })
|
user_id,
|
||||||
}
|
guest_session_id,
|
||||||
ServerMessage::ChatMessageReceived {
|
display_name,
|
||||||
|
content,
|
||||||
|
emotion,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
timestamp,
|
||||||
|
is_whisper,
|
||||||
|
is_same_scene,
|
||||||
|
} => {
|
||||||
|
let chat_msg = ChatMessage {
|
||||||
message_id,
|
message_id,
|
||||||
user_id,
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
display_name,
|
display_name,
|
||||||
content,
|
content,
|
||||||
emotion,
|
emotion,
|
||||||
|
|
@ -626,161 +578,90 @@ fn handle_server_message(
|
||||||
timestamp,
|
timestamp,
|
||||||
is_whisper,
|
is_whisper,
|
||||||
is_same_scene,
|
is_same_scene,
|
||||||
} => {
|
is_system: false,
|
||||||
PostAction::ChatMessage(ChatMessage {
|
};
|
||||||
message_id,
|
on_chat_message.run(chat_msg);
|
||||||
user_id,
|
}
|
||||||
display_name,
|
ServerMessage::LoosePropsSync { props } => {
|
||||||
content,
|
on_loose_props_sync.run(props);
|
||||||
emotion,
|
}
|
||||||
x,
|
ServerMessage::PropDropped { prop } => {
|
||||||
y,
|
on_prop_dropped.run(prop);
|
||||||
timestamp,
|
}
|
||||||
is_whisper,
|
ServerMessage::PropPickedUp { prop_id, .. } => {
|
||||||
is_same_scene,
|
on_prop_picked_up.run(prop_id);
|
||||||
is_system: false,
|
}
|
||||||
})
|
ServerMessage::PropExpired { prop_id } => {
|
||||||
|
// Treat expired props the same as picked up (remove from display)
|
||||||
|
on_prop_picked_up.run(prop_id);
|
||||||
|
}
|
||||||
|
ServerMessage::AvatarUpdated {
|
||||||
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
|
avatar,
|
||||||
|
} => {
|
||||||
|
// Find member and update their avatar layers
|
||||||
|
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||||
|
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||||
|
}) {
|
||||||
|
m.avatar.skin_layer = avatar.skin_layer.clone();
|
||||||
|
m.avatar.clothes_layer = avatar.clothes_layer.clone();
|
||||||
|
m.avatar.accessories_layer = avatar.accessories_layer.clone();
|
||||||
|
m.avatar.emotion_layer = avatar.emotion_layer.clone();
|
||||||
}
|
}
|
||||||
ServerMessage::LoosePropsSync { props } => {
|
on_update.run(members_vec.clone());
|
||||||
PostAction::LoosePropsSync(props)
|
}
|
||||||
}
|
ServerMessage::TeleportApproved {
|
||||||
ServerMessage::PropDropped { prop } => {
|
scene_id,
|
||||||
PostAction::PropDropped(prop)
|
scene_slug,
|
||||||
}
|
} => {
|
||||||
ServerMessage::PropPickedUp { prop_id, .. } => {
|
if let Some(callback) = on_teleport_approved {
|
||||||
PostAction::PropPickedUp(prop_id)
|
callback.run(TeleportInfo {
|
||||||
}
|
|
||||||
ServerMessage::PropExpired { prop_id } => {
|
|
||||||
// Treat expired props the same as picked up (remove from display)
|
|
||||||
PostAction::PropPickedUp(prop_id)
|
|
||||||
}
|
|
||||||
ServerMessage::AvatarUpdated { user_id, avatar } => {
|
|
||||||
// Find member and update their avatar layers
|
|
||||||
if let Some(m) = state.members
|
|
||||||
.iter_mut()
|
|
||||||
.find(|m| m.member.user_id == user_id)
|
|
||||||
{
|
|
||||||
m.avatar.skin_layer = avatar.skin_layer.clone();
|
|
||||||
m.avatar.clothes_layer = avatar.clothes_layer.clone();
|
|
||||||
m.avatar.accessories_layer = avatar.accessories_layer.clone();
|
|
||||||
m.avatar.emotion_layer = avatar.emotion_layer.clone();
|
|
||||||
}
|
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
|
||||||
}
|
|
||||||
ServerMessage::TeleportApproved {
|
|
||||||
scene_id,
|
|
||||||
scene_slug,
|
|
||||||
} => {
|
|
||||||
PostAction::TeleportApproved(TeleportInfo {
|
|
||||||
scene_id,
|
scene_id,
|
||||||
scene_slug,
|
scene_slug,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
ServerMessage::Summoned {
|
}
|
||||||
scene_id,
|
ServerMessage::Summoned {
|
||||||
scene_slug,
|
scene_id,
|
||||||
summoned_by,
|
scene_slug,
|
||||||
} => {
|
summoned_by,
|
||||||
PostAction::Summoned(SummonInfo {
|
} => {
|
||||||
|
if let Some(callback) = on_summoned {
|
||||||
|
callback.run(SummonInfo {
|
||||||
scene_id,
|
scene_id,
|
||||||
scene_slug,
|
scene_slug,
|
||||||
summoned_by,
|
summoned_by,
|
||||||
})
|
});
|
||||||
}
|
|
||||||
ServerMessage::ModCommandResult { success, message } => {
|
|
||||||
PostAction::ModCommandResult(ModCommandResultInfo { success, message })
|
|
||||||
}
|
|
||||||
ServerMessage::MemberIdentityUpdated {
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
is_guest,
|
|
||||||
} => {
|
|
||||||
// Update the internal members list so subsequent updates don't overwrite
|
|
||||||
if let Some(member) = state.members
|
|
||||||
.iter_mut()
|
|
||||||
.find(|m| m.member.user_id == user_id)
|
|
||||||
{
|
|
||||||
member.member.display_name = display_name.clone();
|
|
||||||
member.member.is_guest = is_guest;
|
|
||||||
}
|
|
||||||
PostAction::UpdateMembersAndIdentity(
|
|
||||||
state.members.clone(),
|
|
||||||
MemberIdentityInfo {
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
is_guest,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ServerMessage::AvatarForced {
|
|
||||||
user_id,
|
|
||||||
avatar,
|
|
||||||
reason: _,
|
|
||||||
forced_by: _,
|
|
||||||
} => {
|
|
||||||
// Update the forced user's avatar
|
|
||||||
if let Some(m) = state.members.iter_mut().find(|m| m.member.user_id == user_id) {
|
|
||||||
m.avatar.skin_layer = avatar.skin_layer.clone();
|
|
||||||
m.avatar.clothes_layer = avatar.clothes_layer.clone();
|
|
||||||
m.avatar.accessories_layer = avatar.accessories_layer.clone();
|
|
||||||
m.avatar.emotion_layer = avatar.emotion_layer.clone();
|
|
||||||
}
|
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
|
||||||
}
|
|
||||||
ServerMessage::AvatarCleared {
|
|
||||||
user_id,
|
|
||||||
avatar,
|
|
||||||
cleared_by: _,
|
|
||||||
} => {
|
|
||||||
// Restore the user's original avatar
|
|
||||||
if let Some(m) = state.members.iter_mut().find(|m| m.member.user_id == user_id) {
|
|
||||||
m.avatar.skin_layer = avatar.skin_layer.clone();
|
|
||||||
m.avatar.clothes_layer = avatar.clothes_layer.clone();
|
|
||||||
m.avatar.accessories_layer = avatar.accessories_layer.clone();
|
|
||||||
m.avatar.emotion_layer = avatar.emotion_layer.clone();
|
|
||||||
}
|
|
||||||
PostAction::UpdateMembers(state.members.clone())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}; // state borrow is dropped here
|
ServerMessage::ModCommandResult { success, message } => {
|
||||||
|
if let Some(callback) = on_mod_command_result {
|
||||||
|
callback.run(ModCommandResultInfo { success, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ServerMessage::MemberIdentityUpdated {
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
is_guest,
|
||||||
|
} => {
|
||||||
|
// Update the internal members list so subsequent updates don't overwrite
|
||||||
|
if let Some(member) = members_vec
|
||||||
|
.iter_mut()
|
||||||
|
.find(|m| m.member.user_id == Some(user_id))
|
||||||
|
{
|
||||||
|
member.member.display_name = display_name.clone();
|
||||||
|
member.member.is_guest = is_guest;
|
||||||
|
}
|
||||||
|
on_update.run(members_vec.clone());
|
||||||
|
|
||||||
// Now emit events without holding any borrows
|
if let Some(callback) = on_member_identity_updated {
|
||||||
match action {
|
callback.run(MemberIdentityInfo {
|
||||||
PostAction::None => {}
|
user_id,
|
||||||
PostAction::UpdateMembers(members) => {
|
display_name,
|
||||||
on_event.run(WsEvent::MembersUpdated(members));
|
is_guest,
|
||||||
}
|
});
|
||||||
PostAction::UpdateMembersAndFade(members, fading) => {
|
}
|
||||||
on_event.run(WsEvent::MembersUpdated(members));
|
|
||||||
on_event.run(WsEvent::MemberFading(fading));
|
|
||||||
}
|
|
||||||
PostAction::UpdateMembersAndIdentity(members, info) => {
|
|
||||||
on_event.run(WsEvent::MembersUpdated(members));
|
|
||||||
on_event.run(WsEvent::MemberIdentityUpdated(info));
|
|
||||||
}
|
|
||||||
PostAction::ChatMessage(msg) => {
|
|
||||||
on_event.run(WsEvent::ChatMessage(msg));
|
|
||||||
}
|
|
||||||
PostAction::LoosePropsSync(props) => {
|
|
||||||
on_event.run(WsEvent::LoosePropsSync(props));
|
|
||||||
}
|
|
||||||
PostAction::PropDropped(prop) => {
|
|
||||||
on_event.run(WsEvent::PropDropped(prop));
|
|
||||||
}
|
|
||||||
PostAction::PropPickedUp(prop_id) => {
|
|
||||||
on_event.run(WsEvent::PropPickedUp(prop_id));
|
|
||||||
}
|
|
||||||
PostAction::Error(err) => {
|
|
||||||
on_event.run(WsEvent::Error(err));
|
|
||||||
}
|
|
||||||
PostAction::TeleportApproved(info) => {
|
|
||||||
on_event.run(WsEvent::TeleportApproved(info));
|
|
||||||
}
|
|
||||||
PostAction::Summoned(info) => {
|
|
||||||
on_event.run(WsEvent::Summoned(info));
|
|
||||||
}
|
|
||||||
PostAction::ModCommandResult(info) => {
|
|
||||||
on_event.run(WsEvent::ModCommandResult(info));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -791,10 +672,20 @@ pub fn use_channel_websocket(
|
||||||
_realm_slug: Signal<String>,
|
_realm_slug: Signal<String>,
|
||||||
_channel_id: Signal<Option<uuid::Uuid>>,
|
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||||
_reconnect_trigger: RwSignal<u32>,
|
_reconnect_trigger: RwSignal<u32>,
|
||||||
_on_event: Callback<WsEvent>,
|
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||||
) -> (Signal<WsState>, WsSenderStorage, WsCloserStorage) {
|
_on_chat_message: Callback<ChatMessage>,
|
||||||
|
_on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||||
|
_on_prop_dropped: Callback<LooseProp>,
|
||||||
|
_on_prop_picked_up: Callback<uuid::Uuid>,
|
||||||
|
_on_member_fading: Callback<FadingMember>,
|
||||||
|
_on_welcome: Option<Callback<ChannelMemberInfo>>,
|
||||||
|
_on_error: Option<Callback<WsError>>,
|
||||||
|
_on_teleport_approved: Option<Callback<TeleportInfo>>,
|
||||||
|
_on_summoned: Option<Callback<SummonInfo>>,
|
||||||
|
_on_mod_command_result: Option<Callback<ModCommandResultInfo>>,
|
||||||
|
_on_member_identity_updated: Option<Callback<MemberIdentityInfo>>,
|
||||||
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
let (ws_state, _) = signal(WsState::Disconnected);
|
let (ws_state, _) = signal(WsState::Disconnected);
|
||||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||||
let closer: WsCloserStorage = StoredValue::new_local(None);
|
(Signal::derive(move || ws_state.get()), sender)
|
||||||
(Signal::derive(move || ws_state.get()), sender, closer)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,15 @@ use leptos_router::hooks::use_params_map;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ActiveBubble, AvatarEditorPopup, AvatarStorePopup, Card, ChatInput, ConversationModal,
|
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||||
EmotionKeybindings, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup,
|
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||||||
MessageLog, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer,
|
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
||||||
ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
|
RegisterModal, SettingsPopup, ViewerSettings,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, WsEvent,
|
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS,
|
||||||
|
MemberIdentityInfo, ModCommandResultInfo, SummonInfo, TeleportInfo, WsError,
|
||||||
add_whisper_to_history, use_channel_websocket,
|
add_whisper_to_history, use_channel_websocket,
|
||||||
};
|
};
|
||||||
use crate::utils::LocalStoragePersist;
|
use crate::utils::LocalStoragePersist;
|
||||||
|
|
@ -30,7 +31,7 @@ use chattyness_db::models::{
|
||||||
RealmWithUserRole, Scene, SceneSummary,
|
RealmWithUserRole, Scene, SceneSummary,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::{close_codes, ClientMessage};
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
#[cfg(not(feature = "hydrate"))]
|
#[cfg(not(feature = "hydrate"))]
|
||||||
use crate::components::ws_client::WsSender;
|
use crate::components::ws_client::WsSender;
|
||||||
|
|
@ -64,8 +65,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Chat message state - use StoredValue for WASM compatibility (single-threaded)
|
// Chat message state - use StoredValue for WASM compatibility (single-threaded)
|
||||||
let message_log: StoredValue<MessageLog, LocalStorage> =
|
let message_log: StoredValue<MessageLog, LocalStorage> =
|
||||||
StoredValue::new_local(MessageLog::new());
|
StoredValue::new_local(MessageLog::new());
|
||||||
// Bubble key is now just user_id since guests are regular users with the 'guest' tag
|
let (active_bubbles, set_active_bubbles) =
|
||||||
let (active_bubbles, set_active_bubbles) = signal(HashMap::<Uuid, ActiveBubble>::new());
|
signal(HashMap::<(Option<Uuid>, Option<Uuid>), ActiveBubble>::new());
|
||||||
|
|
||||||
// Inventory popup state
|
// Inventory popup state
|
||||||
let (inventory_open, set_inventory_open) = signal(false);
|
let (inventory_open, set_inventory_open) = signal(false);
|
||||||
|
|
@ -89,9 +90,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
// Store full avatar data for the editor
|
// Store full avatar data for the editor
|
||||||
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
|
let (full_avatar, set_full_avatar) = signal(Option::<AvatarWithPaths>::None);
|
||||||
|
|
||||||
// Avatar store popup state
|
|
||||||
let (avatar_store_open, set_avatar_store_open) = signal(false);
|
|
||||||
|
|
||||||
// Register modal state (for guest-to-user conversion)
|
// Register modal state (for guest-to-user conversion)
|
||||||
let (register_modal_open, set_register_modal_open) = signal(false);
|
let (register_modal_open, set_register_modal_open) = signal(false);
|
||||||
|
|
||||||
|
|
@ -111,8 +109,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64));
|
let (current_position, set_current_position) = signal((400.0_f64, 300.0_f64));
|
||||||
|
|
||||||
// Current user identity (received from WebSocket Welcome message)
|
// Current user identity (received from WebSocket Welcome message)
|
||||||
// Note: Guests are now regular users with the 'guest' tag, so everyone has a user_id
|
|
||||||
let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None);
|
let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None);
|
||||||
|
let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None);
|
||||||
// Whether the current user is a guest (has the 'guest' tag)
|
// Whether the current user is a guest (has the 'guest' tag)
|
||||||
let (is_guest, set_is_guest) = signal(false);
|
let (is_guest, set_is_guest) = signal(false);
|
||||||
|
|
||||||
|
|
@ -237,254 +235,365 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to navigate to a new scene (used by teleport and summon)
|
// WebSocket connection for real-time updates
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let navigate_to_scene = {
|
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
||||||
let slug = slug.clone();
|
// When members are updated (including rejoins), remove any matching fading members
|
||||||
move |scene_id: Uuid, scene_slug: String| {
|
set_fading_members.update(|fading| {
|
||||||
let realm_slug = slug.get_untracked();
|
fading.retain(|f| {
|
||||||
let scene_slug_for_url = scene_slug.clone();
|
!new_members.iter().any(|m| {
|
||||||
let realm_slug_for_url = realm_slug.clone();
|
m.member.user_id == f.member.member.user_id
|
||||||
|
&& m.member.guest_session_id == f.member.member.guest_session_id
|
||||||
spawn_local(async move {
|
})
|
||||||
use gloo_net::http::Request;
|
|
||||||
let response = Request::get(&format!(
|
|
||||||
"/api/realms/{}/scenes/{}",
|
|
||||||
realm_slug, scene_slug
|
|
||||||
))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(resp) = response {
|
|
||||||
if resp.ok() {
|
|
||||||
if let Ok(scene) = resp.json::<Scene>().await {
|
|
||||||
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
|
||||||
set_scene_dimensions.set((w as f64, h as f64));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
if let Ok(history) = window.history() {
|
|
||||||
let new_url = if scene.is_entry_point {
|
|
||||||
format!("/realms/{}", realm_slug_for_url)
|
|
||||||
} else {
|
|
||||||
format!("/realms/{}/scenes/{}", realm_slug_for_url, scene_slug_for_url)
|
|
||||||
};
|
|
||||||
let _ = history.replace_state_with_url(
|
|
||||||
&wasm_bindgen::JsValue::NULL,
|
|
||||||
"",
|
|
||||||
Some(&new_url),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set_current_scene.set(Some(scene));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set_channel_id.set(Some(scene_id));
|
|
||||||
set_members.set(Vec::new());
|
|
||||||
reconnect_trigger.update(|t| *t += 1);
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
};
|
set_members.set(new_members);
|
||||||
|
});
|
||||||
|
|
||||||
// Consolidated WebSocket event handler
|
// Chat message callback
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let navigate_to_scene_for_event = navigate_to_scene.clone();
|
let on_chat_message = Callback::new(move |msg: ChatMessage| {
|
||||||
#[cfg(feature = "hydrate")]
|
// Add to message log
|
||||||
let on_ws_event = Callback::new(move |event: WsEvent| {
|
message_log.update_value(|log| log.push(msg.clone()));
|
||||||
match event {
|
|
||||||
WsEvent::MembersUpdated(new_members) => {
|
|
||||||
// When members are updated (including rejoins), remove any matching fading members
|
|
||||||
set_fading_members.update(|fading| {
|
|
||||||
fading.retain(|f| {
|
|
||||||
!new_members.iter().any(|m| {
|
|
||||||
m.member.user_id == f.member.member.user_id
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
set_members.set(new_members);
|
|
||||||
}
|
|
||||||
WsEvent::ChatMessage(msg) => {
|
|
||||||
// Add to message log
|
|
||||||
message_log.update_value(|log| log.push(msg.clone()));
|
|
||||||
|
|
||||||
// Handle whispers
|
// Handle whispers
|
||||||
if msg.is_whisper {
|
if msg.is_whisper {
|
||||||
// Track whisper for conversation view
|
// Track whisper for conversation view
|
||||||
whisper_messages.update_value(|msgs| {
|
whisper_messages.update_value(|msgs| {
|
||||||
msgs.push(msg.clone());
|
msgs.push(msg.clone());
|
||||||
// Keep last 100 whisper messages
|
// Keep last 100 whisper messages
|
||||||
if msgs.len() > 100 {
|
if msgs.len() > 100 {
|
||||||
msgs.remove(0);
|
msgs.remove(0);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to persistent whisper history in LocalStorage
|
|
||||||
add_whisper_to_history(msg.clone());
|
|
||||||
|
|
||||||
if msg.is_same_scene {
|
|
||||||
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
|
||||||
let key = msg.user_id;
|
|
||||||
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
|
||||||
set_active_bubbles.update(|bubbles| {
|
|
||||||
bubbles.insert(
|
|
||||||
key,
|
|
||||||
ActiveBubble {
|
|
||||||
message: msg,
|
|
||||||
expires_at,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Cross-scene whisper: show as notification toast
|
|
||||||
set_current_notification.set(Some(NotificationMessage::from_chat_message(msg)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular broadcast: show as bubble
|
|
||||||
let key = msg.user_id;
|
|
||||||
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
|
||||||
set_active_bubbles.update(|bubbles| {
|
|
||||||
bubbles.insert(
|
|
||||||
key,
|
|
||||||
ActiveBubble {
|
|
||||||
message: msg,
|
|
||||||
expires_at,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
WsEvent::LoosePropsSync(props) => {
|
|
||||||
set_loose_props.set(props);
|
|
||||||
}
|
|
||||||
WsEvent::PropDropped(prop) => {
|
|
||||||
set_loose_props.update(|props| {
|
|
||||||
props.push(prop);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
WsEvent::PropPickedUp(prop_id) => {
|
|
||||||
set_loose_props.update(|props| {
|
|
||||||
props.retain(|p| p.id != prop_id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
WsEvent::MemberFading(fading) => {
|
|
||||||
set_fading_members.update(|members| {
|
|
||||||
// Remove any existing entry for this user (shouldn't happen, but be safe)
|
|
||||||
members.retain(|m| {
|
|
||||||
m.member.member.user_id != fading.member.member.user_id
|
|
||||||
});
|
|
||||||
members.push(fading);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
WsEvent::Welcome(info) => {
|
|
||||||
set_current_user_id.set(Some(info.user_id));
|
|
||||||
set_current_display_name.set(info.display_name.clone());
|
|
||||||
set_is_guest.set(info.is_guest);
|
|
||||||
}
|
|
||||||
WsEvent::Error(error) => {
|
|
||||||
// Display user-friendly error message
|
|
||||||
let msg = match error.code.as_str() {
|
|
||||||
"WHISPER_TARGET_NOT_FOUND" => error.message,
|
|
||||||
"TELEPORT_DISABLED" => error.message,
|
|
||||||
"SCENE_NOT_FOUND" => error.message,
|
|
||||||
_ => format!("Error: {}", error.message),
|
|
||||||
};
|
|
||||||
set_error_message.set(Some(msg));
|
|
||||||
// Auto-dismiss after 5 seconds
|
|
||||||
gloo_timers::callback::Timeout::new(5000, move || {
|
|
||||||
set_error_message.set(None);
|
|
||||||
})
|
|
||||||
.forget();
|
|
||||||
}
|
|
||||||
WsEvent::TeleportApproved(info) => {
|
|
||||||
let teleport_msg = ChatMessage {
|
|
||||||
message_id: Uuid::new_v4(),
|
|
||||||
user_id: Uuid::nil(),
|
|
||||||
display_name: "[SYSTEM]".to_string(),
|
|
||||||
content: format!("Teleported to scene: {}", info.scene_slug),
|
|
||||||
emotion: "neutral".to_string(),
|
|
||||||
x: 0.0,
|
|
||||||
y: 0.0,
|
|
||||||
timestamp: js_sys::Date::now() as i64,
|
|
||||||
is_whisper: false,
|
|
||||||
is_same_scene: true,
|
|
||||||
is_system: true,
|
|
||||||
};
|
|
||||||
message_log.update_value(|log| log.push(teleport_msg));
|
|
||||||
navigate_to_scene_for_event(info.scene_id, info.scene_slug);
|
|
||||||
}
|
|
||||||
WsEvent::Summoned(info) => {
|
|
||||||
let summon_msg = ChatMessage {
|
|
||||||
message_id: Uuid::new_v4(),
|
|
||||||
user_id: Uuid::nil(),
|
|
||||||
display_name: "[MOD]".to_string(),
|
|
||||||
content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug),
|
|
||||||
emotion: "neutral".to_string(),
|
|
||||||
x: 0.0,
|
|
||||||
y: 0.0,
|
|
||||||
timestamp: js_sys::Date::now() as i64,
|
|
||||||
is_whisper: false,
|
|
||||||
is_same_scene: true,
|
|
||||||
is_system: true,
|
|
||||||
};
|
|
||||||
message_log.update_value(|log| log.push(summon_msg));
|
|
||||||
|
|
||||||
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
// Add to persistent whisper history in LocalStorage
|
||||||
gloo_timers::callback::Timeout::new(3000, move || {
|
add_whisper_to_history(msg.clone());
|
||||||
set_mod_notification.set(None);
|
|
||||||
})
|
|
||||||
.forget();
|
|
||||||
|
|
||||||
navigate_to_scene_for_event(info.scene_id, info.scene_slug);
|
if msg.is_same_scene {
|
||||||
}
|
// Same scene whisper: show as italic bubble (handled by bubble rendering)
|
||||||
WsEvent::ModCommandResult(info) => {
|
let key = (msg.user_id, msg.guest_session_id);
|
||||||
// Log mod command result to message log
|
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
||||||
let status = if info.success { "OK" } else { "FAILED" };
|
set_active_bubbles.update(|bubbles| {
|
||||||
let mod_msg = ChatMessage {
|
bubbles.insert(
|
||||||
message_id: Uuid::new_v4(),
|
key,
|
||||||
user_id: Uuid::nil(), // System/mod message
|
ActiveBubble {
|
||||||
display_name: "[MOD]".to_string(),
|
message: msg,
|
||||||
content: format!("[{}] {}", status, info.message),
|
expires_at,
|
||||||
emotion: "neutral".to_string(),
|
},
|
||||||
x: 0.0,
|
);
|
||||||
y: 0.0,
|
|
||||||
timestamp: js_sys::Date::now() as i64,
|
|
||||||
is_whisper: false,
|
|
||||||
is_same_scene: true,
|
|
||||||
is_system: true,
|
|
||||||
};
|
|
||||||
message_log.update_value(|log| log.push(mod_msg));
|
|
||||||
|
|
||||||
set_mod_notification.set(Some((info.success, info.message)));
|
|
||||||
|
|
||||||
// Auto-dismiss notification after 3 seconds
|
|
||||||
gloo_timers::callback::Timeout::new(3000, move || {
|
|
||||||
set_mod_notification.set(None);
|
|
||||||
})
|
|
||||||
.forget();
|
|
||||||
}
|
|
||||||
WsEvent::MemberIdentityUpdated(info) => {
|
|
||||||
// Update the member's display name in the members list
|
|
||||||
set_members.update(|members| {
|
|
||||||
if let Some(member) = members
|
|
||||||
.iter_mut()
|
|
||||||
.find(|m| m.member.user_id == info.user_id)
|
|
||||||
{
|
|
||||||
member.member.display_name = info.display_name.clone();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Cross-scene whisper: show as notification toast
|
||||||
|
set_current_notification.set(Some(NotificationMessage::from_chat_message(msg)));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Regular broadcast: show as bubble
|
||||||
|
let key = (msg.user_id, msg.guest_session_id);
|
||||||
|
let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS;
|
||||||
|
set_active_bubbles.update(|bubbles| {
|
||||||
|
bubbles.insert(
|
||||||
|
key,
|
||||||
|
ActiveBubble {
|
||||||
|
message: msg,
|
||||||
|
expires_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Loose props callbacks
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let (ws_state, ws_sender, ws_close) = use_channel_websocket(
|
let on_loose_props_sync = Callback::new(move |props: Vec<LooseProp>| {
|
||||||
|
set_loose_props.set(props);
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_prop_dropped = Callback::new(move |prop: LooseProp| {
|
||||||
|
set_loose_props.update(|props| {
|
||||||
|
props.push(prop);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_prop_picked_up = Callback::new(move |prop_id: Uuid| {
|
||||||
|
set_loose_props.update(|props| {
|
||||||
|
props.retain(|p| p.id != prop_id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback when a member starts fading (timeout disconnect)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_member_fading = Callback::new(move |fading: FadingMember| {
|
||||||
|
set_fading_members.update(|members| {
|
||||||
|
// Remove any existing entry for this user (shouldn't happen, but be safe)
|
||||||
|
members.retain(|m| {
|
||||||
|
m.member.member.user_id != fading.member.member.user_id
|
||||||
|
|| m.member.member.guest_session_id != fading.member.member.guest_session_id
|
||||||
|
});
|
||||||
|
members.push(fading);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback to capture current user identity from Welcome message
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_welcome = Callback::new(move |info: ChannelMemberInfo| {
|
||||||
|
set_current_user_id.set(info.user_id);
|
||||||
|
set_current_guest_session_id.set(info.guest_session_id);
|
||||||
|
set_current_display_name.set(info.display_name.clone());
|
||||||
|
set_is_guest.set(info.is_guest);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for WebSocket errors (whisper failures, etc.)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_ws_error = Callback::new(move |error: WsError| {
|
||||||
|
// Display user-friendly error message
|
||||||
|
let msg = match error.code.as_str() {
|
||||||
|
"WHISPER_TARGET_NOT_FOUND" => error.message,
|
||||||
|
"TELEPORT_DISABLED" => error.message,
|
||||||
|
"SCENE_NOT_FOUND" => error.message,
|
||||||
|
_ => format!("Error: {}", error.message),
|
||||||
|
};
|
||||||
|
set_error_message.set(Some(msg));
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
use gloo_timers::callback::Timeout;
|
||||||
|
Timeout::new(5000, move || {
|
||||||
|
set_error_message.set(None);
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for teleport approval - navigate to new scene
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
|
||||||
|
// Log teleport to message log
|
||||||
|
let teleport_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[SYSTEM]".to_string(),
|
||||||
|
content: format!("Teleported to scene: {}", info.scene_slug),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(teleport_msg));
|
||||||
|
|
||||||
|
let scene_id = info.scene_id;
|
||||||
|
let scene_slug = info.scene_slug.clone();
|
||||||
|
let realm_slug = slug.get_untracked();
|
||||||
|
|
||||||
|
// Fetch the new scene data to update the canvas background
|
||||||
|
let scene_slug_for_url = scene_slug.clone();
|
||||||
|
let realm_slug_for_url = realm_slug.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
let response = Request::get(&format!(
|
||||||
|
"/api/realms/{}/scenes/{}",
|
||||||
|
realm_slug, scene_slug
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(resp) = response {
|
||||||
|
if resp.ok() {
|
||||||
|
if let Ok(scene) = resp.json::<Scene>().await {
|
||||||
|
// Update scene dimensions from the new scene
|
||||||
|
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||||||
|
set_scene_dimensions.set((w as f64, h as f64));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL to reflect new scene
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(history) = window.history() {
|
||||||
|
let new_url = if scene.is_entry_point {
|
||||||
|
format!("/realms/{}", realm_slug_for_url)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"/realms/{}/scenes/{}",
|
||||||
|
realm_slug_for_url, scene_slug_for_url
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _ = history.replace_state_with_url(
|
||||||
|
&wasm_bindgen::JsValue::NULL,
|
||||||
|
"",
|
||||||
|
Some(&new_url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current scene for the viewer
|
||||||
|
set_current_scene.set(Some(scene));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update channel_id to trigger WebSocket reconnection
|
||||||
|
set_channel_id.set(Some(scene_id));
|
||||||
|
|
||||||
|
// Clear members since we're switching scenes
|
||||||
|
set_members.set(Vec::new());
|
||||||
|
|
||||||
|
// Trigger a reconnect to ensure fresh connection
|
||||||
|
reconnect_trigger.update(|t| *t += 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for being summoned by a moderator - show notification and teleport
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_summoned = Callback::new(move |info: SummonInfo| {
|
||||||
|
// Log summon to message log
|
||||||
|
let summon_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[MOD]".to_string(),
|
||||||
|
content: format!("Summoned by {} to scene: {}", info.summoned_by, info.scene_slug),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(summon_msg));
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
set_mod_notification.set(Some((true, format!("Summoned by {}", info.summoned_by))));
|
||||||
|
|
||||||
|
// Auto-dismiss notification after 3 seconds
|
||||||
|
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
|
||||||
|
set_mod_notification.set(None);
|
||||||
|
});
|
||||||
|
timeout.forget();
|
||||||
|
|
||||||
|
let scene_id = info.scene_id;
|
||||||
|
let scene_slug = info.scene_slug.clone();
|
||||||
|
let realm_slug = slug.get_untracked();
|
||||||
|
|
||||||
|
// Fetch the new scene data (same as teleport approval)
|
||||||
|
let scene_slug_for_url = scene_slug.clone();
|
||||||
|
let realm_slug_for_url = realm_slug.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
let response = Request::get(&format!(
|
||||||
|
"/api/realms/{}/scenes/{}",
|
||||||
|
realm_slug, scene_slug
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(resp) = response {
|
||||||
|
if resp.ok() {
|
||||||
|
if let Ok(scene) = resp.json::<Scene>().await {
|
||||||
|
// Update scene dimensions from the new scene
|
||||||
|
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
|
||||||
|
set_scene_dimensions.set((w as f64, h as f64));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL to reflect new scene
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(history) = window.history() {
|
||||||
|
let new_url = if scene.is_entry_point {
|
||||||
|
format!("/realms/{}", realm_slug_for_url)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"/realms/{}/scenes/{}",
|
||||||
|
realm_slug_for_url, scene_slug_for_url
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let _ = history.replace_state_with_url(
|
||||||
|
&wasm_bindgen::JsValue::NULL,
|
||||||
|
"",
|
||||||
|
Some(&new_url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current scene for the viewer
|
||||||
|
set_current_scene.set(Some(scene));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update channel_id to trigger WebSocket reconnection
|
||||||
|
set_channel_id.set(Some(scene_id));
|
||||||
|
|
||||||
|
// Clear members since we're switching scenes
|
||||||
|
set_members.set(Vec::new());
|
||||||
|
|
||||||
|
// Trigger a reconnect to ensure fresh connection
|
||||||
|
reconnect_trigger.update(|t| *t += 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for mod command result - show notification
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_mod_command_result = Callback::new(move |info: ModCommandResultInfo| {
|
||||||
|
// Log mod command result to message log
|
||||||
|
let status = if info.success { "OK" } else { "FAILED" };
|
||||||
|
let mod_msg = ChatMessage {
|
||||||
|
message_id: Uuid::new_v4(),
|
||||||
|
user_id: None,
|
||||||
|
guest_session_id: None,
|
||||||
|
display_name: "[MOD]".to_string(),
|
||||||
|
content: format!("[{}] {}", status, info.message),
|
||||||
|
emotion: "neutral".to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
timestamp: js_sys::Date::now() as i64,
|
||||||
|
is_whisper: false,
|
||||||
|
is_same_scene: true,
|
||||||
|
is_system: true,
|
||||||
|
};
|
||||||
|
message_log.update_value(|log| log.push(mod_msg));
|
||||||
|
|
||||||
|
set_mod_notification.set(Some((info.success, info.message)));
|
||||||
|
|
||||||
|
// Auto-dismiss notification after 3 seconds
|
||||||
|
let timeout = gloo_timers::callback::Timeout::new(3000, move || {
|
||||||
|
set_mod_notification.set(None);
|
||||||
|
});
|
||||||
|
timeout.forget();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback for member identity updates (e.g., guest registered as user)
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let on_member_identity_updated = Callback::new(move |info: MemberIdentityInfo| {
|
||||||
|
// Update the member's display name in the members list
|
||||||
|
set_members.update(|members| {
|
||||||
|
if let Some(member) = members
|
||||||
|
.iter_mut()
|
||||||
|
.find(|m| m.member.user_id == Some(info.user_id))
|
||||||
|
{
|
||||||
|
member.member.display_name = info.display_name.clone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let (ws_state, ws_sender) = use_channel_websocket(
|
||||||
slug,
|
slug,
|
||||||
Signal::derive(move || channel_id.get()),
|
Signal::derive(move || channel_id.get()),
|
||||||
reconnect_trigger,
|
reconnect_trigger,
|
||||||
on_ws_event,
|
on_members_update,
|
||||||
|
on_chat_message,
|
||||||
|
on_loose_props_sync,
|
||||||
|
on_prop_dropped,
|
||||||
|
on_prop_picked_up,
|
||||||
|
on_member_fading,
|
||||||
|
Some(on_welcome),
|
||||||
|
Some(on_ws_error),
|
||||||
|
Some(on_teleport_approved),
|
||||||
|
Some(on_summoned),
|
||||||
|
Some(on_mod_command_result),
|
||||||
|
Some(on_member_identity_updated),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
// Set channel ID, current scene, and scene dimensions when entry scene loads
|
||||||
|
|
@ -714,17 +823,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
Rc::new(RefCell::new(None));
|
Rc::new(RefCell::new(None));
|
||||||
let closure_holder_clone = closure_holder.clone();
|
let closure_holder_clone = closure_holder.clone();
|
||||||
|
|
||||||
// Holder for keyup closure (for hotkey help dismissal)
|
|
||||||
let keyup_closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
|
||||||
Rc::new(RefCell::new(None));
|
|
||||||
let keyup_closure_holder_clone = keyup_closure_holder.clone();
|
|
||||||
|
|
||||||
// StoredValue to hold js_sys::Function references for cleanup (Send+Sync compatible)
|
|
||||||
let keydown_fn: StoredValue<Option<js_sys::Function>, LocalStorage> = StoredValue::new_local(None);
|
|
||||||
let keyup_fn: StoredValue<Option<js_sys::Function>, LocalStorage> = StoredValue::new_local(None);
|
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
// Cleanup previous keydown closure if any
|
// Cleanup previous closure if any
|
||||||
if let Some(old_closure) = closure_holder_clone.borrow_mut().take() {
|
if let Some(old_closure) = closure_holder_clone.borrow_mut().take() {
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let _ = window.remove_event_listener_with_callback(
|
let _ = window.remove_event_listener_with_callback(
|
||||||
|
|
@ -734,16 +834,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup previous keyup closure if any
|
|
||||||
if let Some(old_closure) = keyup_closure_holder_clone.borrow_mut().take() {
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let _ = window.remove_event_listener_with_callback(
|
|
||||||
"keyup",
|
|
||||||
old_closure.as_ref().unchecked_ref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_slug = slug.get();
|
let current_slug = slug.get();
|
||||||
if current_slug.is_empty() {
|
if current_slug.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -780,7 +870,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
|| log_open.get_untracked()
|
|| log_open.get_untracked()
|
||||||
|| keybindings_open.get_untracked()
|
|| keybindings_open.get_untracked()
|
||||||
|| avatar_editor_open.get_untracked()
|
|| avatar_editor_open.get_untracked()
|
||||||
|| avatar_store_open.get_untracked()
|
|
||||||
|| register_modal_open.get_untracked()
|
|| register_modal_open.get_untracked()
|
||||||
|| conversation_modal_open.get_untracked()
|
|| conversation_modal_open.get_untracked()
|
||||||
{
|
{
|
||||||
|
|
@ -897,13 +986,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 't' to toggle avatar store (template avatars)
|
|
||||||
if key == "t" || key == "T" {
|
|
||||||
set_avatar_store_open.update(|v| *v = !*v);
|
|
||||||
ev.prevent_default();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle 'l' to toggle message log
|
// Handle 'l' to toggle message log
|
||||||
if key == "l" || key == "L" {
|
if key == "l" || key == "L" {
|
||||||
set_log_open.update(|v| *v = !*v);
|
set_log_open.update(|v| *v = !*v);
|
||||||
|
|
@ -978,35 +1060,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the keyup closure for cleanup
|
// Forget the keyup closure (it lives for the duration of the page)
|
||||||
*keyup_closure_holder_clone.borrow_mut() = Some(keyup_closure);
|
keyup_closure.forget();
|
||||||
|
|
||||||
// Extract and store js_sys::Function references for cleanup
|
|
||||||
// (must be done inside Effect since Rc<RefCell<>> isn't Send+Sync for on_cleanup)
|
|
||||||
if let Some(ref closure) = *closure_holder_clone.borrow() {
|
|
||||||
let func: &js_sys::Function = closure.as_ref().unchecked_ref();
|
|
||||||
keydown_fn.set_value(Some(func.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref closure) = *keyup_closure_holder_clone.borrow() {
|
|
||||||
let func: &js_sys::Function = closure.as_ref().unchecked_ref();
|
|
||||||
keyup_fn.set_value(Some(func.clone()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup event listeners when component unmounts
|
|
||||||
on_cleanup(move || {
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
keydown_fn.with_value(|func| {
|
|
||||||
if let Some(f) = func {
|
|
||||||
let _ = window.remove_event_listener_with_callback("keydown", f);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
keyup_fn.with_value(|func| {
|
|
||||||
if let Some(f) = func {
|
|
||||||
let _ = window.remove_event_listener_with_callback("keyup", f);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save position on page unload (beforeunload event)
|
// Save position on page unload (beforeunload event)
|
||||||
|
|
@ -1043,23 +1098,15 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
set_chat_focused.set(focused);
|
set_chat_focused.set(focused);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create logout callback - explicitly close WebSocket before calling logout API
|
// Create logout callback (WebSocket disconnects automatically)
|
||||||
// Create logout callback - close WebSocket and call logout API
|
|
||||||
let on_logout = Callback::new(move |_: ()| {
|
let on_logout = Callback::new(move |_: ()| {
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
let navigate = navigate.clone();
|
let navigate = navigate.clone();
|
||||||
|
|
||||||
// Close WebSocket explicitly with LOGOUT code (non-blocking, browser handles close handshake)
|
|
||||||
ws_close.with_value(|closer| {
|
|
||||||
if let Some(close_fn) = closer {
|
|
||||||
close_fn(close_codes::LOGOUT, "logout".to_string());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Call logout API immediately - session invalidation doesn't depend on WS close completing
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
|
// WebSocket close handles channel leave automatically
|
||||||
let _: Result<gloo_net::http::Response, gloo_net::Error> =
|
let _: Result<gloo_net::http::Response, gloo_net::Error> =
|
||||||
Request::post("/api/auth/logout").send().await;
|
Request::post("/api/auth/logout").send().await;
|
||||||
navigate("/", Default::default());
|
navigate("/", Default::default());
|
||||||
|
|
@ -1221,6 +1268,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
})
|
})
|
||||||
fading_members=Signal::derive(move || fading_members.get())
|
fading_members=Signal::derive(move || fading_members.get())
|
||||||
current_user_id=Signal::derive(move || current_user_id.get())
|
current_user_id=Signal::derive(move || current_user_id.get())
|
||||||
|
current_guest_session_id=Signal::derive(move || current_guest_session_id.get())
|
||||||
is_guest=Signal::derive(move || is_guest.get())
|
is_guest=Signal::derive(move || is_guest.get())
|
||||||
on_whisper_request=on_whisper_request_cb
|
on_whisper_request=on_whisper_request_cb
|
||||||
/>
|
/>
|
||||||
|
|
@ -1347,37 +1395,6 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar store popup
|
|
||||||
<AvatarStorePopup
|
|
||||||
open=Signal::derive(move || avatar_store_open.get())
|
|
||||||
on_close=Callback::new(move |_: ()| set_avatar_store_open.set(false))
|
|
||||||
realm_slug=Signal::derive(move || slug.get())
|
|
||||||
is_guest=Signal::derive(move || is_guest.get())
|
|
||||||
on_avatar_selected=Callback::new(move |_: ()| {
|
|
||||||
// Refresh avatar data after selection
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
{
|
|
||||||
use gloo_net::http::Request;
|
|
||||||
let current_slug = slug.get();
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
let response = Request::get(&format!("/api/realms/{}/avatar", current_slug))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
if let Ok(resp) = response {
|
|
||||||
if resp.ok() {
|
|
||||||
if let Ok(avatar) = resp.json::<AvatarWithPaths>().await {
|
|
||||||
let avail = avatar.compute_emotion_availability();
|
|
||||||
set_emotion_availability.set(Some(avail));
|
|
||||||
set_skin_preview_path.set(avatar.skin_layer[4].clone());
|
|
||||||
set_full_avatar.set(Some(avatar));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Registration modal for guest-to-user conversion
|
// Registration modal for guest-to-user conversion
|
||||||
{
|
{
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
|
||||||
|
|
@ -129,9 +129,6 @@ pub fn SignupPage() -> impl IntoView {
|
||||||
password: pwd,
|
password: pwd,
|
||||||
confirm_password: confirm_pwd,
|
confirm_password: confirm_pwd,
|
||||||
realm_slug: slug.unwrap(),
|
realm_slug: slug.unwrap(),
|
||||||
birthday: None,
|
|
||||||
gender_preference: None,
|
|
||||||
age_category: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = Request::post("/api/auth/signup")
|
let response = Request::post("/api/auth/signup")
|
||||||
|
|
|
||||||
25
db/reinitialize_all_users.sql
Normal file
25
db/reinitialize_all_users.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- Reinitialize all users with current server props
|
||||||
|
-- Use: psql -d chattyness -f reinitialize_all_users.sql
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_user RECORD;
|
||||||
|
v_count INT := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR v_user IN SELECT id, username FROM auth.users
|
||||||
|
LOOP
|
||||||
|
-- Clear existing data
|
||||||
|
DELETE FROM auth.active_avatars WHERE user_id = v_user.id;
|
||||||
|
DELETE FROM auth.avatars WHERE user_id = v_user.id;
|
||||||
|
DELETE FROM auth.inventory WHERE user_id = v_user.id;
|
||||||
|
|
||||||
|
-- Reinitialize with current server props
|
||||||
|
PERFORM auth.initialize_new_user(v_user.id);
|
||||||
|
|
||||||
|
v_count := v_count + 1;
|
||||||
|
RAISE NOTICE 'Reinitialized user: % (%)', v_user.username, v_user.id;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Total users reinitialized: %', v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
59
db/reinitialize_user.md
Normal file
59
db/reinitialize_user.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Reinitialize User with Default Props
|
||||||
|
|
||||||
|
When stock props or avatars are updated in the database, existing users may need to be reinitialized to receive the new defaults.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Find the user's ID:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, username FROM auth.users WHERE username = 'TARGET_USERNAME';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Clear existing data and reinitialize:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Clear existing props and avatars for the user
|
||||||
|
DELETE FROM auth.active_avatars WHERE user_id = 'USER_UUID';
|
||||||
|
DELETE FROM auth.avatars WHERE user_id = 'USER_UUID';
|
||||||
|
DELETE FROM auth.inventory WHERE user_id = 'USER_UUID';
|
||||||
|
|
||||||
|
-- Reinitialize with current server props
|
||||||
|
SELECT auth.initialize_new_user('USER_UUID');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the results:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) as inventory_count FROM auth.inventory WHERE user_id = 'USER_UUID';
|
||||||
|
SELECT id, name, slot_number FROM auth.avatars WHERE user_id = 'USER_UUID';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Reinitialize ranosh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d chattyness <<'EOF'
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DELETE FROM auth.active_avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
|
||||||
|
DELETE FROM auth.avatars WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
|
||||||
|
DELETE FROM auth.inventory WHERE user_id = '57a12201-ea0f-4545-9ccc-c4e67ea7e2c4';
|
||||||
|
|
||||||
|
SELECT auth.initialize_new_user('57a12201-ea0f-4545-9ccc-c4e67ea7e2c4');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## What `initialize_new_user` Does
|
||||||
|
|
||||||
|
The `auth.initialize_new_user()` function:
|
||||||
|
|
||||||
|
1. Inserts all face-tagged server props into the user's inventory
|
||||||
|
2. Creates a default avatar (slot 0) with:
|
||||||
|
- Face prop in the skin layer (position 4, center)
|
||||||
|
- All emotion props mapped to their respective emotion slots
|
||||||
|
|
@ -68,6 +68,24 @@ EXCEPTION
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Set current guest session ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.set_current_guest_session_id(guest_session_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM set_config('app.current_guest_session_id', guest_session_id::TEXT, false);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Get current guest session ID for RLS
|
||||||
|
CREATE OR REPLACE FUNCTION public.current_guest_session_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN NULLIF(current_setting('app.current_guest_session_id', true), '')::UUID;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
-- Check if current user is a server admin
|
-- Check if current user is a server admin
|
||||||
CREATE OR REPLACE FUNCTION public.is_server_admin()
|
CREATE OR REPLACE FUNCTION public.is_server_admin()
|
||||||
RETURNS BOOLEAN AS $$
|
RETURNS BOOLEAN AS $$
|
||||||
|
|
@ -300,34 +318,4 @@ COMMENT ON FUNCTION scene.clear_stale_instance_members(DOUBLE PRECISION) IS
|
||||||
GRANT EXECUTE ON FUNCTION scene.clear_all_instance_members() TO chattyness_app;
|
GRANT EXECUTE ON FUNCTION scene.clear_all_instance_members() TO chattyness_app;
|
||||||
GRANT EXECUTE ON FUNCTION scene.clear_stale_instance_members(DOUBLE PRECISION) TO chattyness_app;
|
GRANT EXECUTE ON FUNCTION scene.clear_stale_instance_members(DOUBLE PRECISION) TO chattyness_app;
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Guest Cleanup Functions
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- Clean up stale guest accounts that haven't been active in 7 days
|
|
||||||
-- Guests are users with the 'guest' tag in auth.users
|
|
||||||
-- Uses SECURITY DEFINER to bypass RLS
|
|
||||||
CREATE OR REPLACE FUNCTION auth.cleanup_stale_guests()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
deleted_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH deleted AS (
|
|
||||||
DELETE FROM auth.users
|
|
||||||
WHERE 'guest' = ANY(tags)
|
|
||||||
AND last_seen_at < now() - interval '7 days'
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT count(*) INTO deleted_count FROM deleted;
|
|
||||||
|
|
||||||
RETURN deleted_count;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.cleanup_stale_guests() IS
|
|
||||||
'Removes guest accounts (users with guest tag) inactive for 7+ days. Run via cron.';
|
|
||||||
|
|
||||||
-- Grant execute to chattyness_app
|
|
||||||
GRANT EXECUTE ON FUNCTION auth.cleanup_stale_guests() TO chattyness_app;
|
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
|
||||||
170
db/schema/functions/002_user_init.sql
Normal file
170
db/schema/functions/002_user_init.sql
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
-- Chattyness User Initialization Functions
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Functions to initialize new users with default props and avatars.
|
||||||
|
-- Load via: psql -f schema/functions/002_user_init.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Initialize New User with Default Props and Avatar
|
||||||
|
-- =============================================================================
|
||||||
|
-- Called when a new user is created to give them:
|
||||||
|
-- 1. All face-tagged server props in their inventory
|
||||||
|
-- 2. A default avatar (slot 0) with the Face prop and all emotions configured
|
||||||
|
--
|
||||||
|
-- Note: active_avatars entry is NOT created here - it's created when the user
|
||||||
|
-- joins a realm for the first time (per-realm avatar state).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.initialize_new_user(p_user_id UUID)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_avatar_id UUID;
|
||||||
|
v_face_inventory_id UUID;
|
||||||
|
v_neutral_inventory_id UUID;
|
||||||
|
v_happy_inventory_id UUID;
|
||||||
|
v_sad_inventory_id UUID;
|
||||||
|
v_angry_inventory_id UUID;
|
||||||
|
v_surprised_inventory_id UUID;
|
||||||
|
v_thinking_inventory_id UUID;
|
||||||
|
v_laughing_inventory_id UUID;
|
||||||
|
v_crying_inventory_id UUID;
|
||||||
|
v_love_inventory_id UUID;
|
||||||
|
v_confused_inventory_id UUID;
|
||||||
|
v_sleeping_inventory_id UUID;
|
||||||
|
v_wink_inventory_id UUID;
|
||||||
|
v_prop RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Insert all face-tagged server props into user's inventory
|
||||||
|
-- Note: inventory layer/position are only for content layer props (skin/clothes/accessories).
|
||||||
|
-- Emotion props have default_emotion instead of default_layer, so they get NULL layer/position.
|
||||||
|
FOR v_prop IN
|
||||||
|
SELECT id, name, asset_path, default_layer, default_emotion, default_position, slug,
|
||||||
|
is_transferable, is_portable, is_droppable
|
||||||
|
FROM server.props
|
||||||
|
WHERE tags @> ARRAY['face']
|
||||||
|
AND is_active = true
|
||||||
|
LOOP
|
||||||
|
-- Use a local variable for the inserted inventory ID
|
||||||
|
DECLARE
|
||||||
|
v_new_inventory_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO auth.inventory (
|
||||||
|
user_id,
|
||||||
|
server_prop_id,
|
||||||
|
prop_name,
|
||||||
|
prop_asset_path,
|
||||||
|
layer,
|
||||||
|
position,
|
||||||
|
origin,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_droppable
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_user_id,
|
||||||
|
v_prop.id,
|
||||||
|
v_prop.name,
|
||||||
|
v_prop.asset_path,
|
||||||
|
v_prop.default_layer, -- NULL for emotion props
|
||||||
|
CASE WHEN v_prop.default_layer IS NOT NULL THEN v_prop.default_position ELSE NULL END,
|
||||||
|
'server_library',
|
||||||
|
v_prop.is_transferable,
|
||||||
|
v_prop.is_portable,
|
||||||
|
v_prop.is_droppable
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_new_inventory_id;
|
||||||
|
|
||||||
|
-- Track inventory IDs for avatar assignment based on slug
|
||||||
|
CASE v_prop.slug
|
||||||
|
WHEN 'face' THEN v_face_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'neutral' THEN v_neutral_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'smile' THEN v_happy_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'sad' THEN v_sad_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'angry' THEN v_angry_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'surprised' THEN v_surprised_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'thinking' THEN v_thinking_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'laughing' THEN v_laughing_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'crying' THEN v_crying_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'love' THEN v_love_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'confused' THEN v_confused_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'sleeping' THEN v_sleeping_inventory_id := v_new_inventory_id;
|
||||||
|
WHEN 'wink' THEN v_wink_inventory_id := v_new_inventory_id;
|
||||||
|
ELSE NULL;
|
||||||
|
END CASE;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Create default avatar (slot 0) with the Face prop in skin layer
|
||||||
|
-- and all emotion props in their respective emotion slots at position 4 (center)
|
||||||
|
INSERT INTO auth.avatars (
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
slot_number,
|
||||||
|
last_emotion,
|
||||||
|
-- Content layer: Face goes in skin layer, center position
|
||||||
|
l_skin_4,
|
||||||
|
-- Emotion layers: Each emotion prop goes to its matching emotion at center position
|
||||||
|
e_neutral_4,
|
||||||
|
e_happy_4,
|
||||||
|
e_sad_4,
|
||||||
|
e_angry_4,
|
||||||
|
e_surprised_4,
|
||||||
|
e_thinking_4,
|
||||||
|
e_laughing_4,
|
||||||
|
e_crying_4,
|
||||||
|
e_love_4,
|
||||||
|
e_confused_4,
|
||||||
|
e_sleeping_4,
|
||||||
|
e_wink_4
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_user_id,
|
||||||
|
'Default',
|
||||||
|
0,
|
||||||
|
0, -- Start with neutral emotion
|
||||||
|
v_face_inventory_id,
|
||||||
|
v_neutral_inventory_id,
|
||||||
|
v_happy_inventory_id,
|
||||||
|
v_sad_inventory_id,
|
||||||
|
v_angry_inventory_id,
|
||||||
|
v_surprised_inventory_id,
|
||||||
|
v_thinking_inventory_id,
|
||||||
|
v_laughing_inventory_id,
|
||||||
|
v_crying_inventory_id,
|
||||||
|
v_love_inventory_id,
|
||||||
|
v_confused_inventory_id,
|
||||||
|
v_sleeping_inventory_id,
|
||||||
|
v_wink_inventory_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_avatar_id;
|
||||||
|
|
||||||
|
-- Note: We don't create an active_avatars entry here because that's per-realm.
|
||||||
|
-- The active_avatars entry will be created when the user first joins a realm.
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.initialize_new_user(UUID) IS
|
||||||
|
'Initialize a new user with default props in inventory and a default avatar configuration';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Trigger Function for User Registration
|
||||||
|
-- =============================================================================
|
||||||
|
-- Wrapper trigger function that calls initialize_new_user.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.initialize_new_user_trigger()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM auth.initialize_new_user(NEW.id);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.initialize_new_user_trigger() IS
|
||||||
|
'Trigger function to initialize new users on registration';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -45,9 +45,7 @@
|
||||||
\echo 'Phase 3: Creating tables...'
|
\echo 'Phase 3: Creating tables...'
|
||||||
\ir tables/010_server.sql
|
\ir tables/010_server.sql
|
||||||
\ir tables/020_auth.sql
|
\ir tables/020_auth.sql
|
||||||
\ir tables/025_server_avatars.sql
|
|
||||||
\ir tables/030_realm.sql
|
\ir tables/030_realm.sql
|
||||||
\ir tables/035_realm_avatars.sql
|
|
||||||
\ir tables/045_scene.sql
|
\ir tables/045_scene.sql
|
||||||
\ir tables/050_chat.sql
|
\ir tables/050_chat.sql
|
||||||
\ir tables/080_audit.sql
|
\ir tables/080_audit.sql
|
||||||
|
|
@ -58,6 +56,7 @@
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
\echo 'Phase 4: Creating functions...'
|
\echo 'Phase 4: Creating functions...'
|
||||||
\ir functions/001_helpers.sql
|
\ir functions/001_helpers.sql
|
||||||
|
\ir functions/002_user_init.sql
|
||||||
\echo ''
|
\echo ''
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
@ -65,6 +64,7 @@
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
\echo 'Phase 5: Creating triggers...'
|
\echo 'Phase 5: Creating triggers...'
|
||||||
\ir triggers/001_updated_at.sql
|
\ir triggers/001_updated_at.sql
|
||||||
|
\ir triggers/002_user_init.sql
|
||||||
\echo ''
|
\echo ''
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -79,29 +79,6 @@ CREATE POLICY server_props_delete ON server.props
|
||||||
GRANT SELECT ON server.props TO chattyness_app;
|
GRANT SELECT ON server.props TO chattyness_app;
|
||||||
GRANT INSERT, UPDATE, DELETE ON server.props TO chattyness_app;
|
GRANT INSERT, UPDATE, DELETE ON server.props TO chattyness_app;
|
||||||
|
|
||||||
-- server.avatars
|
|
||||||
ALTER TABLE server.avatars ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY server_avatars_select ON server.avatars
|
|
||||||
FOR SELECT TO chattyness_app
|
|
||||||
USING (true);
|
|
||||||
|
|
||||||
CREATE POLICY server_avatars_insert ON server.avatars
|
|
||||||
FOR INSERT TO chattyness_app
|
|
||||||
WITH CHECK (public.is_server_admin());
|
|
||||||
|
|
||||||
CREATE POLICY server_avatars_update ON server.avatars
|
|
||||||
FOR UPDATE TO chattyness_app
|
|
||||||
USING (public.is_server_admin())
|
|
||||||
WITH CHECK (public.is_server_admin());
|
|
||||||
|
|
||||||
CREATE POLICY server_avatars_delete ON server.avatars
|
|
||||||
FOR DELETE TO chattyness_app
|
|
||||||
USING (public.is_server_admin());
|
|
||||||
|
|
||||||
GRANT SELECT ON server.avatars TO chattyness_app;
|
|
||||||
GRANT INSERT, UPDATE, DELETE ON server.avatars TO chattyness_app;
|
|
||||||
|
|
||||||
-- server.audio
|
-- server.audio
|
||||||
ALTER TABLE server.audio ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE server.audio ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
@ -259,6 +236,16 @@ CREATE POLICY auth_sessions_admin ON auth.sessions
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.sessions TO chattyness_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.sessions TO chattyness_app;
|
||||||
|
|
||||||
|
-- auth.guest_sessions
|
||||||
|
ALTER TABLE auth.guest_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY auth_guest_sessions_all ON auth.guest_sessions
|
||||||
|
FOR ALL TO chattyness_app
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.guest_sessions TO chattyness_app;
|
||||||
|
|
||||||
-- auth.tower_sessions
|
-- auth.tower_sessions
|
||||||
ALTER TABLE auth.tower_sessions ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE auth.tower_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
@ -363,12 +350,6 @@ CREATE POLICY auth_active_avatars_view ON auth.active_avatars
|
||||||
FOR SELECT TO chattyness_app
|
FOR SELECT TO chattyness_app
|
||||||
USING (true);
|
USING (true);
|
||||||
|
|
||||||
-- Allow realm moderators to update forced avatar columns on any user in their realm
|
|
||||||
CREATE POLICY auth_active_avatars_mod ON auth.active_avatars
|
|
||||||
FOR UPDATE TO chattyness_app
|
|
||||||
USING (public.is_realm_moderator(realm_id))
|
|
||||||
WITH CHECK (public.is_realm_moderator(realm_id));
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.active_avatars TO chattyness_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON auth.active_avatars TO chattyness_app;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
@ -526,30 +507,6 @@ CREATE POLICY realm_props_modify ON realm.props
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.props TO chattyness_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.props TO chattyness_app;
|
||||||
|
|
||||||
-- realm.avatars
|
|
||||||
ALTER TABLE realm.avatars ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY realm_avatars_select ON realm.avatars
|
|
||||||
FOR SELECT TO chattyness_app
|
|
||||||
USING (
|
|
||||||
public.has_realm_membership(realm_id)
|
|
||||||
OR public.is_server_admin()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE POLICY realm_avatars_modify ON realm.avatars
|
|
||||||
FOR ALL TO chattyness_app
|
|
||||||
USING (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM realm.memberships m
|
|
||||||
WHERE m.realm_id = realm.avatars.realm_id
|
|
||||||
AND m.user_id = public.current_user_id()
|
|
||||||
AND m.role IN ('owner', 'builder')
|
|
||||||
)
|
|
||||||
OR public.is_server_admin()
|
|
||||||
);
|
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON realm.avatars TO chattyness_app;
|
|
||||||
|
|
||||||
-- realm.reports
|
-- realm.reports
|
||||||
ALTER TABLE realm.reports ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE realm.reports ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
@ -717,8 +674,14 @@ CREATE POLICY scene_instance_members_select ON scene.instance_members
|
||||||
|
|
||||||
CREATE POLICY scene_instance_members_own ON scene.instance_members
|
CREATE POLICY scene_instance_members_own ON scene.instance_members
|
||||||
FOR ALL TO chattyness_app
|
FOR ALL TO chattyness_app
|
||||||
USING (user_id = public.current_user_id())
|
USING (
|
||||||
WITH CHECK (user_id = public.current_user_id());
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
);
|
||||||
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.instance_members TO chattyness_app;
|
GRANT SELECT, INSERT, UPDATE, DELETE ON scene.instance_members TO chattyness_app;
|
||||||
|
|
||||||
|
|
@ -913,12 +876,16 @@ CREATE POLICY chat_messages_select ON chat.messages
|
||||||
|
|
||||||
CREATE POLICY chat_messages_insert ON chat.messages
|
CREATE POLICY chat_messages_insert ON chat.messages
|
||||||
FOR INSERT TO chattyness_app
|
FOR INSERT TO chattyness_app
|
||||||
WITH CHECK (user_id = public.current_user_id());
|
WITH CHECK (
|
||||||
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE POLICY chat_messages_update ON chat.messages
|
CREATE POLICY chat_messages_update ON chat.messages
|
||||||
FOR UPDATE TO chattyness_app
|
FOR UPDATE TO chattyness_app
|
||||||
USING (
|
USING (
|
||||||
user_id = public.current_user_id()
|
user_id = public.current_user_id()
|
||||||
|
OR guest_session_id = public.current_guest_session_id()
|
||||||
OR public.is_server_moderator()
|
OR public.is_server_moderator()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,6 @@ CREATE TABLE auth.users (
|
||||||
bio TEXT,
|
bio TEXT,
|
||||||
avatar_url public.url,
|
avatar_url public.url,
|
||||||
|
|
||||||
-- User preferences for default avatar selection
|
|
||||||
birthday DATE,
|
|
||||||
gender_preference auth.gender_preference NOT NULL DEFAULT 'gender_neutral',
|
|
||||||
age_category auth.age_category NOT NULL DEFAULT 'adult',
|
|
||||||
|
|
||||||
reputation_tier server.reputation_tier NOT NULL DEFAULT 'member',
|
reputation_tier server.reputation_tier NOT NULL DEFAULT 'member',
|
||||||
reputation_promoted_at TIMESTAMPTZ,
|
reputation_promoted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
|
@ -639,13 +634,9 @@ CREATE INDEX idx_auth_avatars_default ON auth.avatars (user_id, is_default) WHER
|
||||||
CREATE TABLE auth.active_avatars (
|
CREATE TABLE auth.active_avatars (
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
realm_id UUID NOT NULL, -- FK added in 030_realm.sql after realm.realms exists
|
realm_id UUID NOT NULL, -- FK added in 030_realm.sql after realm.realms exists
|
||||||
avatar_id UUID REFERENCES auth.avatars(id) ON DELETE SET NULL,
|
avatar_id UUID NOT NULL REFERENCES auth.avatars(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
-- User-selected avatars from avatar stores (lower priority than custom avatar)
|
current_emotion SMALLINT NOT NULL DEFAULT 0 CHECK (current_emotion >= 0 AND current_emotion <= 11),
|
||||||
selected_server_avatar_id UUID, -- FK added in 025_server_avatars.sql
|
|
||||||
selected_realm_avatar_id UUID, -- FK added in 035_realm_avatars.sql
|
|
||||||
|
|
||||||
current_emotion server.emotion_state NOT NULL DEFAULT 'happy',
|
|
||||||
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
|
@ -653,12 +644,6 @@ CREATE TABLE auth.active_avatars (
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE auth.active_avatars IS 'Current avatar per user per realm';
|
COMMENT ON TABLE auth.active_avatars IS 'Current avatar per user per realm';
|
||||||
COMMENT ON COLUMN auth.active_avatars.avatar_id IS
|
|
||||||
'User custom avatar (highest priority, nullable for users without custom avatars)';
|
|
||||||
COMMENT ON COLUMN auth.active_avatars.selected_server_avatar_id IS
|
|
||||||
'User-selected server avatar (from avatar store), lower priority than custom avatar';
|
|
||||||
COMMENT ON COLUMN auth.active_avatars.selected_realm_avatar_id IS
|
|
||||||
'User-selected realm avatar (from avatar store), higher priority than server selection';
|
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Server-Level Moderation: IP Bans
|
-- Server-Level Moderation: IP Bans
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- Server Avatars
|
|
||||||
-- =============================================================================
|
|
||||||
-- Pre-configured avatar configurations available globally across all realms.
|
|
||||||
-- These reference server.props directly (not inventory items).
|
|
||||||
--
|
|
||||||
-- Loaded after 020_auth.sql (server.props must exist)
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE server.avatars (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
slug public.slug NOT NULL,
|
|
||||||
name public.nonempty_text NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
thumbnail_path public.asset_path,
|
|
||||||
|
|
||||||
-- Content layers: 3 layers x 9 positions = 27 slots
|
|
||||||
-- All reference server.props(id) directly
|
|
||||||
l_skin_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
l_clothes_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
l_accessories_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
-- Emotion layers: 12 emotions x 9 positions = 108 slots
|
|
||||||
e_neutral_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_happy_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_sad_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_angry_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_surprised_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_thinking_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_laughing_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_crying_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_love_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_confused_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_sleeping_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_wink_0 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_1 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_2 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_3 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_4 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_5 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_6 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_7 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_8 UUID REFERENCES server.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
|
|
||||||
CONSTRAINT uq_server_avatars_slug UNIQUE (slug)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE server.avatars IS 'Pre-configured avatar configurations available globally (references server.props)';
|
|
||||||
|
|
||||||
CREATE INDEX idx_server_avatars_active ON server.avatars (is_active) WHERE is_active = true;
|
|
||||||
CREATE INDEX idx_server_avatars_public ON server.avatars (is_public) WHERE is_public = true;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN server.avatars.is_public IS
|
|
||||||
'When true, avatar appears in the public avatar selection. Uses filtered index.';
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add Default Avatar Columns to server.config
|
|
||||||
-- =============================================================================
|
|
||||||
-- These columns reference server.avatars for gender/age-based default avatars.
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE server.config
|
|
||||||
ADD COLUMN default_avatar_neutral_child UUID REFERENCES server.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN default_avatar_neutral_adult UUID REFERENCES server.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN default_avatar_male_child UUID REFERENCES server.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN default_avatar_male_adult UUID REFERENCES server.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN default_avatar_female_child UUID REFERENCES server.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN default_avatar_female_adult UUID REFERENCES server.avatars(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN server.config.default_avatar_neutral_child IS
|
|
||||||
'Default server avatar for gender-neutral child users';
|
|
||||||
COMMENT ON COLUMN server.config.default_avatar_neutral_adult IS
|
|
||||||
'Default server avatar for gender-neutral adult users';
|
|
||||||
COMMENT ON COLUMN server.config.default_avatar_male_child IS
|
|
||||||
'Default server avatar for male child users';
|
|
||||||
COMMENT ON COLUMN server.config.default_avatar_male_adult IS
|
|
||||||
'Default server avatar for male adult users';
|
|
||||||
COMMENT ON COLUMN server.config.default_avatar_female_child IS
|
|
||||||
'Default server avatar for female child users';
|
|
||||||
COMMENT ON COLUMN server.config.default_avatar_female_adult IS
|
|
||||||
'Default server avatar for female adult users';
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add FK for auth.active_avatars.selected_server_avatar_id
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE auth.active_avatars
|
|
||||||
ADD CONSTRAINT fk_auth_active_avatars_selected_server_avatar
|
|
||||||
FOREIGN KEY (selected_server_avatar_id) REFERENCES server.avatars(id) ON DELETE SET NULL;
|
|
||||||
|
|
@ -35,15 +35,6 @@ CREATE TABLE realm.realms (
|
||||||
|
|
||||||
default_scene_id UUID,
|
default_scene_id UUID,
|
||||||
|
|
||||||
-- Default avatars for this realm (optional, override server defaults)
|
|
||||||
-- FK constraints added in 035_realm_avatars.sql after realm.avatars exists
|
|
||||||
default_avatar_neutral_child UUID,
|
|
||||||
default_avatar_neutral_adult UUID,
|
|
||||||
default_avatar_male_child UUID,
|
|
||||||
default_avatar_male_adult UUID,
|
|
||||||
default_avatar_female_child UUID,
|
|
||||||
default_avatar_female_adult UUID,
|
|
||||||
|
|
||||||
member_count INTEGER NOT NULL DEFAULT 0 CHECK (member_count >= 0),
|
member_count INTEGER NOT NULL DEFAULT 0 CHECK (member_count >= 0),
|
||||||
current_user_count INTEGER NOT NULL DEFAULT 0 CHECK (current_user_count >= 0),
|
current_user_count INTEGER NOT NULL DEFAULT 0 CHECK (current_user_count >= 0),
|
||||||
|
|
||||||
|
|
@ -102,6 +93,35 @@ ALTER TABLE realm.realms
|
||||||
ADD CONSTRAINT fk_realm_realms_default_scene
|
ADD CONSTRAINT fk_realm_realms_default_scene
|
||||||
FOREIGN KEY (default_scene_id) REFERENCES realm.scenes(id) ON DELETE SET NULL;
|
FOREIGN KEY (default_scene_id) REFERENCES realm.scenes(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Guest Sessions (created here since it references realm tables)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Note: current_instance_id FK is added in 045_scene.sql after scene.instances exists
|
||||||
|
|
||||||
|
CREATE TABLE auth.guest_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
guest_name public.display_name NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
|
||||||
|
current_realm_id UUID REFERENCES realm.realms(id) ON DELETE SET NULL,
|
||||||
|
current_instance_id UUID, -- FK added in 045_scene.sql
|
||||||
|
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT uq_auth_guest_sessions_token UNIQUE (token_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.guest_sessions IS 'Anonymous guest sessions';
|
||||||
|
|
||||||
|
CREATE INDEX idx_auth_guest_sessions_expires ON auth.guest_sessions (expires_at);
|
||||||
|
CREATE INDEX idx_auth_guest_sessions_ip ON auth.guest_sessions (ip_address);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Realm Memberships
|
-- Realm Memberships
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- Realm Avatars and Forced Avatar Support
|
|
||||||
-- =============================================================================
|
|
||||||
-- Pre-configured avatar configurations specific to a realm.
|
|
||||||
-- These reference realm.props directly (not inventory items).
|
|
||||||
--
|
|
||||||
-- Also adds forced avatar support to auth.active_avatars and realm.scenes.
|
|
||||||
--
|
|
||||||
-- Loaded after 030_realm.sql (realm.props must exist)
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Realm Avatars Table
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE realm.avatars (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
realm_id UUID NOT NULL REFERENCES realm.realms(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
slug public.slug NOT NULL,
|
|
||||||
name public.nonempty_text NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
thumbnail_path public.asset_path,
|
|
||||||
|
|
||||||
-- Content layers: 3 layers x 9 positions = 27 slots
|
|
||||||
-- All reference realm.props(id) directly (realm props only, no server props)
|
|
||||||
l_skin_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_skin_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
l_clothes_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_clothes_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
l_accessories_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
l_accessories_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
-- Emotion layers: 12 emotions x 9 positions = 108 slots
|
|
||||||
e_neutral_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_neutral_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_happy_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_happy_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_sad_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sad_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_angry_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_angry_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_surprised_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_surprised_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_thinking_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_thinking_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_laughing_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_laughing_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_crying_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_crying_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_love_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_love_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_confused_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_confused_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_sleeping_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_sleeping_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
e_wink_0 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_1 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_2 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_3 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_4 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_5 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_6 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_7 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
e_wink_8 UUID REFERENCES realm.props(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
|
|
||||||
CONSTRAINT uq_realm_avatars_slug UNIQUE (realm_id, slug)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE realm.avatars IS 'Pre-configured avatar configurations specific to a realm (references realm.props only)';
|
|
||||||
|
|
||||||
CREATE INDEX idx_realm_avatars_realm ON realm.avatars (realm_id);
|
|
||||||
CREATE INDEX idx_realm_avatars_active ON realm.avatars (realm_id, is_active) WHERE is_active = true;
|
|
||||||
CREATE INDEX idx_realm_avatars_public ON realm.avatars (realm_id, is_public) WHERE is_public = true;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN realm.avatars.is_public IS
|
|
||||||
'When true, avatar appears in the public avatar selection for this realm. Uses filtered index.';
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add Default Avatar FK Constraints to realm.realms
|
|
||||||
-- =============================================================================
|
|
||||||
-- Now that realm.avatars exists, add the FK constraints for default avatars.
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE realm.realms
|
|
||||||
ADD CONSTRAINT fk_realm_default_avatar_neutral_child
|
|
||||||
FOREIGN KEY (default_avatar_neutral_child) REFERENCES realm.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD CONSTRAINT fk_realm_default_avatar_neutral_adult
|
|
||||||
FOREIGN KEY (default_avatar_neutral_adult) REFERENCES realm.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD CONSTRAINT fk_realm_default_avatar_male_child
|
|
||||||
FOREIGN KEY (default_avatar_male_child) REFERENCES realm.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD CONSTRAINT fk_realm_default_avatar_male_adult
|
|
||||||
FOREIGN KEY (default_avatar_male_adult) REFERENCES realm.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD CONSTRAINT fk_realm_default_avatar_female_child
|
|
||||||
FOREIGN KEY (default_avatar_female_child) REFERENCES realm.avatars(id) ON DELETE SET NULL,
|
|
||||||
ADD CONSTRAINT fk_realm_default_avatar_female_adult
|
|
||||||
FOREIGN KEY (default_avatar_female_adult) REFERENCES realm.avatars(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN realm.realms.default_avatar_neutral_child IS
|
|
||||||
'Default realm avatar for gender-neutral child users (overrides server default)';
|
|
||||||
COMMENT ON COLUMN realm.realms.default_avatar_neutral_adult IS
|
|
||||||
'Default realm avatar for gender-neutral adult users (overrides server default)';
|
|
||||||
COMMENT ON COLUMN realm.realms.default_avatar_male_child IS
|
|
||||||
'Default realm avatar for male child users (overrides server default)';
|
|
||||||
COMMENT ON COLUMN realm.realms.default_avatar_male_adult IS
|
|
||||||
'Default realm avatar for male adult users (overrides server default)';
|
|
||||||
COMMENT ON COLUMN realm.realms.default_avatar_female_child IS
|
|
||||||
'Default realm avatar for female child users (overrides server default)';
|
|
||||||
COMMENT ON COLUMN realm.realms.default_avatar_female_adult IS
|
|
||||||
'Default realm avatar for female adult users (overrides server default)';
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add FK for auth.active_avatars.selected_realm_avatar_id
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE auth.active_avatars
|
|
||||||
ADD CONSTRAINT fk_auth_active_avatars_selected_realm_avatar
|
|
||||||
FOREIGN KEY (selected_realm_avatar_id) REFERENCES realm.avatars(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add Forced Avatar Columns to auth.active_avatars
|
|
||||||
-- =============================================================================
|
|
||||||
-- Tracks when a user has a forced avatar (from mod command or scene entry)
|
|
||||||
|
|
||||||
ALTER TABLE auth.active_avatars
|
|
||||||
ADD COLUMN forced_avatar_id UUID,
|
|
||||||
ADD COLUMN forced_avatar_source TEXT
|
|
||||||
CHECK (forced_avatar_source IS NULL OR forced_avatar_source IN ('server', 'realm', 'scene')),
|
|
||||||
ADD COLUMN forced_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN forced_until TIMESTAMPTZ;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN auth.active_avatars.forced_avatar_id IS
|
|
||||||
'UUID of the forced avatar (from server.avatars or realm.avatars depending on source)';
|
|
||||||
COMMENT ON COLUMN auth.active_avatars.forced_avatar_source IS
|
|
||||||
'Source of forced avatar: server (server.avatars), realm (realm.avatars), or scene (from scene setting)';
|
|
||||||
COMMENT ON COLUMN auth.active_avatars.forced_by IS
|
|
||||||
'User who forced this avatar (moderator), NULL for scene-forced';
|
|
||||||
COMMENT ON COLUMN auth.active_avatars.forced_until IS
|
|
||||||
'When the forced avatar expires, NULL for permanent/until manually cleared';
|
|
||||||
|
|
||||||
-- Index for finding expired forced avatars
|
|
||||||
CREATE INDEX idx_auth_active_avatars_forced_expires
|
|
||||||
ON auth.active_avatars (forced_until)
|
|
||||||
WHERE forced_until IS NOT NULL;
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add Forced Avatar Columns to realm.scenes
|
|
||||||
-- =============================================================================
|
|
||||||
-- Allows scenes to force all users to wear a specific avatar
|
|
||||||
|
|
||||||
ALTER TABLE realm.scenes
|
|
||||||
ADD COLUMN forced_avatar_id UUID,
|
|
||||||
ADD COLUMN forced_avatar_source TEXT
|
|
||||||
CHECK (forced_avatar_source IS NULL OR forced_avatar_source IN ('server', 'realm'));
|
|
||||||
|
|
||||||
COMMENT ON COLUMN realm.scenes.forced_avatar_id IS
|
|
||||||
'UUID of avatar all users must wear in this scene (from server.avatars or realm.avatars)';
|
|
||||||
COMMENT ON COLUMN realm.scenes.forced_avatar_source IS
|
|
||||||
'Source of forced avatar: server (server.avatars) or realm (realm.avatars)';
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- Add New Moderation Action Types
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
ALTER TYPE server.action_type ADD VALUE 'dress_user';
|
|
||||||
ALTER TYPE server.action_type ADD VALUE 'undress_user';
|
|
||||||
ALTER TYPE server.action_type ADD VALUE 'teleport';
|
|
||||||
|
|
||||||
COMMENT ON TYPE server.action_type IS 'Type of moderation action taken (includes dress_user, undress_user, teleport)';
|
|
||||||
|
|
@ -40,19 +40,30 @@ CREATE INDEX idx_scene_instances_scene ON scene.instances (scene_id);
|
||||||
CREATE INDEX idx_scene_instances_type ON scene.instances (scene_id, instance_type);
|
CREATE INDEX idx_scene_instances_type ON scene.instances (scene_id, instance_type);
|
||||||
CREATE INDEX idx_scene_instances_expires ON scene.instances (expires_at) WHERE expires_at IS NOT NULL;
|
CREATE INDEX idx_scene_instances_expires ON scene.instances (expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Add FK from auth.guest_sessions to scene.instances
|
||||||
|
-- =============================================================================
|
||||||
|
-- guest_sessions.current_instance_id was added without FK in 030_realm.sql
|
||||||
|
-- Now we can add the constraint since scene.instances exists
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE auth.guest_sessions
|
||||||
|
ADD CONSTRAINT fk_auth_guest_sessions_instance
|
||||||
|
FOREIGN KEY (current_instance_id) REFERENCES scene.instances(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Instance Members (renamed from realm.channel_members)
|
-- Instance Members (renamed from realm.channel_members)
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- Users currently present in an instance with their positions.
|
-- Users currently present in an instance with their positions.
|
||||||
-- Note: instance_id is actually scene_id in this system (scenes are used directly as instances).
|
-- Note: instance_id is actually scene_id in this system (scenes are used directly as instances).
|
||||||
-- Guests are regular users with the 'guest' tag in auth.users.
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
CREATE TABLE scene.instance_members (
|
CREATE TABLE scene.instance_members (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
instance_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
instance_id UUID NOT NULL REFERENCES realm.scenes(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
guest_session_id UUID REFERENCES auth.guest_sessions(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
position public.virtual_point NOT NULL DEFAULT ST_SetSRID(ST_MakePoint(400, 300), 0),
|
position public.virtual_point NOT NULL DEFAULT ST_SetSRID(ST_MakePoint(400, 300), 0),
|
||||||
|
|
||||||
|
|
@ -63,13 +74,19 @@ CREATE TABLE scene.instance_members (
|
||||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
last_moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
last_moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
CONSTRAINT uq_scene_instance_members_user UNIQUE (instance_id, user_id)
|
CONSTRAINT chk_scene_instance_members_user_or_guest CHECK (
|
||||||
|
(user_id IS NOT NULL AND guest_session_id IS NULL) OR
|
||||||
|
(user_id IS NULL AND guest_session_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT uq_scene_instance_members_user UNIQUE (instance_id, user_id),
|
||||||
|
CONSTRAINT uq_scene_instance_members_guest UNIQUE (instance_id, guest_session_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE scene.instance_members IS 'Users in an instance with positions (guests are users with guest tag)';
|
COMMENT ON TABLE scene.instance_members IS 'Users in an instance with positions';
|
||||||
|
|
||||||
CREATE INDEX idx_scene_instance_members_instance ON scene.instance_members (instance_id);
|
CREATE INDEX idx_scene_instance_members_instance ON scene.instance_members (instance_id);
|
||||||
CREATE INDEX idx_scene_instance_members_user ON scene.instance_members (user_id);
|
CREATE INDEX idx_scene_instance_members_user ON scene.instance_members (user_id) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_scene_instance_members_guest ON scene.instance_members (guest_session_id) WHERE guest_session_id IS NOT NULL;
|
||||||
CREATE INDEX idx_scene_instance_members_position ON scene.instance_members USING GIST (position);
|
CREATE INDEX idx_scene_instance_members_position ON scene.instance_members USING GIST (position);
|
||||||
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,9 @@ CREATE TABLE chat.messages (
|
||||||
|
|
||||||
instance_id UUID NOT NULL REFERENCES scene.instances(id) ON DELETE CASCADE,
|
instance_id UUID NOT NULL REFERENCES scene.instances(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
-- Sender (all users including guests - guests have 'guest' tag in auth.users)
|
-- Sender (either user or guest)
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
guest_session_id UUID REFERENCES auth.guest_sessions(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
-- Cached sender info (in case account deleted)
|
-- Cached sender info (in case account deleted)
|
||||||
sender_name public.display_name NOT NULL,
|
sender_name public.display_name NOT NULL,
|
||||||
|
|
@ -50,7 +51,13 @@ CREATE TABLE chat.messages (
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
-- Timestamps
|
-- Timestamps
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Either user_id or guest_session_id must be set
|
||||||
|
CONSTRAINT chk_chat_messages_sender CHECK (
|
||||||
|
(user_id IS NOT NULL AND guest_session_id IS NULL) OR
|
||||||
|
(user_id IS NULL AND guest_session_id IS NOT NULL)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE chat.messages IS 'Instance messages (design supports future time-based partitioning)';
|
COMMENT ON TABLE chat.messages IS 'Instance messages (design supports future time-based partitioning)';
|
||||||
|
|
|
||||||
26
db/schema/triggers/002_user_init.sql
Normal file
26
db/schema/triggers/002_user_init.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- Chattyness User Initialization Trigger
|
||||||
|
-- PostgreSQL 18
|
||||||
|
--
|
||||||
|
-- Trigger to initialize new users with default props and avatar.
|
||||||
|
-- Load via: psql -f schema/triggers/002_user_init.sql
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- User Registration Trigger
|
||||||
|
-- =============================================================================
|
||||||
|
-- Automatically initializes new users with default props and avatar
|
||||||
|
-- when they are inserted into auth.users.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auth_users_initialize
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION auth.initialize_new_user_trigger();
|
||||||
|
|
||||||
|
COMMENT ON TRIGGER trg_auth_users_initialize ON auth.users IS
|
||||||
|
'Initialize new users with default props and avatar on registration';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -135,21 +135,6 @@ COMMENT ON TYPE server.filter_action IS 'Action to take when content filter matc
|
||||||
-- Authentication Enums
|
-- Authentication Enums
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
-- Gender preference for default avatar selection
|
|
||||||
CREATE TYPE auth.gender_preference AS ENUM (
|
|
||||||
'gender_neutral',
|
|
||||||
'gender_male',
|
|
||||||
'gender_female'
|
|
||||||
);
|
|
||||||
COMMENT ON TYPE auth.gender_preference IS 'User gender preference for selecting default avatars';
|
|
||||||
|
|
||||||
-- Age category for default avatar selection
|
|
||||||
CREATE TYPE auth.age_category AS ENUM (
|
|
||||||
'child',
|
|
||||||
'adult'
|
|
||||||
);
|
|
||||||
COMMENT ON TYPE auth.age_category IS 'User age category for selecting default avatars';
|
|
||||||
|
|
||||||
-- User account tags for feature gating and access control
|
-- User account tags for feature gating and access control
|
||||||
CREATE TYPE auth.user_tag AS ENUM (
|
CREATE TYPE auth.user_tag AS ENUM (
|
||||||
'guest',
|
'guest',
|
||||||
|
|
|
||||||
85
e2e/playwright-report/index.html
Normal file
85
e2e/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
4
e2e/test-results/.last-run.json
Normal file
4
e2e/test-results/.last-run.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
BIN
e2e/test-results/ranosh-patio-daytime-background-admin.png
Normal file
BIN
e2e/test-results/ranosh-patio-daytime-background-admin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
e2e/test-results/ranosh-realm-success.png
Normal file
BIN
e2e/test-results/ranosh-realm-success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -6,9 +6,6 @@
|
||||||
# Options:
|
# Options:
|
||||||
# --force, -f Update existing assets instead of failing with 409 Conflict
|
# --force, -f Update existing assets instead of failing with 409 Conflict
|
||||||
|
|
||||||
# Get script directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
FORCE_FLAG=""
|
FORCE_FLAG=""
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
|
|
@ -19,8 +16,5 @@ for arg in "$@"; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
(cd "$SCRIPT_DIR/avatar" && ./upload-stockavatars.sh $FORCE_FLAG)
|
(cd avatar && ./upload-stockavatars.sh $FORCE_FLAG)
|
||||||
(cd "$SCRIPT_DIR/props" && ./upload-stockprops.sh $FORCE_FLAG)
|
(cd props && ./upload-stockprops.sh $FORCE_FLAG)
|
||||||
|
|
||||||
# Create stock avatar from uploaded props and set as default
|
|
||||||
(cd "$SCRIPT_DIR/avatar" && ./create-stock-avatar.sh $FORCE_FLAG)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue