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
|
|
@ -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