Rework avatars.
Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
This commit is contained in:
parent
e4abdb183f
commit
6fb90e42c3
55 changed files with 7392 additions and 512 deletions
|
|
@ -3,6 +3,8 @@
|
|||
#[cfg(feature = "ssr")]
|
||||
pub mod auth;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod avatars;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod config;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod dashboard;
|
||||
|
|
|
|||
127
crates/chattyness-admin-ui/src/api/avatars.rs
Normal file
127
crates/chattyness-admin-ui/src/api/avatars.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//! Server avatars management API handlers for admin UI.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use chattyness_db::{
|
||||
models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest},
|
||||
queries::server_avatars,
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// =============================================================================
|
||||
// API Types
|
||||
// =============================================================================
|
||||
|
||||
/// Response for avatar creation.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateAvatarResponse {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub is_public: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<ServerAvatar> for CreateAvatarResponse {
|
||||
fn from(avatar: ServerAvatar) -> Self {
|
||||
Self {
|
||||
id: avatar.id,
|
||||
slug: avatar.slug,
|
||||
name: avatar.name,
|
||||
is_public: avatar.is_public,
|
||||
created_at: avatar.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Handlers
|
||||
// =============================================================================
|
||||
|
||||
/// List all server avatars.
|
||||
pub async fn list_avatars(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<ServerAvatarSummary>>, AppError> {
|
||||
let avatars = server_avatars::list_all_server_avatars(&pool).await?;
|
||||
Ok(Json(avatars))
|
||||
}
|
||||
|
||||
/// Create a new server avatar.
|
||||
pub async fn create_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
Json(req): Json<CreateServerAvatarRequest>,
|
||||
) -> Result<Json<CreateAvatarResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Check slug availability
|
||||
let slug = req.slug_or_generate();
|
||||
let available = server_avatars::is_avatar_slug_available(&pool, &slug).await?;
|
||||
if !available {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Avatar slug '{}' is already taken",
|
||||
slug
|
||||
)));
|
||||
}
|
||||
|
||||
// Create the avatar
|
||||
let avatar = server_avatars::create_server_avatar(&pool, &req, None).await?;
|
||||
|
||||
tracing::info!("Created server avatar: {} ({})", avatar.name, avatar.id);
|
||||
|
||||
Ok(Json(CreateAvatarResponse::from(avatar)))
|
||||
}
|
||||
|
||||
/// Get a server avatar by ID.
|
||||
pub async fn get_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<ServerAvatar>, AppError> {
|
||||
let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
||||
Ok(Json(avatar))
|
||||
}
|
||||
|
||||
/// Update a server avatar.
|
||||
pub async fn update_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
|
||||
Json(req): Json<UpdateServerAvatarRequest>,
|
||||
) -> Result<Json<ServerAvatar>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Check avatar exists
|
||||
let existing = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
||||
|
||||
// Update the avatar
|
||||
let avatar = server_avatars::update_server_avatar(&pool, avatar_id, &req).await?;
|
||||
|
||||
tracing::info!("Updated server avatar: {} ({})", existing.name, avatar_id);
|
||||
|
||||
Ok(Json(avatar))
|
||||
}
|
||||
|
||||
/// Delete a server avatar.
|
||||
pub async fn delete_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
axum::extract::Path(avatar_id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
// Get the avatar first to log its name
|
||||
let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
||||
|
||||
// Delete from database
|
||||
server_avatars::delete_server_avatar(&pool, avatar_id).await?;
|
||||
|
||||
tracing::info!("Deleted server avatar: {} ({})", avatar.name, avatar_id);
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@ use axum::{
|
|||
extract::{Path, Query, State},
|
||||
};
|
||||
use chattyness_db::{
|
||||
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
|
||||
queries::owner as queries,
|
||||
models::{
|
||||
CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary,
|
||||
RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest,
|
||||
},
|
||||
queries::{owner as queries, realm_avatars},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -131,3 +134,144 @@ pub async fn transfer_ownership(
|
|||
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Realm Avatar Handlers
|
||||
// =============================================================================
|
||||
|
||||
/// Response for realm avatar creation.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateRealmAvatarResponse {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub is_public: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<RealmAvatar> for CreateRealmAvatarResponse {
|
||||
fn from(avatar: RealmAvatar) -> Self {
|
||||
Self {
|
||||
id: avatar.id,
|
||||
slug: avatar.slug,
|
||||
name: avatar.name,
|
||||
is_public: avatar.is_public,
|
||||
created_at: avatar.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all avatars for a realm.
|
||||
pub async fn list_realm_avatars(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Vec<RealmAvatarSummary>>, AppError> {
|
||||
let realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||
let avatars = realm_avatars::list_all_realm_avatars(&pool, realm.id).await?;
|
||||
Ok(Json(avatars))
|
||||
}
|
||||
|
||||
/// Create a new realm avatar.
|
||||
pub async fn create_realm_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
Json(req): Json<CreateRealmAvatarRequest>,
|
||||
) -> Result<Json<CreateRealmAvatarResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Get realm ID
|
||||
let realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||
|
||||
// Check slug availability
|
||||
let avatar_slug = req.slug_or_generate();
|
||||
let available = realm_avatars::is_avatar_slug_available(&pool, realm.id, &avatar_slug).await?;
|
||||
if !available {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Avatar slug '{}' is already taken in this realm",
|
||||
avatar_slug
|
||||
)));
|
||||
}
|
||||
|
||||
// Create the avatar
|
||||
let avatar = realm_avatars::create_realm_avatar(&pool, realm.id, &req, None).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Created realm avatar: {} ({}) in realm {}",
|
||||
avatar.name,
|
||||
avatar.id,
|
||||
slug
|
||||
);
|
||||
|
||||
Ok(Json(CreateRealmAvatarResponse::from(avatar)))
|
||||
}
|
||||
|
||||
/// Get a realm avatar by ID.
|
||||
pub async fn get_realm_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
Path((slug, avatar_id)): Path<(String, Uuid)>,
|
||||
) -> Result<Json<RealmAvatar>, AppError> {
|
||||
// Verify realm exists
|
||||
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||
|
||||
let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
||||
Ok(Json(avatar))
|
||||
}
|
||||
|
||||
/// Update a realm avatar.
|
||||
pub async fn update_realm_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
Path((slug, avatar_id)): Path<(String, Uuid)>,
|
||||
Json(req): Json<UpdateRealmAvatarRequest>,
|
||||
) -> Result<Json<RealmAvatar>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Verify realm exists
|
||||
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||
|
||||
// Check avatar exists
|
||||
let existing = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
||||
|
||||
// Update the avatar
|
||||
let avatar = realm_avatars::update_realm_avatar(&pool, avatar_id, &req).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Updated realm avatar: {} ({}) in realm {}",
|
||||
existing.name,
|
||||
avatar_id,
|
||||
slug
|
||||
);
|
||||
|
||||
Ok(Json(avatar))
|
||||
}
|
||||
|
||||
/// Delete a realm avatar.
|
||||
pub async fn delete_realm_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
Path((slug, avatar_id)): Path<(String, Uuid)>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
// Verify realm exists
|
||||
let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||
|
||||
// Get the avatar first to log its name
|
||||
let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
|
||||
|
||||
// Delete from database
|
||||
realm_avatars::delete_realm_avatar(&pool, avatar_id).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Deleted realm avatar: {} ({}) from realm {}",
|
||||
avatar.name,
|
||||
avatar_id,
|
||||
slug
|
||||
);
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use axum::{
|
|||
routing::{delete, get, post, put},
|
||||
};
|
||||
|
||||
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
|
||||
use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
|
||||
use crate::app::AdminAppState;
|
||||
|
||||
/// Create the admin API router.
|
||||
|
|
@ -85,6 +85,28 @@ pub fn admin_api_router() -> Router<AdminAppState> {
|
|||
"/props/{prop_id}",
|
||||
get(props::get_prop).delete(props::delete_prop),
|
||||
)
|
||||
// API - Server Avatars
|
||||
.route(
|
||||
"/avatars",
|
||||
get(avatars::list_avatars).post(avatars::create_avatar),
|
||||
)
|
||||
.route(
|
||||
"/avatars/{avatar_id}",
|
||||
get(avatars::get_avatar)
|
||||
.put(avatars::update_avatar)
|
||||
.delete(avatars::delete_avatar),
|
||||
)
|
||||
// API - Realm Avatars
|
||||
.route(
|
||||
"/realms/{slug}/avatars",
|
||||
get(realms::list_realm_avatars).post(realms::create_realm_avatar),
|
||||
)
|
||||
.route(
|
||||
"/realms/{slug}/avatars/{avatar_id}",
|
||||
get(realms::get_realm_avatar)
|
||||
.put(realms::update_realm_avatar)
|
||||
.delete(realms::delete_realm_avatar),
|
||||
)
|
||||
}
|
||||
|
||||
/// Health check endpoint.
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ fn Sidebar(
|
|||
let realms_new_href = format!("{}/realms/new", base_path);
|
||||
let props_href = format!("{}/props", base_path);
|
||||
let props_new_href = format!("{}/props/new", base_path);
|
||||
let avatars_href = format!("{}/avatars", base_path);
|
||||
let avatars_new_href = format!("{}/avatars/new", base_path);
|
||||
|
||||
view! {
|
||||
<nav class="sidebar">
|
||||
|
|
@ -259,6 +261,24 @@ fn Sidebar(
|
|||
/>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-section">
|
||||
<span class="nav-section-title">"Avatars"</span>
|
||||
<ul class="nav-sublist">
|
||||
<NavItem
|
||||
href=avatars_href.clone()
|
||||
label="All Avatars"
|
||||
active=current_page == "avatars"
|
||||
sub=true
|
||||
/>
|
||||
<NavItem
|
||||
href=avatars_new_href.clone()
|
||||
label="Create Avatar"
|
||||
active=current_page == "avatars_new"
|
||||
sub=true
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
}.into_any()
|
||||
} else {
|
||||
// Realm admin: show realm-specific options only
|
||||
|
|
|
|||
|
|
@ -256,3 +256,73 @@ pub struct CreatePropResponse {
|
|||
pub slug: String,
|
||||
pub asset_path: String,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Avatar Models
|
||||
// =============================================================================
|
||||
|
||||
/// Server avatar summary for list display.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct AvatarSummary {
|
||||
pub id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub is_active: bool,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Server avatar detail from API.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct AvatarDetail {
|
||||
pub id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub is_active: bool,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Response for avatar creation.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct CreateAvatarResponse {
|
||||
pub id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub is_public: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Realm avatar summary for list display.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RealmAvatarSummary {
|
||||
pub id: String,
|
||||
pub realm_id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub is_active: bool,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Realm avatar detail from API.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RealmAvatarDetail {
|
||||
pub id: String,
|
||||
pub realm_id: String,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub is_active: bool,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
//! Admin interface Leptos page components.
|
||||
|
||||
mod avatars;
|
||||
mod avatars_detail;
|
||||
mod avatars_new;
|
||||
mod config;
|
||||
mod dashboard;
|
||||
mod login;
|
||||
mod props;
|
||||
mod props_detail;
|
||||
mod props_new;
|
||||
mod realm_avatars;
|
||||
mod realm_avatars_detail;
|
||||
mod realm_avatars_new;
|
||||
mod realm_detail;
|
||||
mod realm_new;
|
||||
mod realms;
|
||||
|
|
@ -17,12 +23,18 @@ mod user_detail;
|
|||
mod user_new;
|
||||
mod users;
|
||||
|
||||
pub use avatars::AvatarsPage;
|
||||
pub use avatars_detail::AvatarsDetailPage;
|
||||
pub use avatars_new::AvatarsNewPage;
|
||||
pub use config::ConfigPage;
|
||||
pub use dashboard::DashboardPage;
|
||||
pub use login::LoginPage;
|
||||
pub use props::PropsPage;
|
||||
pub use props_detail::PropsDetailPage;
|
||||
pub use props_new::PropsNewPage;
|
||||
pub use realm_avatars::RealmAvatarsPage;
|
||||
pub use realm_avatars_detail::RealmAvatarsDetailPage;
|
||||
pub use realm_avatars_new::RealmAvatarsNewPage;
|
||||
pub use realm_detail::RealmDetailPage;
|
||||
pub use realm_new::RealmNewPage;
|
||||
pub use realms::RealmsPage;
|
||||
|
|
|
|||
188
crates/chattyness-admin-ui/src/pages/avatars.rs
Normal file
188
crates/chattyness-admin-ui/src/pages/avatars.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
//! Server avatars list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, EmptyState, PageHeader};
|
||||
use crate::hooks::use_fetch;
|
||||
use crate::models::AvatarSummary;
|
||||
|
||||
/// View mode for avatars listing.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
Table,
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Server avatars page component with table and grid views.
|
||||
#[component]
|
||||
pub fn AvatarsPage() -> impl IntoView {
|
||||
let (view_mode, set_view_mode) = signal(ViewMode::Table);
|
||||
|
||||
let avatars = use_fetch::<Vec<AvatarSummary>>(|| "/api/admin/avatars".to_string());
|
||||
|
||||
view! {
|
||||
<PageHeader title="Server Avatars" subtitle="Manage pre-configured avatar templates">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class=move || if view_mode.get() == ViewMode::Table {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-secondary"
|
||||
}
|
||||
on:click=move |_| set_view_mode.set(ViewMode::Table)
|
||||
>
|
||||
"Table"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || if view_mode.get() == ViewMode::Grid {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-secondary"
|
||||
}
|
||||
on:click=move |_| set_view_mode.set(ViewMode::Grid)
|
||||
>
|
||||
"Grid"
|
||||
</button>
|
||||
<a href="/admin/avatars/new" class="btn btn-primary">"Create Avatar"</a>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading avatars..."</p> }>
|
||||
{move || {
|
||||
avatars.get().map(|maybe_avatars: Option<Vec<AvatarSummary>>| {
|
||||
match maybe_avatars {
|
||||
Some(avatar_list) if !avatar_list.is_empty() => {
|
||||
if view_mode.get() == ViewMode::Table {
|
||||
view! { <AvatarsTable avatars=avatar_list.clone() /> }.into_any()
|
||||
} else {
|
||||
view! { <AvatarsGrid avatars=avatar_list.clone() /> }.into_any()
|
||||
}
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No server avatars found."
|
||||
action_href="/admin/avatars/new"
|
||||
action_text="Create Avatar"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Table view for avatars.
|
||||
#[component]
|
||||
fn AvatarsTable(avatars: Vec<AvatarSummary>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Thumbnail"</th>
|
||||
<th>"Name"</th>
|
||||
<th>"Slug"</th>
|
||||
<th>"Public"</th>
|
||||
<th>"Active"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p));
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
{thumbnail_url.map(|url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=avatar.name.clone()
|
||||
class="avatar-thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<a href=format!("/admin/avatars/{}", avatar.id) class="table-link">
|
||||
{avatar.name}
|
||||
</a>
|
||||
</td>
|
||||
<td><code>{avatar.slug}</code></td>
|
||||
<td>
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if avatar.is_active {
|
||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>{avatar.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid view for avatars with thumbnails.
|
||||
#[component]
|
||||
fn AvatarsGrid(avatars: Vec<AvatarSummary>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="avatars-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; padding: 16px;">
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p))
|
||||
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
|
||||
let avatar_url = format!("/admin/avatars/{}", avatar.id);
|
||||
let avatar_name_for_title = avatar.name.clone();
|
||||
let avatar_name_for_alt = avatar.name.clone();
|
||||
let avatar_name_for_label = avatar.name;
|
||||
let is_active = avatar.is_active;
|
||||
let is_public = avatar.is_public;
|
||||
view! {
|
||||
<a
|
||||
href=avatar_url
|
||||
class="avatars-grid-item"
|
||||
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 12px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s; position: relative;"
|
||||
title=avatar_name_for_title
|
||||
>
|
||||
<img
|
||||
src=thumbnail_url
|
||||
alt=avatar_name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
<span style="font-size: 0.75rem; margin-top: 8px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
|
||||
{avatar_name_for_label}
|
||||
</span>
|
||||
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
||||
{if is_public {
|
||||
view! { <span class="status-badge status-active" style="font-size: 0.6rem; padding: 2px 4px;">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if !is_active {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Inactive"</span> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
306
crates/chattyness-admin-ui/src/pages/avatars_detail.rs
Normal file
306
crates/chattyness-admin-ui/src/pages/avatars_detail.rs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
//! Server avatar detail/edit page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::AvatarDetail;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Server avatar detail page component.
|
||||
#[component]
|
||||
pub fn AvatarsDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
|
||||
let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let avatar = use_fetch_if::<AvatarDetail>(
|
||||
move || !avatar_id().is_empty(),
|
||||
move || format!("{}/avatars/{}", get_api_base(), avatar_id()),
|
||||
);
|
||||
|
||||
view! {
|
||||
<PageHeader title="Avatar Details" subtitle=format!("ID: {}", initial_avatar_id)>
|
||||
<a href="/admin/avatars" class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading avatar..."</p> }>
|
||||
{move || {
|
||||
avatar.get().map(|maybe_avatar| {
|
||||
match maybe_avatar {
|
||||
Some(a) => view! {
|
||||
<AvatarDetailView avatar=a message=message set_message=set_message />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Avatar not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn AvatarDetailView(
|
||||
avatar: AvatarDetail,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id = avatar.id.clone();
|
||||
let slug_display = avatar.slug.clone();
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (delete_pending, set_delete_pending) = signal(false);
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(avatar.name.clone());
|
||||
let (description, set_description) = signal(avatar.description.clone().unwrap_or_default());
|
||||
let (is_public, set_is_public) = signal(avatar.is_public);
|
||||
let (is_active, set_is_active) = signal(avatar.is_active);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(
|
||||
avatar.thumbnail_path.clone().unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Thumbnail preview
|
||||
let thumbnail_preview = move || {
|
||||
let path = thumbnail_path.get();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("/assets/{}", path))
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let avatar_id = avatar_id.clone();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"is_active": is_active.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("{}/avatars/{}", api_base, avatar_id))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Avatar updated successfully!".to_string(), true)));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to update avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id_for_delete = avatar.id.clone();
|
||||
|
||||
let on_delete = move || {
|
||||
set_delete_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let avatar_id = avatar_id_for_delete.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::delete(&format!("{}/avatars/{}", api_base, avatar_id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_delete_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
// Navigate back to avatars list
|
||||
crate::utils::navigate_to("/admin/avatars");
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to delete avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clone values for the view to avoid borrow issues
|
||||
let avatar_name_for_alt = avatar.name.clone();
|
||||
let avatar_name_header = avatar.name.clone();
|
||||
let avatar_desc_header = avatar.description.clone().unwrap_or_default();
|
||||
let avatar_id_display = avatar.id.clone();
|
||||
let avatar_created = avatar.created_at.clone();
|
||||
let avatar_updated = avatar.updated_at.clone();
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="avatar-header" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
||||
{move || {
|
||||
let name_for_alt = avatar_name_for_alt.clone();
|
||||
thumbnail_preview().map(move |url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 8px; background: var(--bg-secondary, #1e293b);"
|
||||
/>
|
||||
})
|
||||
}}
|
||||
<div class="avatar-info">
|
||||
<h2>{avatar_name_header}</h2>
|
||||
<p class="text-muted">{avatar_desc_header}</p>
|
||||
</div>
|
||||
<div class="avatar-badges" style="margin-left: auto; display: flex; gap: 8px;">
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if avatar.is_active {
|
||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="ID">
|
||||
<code>{avatar_id_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Slug">
|
||||
<code>{slug_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{avatar_created}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{avatar_updated}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Edit Avatar Settings">
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"Name"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/avatar-name.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users."</small>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_active.get()
|
||||
on:change=move |ev| set_is_active.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Active"
|
||||
</label>
|
||||
<small class="form-help">"Inactive avatars are not shown to users."</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageAlert message=message />
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="Danger Zone" class="danger-zone">
|
||||
<p class="text-warning">"Deleting an avatar is permanent and cannot be undone."</p>
|
||||
<DeleteConfirmation
|
||||
message="Are you sure you want to delete this avatar? This action cannot be undone."
|
||||
button_text="Delete Avatar"
|
||||
confirm_text="Yes, Delete Avatar"
|
||||
pending=delete_pending
|
||||
on_confirm=on_delete
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
222
crates/chattyness-admin-ui/src/pages/avatars_new.rs
Normal file
222
crates/chattyness-admin-ui/src/pages/avatars_new.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Create new server avatar page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
|
||||
/// Server avatar new page component.
|
||||
#[component]
|
||||
pub fn AvatarsNewPage() -> impl IntoView {
|
||||
// Form state
|
||||
let (name, set_name) = signal(String::new());
|
||||
let (slug, set_slug) = signal(String::new());
|
||||
let (description, set_description) = signal(String::new());
|
||||
let (is_public, set_is_public) = signal(true);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(String::new());
|
||||
|
||||
// UI state
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (created_id, _set_created_id) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let set_created_id = _set_created_id;
|
||||
let (slug_auto, set_slug_auto) = signal(true);
|
||||
|
||||
let update_name = move |ev: leptos::ev::Event| {
|
||||
let new_name = event_target_value(&ev);
|
||||
set_name.set(new_name.clone());
|
||||
if slug_auto.get() {
|
||||
let new_slug = new_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
set_slug.set(new_slug);
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"slug": if slug.get().is_empty() { None::<String> } else { Some(slug.get()) },
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post("/api/admin/avatars")
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateResponse {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
slug: String,
|
||||
}
|
||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||
set_created_id.set(Some(result.id));
|
||||
set_message.set(Some(("Avatar created successfully!".to_string(), true)));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to create avatar".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create Server Avatar" subtitle="Add a new pre-configured avatar template">
|
||||
<a href="/admin/avatars" class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Avatar Details"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
"Name" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
placeholder="Happy Robot"
|
||||
prop:value=move || name.get()
|
||||
on:input=update_name
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug" class="form-label">"Slug (URL)"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||
class="form-input"
|
||||
placeholder="happy-robot"
|
||||
prop:value=move || slug.get()
|
||||
on:input=move |ev| {
|
||||
set_slug_auto.set(false);
|
||||
set_slug.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
placeholder="A cheerful robot avatar for friendly chats"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/happy-robot.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Visibility"</h3>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users. Private avatars are only available to admins."</small>
|
||||
</div>
|
||||
|
||||
<Show when=move || message.get().is_some()>
|
||||
{move || {
|
||||
let (msg, is_success) = message.get().unwrap_or_default();
|
||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||
view! {
|
||||
<div class=class role="alert">
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when=move || created_id.get().is_some()>
|
||||
{move || {
|
||||
let id = created_id.get().unwrap_or_default();
|
||||
view! {
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
<a href=format!("/admin/avatars/{}", id)>
|
||||
"View avatar"
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Creating..." } else { "Create Avatar" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
203
crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Normal file
203
crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
//! Realm avatars list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, EmptyState, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::RealmAvatarSummary;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// View mode for avatars listing.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
Table,
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Realm avatars page component with table and grid views.
|
||||
#[component]
|
||||
pub fn RealmAvatarsPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
|
||||
let (view_mode, set_view_mode) = signal(ViewMode::Table);
|
||||
|
||||
let avatars = use_fetch_if::<Vec<RealmAvatarSummary>>(
|
||||
move || !slug().is_empty(),
|
||||
move || format!("{}/realms/{}/avatars", get_api_base(), slug()),
|
||||
);
|
||||
|
||||
let slug_for_new = initial_slug.clone();
|
||||
let slug_for_back = initial_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Realm Avatars" subtitle=format!("Manage avatars for /{}", initial_slug)>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class=move || if view_mode.get() == ViewMode::Table {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-secondary"
|
||||
}
|
||||
on:click=move |_| set_view_mode.set(ViewMode::Table)
|
||||
>
|
||||
"Table"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || if view_mode.get() == ViewMode::Grid {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-secondary"
|
||||
}
|
||||
on:click=move |_| set_view_mode.set(ViewMode::Grid)
|
||||
>
|
||||
"Grid"
|
||||
</button>
|
||||
<a href=format!("/admin/realms/{}/avatars/new", slug_for_new) class="btn btn-primary">"Create Avatar"</a>
|
||||
<a href=format!("/admin/realms/{}", slug_for_back) class="btn btn-secondary">"Back to Realm"</a>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading avatars..."</p> }>
|
||||
{move || {
|
||||
let slug = slug();
|
||||
avatars.get().map(move |maybe_avatars: Option<Vec<RealmAvatarSummary>>| {
|
||||
match maybe_avatars {
|
||||
Some(avatar_list) if !avatar_list.is_empty() => {
|
||||
if view_mode.get() == ViewMode::Table {
|
||||
view! { <RealmAvatarsTable avatars=avatar_list.clone() realm_slug=slug.clone() /> }.into_any()
|
||||
} else {
|
||||
view! { <RealmAvatarsGrid avatars=avatar_list.clone() realm_slug=slug.clone() /> }.into_any()
|
||||
}
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No realm avatars found."
|
||||
action_href=format!("/admin/realms/{}/avatars/new", slug).leak()
|
||||
action_text="Create Avatar"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Table view for realm avatars.
|
||||
#[component]
|
||||
fn RealmAvatarsTable(avatars: Vec<RealmAvatarSummary>, realm_slug: String) -> impl IntoView {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Thumbnail"</th>
|
||||
<th>"Name"</th>
|
||||
<th>"Slug"</th>
|
||||
<th>"Public"</th>
|
||||
<th>"Active"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p));
|
||||
let detail_url = format!("/admin/realms/{}/avatars/{}", realm_slug, avatar.id);
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
{thumbnail_url.map(|url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=avatar.name.clone()
|
||||
class="avatar-thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<a href=detail_url class="table-link">
|
||||
{avatar.name}
|
||||
</a>
|
||||
</td>
|
||||
<td><code>{avatar.slug}</code></td>
|
||||
<td>
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if avatar.is_active {
|
||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>{avatar.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid view for realm avatars with thumbnails.
|
||||
#[component]
|
||||
fn RealmAvatarsGrid(avatars: Vec<RealmAvatarSummary>, realm_slug: String) -> impl IntoView {
|
||||
view! {
|
||||
<div class="avatars-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; padding: 16px;">
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p))
|
||||
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
|
||||
let avatar_url = format!("/admin/realms/{}/avatars/{}", realm_slug, avatar.id);
|
||||
let avatar_name_for_title = avatar.name.clone();
|
||||
let avatar_name_for_alt = avatar.name.clone();
|
||||
let avatar_name_for_label = avatar.name;
|
||||
let is_active = avatar.is_active;
|
||||
let is_public = avatar.is_public;
|
||||
view! {
|
||||
<a
|
||||
href=avatar_url
|
||||
class="avatars-grid-item"
|
||||
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 12px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s; position: relative;"
|
||||
title=avatar_name_for_title
|
||||
>
|
||||
<img
|
||||
src=thumbnail_url
|
||||
alt=avatar_name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
<span style="font-size: 0.75rem; margin-top: 8px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
|
||||
{avatar_name_for_label}
|
||||
</span>
|
||||
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
||||
{if is_public {
|
||||
view! { <span class="status-badge status-active" style="font-size: 0.6rem; padding: 2px 4px;">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if !is_active {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Inactive"</span> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
332
crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs
Normal file
332
crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
//! Realm avatar detail/edit page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::RealmAvatarDetail;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Realm avatar detail page component.
|
||||
#[component]
|
||||
pub fn RealmAvatarsDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
|
||||
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let avatar = use_fetch_if::<RealmAvatarDetail>(
|
||||
move || !realm_slug().is_empty() && !avatar_id().is_empty(),
|
||||
move || format!("{}/realms/{}/avatars/{}", get_api_base(), realm_slug(), avatar_id()),
|
||||
);
|
||||
|
||||
let realm_slug_for_back = initial_realm_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Realm Avatar Details" subtitle=format!("ID: {}", initial_avatar_id)>
|
||||
<a href=format!("/admin/realms/{}/avatars", realm_slug_for_back) class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading avatar..."</p> }>
|
||||
{move || {
|
||||
let realm_slug = initial_realm_slug.clone();
|
||||
avatar.get().map(move |maybe_avatar| {
|
||||
match maybe_avatar {
|
||||
Some(a) => view! {
|
||||
<RealmAvatarDetailView
|
||||
avatar=a
|
||||
realm_slug=realm_slug.clone()
|
||||
message=message
|
||||
set_message=set_message
|
||||
/>
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Avatar not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn RealmAvatarDetailView(
|
||||
avatar: RealmAvatarDetail,
|
||||
realm_slug: String,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id = avatar.id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let realm_slug_for_api = realm_slug.clone();
|
||||
let realm_slug_for_delete = realm_slug.clone();
|
||||
let slug_display = avatar.slug.clone();
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (delete_pending, set_delete_pending) = signal(false);
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(avatar.name.clone());
|
||||
let (description, set_description) = signal(avatar.description.clone().unwrap_or_default());
|
||||
let (is_public, set_is_public) = signal(avatar.is_public);
|
||||
let (is_active, set_is_active) = signal(avatar.is_active);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(
|
||||
avatar.thumbnail_path.clone().unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Thumbnail preview
|
||||
let thumbnail_preview = move || {
|
||||
let path = thumbnail_path.get();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("/assets/{}", path))
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let avatar_id = avatar_id.clone();
|
||||
let realm_slug = realm_slug_for_api.clone();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"is_active": is_active.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("{}/realms/{}/avatars/{}", api_base, realm_slug, avatar_id))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Avatar updated successfully!".to_string(), true)));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to update avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id_for_delete = avatar.id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let realm_slug_for_delete_api = realm_slug_for_delete.clone();
|
||||
|
||||
let on_delete = move || {
|
||||
set_delete_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let avatar_id = avatar_id_for_delete.clone();
|
||||
let realm_slug = realm_slug_for_delete_api.clone();
|
||||
let nav_slug = realm_slug_for_delete.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::delete(&format!("{}/realms/{}/avatars/{}", api_base, realm_slug, avatar_id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_delete_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
// Navigate back to avatars list
|
||||
crate::utils::navigate_to(&format!("/admin/realms/{}/avatars", nav_slug));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to delete avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clone for the view
|
||||
let avatar_name_header = avatar.name.clone();
|
||||
let avatar_desc_header = avatar.description.clone().unwrap_or_default();
|
||||
let avatar_id_display = avatar.id.clone();
|
||||
let avatar_created = avatar.created_at.clone();
|
||||
let avatar_updated = avatar.updated_at.clone();
|
||||
|
||||
// Additional clone for the closure
|
||||
let avatar_name_for_alt = avatar_name_header.clone();
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="avatar-header" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
||||
{move || {
|
||||
let name_for_alt = avatar_name_for_alt.clone();
|
||||
thumbnail_preview().map(move |url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 8px; background: var(--bg-secondary, #1e293b);"
|
||||
/>
|
||||
})
|
||||
}}
|
||||
<div class="avatar-info">
|
||||
<h2>{avatar_name_header}</h2>
|
||||
<p class="text-muted">{avatar_desc_header}</p>
|
||||
</div>
|
||||
<div class="avatar-badges" style="margin-left: auto; display: flex; gap: 8px;">
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if avatar.is_active {
|
||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="ID">
|
||||
<code>{avatar_id_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Slug">
|
||||
<code>{slug_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Realm">
|
||||
<a href=format!("/admin/realms/{}", realm_slug) class="table-link">
|
||||
{realm_slug.clone()}
|
||||
</a>
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{avatar_created}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{avatar_updated}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Edit Avatar Settings">
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"Name"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/avatar-name.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users."</small>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_active.get()
|
||||
on:change=move |ev| set_is_active.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Active"
|
||||
</label>
|
||||
<small class="form-help">"Inactive avatars are not shown to users."</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageAlert message=message />
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="Danger Zone" class="danger-zone">
|
||||
<p class="text-warning">"Deleting an avatar is permanent and cannot be undone."</p>
|
||||
<DeleteConfirmation
|
||||
message="Are you sure you want to delete this avatar? This action cannot be undone."
|
||||
button_text="Delete Avatar"
|
||||
confirm_text="Yes, Delete Avatar"
|
||||
pending=delete_pending
|
||||
on_confirm=on_delete
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
239
crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs
Normal file
239
crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//! Create new realm avatar page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Realm avatar new page component.
|
||||
#[component]
|
||||
pub fn RealmAvatarsNewPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
#[allow(unused_variables)]
|
||||
let slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(String::new());
|
||||
let (avatar_slug, set_avatar_slug) = signal(String::new());
|
||||
let (description, set_description) = signal(String::new());
|
||||
let (is_public, set_is_public) = signal(true);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(String::new());
|
||||
|
||||
// UI state
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (created_id, _set_created_id) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let set_created_id = _set_created_id;
|
||||
let (slug_auto, set_slug_auto) = signal(true);
|
||||
|
||||
let update_name = move |ev: leptos::ev::Event| {
|
||||
let new_name = event_target_value(&ev);
|
||||
set_name.set(new_name.clone());
|
||||
if slug_auto.get() {
|
||||
let new_slug = new_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
set_avatar_slug.set(new_slug);
|
||||
}
|
||||
};
|
||||
|
||||
let slug_for_back = initial_slug.clone();
|
||||
let slug_for_view = initial_slug.clone();
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let realm_slug = slug();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"slug": if avatar_slug.get().is_empty() { None::<String> } else { Some(avatar_slug.get()) },
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post(&format!("{}/realms/{}/avatars", api_base, realm_slug))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateResponse {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
slug: String,
|
||||
}
|
||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||
set_created_id.set(Some(result.id));
|
||||
set_message.set(Some(("Avatar created successfully!".to_string(), true)));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to create avatar".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create Realm Avatar" subtitle=format!("Add a new avatar for /{}", initial_slug)>
|
||||
<a href=format!("/admin/realms/{}/avatars", slug_for_back) class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Avatar Details"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
"Name" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
placeholder="Happy Robot"
|
||||
prop:value=move || name.get()
|
||||
on:input=update_name
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug" class="form-label">"Slug (URL)"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||
class="form-input"
|
||||
placeholder="happy-robot"
|
||||
prop:value=move || avatar_slug.get()
|
||||
on:input=move |ev| {
|
||||
set_slug_auto.set(false);
|
||||
set_avatar_slug.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
placeholder="A cheerful robot avatar for friendly chats"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/happy-robot.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Visibility"</h3>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users. Private avatars are only available to admins."</small>
|
||||
</div>
|
||||
|
||||
<Show when=move || message.get().is_some()>
|
||||
{move || {
|
||||
let (msg, is_success) = message.get().unwrap_or_default();
|
||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||
view! {
|
||||
<div class=class role="alert">
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<Show when=move || created_id.get().is_some()>
|
||||
{
|
||||
let realm_slug = slug_for_view.clone();
|
||||
move || {
|
||||
let id = created_id.get().unwrap_or_default();
|
||||
let realm_slug = realm_slug.clone();
|
||||
view! {
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
<a href=format!("/admin/realms/{}/avatars/{}", realm_slug, id)>
|
||||
"View avatar"
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</Show>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Creating..." } else { "Create Avatar" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
|
@ -15,9 +15,10 @@ use leptos_router::{
|
|||
|
||||
use crate::components::{AuthenticatedLayout, LoginLayout};
|
||||
use crate::pages::{
|
||||
ConfigPage, DashboardPage, LoginPage, PropsDetailPage, PropsNewPage, PropsPage,
|
||||
RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage, ScenesPage,
|
||||
StaffPage, UserDetailPage, UserNewPage, UsersPage,
|
||||
AvatarsDetailPage, AvatarsNewPage, AvatarsPage, ConfigPage, DashboardPage, LoginPage,
|
||||
PropsDetailPage, PropsNewPage, PropsPage, RealmAvatarsDetailPage, RealmAvatarsNewPage,
|
||||
RealmAvatarsPage, RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage,
|
||||
ScenesPage, StaffPage, UserDetailPage, UserNewPage, UsersPage,
|
||||
};
|
||||
|
||||
/// Admin routes that can be embedded in a parent Router.
|
||||
|
|
@ -91,6 +92,23 @@ pub fn AdminRoutes() -> impl IntoView {
|
|||
</AuthenticatedLayout>
|
||||
} />
|
||||
|
||||
// Server Avatars
|
||||
<Route path=StaticSegment("avatars") view=|| view! {
|
||||
<AuthenticatedLayout current_page="avatars">
|
||||
<AvatarsPage />
|
||||
</AuthenticatedLayout>
|
||||
} />
|
||||
<Route path=(StaticSegment("avatars"), StaticSegment("new")) view=|| view! {
|
||||
<AuthenticatedLayout current_page="avatars_new">
|
||||
<AvatarsNewPage />
|
||||
</AuthenticatedLayout>
|
||||
} />
|
||||
<Route path=(StaticSegment("avatars"), ParamSegment("avatar_id")) view=|| view! {
|
||||
<AuthenticatedLayout current_page="avatars">
|
||||
<AvatarsDetailPage />
|
||||
</AuthenticatedLayout>
|
||||
} />
|
||||
|
||||
// Realms
|
||||
<Route path=StaticSegment("realms") view=|| view! {
|
||||
<AuthenticatedLayout current_page="realms">
|
||||
|
|
@ -120,6 +138,23 @@ pub fn AdminRoutes() -> impl IntoView {
|
|||
</AuthenticatedLayout>
|
||||
} />
|
||||
|
||||
// Realm Avatars (nested under realms)
|
||||
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("avatars")) view=|| view! {
|
||||
<AuthenticatedLayout current_page="realm_avatars">
|
||||
<RealmAvatarsPage />
|
||||
</AuthenticatedLayout>
|
||||
} />
|
||||
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("avatars"), StaticSegment("new")) view=|| view! {
|
||||
<AuthenticatedLayout current_page="realm_avatars_new">
|
||||
<RealmAvatarsNewPage />
|
||||
</AuthenticatedLayout>
|
||||
} />
|
||||
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("avatars"), ParamSegment("avatar_id")) view=|| view! {
|
||||
<AuthenticatedLayout current_page="realm_avatars">
|
||||
<RealmAvatarsDetailPage />
|
||||
</AuthenticatedLayout>
|
||||
} />
|
||||
|
||||
// Realm detail (must come after more specific realm routes)
|
||||
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=|| view! {
|
||||
<AuthenticatedLayout current_page="realms">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,9 @@ pub mod memberships;
|
|||
pub mod moderation;
|
||||
pub mod owner;
|
||||
pub mod props;
|
||||
pub mod realm_avatars;
|
||||
pub mod realms;
|
||||
pub mod scenes;
|
||||
pub mod server_avatars;
|
||||
pub mod spots;
|
||||
pub mod users;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ pub async fn get_active_avatar<'e>(
|
|||
) -> Result<Option<ActiveAvatar>, AppError> {
|
||||
let avatar = sqlx::query_as::<_, ActiveAvatar>(
|
||||
r#"
|
||||
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
|
||||
SELECT
|
||||
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
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
|
|
@ -31,86 +35,54 @@ pub async fn get_active_avatar<'e>(
|
|||
|
||||
/// Set the current emotion for a user in a realm.
|
||||
/// 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>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
conn: &mut PgConnection,
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
emotion: EmotionState,
|
||||
) -> Result<[Option<String>; 9], AppError> {
|
||||
// Map emotion to column prefix
|
||||
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!(
|
||||
// First, update the emotion in active_avatars (uses RLS connection)
|
||||
let update_result = sqlx::query(
|
||||
r#"
|
||||
WITH updated AS (
|
||||
UPDATE auth.active_avatars
|
||||
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
|
||||
UPDATE auth.active_avatars
|
||||
SET current_emotion = $3::server.emotion_state, updated_at = now()
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
prefix = emotion_prefix
|
||||
);
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.bind(emotion.to_string())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
|
||||
.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(
|
||||
if update_result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(
|
||||
"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.
|
||||
///
|
||||
|
|
@ -1469,7 +1441,7 @@ fn collect_uuids(dest: &mut Vec<Uuid>, sources: &[Option<Uuid>]) {
|
|||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct AvatarWithEmotion {
|
||||
pub id: Uuid,
|
||||
pub current_emotion: i16,
|
||||
pub current_emotion: EmotionState,
|
||||
// Content layers
|
||||
pub l_skin_0: Option<Uuid>,
|
||||
pub l_skin_1: Option<Uuid>,
|
||||
|
|
@ -1617,22 +1589,18 @@ pub async fn set_emotion_simple<'e>(
|
|||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
emotion: i16,
|
||||
emotion: EmotionState,
|
||||
) -> Result<(), AppError> {
|
||||
if emotion < 0 || emotion > 11 {
|
||||
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.active_avatars
|
||||
SET current_emotion = $3, updated_at = now()
|
||||
SET current_emotion = $3::server.emotion_state, updated_at = now()
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.bind(emotion)
|
||||
.bind(emotion.to_string())
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
|
|
@ -1723,3 +1691,472 @@ pub async fn update_avatar_slot(
|
|||
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ pub async fn join_channel<'e>(
|
|||
}
|
||||
|
||||
/// Ensure an active avatar exists for a user in a realm.
|
||||
/// Uses the user's default avatar (slot 0) if none exists.
|
||||
/// If user has a custom avatar (slot 0), use it. Otherwise, avatar_id is NULL
|
||||
/// and the system will use server/realm default avatars based on user preferences.
|
||||
pub async fn ensure_active_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
|
|
@ -67,9 +68,9 @@ pub async fn ensure_active_avatar<'e>(
|
|||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
||||
SELECT $1, $2, id, 1
|
||||
FROM auth.avatars
|
||||
WHERE user_id = $1 AND slot_number = 0
|
||||
SELECT $1, $2,
|
||||
(SELECT id FROM auth.avatars WHERE user_id = $1 AND slot_number = 0),
|
||||
'happy'::server.emotion_state
|
||||
ON CONFLICT (user_id, realm_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
|
|
@ -175,7 +176,7 @@ pub async fn get_channel_members<'e>(
|
|||
cm.facing_direction,
|
||||
cm.is_moving,
|
||||
cm.is_afk,
|
||||
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
||||
cm.joined_at,
|
||||
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
||||
FROM scene.instance_members cm
|
||||
|
|
@ -214,7 +215,7 @@ pub async fn get_channel_member<'e>(
|
|||
cm.facing_direction,
|
||||
cm.is_moving,
|
||||
cm.is_afk,
|
||||
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
||||
cm.joined_at,
|
||||
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
||||
FROM scene.instance_members cm
|
||||
|
|
|
|||
880
crates/chattyness-db/src/queries/realm_avatars.rs
Normal file
880
crates/chattyness-db/src/queries/realm_avatars.rs
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
//! Realm avatar queries.
|
||||
//!
|
||||
//! Realm avatars are pre-configured avatar configurations specific to a realm.
|
||||
//! They reference realm.props directly (not inventory items).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use sqlx::PgExecutor;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{AvatarRenderData, EmotionState, RealmAvatar};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// Get a realm avatar by slug within a realm.
|
||||
pub async fn get_realm_avatar_by_slug<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
realm_id: Uuid,
|
||||
slug: &str,
|
||||
) -> Result<Option<RealmAvatar>, AppError> {
|
||||
let avatar = sqlx::query_as::<_, RealmAvatar>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM realm.avatars
|
||||
WHERE realm_id = $1 AND slug = $2 AND is_active = true
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
.bind(slug)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// Get a realm avatar by ID.
|
||||
pub async fn get_realm_avatar_by_id<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar_id: Uuid,
|
||||
) -> Result<Option<RealmAvatar>, AppError> {
|
||||
let avatar = sqlx::query_as::<_, RealmAvatar>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM realm.avatars
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(avatar_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// List all active public realm avatars for a realm.
|
||||
pub async fn list_public_realm_avatars<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
realm_id: Uuid,
|
||||
) -> Result<Vec<RealmAvatar>, AppError> {
|
||||
let avatars = sqlx::query_as::<_, RealmAvatar>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM realm.avatars
|
||||
WHERE realm_id = $1 AND is_active = true AND is_public = true
|
||||
ORDER BY name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatars)
|
||||
}
|
||||
|
||||
/// Row type for prop asset lookup.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct PropAssetRow {
|
||||
id: Uuid,
|
||||
asset_path: String,
|
||||
}
|
||||
|
||||
/// Resolve a realm avatar to render data.
|
||||
/// Joins the avatar's prop UUIDs with realm.props to get asset paths.
|
||||
pub async fn resolve_realm_avatar_to_render_data<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar: &RealmAvatar,
|
||||
current_emotion: EmotionState,
|
||||
) -> Result<AvatarRenderData, AppError> {
|
||||
// Collect all non-null prop UUIDs
|
||||
let mut prop_ids: Vec<Uuid> = Vec::new();
|
||||
|
||||
// Content layers
|
||||
for id in [
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
].iter().flatten() {
|
||||
prop_ids.push(*id);
|
||||
}
|
||||
|
||||
// Get emotion layer slots based on 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],
|
||||
};
|
||||
|
||||
for id in emotion_slots.iter().flatten() {
|
||||
prop_ids.push(*id);
|
||||
}
|
||||
|
||||
// Bulk lookup all prop asset paths from realm.props
|
||||
let prop_map: HashMap<Uuid, String> = if prop_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
let rows = sqlx::query_as::<_, PropAssetRow>(
|
||||
r#"
|
||||
SELECT id, asset_path
|
||||
FROM realm.props
|
||||
WHERE id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&prop_ids)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(|r| (r.id, r.asset_path)).collect()
|
||||
};
|
||||
|
||||
// Helper to look up path
|
||||
let get_path = |id: Option<Uuid>| -> Option<String> {
|
||||
id.and_then(|id| prop_map.get(&id).cloned())
|
||||
};
|
||||
|
||||
Ok(AvatarRenderData {
|
||||
avatar_id: 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]),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a forced realm avatar to a user.
|
||||
pub async fn apply_forced_realm_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
avatar_id: Uuid,
|
||||
forced_by: Option<Uuid>,
|
||||
duration: Option<Duration>,
|
||||
) -> Result<(), AppError> {
|
||||
let forced_until = duration.map(|d| Utc::now() + d);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.active_avatars
|
||||
SET
|
||||
forced_avatar_id = $3,
|
||||
forced_avatar_source = 'realm',
|
||||
forced_by = $4,
|
||||
forced_until = $5,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.bind(avatar_id)
|
||||
.bind(forced_by)
|
||||
.bind(forced_until)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get scene forced avatar configuration.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct SceneForcedAvatar {
|
||||
pub forced_avatar_id: Uuid,
|
||||
pub forced_avatar_source: String,
|
||||
}
|
||||
|
||||
pub async fn get_scene_forced_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
scene_id: Uuid,
|
||||
) -> Result<Option<SceneForcedAvatar>, AppError> {
|
||||
let info = sqlx::query_as::<_, SceneForcedAvatar>(
|
||||
r#"
|
||||
SELECT forced_avatar_id, forced_avatar_source
|
||||
FROM realm.scenes
|
||||
WHERE id = $1
|
||||
AND forced_avatar_id IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.bind(scene_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Apply scene-forced avatar to a user's active avatar.
|
||||
pub async fn apply_scene_forced_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
avatar_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.active_avatars
|
||||
SET
|
||||
forced_avatar_id = $3,
|
||||
forced_avatar_source = 'scene',
|
||||
forced_by = NULL,
|
||||
forced_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.bind(avatar_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CRUD Operations for Admin API
|
||||
// =============================================================================
|
||||
|
||||
use crate::models::{CreateRealmAvatarRequest, RealmAvatarSummary, UpdateRealmAvatarRequest};
|
||||
|
||||
/// List all realm avatars for a realm (for admin).
|
||||
pub async fn list_all_realm_avatars<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
realm_id: Uuid,
|
||||
) -> Result<Vec<RealmAvatarSummary>, AppError> {
|
||||
let avatars = sqlx::query_as::<_, RealmAvatarSummary>(
|
||||
r#"
|
||||
SELECT id, realm_id, slug, name, description, is_public, is_active, thumbnail_path, created_at
|
||||
FROM realm.avatars
|
||||
WHERE realm_id = $1
|
||||
ORDER BY name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatars)
|
||||
}
|
||||
|
||||
/// Check if a realm avatar slug is available within a realm.
|
||||
pub async fn is_avatar_slug_available<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
realm_id: Uuid,
|
||||
slug: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let result: (bool,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT NOT EXISTS(
|
||||
SELECT 1 FROM realm.avatars WHERE realm_id = $1 AND slug = $2
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
.bind(slug)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.0)
|
||||
}
|
||||
|
||||
/// Create a new realm avatar.
|
||||
pub async fn create_realm_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
realm_id: Uuid,
|
||||
req: &CreateRealmAvatarRequest,
|
||||
created_by: Option<Uuid>,
|
||||
) -> Result<RealmAvatar, AppError> {
|
||||
let slug = req.slug_or_generate();
|
||||
|
||||
let avatar = sqlx::query_as::<_, RealmAvatar>(
|
||||
r#"
|
||||
INSERT INTO realm.avatars (
|
||||
realm_id, slug, name, description, is_public, is_active, thumbnail_path, created_by,
|
||||
l_skin_0, l_skin_1, l_skin_2, l_skin_3, l_skin_4, l_skin_5, l_skin_6, l_skin_7, l_skin_8,
|
||||
l_clothes_0, l_clothes_1, l_clothes_2, l_clothes_3, l_clothes_4, l_clothes_5, l_clothes_6, l_clothes_7, l_clothes_8,
|
||||
l_accessories_0, l_accessories_1, l_accessories_2, l_accessories_3, l_accessories_4, l_accessories_5, l_accessories_6, l_accessories_7, l_accessories_8,
|
||||
e_neutral_0, e_neutral_1, e_neutral_2, e_neutral_3, e_neutral_4, e_neutral_5, e_neutral_6, e_neutral_7, e_neutral_8,
|
||||
e_happy_0, e_happy_1, e_happy_2, e_happy_3, e_happy_4, e_happy_5, e_happy_6, e_happy_7, e_happy_8,
|
||||
e_sad_0, e_sad_1, e_sad_2, e_sad_3, e_sad_4, e_sad_5, e_sad_6, e_sad_7, e_sad_8,
|
||||
e_angry_0, e_angry_1, e_angry_2, e_angry_3, e_angry_4, e_angry_5, e_angry_6, e_angry_7, e_angry_8,
|
||||
e_surprised_0, e_surprised_1, e_surprised_2, e_surprised_3, e_surprised_4, e_surprised_5, e_surprised_6, e_surprised_7, e_surprised_8,
|
||||
e_thinking_0, e_thinking_1, e_thinking_2, e_thinking_3, e_thinking_4, e_thinking_5, e_thinking_6, e_thinking_7, e_thinking_8,
|
||||
e_laughing_0, e_laughing_1, e_laughing_2, e_laughing_3, e_laughing_4, e_laughing_5, e_laughing_6, e_laughing_7, e_laughing_8,
|
||||
e_crying_0, e_crying_1, e_crying_2, e_crying_3, e_crying_4, e_crying_5, e_crying_6, e_crying_7, e_crying_8,
|
||||
e_love_0, e_love_1, e_love_2, e_love_3, e_love_4, e_love_5, e_love_6, e_love_7, e_love_8,
|
||||
e_confused_0, e_confused_1, e_confused_2, e_confused_3, e_confused_4, e_confused_5, e_confused_6, e_confused_7, e_confused_8,
|
||||
e_sleeping_0, e_sleeping_1, e_sleeping_2, e_sleeping_3, e_sleeping_4, e_sleeping_5, e_sleeping_6, e_sleeping_7, e_sleeping_8,
|
||||
e_wink_0, e_wink_1, e_wink_2, e_wink_3, e_wink_4, e_wink_5, e_wink_6, e_wink_7, e_wink_8
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, true, $6, $7,
|
||||
$8, $9, $10, $11, $12, $13, $14, $15, $16,
|
||||
$17, $18, $19, $20, $21, $22, $23, $24, $25,
|
||||
$26, $27, $28, $29, $30, $31, $32, $33, $34,
|
||||
$35, $36, $37, $38, $39, $40, $41, $42, $43,
|
||||
$44, $45, $46, $47, $48, $49, $50, $51, $52,
|
||||
$53, $54, $55, $56, $57, $58, $59, $60, $61,
|
||||
$62, $63, $64, $65, $66, $67, $68, $69, $70,
|
||||
$71, $72, $73, $74, $75, $76, $77, $78, $79,
|
||||
$80, $81, $82, $83, $84, $85, $86, $87, $88,
|
||||
$89, $90, $91, $92, $93, $94, $95, $96, $97,
|
||||
$98, $99, $100, $101, $102, $103, $104, $105, $106,
|
||||
$107, $108, $109, $110, $111, $112, $113, $114, $115,
|
||||
$116, $117, $118, $119, $120, $121, $122, $123, $124,
|
||||
$125, $126, $127, $128, $129, $130, $131, $132, $133,
|
||||
$134, $135, $136, $137, $138, $139, $140, $141, $142
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
.bind(&slug)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(req.is_public)
|
||||
.bind(&req.thumbnail_path)
|
||||
.bind(created_by)
|
||||
// Skin layer
|
||||
.bind(req.l_skin_0)
|
||||
.bind(req.l_skin_1)
|
||||
.bind(req.l_skin_2)
|
||||
.bind(req.l_skin_3)
|
||||
.bind(req.l_skin_4)
|
||||
.bind(req.l_skin_5)
|
||||
.bind(req.l_skin_6)
|
||||
.bind(req.l_skin_7)
|
||||
.bind(req.l_skin_8)
|
||||
// Clothes layer
|
||||
.bind(req.l_clothes_0)
|
||||
.bind(req.l_clothes_1)
|
||||
.bind(req.l_clothes_2)
|
||||
.bind(req.l_clothes_3)
|
||||
.bind(req.l_clothes_4)
|
||||
.bind(req.l_clothes_5)
|
||||
.bind(req.l_clothes_6)
|
||||
.bind(req.l_clothes_7)
|
||||
.bind(req.l_clothes_8)
|
||||
// Accessories layer
|
||||
.bind(req.l_accessories_0)
|
||||
.bind(req.l_accessories_1)
|
||||
.bind(req.l_accessories_2)
|
||||
.bind(req.l_accessories_3)
|
||||
.bind(req.l_accessories_4)
|
||||
.bind(req.l_accessories_5)
|
||||
.bind(req.l_accessories_6)
|
||||
.bind(req.l_accessories_7)
|
||||
.bind(req.l_accessories_8)
|
||||
// Neutral emotion
|
||||
.bind(req.e_neutral_0)
|
||||
.bind(req.e_neutral_1)
|
||||
.bind(req.e_neutral_2)
|
||||
.bind(req.e_neutral_3)
|
||||
.bind(req.e_neutral_4)
|
||||
.bind(req.e_neutral_5)
|
||||
.bind(req.e_neutral_6)
|
||||
.bind(req.e_neutral_7)
|
||||
.bind(req.e_neutral_8)
|
||||
// Happy emotion
|
||||
.bind(req.e_happy_0)
|
||||
.bind(req.e_happy_1)
|
||||
.bind(req.e_happy_2)
|
||||
.bind(req.e_happy_3)
|
||||
.bind(req.e_happy_4)
|
||||
.bind(req.e_happy_5)
|
||||
.bind(req.e_happy_6)
|
||||
.bind(req.e_happy_7)
|
||||
.bind(req.e_happy_8)
|
||||
// Sad emotion
|
||||
.bind(req.e_sad_0)
|
||||
.bind(req.e_sad_1)
|
||||
.bind(req.e_sad_2)
|
||||
.bind(req.e_sad_3)
|
||||
.bind(req.e_sad_4)
|
||||
.bind(req.e_sad_5)
|
||||
.bind(req.e_sad_6)
|
||||
.bind(req.e_sad_7)
|
||||
.bind(req.e_sad_8)
|
||||
// Angry emotion
|
||||
.bind(req.e_angry_0)
|
||||
.bind(req.e_angry_1)
|
||||
.bind(req.e_angry_2)
|
||||
.bind(req.e_angry_3)
|
||||
.bind(req.e_angry_4)
|
||||
.bind(req.e_angry_5)
|
||||
.bind(req.e_angry_6)
|
||||
.bind(req.e_angry_7)
|
||||
.bind(req.e_angry_8)
|
||||
// Surprised emotion
|
||||
.bind(req.e_surprised_0)
|
||||
.bind(req.e_surprised_1)
|
||||
.bind(req.e_surprised_2)
|
||||
.bind(req.e_surprised_3)
|
||||
.bind(req.e_surprised_4)
|
||||
.bind(req.e_surprised_5)
|
||||
.bind(req.e_surprised_6)
|
||||
.bind(req.e_surprised_7)
|
||||
.bind(req.e_surprised_8)
|
||||
// Thinking emotion
|
||||
.bind(req.e_thinking_0)
|
||||
.bind(req.e_thinking_1)
|
||||
.bind(req.e_thinking_2)
|
||||
.bind(req.e_thinking_3)
|
||||
.bind(req.e_thinking_4)
|
||||
.bind(req.e_thinking_5)
|
||||
.bind(req.e_thinking_6)
|
||||
.bind(req.e_thinking_7)
|
||||
.bind(req.e_thinking_8)
|
||||
// Laughing emotion
|
||||
.bind(req.e_laughing_0)
|
||||
.bind(req.e_laughing_1)
|
||||
.bind(req.e_laughing_2)
|
||||
.bind(req.e_laughing_3)
|
||||
.bind(req.e_laughing_4)
|
||||
.bind(req.e_laughing_5)
|
||||
.bind(req.e_laughing_6)
|
||||
.bind(req.e_laughing_7)
|
||||
.bind(req.e_laughing_8)
|
||||
// Crying emotion
|
||||
.bind(req.e_crying_0)
|
||||
.bind(req.e_crying_1)
|
||||
.bind(req.e_crying_2)
|
||||
.bind(req.e_crying_3)
|
||||
.bind(req.e_crying_4)
|
||||
.bind(req.e_crying_5)
|
||||
.bind(req.e_crying_6)
|
||||
.bind(req.e_crying_7)
|
||||
.bind(req.e_crying_8)
|
||||
// Love emotion
|
||||
.bind(req.e_love_0)
|
||||
.bind(req.e_love_1)
|
||||
.bind(req.e_love_2)
|
||||
.bind(req.e_love_3)
|
||||
.bind(req.e_love_4)
|
||||
.bind(req.e_love_5)
|
||||
.bind(req.e_love_6)
|
||||
.bind(req.e_love_7)
|
||||
.bind(req.e_love_8)
|
||||
// Confused emotion
|
||||
.bind(req.e_confused_0)
|
||||
.bind(req.e_confused_1)
|
||||
.bind(req.e_confused_2)
|
||||
.bind(req.e_confused_3)
|
||||
.bind(req.e_confused_4)
|
||||
.bind(req.e_confused_5)
|
||||
.bind(req.e_confused_6)
|
||||
.bind(req.e_confused_7)
|
||||
.bind(req.e_confused_8)
|
||||
// Sleeping emotion
|
||||
.bind(req.e_sleeping_0)
|
||||
.bind(req.e_sleeping_1)
|
||||
.bind(req.e_sleeping_2)
|
||||
.bind(req.e_sleeping_3)
|
||||
.bind(req.e_sleeping_4)
|
||||
.bind(req.e_sleeping_5)
|
||||
.bind(req.e_sleeping_6)
|
||||
.bind(req.e_sleeping_7)
|
||||
.bind(req.e_sleeping_8)
|
||||
// Wink emotion
|
||||
.bind(req.e_wink_0)
|
||||
.bind(req.e_wink_1)
|
||||
.bind(req.e_wink_2)
|
||||
.bind(req.e_wink_3)
|
||||
.bind(req.e_wink_4)
|
||||
.bind(req.e_wink_5)
|
||||
.bind(req.e_wink_6)
|
||||
.bind(req.e_wink_7)
|
||||
.bind(req.e_wink_8)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// Update a realm avatar.
|
||||
pub async fn update_realm_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar_id: Uuid,
|
||||
req: &UpdateRealmAvatarRequest,
|
||||
) -> Result<RealmAvatar, AppError> {
|
||||
let avatar = sqlx::query_as::<_, RealmAvatar>(
|
||||
r#"
|
||||
UPDATE realm.avatars SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
is_public = COALESCE($4, is_public),
|
||||
is_active = COALESCE($5, is_active),
|
||||
thumbnail_path = COALESCE($6, thumbnail_path),
|
||||
l_skin_0 = COALESCE($7, l_skin_0),
|
||||
l_skin_1 = COALESCE($8, l_skin_1),
|
||||
l_skin_2 = COALESCE($9, l_skin_2),
|
||||
l_skin_3 = COALESCE($10, l_skin_3),
|
||||
l_skin_4 = COALESCE($11, l_skin_4),
|
||||
l_skin_5 = COALESCE($12, l_skin_5),
|
||||
l_skin_6 = COALESCE($13, l_skin_6),
|
||||
l_skin_7 = COALESCE($14, l_skin_7),
|
||||
l_skin_8 = COALESCE($15, l_skin_8),
|
||||
l_clothes_0 = COALESCE($16, l_clothes_0),
|
||||
l_clothes_1 = COALESCE($17, l_clothes_1),
|
||||
l_clothes_2 = COALESCE($18, l_clothes_2),
|
||||
l_clothes_3 = COALESCE($19, l_clothes_3),
|
||||
l_clothes_4 = COALESCE($20, l_clothes_4),
|
||||
l_clothes_5 = COALESCE($21, l_clothes_5),
|
||||
l_clothes_6 = COALESCE($22, l_clothes_6),
|
||||
l_clothes_7 = COALESCE($23, l_clothes_7),
|
||||
l_clothes_8 = COALESCE($24, l_clothes_8),
|
||||
l_accessories_0 = COALESCE($25, l_accessories_0),
|
||||
l_accessories_1 = COALESCE($26, l_accessories_1),
|
||||
l_accessories_2 = COALESCE($27, l_accessories_2),
|
||||
l_accessories_3 = COALESCE($28, l_accessories_3),
|
||||
l_accessories_4 = COALESCE($29, l_accessories_4),
|
||||
l_accessories_5 = COALESCE($30, l_accessories_5),
|
||||
l_accessories_6 = COALESCE($31, l_accessories_6),
|
||||
l_accessories_7 = COALESCE($32, l_accessories_7),
|
||||
l_accessories_8 = COALESCE($33, l_accessories_8),
|
||||
e_neutral_0 = COALESCE($34, e_neutral_0),
|
||||
e_neutral_1 = COALESCE($35, e_neutral_1),
|
||||
e_neutral_2 = COALESCE($36, e_neutral_2),
|
||||
e_neutral_3 = COALESCE($37, e_neutral_3),
|
||||
e_neutral_4 = COALESCE($38, e_neutral_4),
|
||||
e_neutral_5 = COALESCE($39, e_neutral_5),
|
||||
e_neutral_6 = COALESCE($40, e_neutral_6),
|
||||
e_neutral_7 = COALESCE($41, e_neutral_7),
|
||||
e_neutral_8 = COALESCE($42, e_neutral_8),
|
||||
e_happy_0 = COALESCE($43, e_happy_0),
|
||||
e_happy_1 = COALESCE($44, e_happy_1),
|
||||
e_happy_2 = COALESCE($45, e_happy_2),
|
||||
e_happy_3 = COALESCE($46, e_happy_3),
|
||||
e_happy_4 = COALESCE($47, e_happy_4),
|
||||
e_happy_5 = COALESCE($48, e_happy_5),
|
||||
e_happy_6 = COALESCE($49, e_happy_6),
|
||||
e_happy_7 = COALESCE($50, e_happy_7),
|
||||
e_happy_8 = COALESCE($51, e_happy_8),
|
||||
e_sad_0 = COALESCE($52, e_sad_0),
|
||||
e_sad_1 = COALESCE($53, e_sad_1),
|
||||
e_sad_2 = COALESCE($54, e_sad_2),
|
||||
e_sad_3 = COALESCE($55, e_sad_3),
|
||||
e_sad_4 = COALESCE($56, e_sad_4),
|
||||
e_sad_5 = COALESCE($57, e_sad_5),
|
||||
e_sad_6 = COALESCE($58, e_sad_6),
|
||||
e_sad_7 = COALESCE($59, e_sad_7),
|
||||
e_sad_8 = COALESCE($60, e_sad_8),
|
||||
e_angry_0 = COALESCE($61, e_angry_0),
|
||||
e_angry_1 = COALESCE($62, e_angry_1),
|
||||
e_angry_2 = COALESCE($63, e_angry_2),
|
||||
e_angry_3 = COALESCE($64, e_angry_3),
|
||||
e_angry_4 = COALESCE($65, e_angry_4),
|
||||
e_angry_5 = COALESCE($66, e_angry_5),
|
||||
e_angry_6 = COALESCE($67, e_angry_6),
|
||||
e_angry_7 = COALESCE($68, e_angry_7),
|
||||
e_angry_8 = COALESCE($69, e_angry_8),
|
||||
e_surprised_0 = COALESCE($70, e_surprised_0),
|
||||
e_surprised_1 = COALESCE($71, e_surprised_1),
|
||||
e_surprised_2 = COALESCE($72, e_surprised_2),
|
||||
e_surprised_3 = COALESCE($73, e_surprised_3),
|
||||
e_surprised_4 = COALESCE($74, e_surprised_4),
|
||||
e_surprised_5 = COALESCE($75, e_surprised_5),
|
||||
e_surprised_6 = COALESCE($76, e_surprised_6),
|
||||
e_surprised_7 = COALESCE($77, e_surprised_7),
|
||||
e_surprised_8 = COALESCE($78, e_surprised_8),
|
||||
e_thinking_0 = COALESCE($79, e_thinking_0),
|
||||
e_thinking_1 = COALESCE($80, e_thinking_1),
|
||||
e_thinking_2 = COALESCE($81, e_thinking_2),
|
||||
e_thinking_3 = COALESCE($82, e_thinking_3),
|
||||
e_thinking_4 = COALESCE($83, e_thinking_4),
|
||||
e_thinking_5 = COALESCE($84, e_thinking_5),
|
||||
e_thinking_6 = COALESCE($85, e_thinking_6),
|
||||
e_thinking_7 = COALESCE($86, e_thinking_7),
|
||||
e_thinking_8 = COALESCE($87, e_thinking_8),
|
||||
e_laughing_0 = COALESCE($88, e_laughing_0),
|
||||
e_laughing_1 = COALESCE($89, e_laughing_1),
|
||||
e_laughing_2 = COALESCE($90, e_laughing_2),
|
||||
e_laughing_3 = COALESCE($91, e_laughing_3),
|
||||
e_laughing_4 = COALESCE($92, e_laughing_4),
|
||||
e_laughing_5 = COALESCE($93, e_laughing_5),
|
||||
e_laughing_6 = COALESCE($94, e_laughing_6),
|
||||
e_laughing_7 = COALESCE($95, e_laughing_7),
|
||||
e_laughing_8 = COALESCE($96, e_laughing_8),
|
||||
e_crying_0 = COALESCE($97, e_crying_0),
|
||||
e_crying_1 = COALESCE($98, e_crying_1),
|
||||
e_crying_2 = COALESCE($99, e_crying_2),
|
||||
e_crying_3 = COALESCE($100, e_crying_3),
|
||||
e_crying_4 = COALESCE($101, e_crying_4),
|
||||
e_crying_5 = COALESCE($102, e_crying_5),
|
||||
e_crying_6 = COALESCE($103, e_crying_6),
|
||||
e_crying_7 = COALESCE($104, e_crying_7),
|
||||
e_crying_8 = COALESCE($105, e_crying_8),
|
||||
e_love_0 = COALESCE($106, e_love_0),
|
||||
e_love_1 = COALESCE($107, e_love_1),
|
||||
e_love_2 = COALESCE($108, e_love_2),
|
||||
e_love_3 = COALESCE($109, e_love_3),
|
||||
e_love_4 = COALESCE($110, e_love_4),
|
||||
e_love_5 = COALESCE($111, e_love_5),
|
||||
e_love_6 = COALESCE($112, e_love_6),
|
||||
e_love_7 = COALESCE($113, e_love_7),
|
||||
e_love_8 = COALESCE($114, e_love_8),
|
||||
e_confused_0 = COALESCE($115, e_confused_0),
|
||||
e_confused_1 = COALESCE($116, e_confused_1),
|
||||
e_confused_2 = COALESCE($117, e_confused_2),
|
||||
e_confused_3 = COALESCE($118, e_confused_3),
|
||||
e_confused_4 = COALESCE($119, e_confused_4),
|
||||
e_confused_5 = COALESCE($120, e_confused_5),
|
||||
e_confused_6 = COALESCE($121, e_confused_6),
|
||||
e_confused_7 = COALESCE($122, e_confused_7),
|
||||
e_confused_8 = COALESCE($123, e_confused_8),
|
||||
e_sleeping_0 = COALESCE($124, e_sleeping_0),
|
||||
e_sleeping_1 = COALESCE($125, e_sleeping_1),
|
||||
e_sleeping_2 = COALESCE($126, e_sleeping_2),
|
||||
e_sleeping_3 = COALESCE($127, e_sleeping_3),
|
||||
e_sleeping_4 = COALESCE($128, e_sleeping_4),
|
||||
e_sleeping_5 = COALESCE($129, e_sleeping_5),
|
||||
e_sleeping_6 = COALESCE($130, e_sleeping_6),
|
||||
e_sleeping_7 = COALESCE($131, e_sleeping_7),
|
||||
e_sleeping_8 = COALESCE($132, e_sleeping_8),
|
||||
e_wink_0 = COALESCE($133, e_wink_0),
|
||||
e_wink_1 = COALESCE($134, e_wink_1),
|
||||
e_wink_2 = COALESCE($135, e_wink_2),
|
||||
e_wink_3 = COALESCE($136, e_wink_3),
|
||||
e_wink_4 = COALESCE($137, e_wink_4),
|
||||
e_wink_5 = COALESCE($138, e_wink_5),
|
||||
e_wink_6 = COALESCE($139, e_wink_6),
|
||||
e_wink_7 = COALESCE($140, e_wink_7),
|
||||
e_wink_8 = COALESCE($141, e_wink_8),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(avatar_id)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(req.is_public)
|
||||
.bind(req.is_active)
|
||||
.bind(&req.thumbnail_path)
|
||||
// Skin layer
|
||||
.bind(req.l_skin_0)
|
||||
.bind(req.l_skin_1)
|
||||
.bind(req.l_skin_2)
|
||||
.bind(req.l_skin_3)
|
||||
.bind(req.l_skin_4)
|
||||
.bind(req.l_skin_5)
|
||||
.bind(req.l_skin_6)
|
||||
.bind(req.l_skin_7)
|
||||
.bind(req.l_skin_8)
|
||||
// Clothes layer
|
||||
.bind(req.l_clothes_0)
|
||||
.bind(req.l_clothes_1)
|
||||
.bind(req.l_clothes_2)
|
||||
.bind(req.l_clothes_3)
|
||||
.bind(req.l_clothes_4)
|
||||
.bind(req.l_clothes_5)
|
||||
.bind(req.l_clothes_6)
|
||||
.bind(req.l_clothes_7)
|
||||
.bind(req.l_clothes_8)
|
||||
// Accessories layer
|
||||
.bind(req.l_accessories_0)
|
||||
.bind(req.l_accessories_1)
|
||||
.bind(req.l_accessories_2)
|
||||
.bind(req.l_accessories_3)
|
||||
.bind(req.l_accessories_4)
|
||||
.bind(req.l_accessories_5)
|
||||
.bind(req.l_accessories_6)
|
||||
.bind(req.l_accessories_7)
|
||||
.bind(req.l_accessories_8)
|
||||
// Neutral emotion
|
||||
.bind(req.e_neutral_0)
|
||||
.bind(req.e_neutral_1)
|
||||
.bind(req.e_neutral_2)
|
||||
.bind(req.e_neutral_3)
|
||||
.bind(req.e_neutral_4)
|
||||
.bind(req.e_neutral_5)
|
||||
.bind(req.e_neutral_6)
|
||||
.bind(req.e_neutral_7)
|
||||
.bind(req.e_neutral_8)
|
||||
// Happy emotion
|
||||
.bind(req.e_happy_0)
|
||||
.bind(req.e_happy_1)
|
||||
.bind(req.e_happy_2)
|
||||
.bind(req.e_happy_3)
|
||||
.bind(req.e_happy_4)
|
||||
.bind(req.e_happy_5)
|
||||
.bind(req.e_happy_6)
|
||||
.bind(req.e_happy_7)
|
||||
.bind(req.e_happy_8)
|
||||
// Sad emotion
|
||||
.bind(req.e_sad_0)
|
||||
.bind(req.e_sad_1)
|
||||
.bind(req.e_sad_2)
|
||||
.bind(req.e_sad_3)
|
||||
.bind(req.e_sad_4)
|
||||
.bind(req.e_sad_5)
|
||||
.bind(req.e_sad_6)
|
||||
.bind(req.e_sad_7)
|
||||
.bind(req.e_sad_8)
|
||||
// Angry emotion
|
||||
.bind(req.e_angry_0)
|
||||
.bind(req.e_angry_1)
|
||||
.bind(req.e_angry_2)
|
||||
.bind(req.e_angry_3)
|
||||
.bind(req.e_angry_4)
|
||||
.bind(req.e_angry_5)
|
||||
.bind(req.e_angry_6)
|
||||
.bind(req.e_angry_7)
|
||||
.bind(req.e_angry_8)
|
||||
// Surprised emotion
|
||||
.bind(req.e_surprised_0)
|
||||
.bind(req.e_surprised_1)
|
||||
.bind(req.e_surprised_2)
|
||||
.bind(req.e_surprised_3)
|
||||
.bind(req.e_surprised_4)
|
||||
.bind(req.e_surprised_5)
|
||||
.bind(req.e_surprised_6)
|
||||
.bind(req.e_surprised_7)
|
||||
.bind(req.e_surprised_8)
|
||||
// Thinking emotion
|
||||
.bind(req.e_thinking_0)
|
||||
.bind(req.e_thinking_1)
|
||||
.bind(req.e_thinking_2)
|
||||
.bind(req.e_thinking_3)
|
||||
.bind(req.e_thinking_4)
|
||||
.bind(req.e_thinking_5)
|
||||
.bind(req.e_thinking_6)
|
||||
.bind(req.e_thinking_7)
|
||||
.bind(req.e_thinking_8)
|
||||
// Laughing emotion
|
||||
.bind(req.e_laughing_0)
|
||||
.bind(req.e_laughing_1)
|
||||
.bind(req.e_laughing_2)
|
||||
.bind(req.e_laughing_3)
|
||||
.bind(req.e_laughing_4)
|
||||
.bind(req.e_laughing_5)
|
||||
.bind(req.e_laughing_6)
|
||||
.bind(req.e_laughing_7)
|
||||
.bind(req.e_laughing_8)
|
||||
// Crying emotion
|
||||
.bind(req.e_crying_0)
|
||||
.bind(req.e_crying_1)
|
||||
.bind(req.e_crying_2)
|
||||
.bind(req.e_crying_3)
|
||||
.bind(req.e_crying_4)
|
||||
.bind(req.e_crying_5)
|
||||
.bind(req.e_crying_6)
|
||||
.bind(req.e_crying_7)
|
||||
.bind(req.e_crying_8)
|
||||
// Love emotion
|
||||
.bind(req.e_love_0)
|
||||
.bind(req.e_love_1)
|
||||
.bind(req.e_love_2)
|
||||
.bind(req.e_love_3)
|
||||
.bind(req.e_love_4)
|
||||
.bind(req.e_love_5)
|
||||
.bind(req.e_love_6)
|
||||
.bind(req.e_love_7)
|
||||
.bind(req.e_love_8)
|
||||
// Confused emotion
|
||||
.bind(req.e_confused_0)
|
||||
.bind(req.e_confused_1)
|
||||
.bind(req.e_confused_2)
|
||||
.bind(req.e_confused_3)
|
||||
.bind(req.e_confused_4)
|
||||
.bind(req.e_confused_5)
|
||||
.bind(req.e_confused_6)
|
||||
.bind(req.e_confused_7)
|
||||
.bind(req.e_confused_8)
|
||||
// Sleeping emotion
|
||||
.bind(req.e_sleeping_0)
|
||||
.bind(req.e_sleeping_1)
|
||||
.bind(req.e_sleeping_2)
|
||||
.bind(req.e_sleeping_3)
|
||||
.bind(req.e_sleeping_4)
|
||||
.bind(req.e_sleeping_5)
|
||||
.bind(req.e_sleeping_6)
|
||||
.bind(req.e_sleeping_7)
|
||||
.bind(req.e_sleeping_8)
|
||||
// Wink emotion
|
||||
.bind(req.e_wink_0)
|
||||
.bind(req.e_wink_1)
|
||||
.bind(req.e_wink_2)
|
||||
.bind(req.e_wink_3)
|
||||
.bind(req.e_wink_4)
|
||||
.bind(req.e_wink_5)
|
||||
.bind(req.e_wink_6)
|
||||
.bind(req.e_wink_7)
|
||||
.bind(req.e_wink_8)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// Delete a realm avatar.
|
||||
pub async fn delete_realm_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM realm.avatars
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(avatar_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
902
crates/chattyness-db/src/queries/server_avatars.rs
Normal file
902
crates/chattyness-db/src/queries/server_avatars.rs
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
//! Server avatar queries.
|
||||
//!
|
||||
//! Server avatars are pre-configured avatar configurations available globally
|
||||
//! across all realms. They reference server.props directly (not inventory items).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use sqlx::PgExecutor;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{AvatarRenderData, EmotionState, ServerAvatar};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// Get a server avatar by slug.
|
||||
pub async fn get_server_avatar_by_slug<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
slug: &str,
|
||||
) -> Result<Option<ServerAvatar>, AppError> {
|
||||
let avatar = sqlx::query_as::<_, ServerAvatar>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM server.avatars
|
||||
WHERE slug = $1 AND is_active = true
|
||||
"#,
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// Get a server avatar by ID.
|
||||
pub async fn get_server_avatar_by_id<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar_id: Uuid,
|
||||
) -> Result<Option<ServerAvatar>, AppError> {
|
||||
let avatar = sqlx::query_as::<_, ServerAvatar>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM server.avatars
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(avatar_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// List all active public server avatars.
|
||||
pub async fn list_public_server_avatars<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
) -> Result<Vec<ServerAvatar>, AppError> {
|
||||
let avatars = sqlx::query_as::<_, ServerAvatar>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM server.avatars
|
||||
WHERE is_active = true AND is_public = true
|
||||
ORDER BY name ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatars)
|
||||
}
|
||||
|
||||
/// Row type for prop asset lookup.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct PropAssetRow {
|
||||
id: Uuid,
|
||||
asset_path: String,
|
||||
}
|
||||
|
||||
/// Resolve a server avatar to render data.
|
||||
/// Joins the avatar's prop UUIDs with server.props to get asset paths.
|
||||
pub async fn resolve_server_avatar_to_render_data<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar: &ServerAvatar,
|
||||
current_emotion: EmotionState,
|
||||
) -> Result<AvatarRenderData, AppError> {
|
||||
// Collect all non-null prop UUIDs
|
||||
let mut prop_ids: Vec<Uuid> = Vec::new();
|
||||
|
||||
// Content layers
|
||||
for id in [
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
].iter().flatten() {
|
||||
prop_ids.push(*id);
|
||||
}
|
||||
|
||||
// Get emotion layer slots based on 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],
|
||||
};
|
||||
|
||||
for id in emotion_slots.iter().flatten() {
|
||||
prop_ids.push(*id);
|
||||
}
|
||||
|
||||
// Bulk lookup all prop asset paths
|
||||
let prop_map: HashMap<Uuid, String> = if prop_ids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
let rows = sqlx::query_as::<_, PropAssetRow>(
|
||||
r#"
|
||||
SELECT id, asset_path
|
||||
FROM server.props
|
||||
WHERE id = ANY($1)
|
||||
"#,
|
||||
)
|
||||
.bind(&prop_ids)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(|r| (r.id, r.asset_path)).collect()
|
||||
};
|
||||
|
||||
// Helper to look up path
|
||||
let get_path = |id: Option<Uuid>| -> Option<String> {
|
||||
id.and_then(|id| prop_map.get(&id).cloned())
|
||||
};
|
||||
|
||||
Ok(AvatarRenderData {
|
||||
avatar_id: 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]),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a forced server avatar to a user.
|
||||
pub async fn apply_forced_server_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
avatar_id: Uuid,
|
||||
forced_by: Option<Uuid>,
|
||||
duration: Option<Duration>,
|
||||
) -> Result<(), AppError> {
|
||||
let forced_until = duration.map(|d| Utc::now() + d);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.active_avatars
|
||||
SET
|
||||
forced_avatar_id = $3,
|
||||
forced_avatar_source = 'server',
|
||||
forced_by = $4,
|
||||
forced_until = $5,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.bind(avatar_id)
|
||||
.bind(forced_by)
|
||||
.bind(forced_until)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the forced avatar for a user.
|
||||
pub async fn clear_forced_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.active_avatars
|
||||
SET
|
||||
forced_avatar_id = NULL,
|
||||
forced_avatar_source = NULL,
|
||||
forced_by = NULL,
|
||||
forced_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND realm_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a user has an active forced avatar (not expired).
|
||||
pub async fn has_active_forced_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
) -> Result<bool, AppError> {
|
||||
let result: (bool,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM auth.active_avatars
|
||||
WHERE user_id = $1
|
||||
AND realm_id = $2
|
||||
AND forced_avatar_id IS NOT NULL
|
||||
AND (forced_until IS NULL OR forced_until > now())
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.0)
|
||||
}
|
||||
|
||||
/// Get the forced avatar info for a user if active.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct ForcedAvatarInfo {
|
||||
pub forced_avatar_id: Uuid,
|
||||
pub forced_avatar_source: String,
|
||||
pub forced_by: Option<Uuid>,
|
||||
pub forced_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn get_forced_avatar_info<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
) -> Result<Option<ForcedAvatarInfo>, AppError> {
|
||||
let info = sqlx::query_as::<_, ForcedAvatarInfo>(
|
||||
r#"
|
||||
SELECT
|
||||
forced_avatar_id,
|
||||
forced_avatar_source,
|
||||
forced_by,
|
||||
forced_until
|
||||
FROM auth.active_avatars
|
||||
WHERE user_id = $1
|
||||
AND realm_id = $2
|
||||
AND forced_avatar_id IS NOT NULL
|
||||
AND (forced_until IS NULL OR forced_until > now())
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CRUD Operations for Admin API
|
||||
// =============================================================================
|
||||
|
||||
use crate::models::{CreateServerAvatarRequest, ServerAvatarSummary, UpdateServerAvatarRequest};
|
||||
|
||||
/// List all server avatars (for admin).
|
||||
pub async fn list_all_server_avatars<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
) -> Result<Vec<ServerAvatarSummary>, AppError> {
|
||||
let avatars = sqlx::query_as::<_, ServerAvatarSummary>(
|
||||
r#"
|
||||
SELECT id, slug, name, description, is_public, is_active, thumbnail_path, created_at
|
||||
FROM server.avatars
|
||||
ORDER BY name ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatars)
|
||||
}
|
||||
|
||||
/// Check if a server avatar slug is available.
|
||||
pub async fn is_avatar_slug_available<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
slug: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let result: (bool,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT NOT EXISTS(
|
||||
SELECT 1 FROM server.avatars WHERE slug = $1
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.0)
|
||||
}
|
||||
|
||||
/// Create a new server avatar.
|
||||
pub async fn create_server_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
req: &CreateServerAvatarRequest,
|
||||
created_by: Option<Uuid>,
|
||||
) -> Result<ServerAvatar, AppError> {
|
||||
let slug = req.slug_or_generate();
|
||||
|
||||
let avatar = sqlx::query_as::<_, ServerAvatar>(
|
||||
r#"
|
||||
INSERT INTO server.avatars (
|
||||
slug, name, description, is_public, is_active, thumbnail_path, created_by,
|
||||
l_skin_0, l_skin_1, l_skin_2, l_skin_3, l_skin_4, l_skin_5, l_skin_6, l_skin_7, l_skin_8,
|
||||
l_clothes_0, l_clothes_1, l_clothes_2, l_clothes_3, l_clothes_4, l_clothes_5, l_clothes_6, l_clothes_7, l_clothes_8,
|
||||
l_accessories_0, l_accessories_1, l_accessories_2, l_accessories_3, l_accessories_4, l_accessories_5, l_accessories_6, l_accessories_7, l_accessories_8,
|
||||
e_neutral_0, e_neutral_1, e_neutral_2, e_neutral_3, e_neutral_4, e_neutral_5, e_neutral_6, e_neutral_7, e_neutral_8,
|
||||
e_happy_0, e_happy_1, e_happy_2, e_happy_3, e_happy_4, e_happy_5, e_happy_6, e_happy_7, e_happy_8,
|
||||
e_sad_0, e_sad_1, e_sad_2, e_sad_3, e_sad_4, e_sad_5, e_sad_6, e_sad_7, e_sad_8,
|
||||
e_angry_0, e_angry_1, e_angry_2, e_angry_3, e_angry_4, e_angry_5, e_angry_6, e_angry_7, e_angry_8,
|
||||
e_surprised_0, e_surprised_1, e_surprised_2, e_surprised_3, e_surprised_4, e_surprised_5, e_surprised_6, e_surprised_7, e_surprised_8,
|
||||
e_thinking_0, e_thinking_1, e_thinking_2, e_thinking_3, e_thinking_4, e_thinking_5, e_thinking_6, e_thinking_7, e_thinking_8,
|
||||
e_laughing_0, e_laughing_1, e_laughing_2, e_laughing_3, e_laughing_4, e_laughing_5, e_laughing_6, e_laughing_7, e_laughing_8,
|
||||
e_crying_0, e_crying_1, e_crying_2, e_crying_3, e_crying_4, e_crying_5, e_crying_6, e_crying_7, e_crying_8,
|
||||
e_love_0, e_love_1, e_love_2, e_love_3, e_love_4, e_love_5, e_love_6, e_love_7, e_love_8,
|
||||
e_confused_0, e_confused_1, e_confused_2, e_confused_3, e_confused_4, e_confused_5, e_confused_6, e_confused_7, e_confused_8,
|
||||
e_sleeping_0, e_sleeping_1, e_sleeping_2, e_sleeping_3, e_sleeping_4, e_sleeping_5, e_sleeping_6, e_sleeping_7, e_sleeping_8,
|
||||
e_wink_0, e_wink_1, e_wink_2, e_wink_3, e_wink_4, e_wink_5, e_wink_6, e_wink_7, e_wink_8
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, true, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||
$16, $17, $18, $19, $20, $21, $22, $23, $24,
|
||||
$25, $26, $27, $28, $29, $30, $31, $32, $33,
|
||||
$34, $35, $36, $37, $38, $39, $40, $41, $42,
|
||||
$43, $44, $45, $46, $47, $48, $49, $50, $51,
|
||||
$52, $53, $54, $55, $56, $57, $58, $59, $60,
|
||||
$61, $62, $63, $64, $65, $66, $67, $68, $69,
|
||||
$70, $71, $72, $73, $74, $75, $76, $77, $78,
|
||||
$79, $80, $81, $82, $83, $84, $85, $86, $87,
|
||||
$88, $89, $90, $91, $92, $93, $94, $95, $96,
|
||||
$97, $98, $99, $100, $101, $102, $103, $104, $105,
|
||||
$106, $107, $108, $109, $110, $111, $112, $113, $114,
|
||||
$115, $116, $117, $118, $119, $120, $121, $122, $123,
|
||||
$124, $125, $126, $127, $128, $129, $130, $131, $132,
|
||||
$133, $134, $135, $136, $137, $138, $139, $140, $141
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(&slug)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(req.is_public)
|
||||
.bind(&req.thumbnail_path)
|
||||
.bind(created_by)
|
||||
// Skin layer
|
||||
.bind(req.l_skin_0)
|
||||
.bind(req.l_skin_1)
|
||||
.bind(req.l_skin_2)
|
||||
.bind(req.l_skin_3)
|
||||
.bind(req.l_skin_4)
|
||||
.bind(req.l_skin_5)
|
||||
.bind(req.l_skin_6)
|
||||
.bind(req.l_skin_7)
|
||||
.bind(req.l_skin_8)
|
||||
// Clothes layer
|
||||
.bind(req.l_clothes_0)
|
||||
.bind(req.l_clothes_1)
|
||||
.bind(req.l_clothes_2)
|
||||
.bind(req.l_clothes_3)
|
||||
.bind(req.l_clothes_4)
|
||||
.bind(req.l_clothes_5)
|
||||
.bind(req.l_clothes_6)
|
||||
.bind(req.l_clothes_7)
|
||||
.bind(req.l_clothes_8)
|
||||
// Accessories layer
|
||||
.bind(req.l_accessories_0)
|
||||
.bind(req.l_accessories_1)
|
||||
.bind(req.l_accessories_2)
|
||||
.bind(req.l_accessories_3)
|
||||
.bind(req.l_accessories_4)
|
||||
.bind(req.l_accessories_5)
|
||||
.bind(req.l_accessories_6)
|
||||
.bind(req.l_accessories_7)
|
||||
.bind(req.l_accessories_8)
|
||||
// Neutral emotion
|
||||
.bind(req.e_neutral_0)
|
||||
.bind(req.e_neutral_1)
|
||||
.bind(req.e_neutral_2)
|
||||
.bind(req.e_neutral_3)
|
||||
.bind(req.e_neutral_4)
|
||||
.bind(req.e_neutral_5)
|
||||
.bind(req.e_neutral_6)
|
||||
.bind(req.e_neutral_7)
|
||||
.bind(req.e_neutral_8)
|
||||
// Happy emotion
|
||||
.bind(req.e_happy_0)
|
||||
.bind(req.e_happy_1)
|
||||
.bind(req.e_happy_2)
|
||||
.bind(req.e_happy_3)
|
||||
.bind(req.e_happy_4)
|
||||
.bind(req.e_happy_5)
|
||||
.bind(req.e_happy_6)
|
||||
.bind(req.e_happy_7)
|
||||
.bind(req.e_happy_8)
|
||||
// Sad emotion
|
||||
.bind(req.e_sad_0)
|
||||
.bind(req.e_sad_1)
|
||||
.bind(req.e_sad_2)
|
||||
.bind(req.e_sad_3)
|
||||
.bind(req.e_sad_4)
|
||||
.bind(req.e_sad_5)
|
||||
.bind(req.e_sad_6)
|
||||
.bind(req.e_sad_7)
|
||||
.bind(req.e_sad_8)
|
||||
// Angry emotion
|
||||
.bind(req.e_angry_0)
|
||||
.bind(req.e_angry_1)
|
||||
.bind(req.e_angry_2)
|
||||
.bind(req.e_angry_3)
|
||||
.bind(req.e_angry_4)
|
||||
.bind(req.e_angry_5)
|
||||
.bind(req.e_angry_6)
|
||||
.bind(req.e_angry_7)
|
||||
.bind(req.e_angry_8)
|
||||
// Surprised emotion
|
||||
.bind(req.e_surprised_0)
|
||||
.bind(req.e_surprised_1)
|
||||
.bind(req.e_surprised_2)
|
||||
.bind(req.e_surprised_3)
|
||||
.bind(req.e_surprised_4)
|
||||
.bind(req.e_surprised_5)
|
||||
.bind(req.e_surprised_6)
|
||||
.bind(req.e_surprised_7)
|
||||
.bind(req.e_surprised_8)
|
||||
// Thinking emotion
|
||||
.bind(req.e_thinking_0)
|
||||
.bind(req.e_thinking_1)
|
||||
.bind(req.e_thinking_2)
|
||||
.bind(req.e_thinking_3)
|
||||
.bind(req.e_thinking_4)
|
||||
.bind(req.e_thinking_5)
|
||||
.bind(req.e_thinking_6)
|
||||
.bind(req.e_thinking_7)
|
||||
.bind(req.e_thinking_8)
|
||||
// Laughing emotion
|
||||
.bind(req.e_laughing_0)
|
||||
.bind(req.e_laughing_1)
|
||||
.bind(req.e_laughing_2)
|
||||
.bind(req.e_laughing_3)
|
||||
.bind(req.e_laughing_4)
|
||||
.bind(req.e_laughing_5)
|
||||
.bind(req.e_laughing_6)
|
||||
.bind(req.e_laughing_7)
|
||||
.bind(req.e_laughing_8)
|
||||
// Crying emotion
|
||||
.bind(req.e_crying_0)
|
||||
.bind(req.e_crying_1)
|
||||
.bind(req.e_crying_2)
|
||||
.bind(req.e_crying_3)
|
||||
.bind(req.e_crying_4)
|
||||
.bind(req.e_crying_5)
|
||||
.bind(req.e_crying_6)
|
||||
.bind(req.e_crying_7)
|
||||
.bind(req.e_crying_8)
|
||||
// Love emotion
|
||||
.bind(req.e_love_0)
|
||||
.bind(req.e_love_1)
|
||||
.bind(req.e_love_2)
|
||||
.bind(req.e_love_3)
|
||||
.bind(req.e_love_4)
|
||||
.bind(req.e_love_5)
|
||||
.bind(req.e_love_6)
|
||||
.bind(req.e_love_7)
|
||||
.bind(req.e_love_8)
|
||||
// Confused emotion
|
||||
.bind(req.e_confused_0)
|
||||
.bind(req.e_confused_1)
|
||||
.bind(req.e_confused_2)
|
||||
.bind(req.e_confused_3)
|
||||
.bind(req.e_confused_4)
|
||||
.bind(req.e_confused_5)
|
||||
.bind(req.e_confused_6)
|
||||
.bind(req.e_confused_7)
|
||||
.bind(req.e_confused_8)
|
||||
// Sleeping emotion
|
||||
.bind(req.e_sleeping_0)
|
||||
.bind(req.e_sleeping_1)
|
||||
.bind(req.e_sleeping_2)
|
||||
.bind(req.e_sleeping_3)
|
||||
.bind(req.e_sleeping_4)
|
||||
.bind(req.e_sleeping_5)
|
||||
.bind(req.e_sleeping_6)
|
||||
.bind(req.e_sleeping_7)
|
||||
.bind(req.e_sleeping_8)
|
||||
// Wink emotion
|
||||
.bind(req.e_wink_0)
|
||||
.bind(req.e_wink_1)
|
||||
.bind(req.e_wink_2)
|
||||
.bind(req.e_wink_3)
|
||||
.bind(req.e_wink_4)
|
||||
.bind(req.e_wink_5)
|
||||
.bind(req.e_wink_6)
|
||||
.bind(req.e_wink_7)
|
||||
.bind(req.e_wink_8)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// Update a server avatar.
|
||||
pub async fn update_server_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar_id: Uuid,
|
||||
req: &UpdateServerAvatarRequest,
|
||||
) -> Result<ServerAvatar, AppError> {
|
||||
let avatar = sqlx::query_as::<_, ServerAvatar>(
|
||||
r#"
|
||||
UPDATE server.avatars SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
is_public = COALESCE($4, is_public),
|
||||
is_active = COALESCE($5, is_active),
|
||||
thumbnail_path = COALESCE($6, thumbnail_path),
|
||||
l_skin_0 = COALESCE($7, l_skin_0),
|
||||
l_skin_1 = COALESCE($8, l_skin_1),
|
||||
l_skin_2 = COALESCE($9, l_skin_2),
|
||||
l_skin_3 = COALESCE($10, l_skin_3),
|
||||
l_skin_4 = COALESCE($11, l_skin_4),
|
||||
l_skin_5 = COALESCE($12, l_skin_5),
|
||||
l_skin_6 = COALESCE($13, l_skin_6),
|
||||
l_skin_7 = COALESCE($14, l_skin_7),
|
||||
l_skin_8 = COALESCE($15, l_skin_8),
|
||||
l_clothes_0 = COALESCE($16, l_clothes_0),
|
||||
l_clothes_1 = COALESCE($17, l_clothes_1),
|
||||
l_clothes_2 = COALESCE($18, l_clothes_2),
|
||||
l_clothes_3 = COALESCE($19, l_clothes_3),
|
||||
l_clothes_4 = COALESCE($20, l_clothes_4),
|
||||
l_clothes_5 = COALESCE($21, l_clothes_5),
|
||||
l_clothes_6 = COALESCE($22, l_clothes_6),
|
||||
l_clothes_7 = COALESCE($23, l_clothes_7),
|
||||
l_clothes_8 = COALESCE($24, l_clothes_8),
|
||||
l_accessories_0 = COALESCE($25, l_accessories_0),
|
||||
l_accessories_1 = COALESCE($26, l_accessories_1),
|
||||
l_accessories_2 = COALESCE($27, l_accessories_2),
|
||||
l_accessories_3 = COALESCE($28, l_accessories_3),
|
||||
l_accessories_4 = COALESCE($29, l_accessories_4),
|
||||
l_accessories_5 = COALESCE($30, l_accessories_5),
|
||||
l_accessories_6 = COALESCE($31, l_accessories_6),
|
||||
l_accessories_7 = COALESCE($32, l_accessories_7),
|
||||
l_accessories_8 = COALESCE($33, l_accessories_8),
|
||||
e_neutral_0 = COALESCE($34, e_neutral_0),
|
||||
e_neutral_1 = COALESCE($35, e_neutral_1),
|
||||
e_neutral_2 = COALESCE($36, e_neutral_2),
|
||||
e_neutral_3 = COALESCE($37, e_neutral_3),
|
||||
e_neutral_4 = COALESCE($38, e_neutral_4),
|
||||
e_neutral_5 = COALESCE($39, e_neutral_5),
|
||||
e_neutral_6 = COALESCE($40, e_neutral_6),
|
||||
e_neutral_7 = COALESCE($41, e_neutral_7),
|
||||
e_neutral_8 = COALESCE($42, e_neutral_8),
|
||||
e_happy_0 = COALESCE($43, e_happy_0),
|
||||
e_happy_1 = COALESCE($44, e_happy_1),
|
||||
e_happy_2 = COALESCE($45, e_happy_2),
|
||||
e_happy_3 = COALESCE($46, e_happy_3),
|
||||
e_happy_4 = COALESCE($47, e_happy_4),
|
||||
e_happy_5 = COALESCE($48, e_happy_5),
|
||||
e_happy_6 = COALESCE($49, e_happy_6),
|
||||
e_happy_7 = COALESCE($50, e_happy_7),
|
||||
e_happy_8 = COALESCE($51, e_happy_8),
|
||||
e_sad_0 = COALESCE($52, e_sad_0),
|
||||
e_sad_1 = COALESCE($53, e_sad_1),
|
||||
e_sad_2 = COALESCE($54, e_sad_2),
|
||||
e_sad_3 = COALESCE($55, e_sad_3),
|
||||
e_sad_4 = COALESCE($56, e_sad_4),
|
||||
e_sad_5 = COALESCE($57, e_sad_5),
|
||||
e_sad_6 = COALESCE($58, e_sad_6),
|
||||
e_sad_7 = COALESCE($59, e_sad_7),
|
||||
e_sad_8 = COALESCE($60, e_sad_8),
|
||||
e_angry_0 = COALESCE($61, e_angry_0),
|
||||
e_angry_1 = COALESCE($62, e_angry_1),
|
||||
e_angry_2 = COALESCE($63, e_angry_2),
|
||||
e_angry_3 = COALESCE($64, e_angry_3),
|
||||
e_angry_4 = COALESCE($65, e_angry_4),
|
||||
e_angry_5 = COALESCE($66, e_angry_5),
|
||||
e_angry_6 = COALESCE($67, e_angry_6),
|
||||
e_angry_7 = COALESCE($68, e_angry_7),
|
||||
e_angry_8 = COALESCE($69, e_angry_8),
|
||||
e_surprised_0 = COALESCE($70, e_surprised_0),
|
||||
e_surprised_1 = COALESCE($71, e_surprised_1),
|
||||
e_surprised_2 = COALESCE($72, e_surprised_2),
|
||||
e_surprised_3 = COALESCE($73, e_surprised_3),
|
||||
e_surprised_4 = COALESCE($74, e_surprised_4),
|
||||
e_surprised_5 = COALESCE($75, e_surprised_5),
|
||||
e_surprised_6 = COALESCE($76, e_surprised_6),
|
||||
e_surprised_7 = COALESCE($77, e_surprised_7),
|
||||
e_surprised_8 = COALESCE($78, e_surprised_8),
|
||||
e_thinking_0 = COALESCE($79, e_thinking_0),
|
||||
e_thinking_1 = COALESCE($80, e_thinking_1),
|
||||
e_thinking_2 = COALESCE($81, e_thinking_2),
|
||||
e_thinking_3 = COALESCE($82, e_thinking_3),
|
||||
e_thinking_4 = COALESCE($83, e_thinking_4),
|
||||
e_thinking_5 = COALESCE($84, e_thinking_5),
|
||||
e_thinking_6 = COALESCE($85, e_thinking_6),
|
||||
e_thinking_7 = COALESCE($86, e_thinking_7),
|
||||
e_thinking_8 = COALESCE($87, e_thinking_8),
|
||||
e_laughing_0 = COALESCE($88, e_laughing_0),
|
||||
e_laughing_1 = COALESCE($89, e_laughing_1),
|
||||
e_laughing_2 = COALESCE($90, e_laughing_2),
|
||||
e_laughing_3 = COALESCE($91, e_laughing_3),
|
||||
e_laughing_4 = COALESCE($92, e_laughing_4),
|
||||
e_laughing_5 = COALESCE($93, e_laughing_5),
|
||||
e_laughing_6 = COALESCE($94, e_laughing_6),
|
||||
e_laughing_7 = COALESCE($95, e_laughing_7),
|
||||
e_laughing_8 = COALESCE($96, e_laughing_8),
|
||||
e_crying_0 = COALESCE($97, e_crying_0),
|
||||
e_crying_1 = COALESCE($98, e_crying_1),
|
||||
e_crying_2 = COALESCE($99, e_crying_2),
|
||||
e_crying_3 = COALESCE($100, e_crying_3),
|
||||
e_crying_4 = COALESCE($101, e_crying_4),
|
||||
e_crying_5 = COALESCE($102, e_crying_5),
|
||||
e_crying_6 = COALESCE($103, e_crying_6),
|
||||
e_crying_7 = COALESCE($104, e_crying_7),
|
||||
e_crying_8 = COALESCE($105, e_crying_8),
|
||||
e_love_0 = COALESCE($106, e_love_0),
|
||||
e_love_1 = COALESCE($107, e_love_1),
|
||||
e_love_2 = COALESCE($108, e_love_2),
|
||||
e_love_3 = COALESCE($109, e_love_3),
|
||||
e_love_4 = COALESCE($110, e_love_4),
|
||||
e_love_5 = COALESCE($111, e_love_5),
|
||||
e_love_6 = COALESCE($112, e_love_6),
|
||||
e_love_7 = COALESCE($113, e_love_7),
|
||||
e_love_8 = COALESCE($114, e_love_8),
|
||||
e_confused_0 = COALESCE($115, e_confused_0),
|
||||
e_confused_1 = COALESCE($116, e_confused_1),
|
||||
e_confused_2 = COALESCE($117, e_confused_2),
|
||||
e_confused_3 = COALESCE($118, e_confused_3),
|
||||
e_confused_4 = COALESCE($119, e_confused_4),
|
||||
e_confused_5 = COALESCE($120, e_confused_5),
|
||||
e_confused_6 = COALESCE($121, e_confused_6),
|
||||
e_confused_7 = COALESCE($122, e_confused_7),
|
||||
e_confused_8 = COALESCE($123, e_confused_8),
|
||||
e_sleeping_0 = COALESCE($124, e_sleeping_0),
|
||||
e_sleeping_1 = COALESCE($125, e_sleeping_1),
|
||||
e_sleeping_2 = COALESCE($126, e_sleeping_2),
|
||||
e_sleeping_3 = COALESCE($127, e_sleeping_3),
|
||||
e_sleeping_4 = COALESCE($128, e_sleeping_4),
|
||||
e_sleeping_5 = COALESCE($129, e_sleeping_5),
|
||||
e_sleeping_6 = COALESCE($130, e_sleeping_6),
|
||||
e_sleeping_7 = COALESCE($131, e_sleeping_7),
|
||||
e_sleeping_8 = COALESCE($132, e_sleeping_8),
|
||||
e_wink_0 = COALESCE($133, e_wink_0),
|
||||
e_wink_1 = COALESCE($134, e_wink_1),
|
||||
e_wink_2 = COALESCE($135, e_wink_2),
|
||||
e_wink_3 = COALESCE($136, e_wink_3),
|
||||
e_wink_4 = COALESCE($137, e_wink_4),
|
||||
e_wink_5 = COALESCE($138, e_wink_5),
|
||||
e_wink_6 = COALESCE($139, e_wink_6),
|
||||
e_wink_7 = COALESCE($140, e_wink_7),
|
||||
e_wink_8 = COALESCE($141, e_wink_8),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(avatar_id)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(req.is_public)
|
||||
.bind(req.is_active)
|
||||
.bind(&req.thumbnail_path)
|
||||
// Skin layer
|
||||
.bind(req.l_skin_0)
|
||||
.bind(req.l_skin_1)
|
||||
.bind(req.l_skin_2)
|
||||
.bind(req.l_skin_3)
|
||||
.bind(req.l_skin_4)
|
||||
.bind(req.l_skin_5)
|
||||
.bind(req.l_skin_6)
|
||||
.bind(req.l_skin_7)
|
||||
.bind(req.l_skin_8)
|
||||
// Clothes layer
|
||||
.bind(req.l_clothes_0)
|
||||
.bind(req.l_clothes_1)
|
||||
.bind(req.l_clothes_2)
|
||||
.bind(req.l_clothes_3)
|
||||
.bind(req.l_clothes_4)
|
||||
.bind(req.l_clothes_5)
|
||||
.bind(req.l_clothes_6)
|
||||
.bind(req.l_clothes_7)
|
||||
.bind(req.l_clothes_8)
|
||||
// Accessories layer
|
||||
.bind(req.l_accessories_0)
|
||||
.bind(req.l_accessories_1)
|
||||
.bind(req.l_accessories_2)
|
||||
.bind(req.l_accessories_3)
|
||||
.bind(req.l_accessories_4)
|
||||
.bind(req.l_accessories_5)
|
||||
.bind(req.l_accessories_6)
|
||||
.bind(req.l_accessories_7)
|
||||
.bind(req.l_accessories_8)
|
||||
// Neutral emotion
|
||||
.bind(req.e_neutral_0)
|
||||
.bind(req.e_neutral_1)
|
||||
.bind(req.e_neutral_2)
|
||||
.bind(req.e_neutral_3)
|
||||
.bind(req.e_neutral_4)
|
||||
.bind(req.e_neutral_5)
|
||||
.bind(req.e_neutral_6)
|
||||
.bind(req.e_neutral_7)
|
||||
.bind(req.e_neutral_8)
|
||||
// Happy emotion
|
||||
.bind(req.e_happy_0)
|
||||
.bind(req.e_happy_1)
|
||||
.bind(req.e_happy_2)
|
||||
.bind(req.e_happy_3)
|
||||
.bind(req.e_happy_4)
|
||||
.bind(req.e_happy_5)
|
||||
.bind(req.e_happy_6)
|
||||
.bind(req.e_happy_7)
|
||||
.bind(req.e_happy_8)
|
||||
// Sad emotion
|
||||
.bind(req.e_sad_0)
|
||||
.bind(req.e_sad_1)
|
||||
.bind(req.e_sad_2)
|
||||
.bind(req.e_sad_3)
|
||||
.bind(req.e_sad_4)
|
||||
.bind(req.e_sad_5)
|
||||
.bind(req.e_sad_6)
|
||||
.bind(req.e_sad_7)
|
||||
.bind(req.e_sad_8)
|
||||
// Angry emotion
|
||||
.bind(req.e_angry_0)
|
||||
.bind(req.e_angry_1)
|
||||
.bind(req.e_angry_2)
|
||||
.bind(req.e_angry_3)
|
||||
.bind(req.e_angry_4)
|
||||
.bind(req.e_angry_5)
|
||||
.bind(req.e_angry_6)
|
||||
.bind(req.e_angry_7)
|
||||
.bind(req.e_angry_8)
|
||||
// Surprised emotion
|
||||
.bind(req.e_surprised_0)
|
||||
.bind(req.e_surprised_1)
|
||||
.bind(req.e_surprised_2)
|
||||
.bind(req.e_surprised_3)
|
||||
.bind(req.e_surprised_4)
|
||||
.bind(req.e_surprised_5)
|
||||
.bind(req.e_surprised_6)
|
||||
.bind(req.e_surprised_7)
|
||||
.bind(req.e_surprised_8)
|
||||
// Thinking emotion
|
||||
.bind(req.e_thinking_0)
|
||||
.bind(req.e_thinking_1)
|
||||
.bind(req.e_thinking_2)
|
||||
.bind(req.e_thinking_3)
|
||||
.bind(req.e_thinking_4)
|
||||
.bind(req.e_thinking_5)
|
||||
.bind(req.e_thinking_6)
|
||||
.bind(req.e_thinking_7)
|
||||
.bind(req.e_thinking_8)
|
||||
// Laughing emotion
|
||||
.bind(req.e_laughing_0)
|
||||
.bind(req.e_laughing_1)
|
||||
.bind(req.e_laughing_2)
|
||||
.bind(req.e_laughing_3)
|
||||
.bind(req.e_laughing_4)
|
||||
.bind(req.e_laughing_5)
|
||||
.bind(req.e_laughing_6)
|
||||
.bind(req.e_laughing_7)
|
||||
.bind(req.e_laughing_8)
|
||||
// Crying emotion
|
||||
.bind(req.e_crying_0)
|
||||
.bind(req.e_crying_1)
|
||||
.bind(req.e_crying_2)
|
||||
.bind(req.e_crying_3)
|
||||
.bind(req.e_crying_4)
|
||||
.bind(req.e_crying_5)
|
||||
.bind(req.e_crying_6)
|
||||
.bind(req.e_crying_7)
|
||||
.bind(req.e_crying_8)
|
||||
// Love emotion
|
||||
.bind(req.e_love_0)
|
||||
.bind(req.e_love_1)
|
||||
.bind(req.e_love_2)
|
||||
.bind(req.e_love_3)
|
||||
.bind(req.e_love_4)
|
||||
.bind(req.e_love_5)
|
||||
.bind(req.e_love_6)
|
||||
.bind(req.e_love_7)
|
||||
.bind(req.e_love_8)
|
||||
// Confused emotion
|
||||
.bind(req.e_confused_0)
|
||||
.bind(req.e_confused_1)
|
||||
.bind(req.e_confused_2)
|
||||
.bind(req.e_confused_3)
|
||||
.bind(req.e_confused_4)
|
||||
.bind(req.e_confused_5)
|
||||
.bind(req.e_confused_6)
|
||||
.bind(req.e_confused_7)
|
||||
.bind(req.e_confused_8)
|
||||
// Sleeping emotion
|
||||
.bind(req.e_sleeping_0)
|
||||
.bind(req.e_sleeping_1)
|
||||
.bind(req.e_sleeping_2)
|
||||
.bind(req.e_sleeping_3)
|
||||
.bind(req.e_sleeping_4)
|
||||
.bind(req.e_sleeping_5)
|
||||
.bind(req.e_sleeping_6)
|
||||
.bind(req.e_sleeping_7)
|
||||
.bind(req.e_sleeping_8)
|
||||
// Wink emotion
|
||||
.bind(req.e_wink_0)
|
||||
.bind(req.e_wink_1)
|
||||
.bind(req.e_wink_2)
|
||||
.bind(req.e_wink_3)
|
||||
.bind(req.e_wink_4)
|
||||
.bind(req.e_wink_5)
|
||||
.bind(req.e_wink_6)
|
||||
.bind(req.e_wink_7)
|
||||
.bind(req.e_wink_8)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(avatar)
|
||||
}
|
||||
|
||||
/// Delete a server avatar.
|
||||
pub async fn delete_server_avatar<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
avatar_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM server.avatars
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(avatar_id)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
use sqlx::{PgConnection, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{StaffMember, User, UserWithAuth};
|
||||
use crate::models::{AgeCategory, GenderPreference, StaffMember, User, UserWithAuth};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// Get a user by their ID.
|
||||
|
|
@ -17,6 +17,9 @@ pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, App
|
|||
display_name,
|
||||
bio,
|
||||
avatar_url,
|
||||
birthday,
|
||||
gender_preference,
|
||||
age_category,
|
||||
reputation_tier,
|
||||
status,
|
||||
email_verified,
|
||||
|
|
@ -45,6 +48,9 @@ pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Optio
|
|||
display_name,
|
||||
bio,
|
||||
avatar_url,
|
||||
birthday,
|
||||
gender_preference,
|
||||
age_category,
|
||||
reputation_tier,
|
||||
status,
|
||||
email_verified,
|
||||
|
|
@ -73,6 +79,9 @@ pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User
|
|||
display_name,
|
||||
bio,
|
||||
avatar_url,
|
||||
birthday,
|
||||
gender_preference,
|
||||
age_category,
|
||||
reputation_tier,
|
||||
status,
|
||||
email_verified,
|
||||
|
|
@ -182,6 +191,9 @@ pub async fn get_user_by_session(
|
|||
u.display_name,
|
||||
u.bio,
|
||||
u.avatar_url,
|
||||
u.birthday,
|
||||
u.gender_preference,
|
||||
u.age_category,
|
||||
u.reputation_tier,
|
||||
u.status,
|
||||
u.email_verified,
|
||||
|
|
@ -440,6 +452,20 @@ pub async fn create_user_conn(
|
|||
email: Option<&str>,
|
||||
display_name: &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> {
|
||||
use argon2::{
|
||||
Argon2, PasswordHasher,
|
||||
|
|
@ -455,8 +481,11 @@ pub async fn create_user_conn(
|
|||
|
||||
let (user_id,): (Uuid,) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status)
|
||||
VALUES ($1, $2, $3, $4, 'local', 'active')
|
||||
INSERT INTO auth.users (
|
||||
username, email, password_hash, display_name, auth_provider, status,
|
||||
birthday, gender_preference, age_category
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'local', 'active', $5, COALESCE($6, 'gender_neutral'), COALESCE($7, 'adult'))
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
|
|
@ -464,6 +493,9 @@ pub async fn create_user_conn(
|
|||
.bind(email)
|
||||
.bind(&password_hash)
|
||||
.bind(display_name)
|
||||
.bind(birthday)
|
||||
.bind(gender_preference)
|
||||
.bind(age_category)
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
|
||||
|
|
@ -590,3 +622,97 @@ pub async fn create_guest_user(pool: &PgPool, guest_name: &str) -> Result<Uuid,
|
|||
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
|
||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp};
|
||||
|
||||
/// Default function for serde that returns true (for is_same_scene field).
|
||||
/// Must be pub for serde derive macro to access via full path.
|
||||
|
|
@ -274,4 +274,26 @@ pub enum ServerMessage {
|
|||
/// Whether the member is still a guest.
|
||||
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,6 +16,8 @@ pub struct AppConfig {
|
|||
pub websocket: WebSocketConfig,
|
||||
/// Stale member cleanup configuration.
|
||||
pub cleanup: CleanupConfig,
|
||||
/// Signup form configuration.
|
||||
pub signup: SignupConfig,
|
||||
}
|
||||
|
||||
/// WebSocket configuration.
|
||||
|
|
@ -40,11 +42,62 @@ pub struct CleanupConfig {
|
|||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
websocket: WebSocketConfig::default(),
|
||||
cleanup: CleanupConfig::default(),
|
||||
signup: SignupConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +121,28 @@ 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 {
|
||||
/// Load configuration from a TOML file.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ use tower_sessions::Session;
|
|||
|
||||
use chattyness_db::{
|
||||
models::{
|
||||
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
|
||||
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
|
||||
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
|
||||
RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary,
|
||||
AccountStatus, AgeCategory, AuthenticatedUser, CurrentUserResponse, GenderPreference,
|
||||
GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest,
|
||||
LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole,
|
||||
RealmSummary, RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse,
|
||||
UserSummary,
|
||||
},
|
||||
queries::{guests, memberships, realms, users},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use chattyness_shared::{AgeConfig, GenderConfig, SignupConfig};
|
||||
|
||||
use crate::auth::{
|
||||
AuthUser, OptionalAuthUser,
|
||||
|
|
@ -249,6 +251,7 @@ pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError>
|
|||
pub async fn signup(
|
||||
rls_conn: crate::auth::RlsConn,
|
||||
State(pool): State<PgPool>,
|
||||
State(signup_config): State<SignupConfig>,
|
||||
session: Session,
|
||||
Json(req): Json<SignupRequest>,
|
||||
) -> Result<Json<SignupResponse>, AppError> {
|
||||
|
|
@ -273,6 +276,29 @@ pub async fn signup(
|
|||
.await?
|
||||
.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
|
||||
let email_opt = req.email.as_ref().and_then(|e| {
|
||||
let trimmed = e.trim();
|
||||
|
|
@ -284,12 +310,15 @@ pub async fn signup(
|
|||
});
|
||||
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
let user_id = users::create_user_conn(
|
||||
let user_id = users::create_user_with_preferences_conn(
|
||||
&mut *conn,
|
||||
&req.username,
|
||||
email_opt,
|
||||
req.display_name.trim(),
|
||||
&req.password,
|
||||
req.birthday,
|
||||
gender_preference,
|
||||
age_category,
|
||||
)
|
||||
.await?;
|
||||
drop(conn);
|
||||
|
|
@ -530,3 +559,65 @@ pub async fn register_guest(
|
|||
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,10 +7,13 @@ use axum::Json;
|
|||
use axum::extract::Path;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest},
|
||||
queries::{avatars, realms},
|
||||
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest, RealmAvatar, ServerAvatar},
|
||||
queries::{avatars, realm_avatars, realms, server_avatars},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use sqlx::PgPool;
|
||||
use axum::extract::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{AuthUser, RlsConn};
|
||||
|
||||
|
|
@ -127,3 +130,136 @@ pub async fn clear_slot(
|
|||
|
||||
Ok(Json(avatar))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Avatar Store Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/// List public server avatars.
|
||||
///
|
||||
/// GET /api/server/avatars
|
||||
///
|
||||
/// Returns all public, active server avatars that users can select from.
|
||||
pub async fn list_server_avatars(
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<ServerAvatar>>, AppError> {
|
||||
let avatars = server_avatars::list_public_server_avatars(&pool).await?;
|
||||
Ok(Json(avatars))
|
||||
}
|
||||
|
||||
/// List public realm avatars.
|
||||
///
|
||||
/// GET /api/realms/{slug}/avatars
|
||||
///
|
||||
/// Returns all public, active realm avatars for the specified realm.
|
||||
pub async fn list_realm_avatars(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Vec<RealmAvatar>>, 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(&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,6 +31,10 @@ pub fn api_router() -> Router<AppState> {
|
|||
"/auth/register-guest",
|
||||
axum::routing::post(auth::register_guest),
|
||||
)
|
||||
.route(
|
||||
"/auth/preferences",
|
||||
axum::routing::put(auth::update_preferences),
|
||||
)
|
||||
// Realm routes (READ-ONLY)
|
||||
.route("/realms", get(realms::list_realms))
|
||||
.route("/realms/{slug}", get(realms::get_realm))
|
||||
|
|
@ -62,6 +66,17 @@ pub fn api_router() -> Router<AppState> {
|
|||
"/realms/{slug}/avatar/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
|
||||
.route("/user/{uuid}/inventory", get(inventory::get_user_inventory))
|
||||
.route(
|
||||
|
|
|
|||
|
|
@ -18,14 +18,33 @@ use tokio::sync::{broadcast, mpsc};
|
|||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
||||
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users},
|
||||
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
|
||||
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
|
||||
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use chattyness_shared::WebSocketConfig;
|
||||
|
||||
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.
|
||||
pub struct ChannelState {
|
||||
|
|
@ -271,8 +290,22 @@ async fn handle_socket(
|
|||
}
|
||||
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
|
||||
let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await {
|
||||
let members = match get_members_with_avatars(&mut conn, &pool, channel_id, realm_id).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Failed to get members: {:?}", e);
|
||||
|
|
@ -338,11 +371,13 @@ async fn handle_socket(
|
|||
let member_display_name = member.display_name.clone();
|
||||
|
||||
// Broadcast join to others
|
||||
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
||||
// Use effective avatar resolution to handle priority chain:
|
||||
// forced > custom > selected realm > selected server > realm default > server default
|
||||
let avatar = avatars::get_effective_avatar_render_data(&pool, user.id, realm_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|a| a.to_render_data())
|
||||
.map(|(render_data, _source)| render_data)
|
||||
.unwrap_or_default();
|
||||
let join_msg = ServerMessage::MemberJoined {
|
||||
member: ChannelMemberWithAvatar { member, avatar },
|
||||
|
|
@ -458,6 +493,7 @@ async fn handle_socket(
|
|||
};
|
||||
let emotion_layer = match avatars::set_emotion(
|
||||
&mut *recv_conn,
|
||||
&pool,
|
||||
user_id,
|
||||
realm_id,
|
||||
emotion_state,
|
||||
|
|
@ -711,16 +747,16 @@ async fn handle_socket(
|
|||
}
|
||||
}
|
||||
ClientMessage::SyncAvatar => {
|
||||
// Fetch current avatar from database and broadcast to channel
|
||||
match avatars::get_avatar_with_paths_conn(
|
||||
&mut *recv_conn,
|
||||
// Fetch current effective avatar from database and broadcast to channel
|
||||
// Uses the priority chain: forced > custom > selected realm > selected server > realm default > server default
|
||||
match avatars::get_effective_avatar_render_data(
|
||||
&pool,
|
||||
user_id,
|
||||
realm_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(avatar_with_paths)) => {
|
||||
let render_data = avatar_with_paths.to_render_data();
|
||||
Ok(Some((render_data, _source))) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] User {} syncing avatar to channel",
|
||||
|
|
@ -1066,6 +1102,280 @@ async fn handle_socket(
|
|||
}).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: Some(target_user_id),
|
||||
guest_session_id: None,
|
||||
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: Some(target_user_id),
|
||||
guest_session_id: None,
|
||||
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 {
|
||||
success: false,
|
||||
|
|
@ -1233,23 +1543,24 @@ async fn handle_socket(
|
|||
/// Helper: Get all channel members with their avatar render data.
|
||||
async fn get_members_with_avatars(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
pool: &PgPool,
|
||||
channel_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
||||
// Get members first
|
||||
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
|
||||
|
||||
// Fetch avatar data for each member using full avatar with paths
|
||||
// This avoids the CASE statement approach and handles all emotions correctly
|
||||
// Fetch avatar data for each member using the effective avatar resolution
|
||||
// This handles the priority chain: forced > custom > selected realm > selected server > realm default > server default
|
||||
let mut result = Vec::with_capacity(members.len());
|
||||
for member in members {
|
||||
let avatar = if let Some(user_id) = member.user_id {
|
||||
// Get full avatar and convert to render data for current emotion
|
||||
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
|
||||
// Use the new effective avatar resolution which handles all priority levels
|
||||
avatars::get_effective_avatar_render_data(pool, user_id, realm_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|a| a.to_render_data())
|
||||
.map(|(render_data, _source)| render_data)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// Guest users don't have avatars
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||
use crate::api::WebSocketState;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use chattyness_shared::WebSocketConfig;
|
||||
use chattyness_shared::{SignupConfig, WebSocketConfig};
|
||||
|
||||
/// Application state for the public app.
|
||||
#[cfg(feature = "ssr")]
|
||||
|
|
@ -23,6 +23,7 @@ pub struct AppState {
|
|||
pub leptos_options: LeptosOptions,
|
||||
pub ws_state: Arc<WebSocketState>,
|
||||
pub ws_config: WebSocketConfig,
|
||||
pub signup_config: SignupConfig,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
|
|
@ -53,6 +54,13 @@ 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.
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
view! {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod avatar_canvas;
|
||||
pub mod avatar_editor;
|
||||
pub mod avatar_store;
|
||||
pub mod chat;
|
||||
pub mod chat_types;
|
||||
pub mod context_menu;
|
||||
|
|
@ -28,6 +29,7 @@ pub mod ws_client;
|
|||
|
||||
pub use avatar_canvas::*;
|
||||
pub use avatar_editor::*;
|
||||
pub use avatar_store::*;
|
||||
pub use chat::*;
|
||||
pub use chat_types::*;
|
||||
pub use context_menu::*;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
||||
|
||||
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
||||
|
||||
|
|
@ -736,7 +736,7 @@ pub fn AvatarCanvas(
|
|||
|
||||
// Draw emotion badge if non-neutral
|
||||
let current_emotion = m.member.current_emotion;
|
||||
if current_emotion > 0 {
|
||||
if current_emotion != EmotionState::Neutral {
|
||||
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_y = layout.avatar_cy - layout.avatar_size / 2.0 - badge_size / 2.0;
|
||||
|
|
|
|||
513
crates/chattyness-user-ui/src/components/avatar_store.rs
Normal file
513
crates/chattyness-user-ui/src/components/avatar_store.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
//! 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::{RealmAvatar, ServerAvatar};
|
||||
|
||||
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::<ServerAvatar>::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::<RealmAvatar>::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<ServerAvatar>>().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<RealmAvatar>>().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,
|
||||
thumbnail_path: a.thumbnail_path,
|
||||
}).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,
|
||||
thumbnail_path: a.thumbnail_path,
|
||||
}).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.
|
||||
#[derive(Clone)]
|
||||
struct AvatarInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
thumbnail_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p))
|
||||
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || format!(
|
||||
"aspect-square rounded-lg border-2 transition-all p-2 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
|
||||
>
|
||||
<img
|
||||
src=thumbnail_url
|
||||
alt=""
|
||||
class="w-full h-full object-contain rounded"
|
||||
/>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
|
@ -903,6 +903,12 @@ pub fn ChatInput(
|
|||
<span class="text-purple-600">" | "</span>
|
||||
<span class="text-purple-300">"teleport"</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>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ pub fn HotkeyHelp(
|
|||
<HotkeyRow key="i" description="Inventory" />
|
||||
<HotkeyRow key="k" description="Keybindings" />
|
||||
<HotkeyRow key="a" description="Avatar editor" />
|
||||
<HotkeyRow key="t" description="Avatar store" />
|
||||
<HotkeyRow key="l" description="Message log" />
|
||||
|
||||
// Emotions
|
||||
|
|
|
|||
|
|
@ -533,11 +533,10 @@ fn handle_server_message(
|
|||
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||
}) {
|
||||
// Convert emotion name to index for internal state
|
||||
// Parse emotion name to EmotionState
|
||||
m.member.current_emotion = emotion
|
||||
.parse::<EmotionState>()
|
||||
.map(|e| e.to_index() as i16)
|
||||
.unwrap_or(0);
|
||||
.unwrap_or_default();
|
||||
m.avatar.emotion_layer = emotion_layer;
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
|
|
@ -663,6 +662,35 @@ fn handle_server_message(
|
|||
});
|
||||
}
|
||||
}
|
||||
ServerMessage::AvatarForced {
|
||||
user_id,
|
||||
avatar,
|
||||
reason: _,
|
||||
forced_by: _,
|
||||
} => {
|
||||
// Update the forced user's avatar
|
||||
if let Some(m) = members_vec.iter_mut().find(|m| m.member.user_id == Some(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();
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::AvatarCleared {
|
||||
user_id,
|
||||
avatar,
|
||||
cleared_by: _,
|
||||
} => {
|
||||
// Restore the user's original avatar
|
||||
if let Some(m) = members_vec.iter_mut().find(|m| m.member.user_id == Some(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();
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ use leptos_router::hooks::use_params_map;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::components::{
|
||||
ActiveBubble, AvatarEditorPopup, Card, ChatInput, ConversationModal, EmotionKeybindings,
|
||||
FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup, MessageLog,
|
||||
NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer, ReconnectionOverlay,
|
||||
RegisterModal, SettingsPopup, ViewerSettings,
|
||||
ActiveBubble, AvatarEditorPopup, AvatarStorePopup, Card, ChatInput, ConversationModal,
|
||||
EmotionKeybindings, FadingMember, HotkeyHelp, InventoryPopup, KeybindingsPopup, LogPopup,
|
||||
MessageLog, NotificationMessage, NotificationToast, RealmHeader, RealmSceneViewer,
|
||||
ReconnectionOverlay, RegisterModal, SettingsPopup, ViewerSettings,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::components::{
|
||||
|
|
@ -90,6 +90,9 @@ pub fn RealmPage() -> impl IntoView {
|
|||
// Store full avatar data for the editor
|
||||
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)
|
||||
let (register_modal_open, set_register_modal_open) = signal(false);
|
||||
|
||||
|
|
@ -870,6 +873,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
|| log_open.get_untracked()
|
||||
|| keybindings_open.get_untracked()
|
||||
|| avatar_editor_open.get_untracked()
|
||||
|| avatar_store_open.get_untracked()
|
||||
|| register_modal_open.get_untracked()
|
||||
|| conversation_modal_open.get_untracked()
|
||||
{
|
||||
|
|
@ -986,6 +990,13 @@ pub fn RealmPage() -> impl IntoView {
|
|||
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
|
||||
if key == "l" || key == "L" {
|
||||
set_log_open.update(|v| *v = !*v);
|
||||
|
|
@ -1395,6 +1406,37 @@ 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
|
||||
{
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ pub fn SignupPage() -> impl IntoView {
|
|||
password: pwd,
|
||||
confirm_password: confirm_pwd,
|
||||
realm_slug: slug.unwrap(),
|
||||
birthday: None,
|
||||
gender_preference: None,
|
||||
age_category: None,
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/signup")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue