add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
11
crates/chattyness-user-ui/src/api.rs
Normal file
11
crates/chattyness-user-ui/src/api.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! REST API module for user UI.
|
||||
|
||||
pub mod auth;
|
||||
pub mod avatars;
|
||||
pub mod realms;
|
||||
pub mod routes;
|
||||
pub mod scenes;
|
||||
pub mod websocket;
|
||||
|
||||
pub use routes::*;
|
||||
pub use websocket::WebSocketState;
|
||||
474
crates/chattyness-user-ui/src/api/auth.rs
Normal file
474
crates/chattyness-user-ui/src/api/auth.rs
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
//! Authentication API handlers.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
Json,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{
|
||||
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
|
||||
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
|
||||
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
|
||||
SignupRequest, SignupResponse, UserSummary,
|
||||
},
|
||||
queries::{guests, memberships, realms, users},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
use crate::auth::{
|
||||
session::{
|
||||
hash_token, generate_token, SESSION_CURRENT_REALM_KEY, SESSION_GUEST_ID_KEY,
|
||||
SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY,
|
||||
},
|
||||
AuthUser, OptionalAuthUser,
|
||||
};
|
||||
|
||||
/// Get current user info.
|
||||
pub async fn get_current_user(
|
||||
State(pool): State<PgPool>,
|
||||
OptionalAuthUser(user): OptionalAuthUser,
|
||||
) -> Result<Json<CurrentUserResponse>, AppError> {
|
||||
match user {
|
||||
Some(user) => {
|
||||
// Get staff role if any
|
||||
let staff_role = memberships::get_user_staff_role(&pool, user.id).await?;
|
||||
|
||||
Ok(Json(CurrentUserResponse {
|
||||
user: Some(AuthenticatedUser {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
staff_role,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
None => Ok(Json(CurrentUserResponse { user: None })),
|
||||
}
|
||||
}
|
||||
|
||||
/// Login handler.
|
||||
pub async fn login(
|
||||
rls_conn: crate::auth::RlsConn,
|
||||
State(pool): State<PgPool>,
|
||||
session: Session,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Verify credentials
|
||||
let user = users::verify_password_with_reset_flag(&pool, &req.username, &req.password)
|
||||
.await?
|
||||
.ok_or(AppError::InvalidCredentials)?;
|
||||
|
||||
// Set RLS context to the authenticated user for subsequent operations
|
||||
rls_conn.set_user_id(user.id).await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
||||
|
||||
// Check account status
|
||||
if user.status != AccountStatus::Active {
|
||||
return Err(AppError::AccountSuspended);
|
||||
}
|
||||
|
||||
// Create user summary for response
|
||||
let user_summary = UserSummary {
|
||||
id: user.id,
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
avatar_url: user.avatar_url.clone(),
|
||||
};
|
||||
|
||||
// Handle based on login type
|
||||
match req.login_type {
|
||||
LoginType::Staff => {
|
||||
// Verify user is a staff member
|
||||
let staff_role = memberships::get_user_staff_role(&pool, user.id)
|
||||
.await?
|
||||
.ok_or(AppError::NotStaffMember)?;
|
||||
|
||||
// Store session data
|
||||
session
|
||||
.insert(SESSION_USER_ID_KEY, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_LOGIN_TYPE_KEY, "staff")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
// Check for forced password reset
|
||||
if user.force_pw_reset {
|
||||
session
|
||||
.insert(SESSION_ORIGINAL_DEST_KEY, "/staff")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
return Ok(Json(LoginResponse {
|
||||
user: user_summary,
|
||||
redirect_url: "/password-reset".to_string(),
|
||||
requires_pw_reset: true,
|
||||
is_member: None,
|
||||
original_destination: Some("/staff".to_string()),
|
||||
staff_role: Some(staff_role),
|
||||
realm: None,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
user: user_summary,
|
||||
redirect_url: "/staff".to_string(),
|
||||
requires_pw_reset: false,
|
||||
is_member: None,
|
||||
original_destination: None,
|
||||
staff_role: Some(staff_role),
|
||||
realm: None,
|
||||
}))
|
||||
}
|
||||
LoginType::Realm => {
|
||||
let realm_slug = req.realm_slug.as_ref().ok_or_else(|| {
|
||||
AppError::Validation("Realm slug is required for realm login".to_string())
|
||||
})?;
|
||||
|
||||
// Get the realm
|
||||
let realm = realms::get_realm_by_slug(&pool, realm_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", realm_slug)))?;
|
||||
|
||||
// Check if user is a member
|
||||
let is_member = memberships::is_member(&pool, user.id, realm.id).await?;
|
||||
|
||||
// Store session data
|
||||
session
|
||||
.insert(SESSION_USER_ID_KEY, user.id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
let redirect_url = format!("/realms/{}", realm.slug);
|
||||
|
||||
// Check for forced password reset
|
||||
if user.force_pw_reset {
|
||||
session
|
||||
.insert(SESSION_ORIGINAL_DEST_KEY, redirect_url.clone())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
return Ok(Json(LoginResponse {
|
||||
user: user_summary,
|
||||
redirect_url: "/password-reset".to_string(),
|
||||
requires_pw_reset: true,
|
||||
is_member: Some(is_member),
|
||||
original_destination: Some(redirect_url),
|
||||
staff_role: None,
|
||||
realm: Some(RealmSummary {
|
||||
id: realm.id,
|
||||
name: realm.name,
|
||||
slug: realm.slug,
|
||||
tagline: realm.tagline,
|
||||
privacy: realm.privacy,
|
||||
is_nsfw: realm.is_nsfw,
|
||||
thumbnail_path: realm.thumbnail_path,
|
||||
member_count: realm.member_count,
|
||||
current_user_count: realm.current_user_count,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// If not a member, include realm info for join confirmation
|
||||
if !is_member {
|
||||
return Ok(Json(LoginResponse {
|
||||
user: user_summary,
|
||||
redirect_url: redirect_url.clone(),
|
||||
requires_pw_reset: false,
|
||||
is_member: Some(false),
|
||||
original_destination: None,
|
||||
staff_role: None,
|
||||
realm: Some(RealmSummary {
|
||||
id: realm.id,
|
||||
name: realm.name,
|
||||
slug: realm.slug,
|
||||
tagline: realm.tagline,
|
||||
privacy: realm.privacy,
|
||||
is_nsfw: realm.is_nsfw,
|
||||
thumbnail_path: realm.thumbnail_path,
|
||||
member_count: realm.member_count,
|
||||
current_user_count: realm.current_user_count,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// User is a member, update last visited (using RLS connection)
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
memberships::update_last_visited_conn(&mut *conn, user.id, realm.id).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
user: user_summary,
|
||||
redirect_url,
|
||||
requires_pw_reset: false,
|
||||
is_member: Some(true),
|
||||
original_destination: None,
|
||||
staff_role: None,
|
||||
realm: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout response.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct LogoutResponse {
|
||||
pub success: bool,
|
||||
pub redirect_url: String,
|
||||
}
|
||||
|
||||
/// Logout handler.
|
||||
pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError> {
|
||||
// Flush the session (removes all data and invalidates the session)
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
Ok(Json(LogoutResponse {
|
||||
success: true,
|
||||
redirect_url: "/".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Signup handler.
|
||||
pub async fn signup(
|
||||
rls_conn: crate::auth::RlsConn,
|
||||
State(pool): State<PgPool>,
|
||||
session: Session,
|
||||
Json(req): Json<SignupRequest>,
|
||||
) -> Result<Json<SignupResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Check username availability (can use pool for read-only checks)
|
||||
if users::username_exists(&pool, &req.username).await? {
|
||||
return Err(AppError::Conflict("Username already taken".to_string()));
|
||||
}
|
||||
|
||||
// Check email availability if provided
|
||||
if let Some(ref email) = req.email {
|
||||
let email_trimmed = email.trim();
|
||||
if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? {
|
||||
return Err(AppError::Conflict("Email already registered".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the realm
|
||||
let realm = realms::get_realm_by_slug(&pool, &req.realm_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
|
||||
|
||||
// Create the user using RLS connection
|
||||
let email_opt = req.email.as_ref().and_then(|e| {
|
||||
let trimmed = e.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
});
|
||||
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
let user_id =
|
||||
users::create_user_conn(&mut *conn, &req.username, email_opt, req.display_name.trim(), &req.password)
|
||||
.await?;
|
||||
drop(conn);
|
||||
|
||||
// Set RLS context to the new user for membership creation
|
||||
rls_conn.set_user_id(user_id).await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
||||
|
||||
// Create membership using RLS connection (now has user context)
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
let membership_id =
|
||||
memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member).await?;
|
||||
|
||||
// Set up session (user is logged in)
|
||||
session
|
||||
.insert(SESSION_USER_ID_KEY, user_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
let redirect_url = format!("/realms/{}", realm.slug);
|
||||
|
||||
Ok(Json(SignupResponse {
|
||||
user: UserSummary {
|
||||
id: user_id,
|
||||
username: req.username,
|
||||
display_name: req.display_name.trim().to_string(),
|
||||
avatar_url: None,
|
||||
},
|
||||
redirect_url,
|
||||
membership_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Guest login handler.
|
||||
pub async fn guest_login(
|
||||
State(pool): State<PgPool>,
|
||||
session: Session,
|
||||
Json(req): Json<GuestLoginRequest>,
|
||||
) -> Result<Json<GuestLoginResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Get the realm
|
||||
let realm = realms::get_realm_by_slug(&pool, &req.realm_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
|
||||
|
||||
// Check if realm allows guest access
|
||||
if !realm.allow_guest_access {
|
||||
return Err(AppError::Forbidden(
|
||||
"This realm does not allow guest access".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate guest name and session token
|
||||
let guest_name = guests::generate_guest_name();
|
||||
let token = generate_token();
|
||||
let token_hash = hash_token(&token);
|
||||
let expires_at = guests::guest_session_expiry();
|
||||
|
||||
// Create guest session in database
|
||||
let guest_id = guests::create_guest_session(
|
||||
&pool,
|
||||
&guest_name,
|
||||
realm.id,
|
||||
&token_hash,
|
||||
None, // user_agent
|
||||
None, // ip_address
|
||||
expires_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Set up tower session
|
||||
session
|
||||
.insert(SESSION_GUEST_ID_KEY, guest_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_LOGIN_TYPE_KEY, "guest")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
session
|
||||
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
let redirect_url = format!("/realms/{}", realm.slug);
|
||||
|
||||
Ok(Json(GuestLoginResponse {
|
||||
guest_name,
|
||||
guest_id,
|
||||
redirect_url,
|
||||
realm: RealmSummary {
|
||||
id: realm.id,
|
||||
name: realm.name,
|
||||
slug: realm.slug,
|
||||
tagline: realm.tagline,
|
||||
privacy: realm.privacy,
|
||||
is_nsfw: realm.is_nsfw,
|
||||
thumbnail_path: realm.thumbnail_path,
|
||||
member_count: realm.member_count,
|
||||
current_user_count: realm.current_user_count,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Join realm handler.
|
||||
pub async fn join_realm(
|
||||
rls_conn: crate::auth::RlsConn,
|
||||
State(pool): State<PgPool>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<JoinRealmRequest>,
|
||||
) -> Result<Json<JoinRealmResponse>, AppError> {
|
||||
// Get the realm to verify it exists and check privacy
|
||||
let realm = realms::get_realm_by_id(&pool, req.realm_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Realm not found".to_string()))?;
|
||||
|
||||
// Check if user is already a member
|
||||
let is_member = memberships::is_member(&pool, user.id, realm.id).await?;
|
||||
if is_member {
|
||||
return Err(AppError::Conflict(
|
||||
"Already a member of this realm".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// For private realms, don't allow direct join (would need invitation)
|
||||
if realm.privacy == chattyness_db::models::RealmPrivacy::Private {
|
||||
return Err(AppError::Forbidden(
|
||||
"Cannot join private realms without an invitation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create the membership using RLS connection (policy requires user_id = current_user_id)
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
let membership_id =
|
||||
memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member).await?;
|
||||
|
||||
Ok(Json(JoinRealmResponse {
|
||||
success: true,
|
||||
membership_id,
|
||||
redirect_url: format!("/realms/{}", realm.slug),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Password reset handler.
|
||||
pub async fn reset_password(
|
||||
rls_conn: crate::auth::RlsConn,
|
||||
session: Session,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<PasswordResetRequest>,
|
||||
) -> Result<Json<PasswordResetResponse>, AppError> {
|
||||
// Validate the request
|
||||
req.validate()?;
|
||||
|
||||
// Update the password using RLS connection (required for RLS policy)
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
users::update_password_conn(&mut *conn, user.id, &req.new_password).await?;
|
||||
|
||||
// Get the original destination from session
|
||||
let original_dest: Option<String> = session
|
||||
.get(SESSION_ORIGINAL_DEST_KEY)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
// Clear the original destination from session
|
||||
session
|
||||
.remove::<String>(SESSION_ORIGINAL_DEST_KEY)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||
|
||||
let redirect_url = original_dest.unwrap_or_else(|| "/".to_string());
|
||||
|
||||
Ok(Json(PasswordResetResponse {
|
||||
success: true,
|
||||
redirect_url,
|
||||
}))
|
||||
}
|
||||
39
crates/chattyness-user-ui/src/api/avatars.rs
Normal file
39
crates/chattyness-user-ui/src/api/avatars.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
//! Avatar API handlers for user UI.
|
||||
//!
|
||||
//! Handles avatar rendering data retrieval.
|
||||
//! Note: Emotion switching is now handled via WebSocket.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use chattyness_db::{
|
||||
models::AvatarRenderData,
|
||||
queries::{avatars, realms},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
/// Get current avatar render data.
|
||||
///
|
||||
/// GET /api/realms/{slug}/avatar/current
|
||||
///
|
||||
/// Returns the render data for the user's active avatar in this realm.
|
||||
pub async fn get_current_avatar(
|
||||
State(pool): State<PgPool>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<AvatarRenderData>, AppError> {
|
||||
// Get realm
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
// Get render data
|
||||
let render_data = avatars::get_avatar_render_data(&pool, user.id, realm.id).await?;
|
||||
|
||||
Ok(Json(render_data))
|
||||
}
|
||||
80
crates/chattyness-user-ui/src/api/realms.rs
Normal file
80
crates/chattyness-user-ui/src/api/realms.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! Realm API handlers for user UI (READ-ONLY).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{RealmSummary, RealmWithUserRole},
|
||||
queries::{memberships, realms},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
use crate::auth::OptionalAuthUser;
|
||||
|
||||
/// List query params.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListParams {
|
||||
pub include_nsfw: Option<bool>,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// List response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListResponse {
|
||||
pub realms: Vec<RealmSummary>,
|
||||
}
|
||||
|
||||
/// List public realms.
|
||||
pub async fn list_realms(
|
||||
State(pool): State<PgPool>,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> Result<Json<ListResponse>, AppError> {
|
||||
let limit = params.limit.unwrap_or(20).min(100);
|
||||
let offset = params.page.unwrap_or(0) * limit;
|
||||
let include_nsfw = params.include_nsfw.unwrap_or(false);
|
||||
|
||||
let realm_list = realms::list_public_realms(&pool, include_nsfw, limit, offset).await?;
|
||||
|
||||
Ok(Json(ListResponse { realms: realm_list }))
|
||||
}
|
||||
|
||||
/// Get a realm by slug with user role.
|
||||
pub async fn get_realm(
|
||||
State(pool): State<PgPool>,
|
||||
OptionalAuthUser(maybe_user): OptionalAuthUser,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<RealmWithUserRole>, AppError> {
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
// Get the user's role if authenticated
|
||||
let user_role = if let Some(user) = maybe_user {
|
||||
let membership = memberships::get_user_membership(&pool, user.id, realm.id).await?;
|
||||
membership.map(|m| m.role)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(RealmWithUserRole { realm, user_role }))
|
||||
}
|
||||
|
||||
/// Check slug availability response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlugAvailableResponse {
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Check if a realm slug is available.
|
||||
pub async fn check_slug_available(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<SlugAvailableResponse>, AppError> {
|
||||
let available = realms::is_slug_available(&pool, &slug).await?;
|
||||
Ok(Json(SlugAvailableResponse { available }))
|
||||
}
|
||||
57
crates/chattyness-user-ui/src/api/routes.rs
Normal file
57
crates/chattyness-user-ui/src/api/routes.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! API routes for user UI.
|
||||
//!
|
||||
//! This router provides READ-ONLY access to realms, scenes, and spots.
|
||||
//! All create/update/delete operations are handled by the admin-ui.
|
||||
//! Channel presence is handled via WebSocket.
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
use super::{auth, avatars, realms, scenes, websocket};
|
||||
use crate::app::AppState;
|
||||
|
||||
/// Build the API router for user UI.
|
||||
///
|
||||
/// Note: This router is READ-ONLY for realms/scenes/spots.
|
||||
/// Auth routes (login, logout, signup, join-realm) are allowed.
|
||||
/// Channel presence (join, leave, position, emotion, members) is handled via WebSocket.
|
||||
pub fn api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Auth routes (these are user-facing operations)
|
||||
.route("/auth/me", get(auth::get_current_user))
|
||||
.route("/auth/login", axum::routing::post(auth::login))
|
||||
.route("/auth/logout", axum::routing::post(auth::logout))
|
||||
.route("/auth/signup", axum::routing::post(auth::signup))
|
||||
.route("/auth/guest", axum::routing::post(auth::guest_login))
|
||||
.route("/auth/join-realm", axum::routing::post(auth::join_realm))
|
||||
.route(
|
||||
"/auth/reset-password",
|
||||
axum::routing::post(auth::reset_password),
|
||||
)
|
||||
// Realm routes (READ-ONLY)
|
||||
.route("/realms", get(realms::list_realms))
|
||||
.route("/realms/{slug}", get(realms::get_realm))
|
||||
.route("/realms/{slug}/available", get(realms::check_slug_available))
|
||||
// Scene routes (READ-ONLY)
|
||||
.route("/realms/{slug}/entry-scene", get(scenes::get_entry_scene))
|
||||
.route("/realms/{slug}/scenes", get(scenes::list_scenes))
|
||||
.route("/realms/{slug}/scenes/{scene_slug}", get(scenes::get_scene))
|
||||
// Spot routes (READ-ONLY)
|
||||
.route(
|
||||
"/realms/{slug}/scenes/{scene_slug}/spots",
|
||||
get(scenes::list_spots),
|
||||
)
|
||||
.route(
|
||||
"/realms/{slug}/scenes/{scene_slug}/spots/{spot_id}",
|
||||
get(scenes::get_spot),
|
||||
)
|
||||
// WebSocket route for channel presence (handles join, leave, position, emotion, members)
|
||||
.route(
|
||||
"/realms/{slug}/channels/{channel_id}/ws",
|
||||
get(websocket::ws_handler::<AppState>),
|
||||
)
|
||||
// Avatar routes (require authentication)
|
||||
.route(
|
||||
"/realms/{slug}/avatar/current",
|
||||
get(avatars::get_current_avatar),
|
||||
)
|
||||
}
|
||||
92
crates/chattyness-user-ui/src/api/scenes.rs
Normal file
92
crates/chattyness-user-ui/src/api/scenes.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! Scene and Spot API handlers for user UI (READ-ONLY).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{Scene, SceneSummary, Spot, SpotSummary},
|
||||
queries::{realms, scenes, spots},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// Get the entry scene for a realm.
|
||||
///
|
||||
/// GET /api/realms/{slug}/entry-scene
|
||||
///
|
||||
/// Returns the realm's default/entry scene. This endpoint is public.
|
||||
pub async fn get_entry_scene(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Scene>, AppError> {
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
let scene = scenes::get_entry_scene_for_realm(&pool, realm.id, realm.default_scene_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("No entry scene found for this realm".to_string()))?;
|
||||
|
||||
Ok(Json(scene))
|
||||
}
|
||||
|
||||
/// List scenes for a realm.
|
||||
pub async fn list_scenes(
|
||||
State(pool): State<PgPool>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Vec<SceneSummary>>, AppError> {
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
let scene_list = scenes::list_scenes_for_realm(&pool, realm.id).await?;
|
||||
Ok(Json(scene_list))
|
||||
}
|
||||
|
||||
/// Get a scene by slug.
|
||||
pub async fn get_scene(
|
||||
State(pool): State<PgPool>,
|
||||
Path((slug, scene_slug)): Path<(String, String)>,
|
||||
) -> Result<Json<Scene>, AppError> {
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
let scene = scenes::get_scene_by_slug(&pool, realm.id, &scene_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Scene '{}' not found", scene_slug)))?;
|
||||
|
||||
Ok(Json(scene))
|
||||
}
|
||||
|
||||
/// List spots for a scene.
|
||||
pub async fn list_spots(
|
||||
State(pool): State<PgPool>,
|
||||
Path((slug, scene_slug)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<SpotSummary>>, AppError> {
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
let scene = scenes::get_scene_by_slug(&pool, realm.id, &scene_slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Scene '{}' not found", scene_slug)))?;
|
||||
|
||||
let spot_list = spots::list_spots_for_scene(&pool, scene.id).await?;
|
||||
Ok(Json(spot_list))
|
||||
}
|
||||
|
||||
/// Get a spot by ID.
|
||||
pub async fn get_spot(
|
||||
State(pool): State<PgPool>,
|
||||
Path((_slug, _scene_slug, spot_id)): Path<(String, String, Uuid)>,
|
||||
) -> Result<Json<Spot>, AppError> {
|
||||
let spot = spots::get_spot_by_id(&pool, spot_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||
|
||||
Ok(Json(spot))
|
||||
}
|
||||
399
crates/chattyness-user-ui/src/api/websocket.rs
Normal file
399
crates/chattyness-user-ui/src/api/websocket.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
//! WebSocket handler for channel presence.
|
||||
//!
|
||||
//! Handles real-time position updates, emotion changes, and member synchronization.
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
FromRef, Path, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{AvatarRenderData, ChannelMemberWithAvatar, User},
|
||||
queries::{avatars, channel_members, realms, scenes},
|
||||
ws_messages::{ClientMessage, ServerMessage},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
/// Channel state for broadcasting updates.
|
||||
pub struct ChannelState {
|
||||
/// Broadcast sender for this channel.
|
||||
tx: broadcast::Sender<ServerMessage>,
|
||||
}
|
||||
|
||||
/// Global state for all WebSocket connections.
|
||||
pub struct WebSocketState {
|
||||
/// Map of channel_id -> ChannelState.
|
||||
channels: DashMap<Uuid, Arc<ChannelState>>,
|
||||
}
|
||||
|
||||
impl Default for WebSocketState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebSocketState {
|
||||
/// Create a new WebSocket state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a channel state.
|
||||
fn get_or_create_channel(&self, channel_id: Uuid) -> Arc<ChannelState> {
|
||||
self.channels
|
||||
.entry(channel_id)
|
||||
.or_insert_with(|| {
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
Arc::new(ChannelState { tx })
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket upgrade handler.
|
||||
///
|
||||
/// GET /api/realms/{slug}/channels/{channel_id}/ws
|
||||
pub async fn ws_handler<S>(
|
||||
Path((slug, channel_id)): Path<(String, Uuid)>,
|
||||
auth_result: Result<AuthUser, crate::auth::AuthError>,
|
||||
State(pool): State<PgPool>,
|
||||
State(ws_state): State<Arc<WebSocketState>>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Result<impl IntoResponse, AppError>
|
||||
where
|
||||
S: Send + Sync,
|
||||
PgPool: FromRef<S>,
|
||||
Arc<WebSocketState>: FromRef<S>,
|
||||
{
|
||||
// Log auth result before checking
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] Connection attempt to {}/channels/{} - auth: {:?}",
|
||||
slug,
|
||||
channel_id,
|
||||
auth_result.as_ref().map(|a| a.0.id).map_err(|e| format!("{:?}", e))
|
||||
);
|
||||
|
||||
let AuthUser(user) = auth_result.map_err(|e| {
|
||||
tracing::warn!("[WS] Auth failed for {}/channels/{}: {:?}", slug, channel_id, e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
// Verify realm exists
|
||||
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
// Verify channel (scene) exists and belongs to this realm
|
||||
let scene = scenes::get_scene_by_id(&pool, channel_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Channel not found".to_string()))?;
|
||||
|
||||
if scene.realm_id != realm.id {
|
||||
return Err(AppError::NotFound(
|
||||
"Channel not found in this realm".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] Upgrading connection for user {} to channel {}",
|
||||
user.id,
|
||||
channel_id
|
||||
);
|
||||
|
||||
Ok(ws.on_upgrade(move |socket| {
|
||||
handle_socket(socket, user, channel_id, realm.id, pool, ws_state)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Set RLS context on a database connection.
|
||||
async fn set_rls_user_id(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||
.bind(user_id)
|
||||
.execute(&mut **conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an active WebSocket connection.
|
||||
async fn handle_socket(
|
||||
socket: WebSocket,
|
||||
user: User,
|
||||
channel_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
pool: PgPool,
|
||||
ws_state: Arc<WebSocketState>,
|
||||
) {
|
||||
tracing::info!(
|
||||
"[WS] handle_socket started for user {} channel {} realm {}",
|
||||
user.id,
|
||||
channel_id,
|
||||
realm_id
|
||||
);
|
||||
|
||||
// Acquire a dedicated connection for setup operations
|
||||
let mut conn = match pool.acquire().await {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Failed to acquire DB connection: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Set RLS context on this dedicated connection
|
||||
if let Err(e) = set_rls_user_id(&mut conn, user.id).await {
|
||||
tracing::error!("[WS] Failed to set RLS context for user {}: {:?}", user.id, e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("[WS] RLS context set on dedicated connection");
|
||||
|
||||
let channel_state = ws_state.get_or_create_channel(channel_id);
|
||||
let mut rx = channel_state.tx.subscribe();
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Ensure active avatar
|
||||
tracing::info!("[WS] Ensuring active avatar...");
|
||||
if let Err(e) = channel_members::ensure_active_avatar(&mut *conn, user.id, realm_id).await {
|
||||
tracing::error!("[WS] Failed to ensure avatar for user {}: {:?}", user.id, e);
|
||||
return;
|
||||
}
|
||||
tracing::info!("[WS] Avatar ensured");
|
||||
|
||||
// Join the channel
|
||||
tracing::info!("[WS] Joining channel...");
|
||||
if let Err(e) = channel_members::join_channel(&mut *conn, channel_id, user.id).await {
|
||||
tracing::error!(
|
||||
"[WS] Failed to join channel {} for user {}: {:?}",
|
||||
channel_id,
|
||||
user.id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::info!("[WS] Channel joined");
|
||||
|
||||
// Get initial state
|
||||
let members = match get_members_with_avatars(&mut *conn, channel_id, realm_id).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Failed to get members: {:?}", e);
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let member = match channel_members::get_channel_member(&mut *conn, channel_id, user.id, realm_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
tracing::error!("[WS] Failed to get member info for user {}", user.id);
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Error getting member info: {:?}", e);
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send welcome message
|
||||
let welcome = ServerMessage::Welcome {
|
||||
member: member.clone(),
|
||||
members,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&welcome) {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!("[WS->Client] {}", json);
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast join to others
|
||||
let avatar = avatars::get_avatar_render_data(&mut *conn, user.id, realm_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let join_msg = ServerMessage::MemberJoined {
|
||||
member: ChannelMemberWithAvatar { member, avatar },
|
||||
};
|
||||
let _ = channel_state.tx.send(join_msg);
|
||||
|
||||
let user_id = user.id;
|
||||
let tx = channel_state.tx.clone();
|
||||
|
||||
// Acquire a second dedicated connection for the receive task
|
||||
// This connection needs its own RLS context
|
||||
let mut recv_conn = match pool.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Failed to acquire recv connection: {:?}", e);
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = set_rls_user_id(&mut recv_conn, user_id).await {
|
||||
tracing::error!("[WS] Failed to set RLS on recv connection: {:?}", e);
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the setup connection - we'll use recv_conn for the receive task
|
||||
// and pool for cleanup (which will use the same RLS context issue, but leave_channel
|
||||
// needs user_id match anyway)
|
||||
drop(conn);
|
||||
|
||||
// Spawn task to handle incoming messages from client
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
if let Message::Text(text) = msg {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!("[WS<-Client] {}", text);
|
||||
|
||||
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match client_msg {
|
||||
ClientMessage::UpdatePosition { x, y } => {
|
||||
if let Err(e) =
|
||||
channel_members::update_position(&mut *recv_conn, channel_id, user_id, x, y)
|
||||
.await
|
||||
{
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::error!("[WS] Position update failed: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
let _ = tx.send(ServerMessage::PositionUpdated {
|
||||
user_id: Some(user_id),
|
||||
guest_session_id: None,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
ClientMessage::UpdateEmotion { emotion } => {
|
||||
if emotion > 9 {
|
||||
continue;
|
||||
}
|
||||
let emotion_layer = match avatars::set_emotion(
|
||||
&mut *recv_conn,
|
||||
user_id,
|
||||
realm_id,
|
||||
emotion as i16,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(layer) => layer,
|
||||
Err(e) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::error!("[WS] Emotion update failed: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = tx.send(ServerMessage::EmotionUpdated {
|
||||
user_id: Some(user_id),
|
||||
guest_session_id: None,
|
||||
emotion,
|
||||
emotion_layer,
|
||||
});
|
||||
}
|
||||
ClientMessage::Ping => {
|
||||
// Respond with pong directly (not broadcast)
|
||||
// This is handled in the send task via individual message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return the connection so we can use it for cleanup
|
||||
recv_conn
|
||||
});
|
||||
|
||||
// Spawn task to forward broadcasts to this client
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!("[WS->Client] {}", json);
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either task to complete
|
||||
tokio::select! {
|
||||
recv_result = recv_task => {
|
||||
// recv_task finished, get connection back for cleanup
|
||||
if let Ok(mut cleanup_conn) = recv_result {
|
||||
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
||||
} else {
|
||||
// Task panicked, use pool (RLS may fail but try anyway)
|
||||
let _ = channel_members::leave_channel(&pool, channel_id, user_id).await;
|
||||
}
|
||||
}
|
||||
_ = send_task => {
|
||||
// send_task finished first, need to acquire a new connection for cleanup
|
||||
if let Ok(mut cleanup_conn) = pool.acquire().await {
|
||||
let _ = set_rls_user_id(&mut cleanup_conn, user_id).await;
|
||||
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[WS] User {} disconnected from channel {}",
|
||||
user_id,
|
||||
channel_id
|
||||
);
|
||||
|
||||
// Broadcast departure
|
||||
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
||||
user_id: Some(user_id),
|
||||
guest_session_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper: Get all channel members with their avatar render data.
|
||||
async fn get_members_with_avatars<'e>(
|
||||
executor: impl sqlx::PgExecutor<'e>,
|
||||
channel_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
||||
// Get members first, then we need to get avatars
|
||||
// But executor is consumed by the first query, so we need the pool
|
||||
// Actually, let's just inline this to avoid the complexity
|
||||
let members = channel_members::get_channel_members(executor, channel_id, realm_id).await?;
|
||||
|
||||
// For avatar data, we'll just return default for now since the query
|
||||
// would need another executor
|
||||
let result: Vec<ChannelMemberWithAvatar> = members
|
||||
.into_iter()
|
||||
.map(|member| ChannelMemberWithAvatar {
|
||||
member,
|
||||
avatar: AvatarRenderData::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
83
crates/chattyness-user-ui/src/app.rs
Normal file
83
crates/chattyness-user-ui/src/app.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Leptos application root and router for public app.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
||||
use leptos_router::components::Router;
|
||||
|
||||
use crate::routes::UserRoutes;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use crate::api::WebSocketState;
|
||||
|
||||
/// Application state for the public app.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub ws_state: Arc<WebSocketState>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl axum::extract::FromRef<AppState> for sqlx::PgPool {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.pool.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl axum::extract::FromRef<AppState> for LeptosOptions {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.leptos_options.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl axum::extract::FromRef<AppState> for Arc<WebSocketState> {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.ws_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell component for SSR.
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<AutoReload options=options.clone() />
|
||||
<HydrationScripts options />
|
||||
<MetaTags />
|
||||
</head>
|
||||
<body class="bg-gray-900 text-white antialiased" data-app="user">
|
||||
<App />
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
/// Main application component.
|
||||
///
|
||||
/// This wraps `UserRoutes` with a `Router` for standalone use.
|
||||
/// For embedding in a combined app (e.g., chattyness-app), use `UserRoutes` directly.
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provide meta context for title and meta tags
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/static/chattyness-app.css" />
|
||||
<Title text="Chattyness - Virtual Community Spaces" />
|
||||
|
||||
<Router>
|
||||
<main>
|
||||
<UserRoutes />
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
13
crates/chattyness-user-ui/src/auth.rs
Normal file
13
crates/chattyness-user-ui/src/auth.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//! Authentication module for user UI.
|
||||
//!
|
||||
//! Provides session-based authentication using tower-sessions.
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod middleware;
|
||||
pub mod rls;
|
||||
pub mod session;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use middleware::*;
|
||||
pub use rls::*;
|
||||
pub use session::*;
|
||||
115
crates/chattyness-user-ui/src/auth/middleware.rs
Normal file
115
crates/chattyness-user-ui/src/auth/middleware.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! Authentication middleware and extractors.
|
||||
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::User;
|
||||
use chattyness_error::ErrorResponse;
|
||||
|
||||
use super::session::SESSION_USER_ID_KEY;
|
||||
|
||||
/// Extractor for an authenticated user.
|
||||
///
|
||||
/// Returns 401 Unauthorized if the user is not authenticated.
|
||||
pub struct AuthUser(pub User);
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
PgPool: FromRef<S>,
|
||||
{
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Get session from request
|
||||
let session = Session::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| AuthError::SessionError)?;
|
||||
|
||||
// Get user ID from session
|
||||
let user_id: Option<Uuid> = session
|
||||
.get(SESSION_USER_ID_KEY)
|
||||
.await
|
||||
.map_err(|_| AuthError::SessionError)?;
|
||||
|
||||
let user_id = user_id.ok_or(AuthError::Unauthorized)?;
|
||||
|
||||
// Get the database pool from state
|
||||
let pool = PgPool::from_ref(state);
|
||||
|
||||
// Fetch the user from the database
|
||||
let user = chattyness_db::queries::users::get_user_by_id(&pool, user_id)
|
||||
.await
|
||||
.map_err(|_| AuthError::InternalError)?
|
||||
.ok_or(AuthError::Unauthorized)?;
|
||||
|
||||
Ok(AuthUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor for an optional authenticated user.
|
||||
///
|
||||
/// Returns None if the user is not authenticated.
|
||||
pub struct OptionalAuthUser(pub Option<User>);
|
||||
|
||||
impl<S> FromRequestParts<S> for OptionalAuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
PgPool: FromRef<S>,
|
||||
{
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match AuthUser::from_request_parts(parts, state).await {
|
||||
Ok(AuthUser(user)) => Ok(OptionalAuthUser(Some(user))),
|
||||
Err(AuthError::Unauthorized) => Ok(OptionalAuthUser(None)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication errors.
|
||||
#[derive(Debug)]
|
||||
pub enum AuthError {
|
||||
Unauthorized,
|
||||
SessionError,
|
||||
InternalError,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AuthError::Unauthorized => (StatusCode::UNAUTHORIZED, "Authentication required"),
|
||||
AuthError::SessionError => (StatusCode::INTERNAL_SERVER_ERROR, "Session error"),
|
||||
AuthError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
error: message.to_string(),
|
||||
code: Some(format!("{:?}", self)),
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for chattyness_error::AppError {
|
||||
fn from(err: AuthError) -> Self {
|
||||
match err {
|
||||
AuthError::Unauthorized => chattyness_error::AppError::Unauthorized,
|
||||
AuthError::SessionError => {
|
||||
chattyness_error::AppError::Internal("Session error".to_string())
|
||||
}
|
||||
AuthError::InternalError => {
|
||||
chattyness_error::AppError::Internal("Internal error".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
296
crates/chattyness-user-ui/src/auth/rls.rs
Normal file
296
crates/chattyness-user-ui/src/auth/rls.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! Row-Level Security (RLS) middleware for PostgreSQL.
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, Request, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use sqlx::{pool::PoolConnection, postgres::PgConnection, PgPool, Postgres};
|
||||
use std::{
|
||||
future::Future,
|
||||
ops::{Deref, DerefMut},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
use tower::{Layer, Service};
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::session::{SESSION_GUEST_ID_KEY, SESSION_USER_ID_KEY};
|
||||
use chattyness_error::ErrorResponse;
|
||||
|
||||
// =============================================================================
|
||||
// RLS Connection Wrapper
|
||||
// =============================================================================
|
||||
|
||||
struct RlsConnectionInner {
|
||||
conn: Option<PoolConnection<Postgres>>,
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl Drop for RlsConnectionInner {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut conn) = self.conn.take() {
|
||||
let pool = self.pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
let _ = sqlx::query("SELECT public.set_current_guest_session_id(NULL)")
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
drop(conn);
|
||||
drop(pool);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A database connection with RLS user ID already set.
|
||||
#[derive(Clone)]
|
||||
pub struct RlsConnection {
|
||||
inner: Arc<Mutex<RlsConnectionInner>>,
|
||||
}
|
||||
|
||||
impl RlsConnection {
|
||||
fn new(conn: PoolConnection<Postgres>, pool: PgPool) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(RlsConnectionInner {
|
||||
conn: Some(conn),
|
||||
pool,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire exclusive access to the RLS connection.
|
||||
pub async fn acquire(&self) -> RlsGuard<'_> {
|
||||
RlsGuard {
|
||||
guard: self.inner.lock().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the current user ID on the RLS connection.
|
||||
/// Use this after creating a new user to set the context for subsequent operations.
|
||||
pub async fn set_user_id(&self, user_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
let conn = guard.conn.as_mut().expect("RlsConnection already consumed");
|
||||
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||
.bind(user_id)
|
||||
.execute(&mut **conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A guard providing mutable access to an RLS-configured database connection.
|
||||
pub struct RlsGuard<'a> {
|
||||
guard: MutexGuard<'a, RlsConnectionInner>,
|
||||
}
|
||||
|
||||
impl Deref for RlsGuard<'_> {
|
||||
type Target = PgConnection;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.guard
|
||||
.conn
|
||||
.as_ref()
|
||||
.expect("RlsConnection already consumed")
|
||||
.deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for RlsGuard<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.guard
|
||||
.conn
|
||||
.as_mut()
|
||||
.expect("RlsConnection already consumed")
|
||||
.deref_mut()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RLS Connection Extractor
|
||||
// =============================================================================
|
||||
|
||||
/// Extractor for an RLS-enabled database connection.
|
||||
pub struct RlsConn(pub RlsConnection);
|
||||
|
||||
impl<S> FromRequestParts<S> for RlsConn
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = RlsError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.remove::<RlsConnection>()
|
||||
.map(RlsConn)
|
||||
.ok_or(RlsError::NoConnection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors related to RLS connection handling.
|
||||
#[derive(Debug)]
|
||||
pub enum RlsError {
|
||||
NoConnection,
|
||||
DatabaseError(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for RlsError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
RlsError::NoConnection => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RLS connection not available",
|
||||
),
|
||||
RlsError::DatabaseError(msg) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, msg.leak() as &'static str)
|
||||
}
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
error: message.to_string(),
|
||||
code: Some("RLS_ERROR".to_string()),
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RLS Middleware Layer
|
||||
// =============================================================================
|
||||
|
||||
/// Layer that provides RLS-enabled database connections per request.
|
||||
#[derive(Clone)]
|
||||
pub struct RlsLayer {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl RlsLayer {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for RlsLayer {
|
||||
type Service = RlsMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
RlsMiddleware {
|
||||
inner,
|
||||
pool: self.pool.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware that sets up RLS connections per request.
|
||||
#[derive(Clone)]
|
||||
pub struct RlsMiddleware<S> {
|
||||
inner: S,
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl<S, B> Service<Request<B>> for RlsMiddleware<S>
|
||||
where
|
||||
S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
|
||||
S::Future: Send,
|
||||
B: Send + 'static,
|
||||
{
|
||||
type Response = Response;
|
||||
type Error = S::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, mut request: Request<B>) -> Self::Future {
|
||||
let pool = self.pool.clone();
|
||||
let mut inner = self.inner.clone();
|
||||
|
||||
let session = request.extensions().get::<Session>().cloned();
|
||||
|
||||
Box::pin(async move {
|
||||
let (user_id, guest_session_id) = get_session_ids(session).await;
|
||||
|
||||
match acquire_rls_connection(&pool, user_id, guest_session_id).await {
|
||||
Ok(rls_conn) => {
|
||||
request.extensions_mut().insert(rls_conn);
|
||||
inner.call(request).await
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to acquire RLS connection: {}", e);
|
||||
Ok(RlsError::DatabaseError(e.to_string()).into_response())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_session_ids(session: Option<Session>) -> (Option<Uuid>, Option<Uuid>) {
|
||||
let Some(session) = session else {
|
||||
return (None, None);
|
||||
};
|
||||
|
||||
let user_id = session
|
||||
.get::<Uuid>(SESSION_USER_ID_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let guest_session_id = session
|
||||
.get::<Uuid>(SESSION_GUEST_ID_KEY)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
(user_id, guest_session_id)
|
||||
}
|
||||
|
||||
async fn acquire_rls_connection(
|
||||
pool: &PgPool,
|
||||
user_id: Option<Uuid>,
|
||||
guest_session_id: Option<Uuid>,
|
||||
) -> Result<RlsConnection, sqlx::Error> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
if user_id.is_some() {
|
||||
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||
.bind(user_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
} else if guest_session_id.is_some() {
|
||||
sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query("SELECT public.set_current_guest_session_id($1)")
|
||||
.bind(guest_session_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(RlsConnection::new(conn, pool.clone()))
|
||||
}
|
||||
|
||||
impl std::ops::Deref for RlsConn {
|
||||
type Target = RlsConnection;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for RlsConn {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
58
crates/chattyness-user-ui/src/auth/session.rs
Normal file
58
crates/chattyness-user-ui/src/auth/session.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Session management using tower-sessions.
|
||||
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer};
|
||||
use tower_sessions_sqlx_store::PostgresStore;
|
||||
|
||||
/// Session cookie name.
|
||||
pub const SESSION_COOKIE_NAME: &str = "chattyness_session";
|
||||
|
||||
/// Session user ID key.
|
||||
pub const SESSION_USER_ID_KEY: &str = "user_id";
|
||||
|
||||
/// Session login type key (staff or realm).
|
||||
pub const SESSION_LOGIN_TYPE_KEY: &str = "login_type";
|
||||
|
||||
/// Session current realm ID key (for realm logins).
|
||||
pub const SESSION_CURRENT_REALM_KEY: &str = "current_realm_id";
|
||||
|
||||
/// Session original destination key (for password reset redirect).
|
||||
pub const SESSION_ORIGINAL_DEST_KEY: &str = "original_destination";
|
||||
|
||||
/// Session guest ID key (for guest sessions).
|
||||
pub const SESSION_GUEST_ID_KEY: &str = "guest_id";
|
||||
|
||||
/// Create the session management layer.
|
||||
pub async fn create_session_layer(
|
||||
pool: PgPool,
|
||||
secure: bool,
|
||||
) -> SessionManagerLayer<PostgresStore> {
|
||||
let session_store = PostgresStore::new(pool)
|
||||
.with_schema_name("auth")
|
||||
.expect("Invalid schema name for session store")
|
||||
.with_table_name("tower_sessions")
|
||||
.expect("Invalid table name for session store");
|
||||
|
||||
SessionManagerLayer::new(session_store)
|
||||
.with_name(SESSION_COOKIE_NAME)
|
||||
.with_secure(secure)
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_http_only(true)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::days(7)))
|
||||
}
|
||||
|
||||
/// Hash a session token for storage.
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Generate a random session token.
|
||||
pub fn generate_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 32] = rng.r#gen();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
17
crates/chattyness-user-ui/src/components.rs
Normal file
17
crates/chattyness-user-ui/src/components.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! Reusable UI components.
|
||||
|
||||
pub mod chat;
|
||||
pub mod editor;
|
||||
pub mod forms;
|
||||
pub mod layout;
|
||||
pub mod modals;
|
||||
pub mod scene_viewer;
|
||||
pub mod ws_client;
|
||||
|
||||
pub use chat::*;
|
||||
pub use editor::*;
|
||||
pub use forms::*;
|
||||
pub use layout::*;
|
||||
pub use modals::*;
|
||||
pub use scene_viewer::*;
|
||||
pub use ws_client::*;
|
||||
38
crates/chattyness-user-ui/src/components/chat.rs
Normal file
38
crates/chattyness-user-ui/src/components/chat.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Chat components for realm chat interface.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Chat input component (placeholder UI).
|
||||
///
|
||||
/// Displays a text input field for typing messages.
|
||||
/// Currently non-functional - just UI placeholder.
|
||||
#[component]
|
||||
pub fn ChatInput() -> impl IntoView {
|
||||
let (message, set_message) = signal(String::new());
|
||||
|
||||
view! {
|
||||
<div class="chat-input-container w-full max-w-4xl mx-auto">
|
||||
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type a message..."
|
||||
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
||||
prop:value=move || message.get()
|
||||
on:input=move |ev| {
|
||||
set_message.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=move || message.get().trim().is_empty()
|
||||
>
|
||||
"Send"
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs mt-2 text-center">
|
||||
"Chat functionality coming soon"
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
357
crates/chattyness-user-ui/src/components/editor.rs
Normal file
357
crates/chattyness-user-ui/src/components/editor.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! Scene editor components.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::SpotSummary;
|
||||
|
||||
/// Drawing mode for spot editor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DrawingMode {
|
||||
#[default]
|
||||
Select,
|
||||
Polygon,
|
||||
Rectangle,
|
||||
}
|
||||
|
||||
/// Toolbar for selecting drawing mode.
|
||||
#[component]
|
||||
pub fn DrawingModeToolbar(
|
||||
#[prop(into)] mode: Signal<DrawingMode>,
|
||||
on_change: Callback<DrawingMode>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex gap-2" role="radiogroup" aria-label="Drawing mode">
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||
if mode.get() == DrawingMode::Select {
|
||||
format!("{} bg-blue-600 text-white", base)
|
||||
} else {
|
||||
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_change.run(DrawingMode::Select)
|
||||
aria-pressed=move || mode.get() == DrawingMode::Select
|
||||
>
|
||||
"Select"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||
if mode.get() == DrawingMode::Rectangle {
|
||||
format!("{} bg-blue-600 text-white", base)
|
||||
} else {
|
||||
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_change.run(DrawingMode::Rectangle)
|
||||
aria-pressed=move || mode.get() == DrawingMode::Rectangle
|
||||
>
|
||||
"Rectangle"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||
if mode.get() == DrawingMode::Polygon {
|
||||
format!("{} bg-blue-600 text-white", base)
|
||||
} else {
|
||||
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_change.run(DrawingMode::Polygon)
|
||||
aria-pressed=move || mode.get() == DrawingMode::Polygon
|
||||
>
|
||||
"Polygon"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas for displaying scene with spots.
|
||||
#[component]
|
||||
pub fn SceneCanvas(
|
||||
#[prop(into)] width: Signal<u32>,
|
||||
#[prop(into)] height: Signal<u32>,
|
||||
#[prop(into)] background_color: Signal<Option<String>>,
|
||||
#[prop(into)] background_image: Signal<Option<String>>,
|
||||
#[prop(into)] spots: Signal<Vec<SpotSummary>>,
|
||||
#[prop(into)] selected_spot_id: Signal<Option<Uuid>>,
|
||||
on_spot_click: Callback<Uuid>,
|
||||
) -> impl IntoView {
|
||||
let canvas_style = Signal::derive(move || {
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
if let Some(img) = background_image.get() {
|
||||
format!(
|
||||
"width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center;",
|
||||
w, h, img
|
||||
)
|
||||
} else {
|
||||
format!("width: {}px; height: {}px; background-color: {};", w, h, bg_color)
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900">
|
||||
<div class="relative" style=move || canvas_style.get()>
|
||||
{move || {
|
||||
spots
|
||||
.get()
|
||||
.into_iter()
|
||||
.map(|spot| {
|
||||
let spot_id = spot.id;
|
||||
let is_selected = selected_spot_id.get() == Some(spot_id);
|
||||
let style = parse_wkt_to_style(&spot.region_wkt);
|
||||
view! {
|
||||
<div
|
||||
class=move || {
|
||||
let base = "absolute border-2 cursor-pointer transition-colors";
|
||||
if is_selected {
|
||||
format!("{} border-blue-500 bg-blue-500/30", base)
|
||||
} else {
|
||||
format!("{} border-green-500/50 bg-green-500/20 hover:bg-green-500/30", base)
|
||||
}
|
||||
}
|
||||
style=style
|
||||
on:click=move |_| on_spot_click.run(spot_id)
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas for drawing new spots.
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
pub fn SpotDrawer(
|
||||
#[prop(into)] width: Signal<u32>,
|
||||
#[prop(into)] height: Signal<u32>,
|
||||
#[prop(into)] mode: Signal<DrawingMode>,
|
||||
on_complete: Callback<String>,
|
||||
#[prop(into)] background_color: Signal<Option<String>>,
|
||||
#[prop(into)] background_image: Signal<Option<String>>,
|
||||
#[prop(into)] existing_spots_wkt: Signal<Vec<String>>,
|
||||
) -> impl IntoView {
|
||||
let (drawing_points, _set_drawing_points) = signal(Vec::<(f64, f64)>::new());
|
||||
let (is_drawing, _set_is_drawing) = signal(false);
|
||||
let (start_point, _set_start_point) = signal(Option::<(f64, f64)>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let (set_drawing_points, set_is_drawing, set_start_point) =
|
||||
(_set_drawing_points, _set_is_drawing, _set_start_point);
|
||||
|
||||
let canvas_style = Signal::derive(move || {
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
if let Some(img) = background_image.get() {
|
||||
format!(
|
||||
"width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center; cursor: crosshair;",
|
||||
w, h, img
|
||||
)
|
||||
} else {
|
||||
format!("width: {}px; height: {}px; background-color: {}; cursor: crosshair;", w, h, bg_color)
|
||||
}
|
||||
});
|
||||
|
||||
let on_mouse_down = move |ev: leptos::ev::MouseEvent| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let rect = ev
|
||||
.target()
|
||||
.and_then(|t| {
|
||||
use wasm_bindgen::JsCast;
|
||||
t.dyn_ref::<web_sys::HtmlElement>()
|
||||
.map(|el| el.get_bounding_client_rect())
|
||||
});
|
||||
|
||||
if let Some(rect) = rect {
|
||||
let x = ev.client_x() as f64 - rect.left();
|
||||
let y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
match mode.get() {
|
||||
DrawingMode::Rectangle => {
|
||||
set_start_point.set(Some((x, y)));
|
||||
set_is_drawing.set(true);
|
||||
}
|
||||
DrawingMode::Polygon => {
|
||||
let mut points = drawing_points.get();
|
||||
points.push((x, y));
|
||||
set_drawing_points.set(points);
|
||||
}
|
||||
DrawingMode::Select => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_mouse_up = move |ev: leptos::ev::MouseEvent| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
if mode.get() == DrawingMode::Rectangle && is_drawing.get() {
|
||||
if let Some((start_x, start_y)) = start_point.get() {
|
||||
let rect = ev
|
||||
.target()
|
||||
.and_then(|t| {
|
||||
use wasm_bindgen::JsCast;
|
||||
t.dyn_ref::<web_sys::HtmlElement>()
|
||||
.map(|el| el.get_bounding_client_rect())
|
||||
});
|
||||
|
||||
if let Some(rect) = rect {
|
||||
let end_x = ev.client_x() as f64 - rect.left();
|
||||
let end_y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
let min_x = start_x.min(end_x);
|
||||
let min_y = start_y.min(end_y);
|
||||
let max_x = start_x.max(end_x);
|
||||
let max_y = start_y.max(end_y);
|
||||
|
||||
if (max_x - min_x) > 10.0 && (max_y - min_y) > 10.0 {
|
||||
let wkt = format!(
|
||||
"POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))",
|
||||
min_x, min_y, max_x, min_y, max_x, max_y, min_x, max_y, min_x, min_y
|
||||
);
|
||||
on_complete.run(wkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
set_is_drawing.set(false);
|
||||
set_start_point.set(None);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_double_click = move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
if mode.get() == DrawingMode::Polygon {
|
||||
let points = drawing_points.get();
|
||||
if points.len() >= 3 {
|
||||
let wkt = points_to_wkt(&points);
|
||||
on_complete.run(wkt);
|
||||
}
|
||||
set_drawing_points.set(Vec::new());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900">
|
||||
<div
|
||||
class="relative"
|
||||
style=move || canvas_style.get()
|
||||
on:mousedown=on_mouse_down
|
||||
on:mouseup=on_mouse_up
|
||||
on:dblclick=on_double_click
|
||||
>
|
||||
// Render existing spots
|
||||
{move || {
|
||||
existing_spots_wkt
|
||||
.get()
|
||||
.into_iter()
|
||||
.map(|wkt| {
|
||||
let style = parse_wkt_to_style(&wkt);
|
||||
view! {
|
||||
<div
|
||||
class="absolute border-2 border-gray-500/50 bg-gray-500/20"
|
||||
style=style
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
|
||||
// Render drawing preview
|
||||
{move || {
|
||||
let points = drawing_points.get();
|
||||
if !points.is_empty() && mode.get() == DrawingMode::Polygon {
|
||||
let svg_points: String = points
|
||||
.iter()
|
||||
.map(|(x, y)| format!("{},{}", x, y))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Some(view! {
|
||||
<svg class="absolute inset-0 pointer-events-none">
|
||||
<polyline
|
||||
points=svg_points
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse WKT polygon to CSS positioning style.
|
||||
fn parse_wkt_to_style(wkt: &str) -> String {
|
||||
let trimmed = wkt.trim();
|
||||
if let Some(coords_str) = trimmed
|
||||
.strip_prefix("POLYGON((")
|
||||
.and_then(|s| s.strip_suffix("))"))
|
||||
{
|
||||
let points: Vec<(f64, f64)> = coords_str
|
||||
.split(',')
|
||||
.filter_map(|p| {
|
||||
let coords: Vec<&str> = p.trim().split_whitespace().collect();
|
||||
if coords.len() >= 2 {
|
||||
Some((coords[0].parse().ok()?, coords[1].parse().ok()?))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if points.len() >= 4 {
|
||||
let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
|
||||
let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
|
||||
let max_x = points.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max);
|
||||
let max_y = points.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
return format!(
|
||||
"left: {}px; top: {}px; width: {}px; height: {}px;",
|
||||
min_x,
|
||||
min_y,
|
||||
max_x - min_x,
|
||||
max_y - min_y
|
||||
);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Convert points to WKT polygon.
|
||||
#[allow(dead_code)]
|
||||
fn points_to_wkt(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let coords: String = points
|
||||
.iter()
|
||||
.chain(std::iter::once(&points[0]))
|
||||
.map(|(x, y)| format!("{} {}", x, y))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
format!("POLYGON(({})", coords)
|
||||
}
|
||||
345
crates/chattyness-user-ui/src/components/forms.rs
Normal file
345
crates/chattyness-user-ui/src/components/forms.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
//! Form components with WCAG 2.2 AA accessibility.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Text input field with label.
|
||||
#[component]
|
||||
pub fn TextInput(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(default = "text")] input_type: &'static str,
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(optional)] minlength: Option<i32>,
|
||||
#[prop(optional)] maxlength: Option<i32>,
|
||||
#[prop(optional)] pattern: &'static str,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_input: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
type=input_type
|
||||
id=input_id
|
||||
name=name
|
||||
placeholder=placeholder
|
||||
required=required
|
||||
minlength=minlength
|
||||
maxlength=maxlength
|
||||
pattern=if pattern.is_empty() { None } else { Some(pattern) }
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_input.run(event_target_value(&ev))
|
||||
/>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Textarea field with label.
|
||||
#[component]
|
||||
pub fn TextArea(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(default = 3)] rows: i32,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_input: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<textarea
|
||||
id=input_id
|
||||
name=name
|
||||
placeholder=placeholder
|
||||
required=required
|
||||
rows=rows
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base resize-y"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_input.run(event_target_value(&ev))
|
||||
/>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Radio button group.
|
||||
#[component]
|
||||
pub fn RadioGroup(
|
||||
name: &'static str,
|
||||
legend: &'static str,
|
||||
options: Vec<(&'static str, &'static str, &'static str)>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<fieldset class="space-y-3">
|
||||
<legend class="block text-sm font-medium text-gray-300 mb-2">{legend}</legend>
|
||||
<div class="space-y-2">
|
||||
{options
|
||||
.into_iter()
|
||||
.map(|(val, label, description)| {
|
||||
let val_clone = val.to_string();
|
||||
let is_selected = Signal::derive(move || value.get() == val);
|
||||
view! {
|
||||
<label class="flex items-start space-x-3 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name=name
|
||||
value=val
|
||||
checked=move || is_selected.get()
|
||||
on:change=move |_| on_change.run(val_clone.clone())
|
||||
class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-white group-hover:text-blue-400 transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
<p class="text-sm text-gray-400">{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkbox input.
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(optional)] description: &'static str,
|
||||
#[prop(into)] checked: Signal<bool>,
|
||||
on_change: Callback<bool>,
|
||||
) -> impl IntoView {
|
||||
let has_description = !description.is_empty();
|
||||
|
||||
view! {
|
||||
<label class="flex items-start space-x-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
name=name
|
||||
prop:checked=move || checked.get()
|
||||
on:change=move |ev| on_change.run(event_target_checked(&ev))
|
||||
class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-white group-hover:text-blue-400 transition-colors">{label}</span>
|
||||
{if has_description {
|
||||
view! { <p class="text-sm text-gray-400">{description}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// Range slider input.
|
||||
#[component]
|
||||
pub fn RangeSlider(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
min: i32,
|
||||
max: i32,
|
||||
#[prop(default = 1)] step: i32,
|
||||
#[prop(into)] value: Signal<i32>,
|
||||
on_change: Callback<i32>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<span class="text-sm text-gray-400">{move || value.get()}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id=input_id
|
||||
name=name
|
||||
min=min
|
||||
max=max
|
||||
step=step
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(val) = event_target_value(&ev).parse::<i32>() {
|
||||
on_change.run(val);
|
||||
}
|
||||
}
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary submit button.
|
||||
#[component]
|
||||
pub fn SubmitButton(
|
||||
#[prop(default = "Submit")] text: &'static str,
|
||||
#[prop(default = "Submitting...")] loading_text: &'static str,
|
||||
#[prop(into)] pending: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<button type="submit" disabled=move || pending.get() class="btn-primary w-full">
|
||||
{move || if pending.get() { loading_text } else { text }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
/// Error alert box.
|
||||
#[component]
|
||||
pub fn ErrorAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView {
|
||||
view! {
|
||||
<Show when=move || message.get().is_some()>
|
||||
<div class="error-message" role="alert">
|
||||
<p>{move || message.get().unwrap_or_default()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Success alert box.
|
||||
#[component]
|
||||
pub fn SuccessAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView {
|
||||
view! {
|
||||
<Show when=move || message.get().is_some()>
|
||||
<div
|
||||
class="p-4 bg-green-900/50 border border-green-500 rounded-lg text-green-200"
|
||||
role="alert"
|
||||
>
|
||||
<p>{move || message.get().unwrap_or_default()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Color picker component.
|
||||
#[component]
|
||||
pub fn ColorPicker(
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
label: &'static str,
|
||||
id: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex items-center gap-3">
|
||||
<label for=id class="text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id=id
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
class="w-10 h-10 rounded border border-gray-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
class="input-base w-24 text-sm"
|
||||
placeholder="#1a1a2e"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Color palette component.
|
||||
#[component]
|
||||
pub fn ColorPalette(#[prop(into)] value: Signal<String>, on_change: Callback<String>) -> impl IntoView {
|
||||
let colors = [
|
||||
"#1a1a2e", "#16213e", "#0f3460", "#e94560", "#533483", "#2c3e50", "#1e8449", "#d35400",
|
||||
];
|
||||
|
||||
view! {
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{colors
|
||||
.into_iter()
|
||||
.map(|color| {
|
||||
let is_selected = Signal::derive(move || value.get() == color);
|
||||
let color_string = color.to_string();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "w-8 h-8 rounded border-2 transition-transform hover:scale-110";
|
||||
if is_selected.get() {
|
||||
format!("{} border-white", base)
|
||||
} else {
|
||||
format!("{} border-transparent", base)
|
||||
}
|
||||
}
|
||||
style=format!("background-color: {}", color)
|
||||
title=color
|
||||
on:click=move |_| on_change.run(color_string.clone())
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the checked state of a checkbox input.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn event_target_checked(ev: &leptos::ev::Event) -> bool {
|
||||
use wasm_bindgen::JsCast;
|
||||
ev.target()
|
||||
.and_then(|t| t.dyn_ref::<web_sys::HtmlInputElement>().map(|el| el.checked()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stub for SSR.
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
fn event_target_checked(_ev: &leptos::ev::Event) -> bool {
|
||||
false
|
||||
}
|
||||
214
crates/chattyness-user-ui/src/components/layout.rs
Normal file
214
crates/chattyness-user-ui/src/components/layout.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Layout components.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Main page layout wrapper.
|
||||
#[component]
|
||||
pub fn PageLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class="min-h-screen bg-gray-900 text-white flex flex-col overflow-x-hidden">
|
||||
<Header />
|
||||
<main class="flex-1">{children()}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple site header for non-realm pages.
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
view! {
|
||||
<header class="bg-gray-800 border-b border-gray-700">
|
||||
<nav class="px-4" aria-label="Main navigation">
|
||||
<div class="flex items-center h-16">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center space-x-2 text-xl font-bold text-white hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<img src="/icons/castle.svg" alt="" class="w-6 h-6" aria-hidden="true" />
|
||||
<span>"Chattyness"</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
/// Realm-specific header with realm/scene info and user actions.
|
||||
#[component]
|
||||
pub fn RealmHeader(
|
||||
realm_name: String,
|
||||
realm_slug: String,
|
||||
realm_description: Option<String>,
|
||||
scene_name: String,
|
||||
scene_description: Option<String>,
|
||||
online_count: i32,
|
||||
total_members: i32,
|
||||
max_capacity: i32,
|
||||
can_admin: bool,
|
||||
on_logout: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let stats_tooltip = format!("Members: {} / Max: {}", total_members, max_capacity);
|
||||
let online_text = format!("{} ONLINE", online_count);
|
||||
let admin_url = format!("/admin/realms/{}", realm_slug);
|
||||
|
||||
view! {
|
||||
<header class="bg-gray-800 border-b border-gray-700">
|
||||
<div class="flex items-center justify-between h-16 px-4">
|
||||
// Left side: Logo + Realm/Scene info
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center space-x-2 text-xl font-bold text-white hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<img src="/icons/castle.svg" alt="" class="w-6 h-6" aria-hidden="true" />
|
||||
<span>"Chattyness"</span>
|
||||
</a>
|
||||
<span class="text-gray-500">"|"</span>
|
||||
<span
|
||||
class="text-white font-medium cursor-default"
|
||||
title=realm_description.unwrap_or_default()
|
||||
>
|
||||
{realm_name}
|
||||
</span>
|
||||
<span class="text-gray-500">"/"</span>
|
||||
<span
|
||||
class="text-gray-300 cursor-default"
|
||||
title=scene_description.unwrap_or_default()
|
||||
>
|
||||
{scene_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
// Right side: Stats + Actions
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-green-400 font-medium cursor-default" title=stats_tooltip>
|
||||
{online_text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
on:click=move |_| on_logout.run(())
|
||||
>
|
||||
"Logout"
|
||||
</button>
|
||||
{can_admin.then(|| {
|
||||
view! {
|
||||
<a
|
||||
href=admin_url
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
"Admin"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
/// Site footer.
|
||||
#[component]
|
||||
pub fn Footer() -> impl IntoView {
|
||||
view! {
|
||||
<footer class="bg-gray-800 border-t border-gray-700 py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between">
|
||||
<p class="text-gray-400 text-sm">
|
||||
"Built with "
|
||||
<a
|
||||
href="https://leptos.dev"
|
||||
class="text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
"Leptos"
|
||||
</a>
|
||||
" and "
|
||||
<a
|
||||
href="https://www.rust-lang.org"
|
||||
class="text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
"Rust"
|
||||
</a>
|
||||
"."
|
||||
</p>
|
||||
<nav class="flex items-center space-x-4 mt-4 md:mt-0" aria-label="Footer navigation">
|
||||
<a
|
||||
href="/about"
|
||||
class="text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
"About"
|
||||
</a>
|
||||
<a
|
||||
href="/privacy"
|
||||
class="text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
"Privacy"
|
||||
</a>
|
||||
<a
|
||||
href="/terms"
|
||||
class="text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
"Terms"
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple centered layout for forms and dialogs.
|
||||
#[component]
|
||||
pub fn CenteredLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Card container.
|
||||
#[component]
|
||||
pub fn Card(#[prop(optional)] class: &'static str, children: Children) -> impl IntoView {
|
||||
let base_class = "bg-gray-800 rounded-lg shadow-xl";
|
||||
let combined_class = if class.is_empty() {
|
||||
base_class.to_string()
|
||||
} else {
|
||||
format!("{} {}", base_class, class)
|
||||
};
|
||||
|
||||
view! { <div class=combined_class>{children()}</div> }
|
||||
}
|
||||
|
||||
/// Scene thumbnail component.
|
||||
#[component]
|
||||
pub fn SceneThumbnail(scene: chattyness_db::models::SceneSummary) -> impl IntoView {
|
||||
let background_style = match (&scene.background_image_path, &scene.background_color) {
|
||||
(Some(path), _) => format!("background-image: url('{}'); background-size: cover; background-position: center;", path),
|
||||
(None, Some(color)) => format!("background-color: {};", color),
|
||||
(None, None) => "background-color: #1a1a2e;".to_string(),
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card class="overflow-hidden">
|
||||
<div class="h-32 w-full" style=background_style></div>
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-white">{scene.name}</h3>
|
||||
<p class="text-gray-400 text-sm">"/" {scene.slug}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
{scene.is_entry_point.then(|| view! {
|
||||
<span class="text-xs px-2 py-0.5 bg-green-600 rounded">"Entry"</span>
|
||||
})}
|
||||
{scene.is_hidden.then(|| view! {
|
||||
<span class="text-xs px-2 py-0.5 bg-gray-600 rounded">"Hidden"</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
200
crates/chattyness-user-ui/src/components/modals.rs
Normal file
200
crates/chattyness-user-ui/src/components/modals.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
//! Modal components with WCAG 2.2 AA accessibility.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Confirmation modal for joining a realm.
|
||||
#[component]
|
||||
pub fn JoinRealmModal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
realm_name: String,
|
||||
realm_slug: String,
|
||||
#[prop(into)] pending: Signal<bool>,
|
||||
on_confirm: Callback<()>,
|
||||
on_cancel: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let on_cancel_backdrop = on_cancel.clone();
|
||||
let on_cancel_close = on_cancel.clone();
|
||||
let on_cancel_button = on_cancel.clone();
|
||||
|
||||
let (name_sig, _) = signal(realm_name);
|
||||
let (slug_sig, _) = signal(realm_slug);
|
||||
|
||||
view! {
|
||||
<Show when=move || open.get()>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="join-modal-title"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
on:click=move |_| on_cancel_backdrop.run(())
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_cancel_close.run(())
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/icons/castle.svg"
|
||||
alt=""
|
||||
class="w-8 h-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 id="join-modal-title" class="text-xl font-bold text-white mb-2">
|
||||
"Join " {move || name_sig.get()} "?"
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-6">
|
||||
"You're not a member of "
|
||||
<span class="text-blue-400 font-medium">{move || slug_sig.get()}</span>
|
||||
" yet. Would you like to join this realm?"
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary px-6 py-2"
|
||||
on:click=move |_| on_cancel_button.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary px-6 py-2"
|
||||
on:click=move |_| on_confirm.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Joining..." } else { "Join Realm" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-4">
|
||||
"You can leave a realm at any time from your profile settings."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation modal for general actions.
|
||||
#[component]
|
||||
pub fn ConfirmModal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
title: &'static str,
|
||||
message: String,
|
||||
#[prop(default = "Confirm")] confirm_text: &'static str,
|
||||
#[prop(default = "Cancel")] cancel_text: &'static str,
|
||||
#[prop(default = false)] destructive: bool,
|
||||
#[prop(into)] pending: Signal<bool>,
|
||||
on_confirm: Callback<()>,
|
||||
on_cancel: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let on_cancel_backdrop = on_cancel.clone();
|
||||
let on_cancel_close = on_cancel.clone();
|
||||
let on_cancel_button = on_cancel.clone();
|
||||
|
||||
let (message_sig, _) = signal(message);
|
||||
|
||||
let confirm_class = if destructive {
|
||||
"bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
} else {
|
||||
"btn-primary px-6 py-2"
|
||||
};
|
||||
|
||||
view! {
|
||||
<Show when=move || open.get()>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
on:click=move |_| on_cancel_backdrop.run(())
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_cancel_close.run(())
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<h3 id="confirm-modal-title" class="text-xl font-bold text-white mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-6">{move || message_sig.get()}</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary px-6 py-2"
|
||||
on:click=move |_| on_cancel_button.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{cancel_text}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=confirm_class
|
||||
on:click=move |_| on_confirm.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Please wait..." } else { confirm_text }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
421
crates/chattyness-user-ui/src/components/scene_viewer.rs
Normal file
421
crates/chattyness-user-ui/src/components/scene_viewer.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
//! Scene viewer component for displaying realm scenes with avatars.
|
||||
//!
|
||||
//! Uses layered canvases for efficient rendering:
|
||||
//! - Background canvas: Static, drawn once when scene loads
|
||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
||||
|
||||
/// Parse bounds WKT to extract width and height.
|
||||
///
|
||||
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
||||
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
|
||||
let trimmed = bounds_wkt.trim();
|
||||
let coords_str = trimmed
|
||||
.strip_prefix("POLYGON((")
|
||||
.and_then(|s| s.strip_suffix("))"))?;
|
||||
|
||||
let points: Vec<&str> = coords_str.split(',').collect();
|
||||
if points.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut max_x: f64 = 0.0;
|
||||
let mut max_y: f64 = 0.0;
|
||||
|
||||
for point in points.iter() {
|
||||
let coords: Vec<&str> = point.trim().split_whitespace().collect();
|
||||
if coords.len() >= 2 {
|
||||
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if max_x > 0.0 && max_y > 0.0 {
|
||||
Some((max_x as u32, max_y as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene viewer component for displaying a realm scene with avatars.
|
||||
///
|
||||
/// Uses two layered canvases:
|
||||
/// - Background canvas (z-index 0): Static background, drawn once
|
||||
/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates
|
||||
#[component]
|
||||
pub fn RealmSceneViewer(
|
||||
scene: Scene,
|
||||
#[allow(unused)]
|
||||
realm_slug: String,
|
||||
#[prop(into)]
|
||||
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||
#[prop(into)]
|
||||
on_move: Callback<(f64, f64)>,
|
||||
) -> impl IntoView {
|
||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
||||
|
||||
let bg_color = scene
|
||||
.background_color
|
||||
.clone()
|
||||
.unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let has_background_image = scene.background_image_path.is_some();
|
||||
#[allow(unused_variables)]
|
||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||
|
||||
// Two separate canvas refs for layered rendering
|
||||
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||
let scale_x = StoredValue::new(1.0_f64);
|
||||
let scale_y = StoredValue::new(1.0_f64);
|
||||
let offset_x = StoredValue::new(0.0_f64);
|
||||
let offset_y = StoredValue::new(0.0_f64);
|
||||
|
||||
// Handle canvas click for movement (on avatar canvas - topmost layer)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_canvas_click = {
|
||||
let on_move = on_move.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let rect = canvas_el.get_bounding_client_rect();
|
||||
|
||||
let canvas_x = ev.client_x() as f64 - rect.left();
|
||||
let canvas_y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
if sx > 0.0 && sy > 0.0 {
|
||||
let scene_x = (canvas_x - ox) / sx;
|
||||
let scene_y = (canvas_y - oy) / sy;
|
||||
|
||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
||||
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||
|
||||
on_move.run((scene_x, scene_y));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
let image_path_clone = image_path.clone();
|
||||
let bg_color_clone = bg_color.clone();
|
||||
let scene_width_f = scene_width as f64;
|
||||
let scene_height_f = scene_height as f64;
|
||||
|
||||
// Flag to track if background has been drawn
|
||||
let bg_drawn = Rc::new(RefCell::new(false));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - runs once on mount, draws static background
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Don't track any reactive signals - this should only run once
|
||||
let Some(canvas) = bg_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Skip if already drawn
|
||||
if *bg_drawn.borrow() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
let bg_color = bg_color_clone.clone();
|
||||
let image_path = image_path_clone.clone();
|
||||
let bg_drawn_inner = bg_drawn.clone();
|
||||
|
||||
let draw_bg = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
|
||||
// Mark background as drawn
|
||||
*bg_drawn_inner.borrow_mut() = true;
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_bg.as_ref().unchecked_ref());
|
||||
draw_bg.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Avatar Effect - runs when members change, redraws avatars only
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track members signal - this Effect reruns when members change
|
||||
let current_members = members.get();
|
||||
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_avatars_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize avatar canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency (not fill - keeps canvas transparent)
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Draw avatars
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref());
|
||||
draw_avatars_closure.forget();
|
||||
});
|
||||
}
|
||||
|
||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||
|
||||
view! {
|
||||
<div class="scene-container w-full h-full flex justify-center items-center">
|
||||
<div
|
||||
class="scene-canvas relative overflow-hidden cursor-pointer"
|
||||
style:background-color=bg_color.clone()
|
||||
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
|
||||
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
|
||||
style:max-height="calc(100vh - 64px)"
|
||||
>
|
||||
// Background layer - static, drawn once
|
||||
<canvas
|
||||
node_ref=bg_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Avatar layer - dynamic, transparent background
|
||||
<canvas
|
||||
node_ref=avatar_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
aria-label=format!("Scene: {}", scene.name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
on_canvas_click(ev);
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
}
|
||||
>
|
||||
{format!("Scene: {}", scene.name)}
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn normalize_asset_path(path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/static/{}", path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_avatars(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
members: &[ChannelMemberWithAvatar],
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
) {
|
||||
for member in members {
|
||||
let x = member.member.position_x * scale_x + offset_x;
|
||||
let y = member.member.position_y * scale_y + offset_y;
|
||||
|
||||
let avatar_size = 48.0 * scale_x.min(scale_y);
|
||||
|
||||
// Draw avatar placeholder circle
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#6366f1");
|
||||
ctx.fill();
|
||||
|
||||
// Draw skin layer sprite if available
|
||||
if let Some(ref skin_path) = member.avatar.skin_layer[4] {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x;
|
||||
let draw_y = y - avatar_size;
|
||||
let size = avatar_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x - size / 2.0, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(skin_path));
|
||||
}
|
||||
|
||||
// Draw emotion overlay if available
|
||||
if let Some(ref emotion_path) = member.avatar.emotion_layer[4] {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x;
|
||||
let draw_y = y - avatar_size;
|
||||
let size = avatar_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x - size / 2.0, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(emotion_path));
|
||||
}
|
||||
|
||||
// Draw emotion indicator on avatar
|
||||
let emotion = member.member.current_emotion;
|
||||
if emotion > 0 {
|
||||
// Draw emotion number in a small badge
|
||||
let badge_size = 16.0 * scale_x.min(scale_y);
|
||||
let badge_x = x + avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = y - avatar_size - badge_size / 2.0;
|
||||
|
||||
// Badge background
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#f59e0b"); // Amber color for emotion badge
|
||||
ctx.fill();
|
||||
|
||||
// Emotion number
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
||||
}
|
||||
|
||||
// Draw display name
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||
}
|
||||
}
|
||||
257
crates/chattyness-user-ui/src/components/ws_client.rs
Normal file
257
crates/chattyness-user-ui/src/components/ws_client.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
//! WebSocket client for channel presence.
|
||||
//!
|
||||
//! Provides a Leptos hook to manage WebSocket connections for real-time
|
||||
//! position updates, emotion changes, and member synchronization.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
|
||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
|
||||
|
||||
/// WebSocket connection state.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum WsState {
|
||||
/// Attempting to connect.
|
||||
Connecting,
|
||||
/// Connected and ready.
|
||||
Connected,
|
||||
/// Disconnected (not connected).
|
||||
Disconnected,
|
||||
/// Connection error occurred.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Sender function type for WebSocket messages.
|
||||
pub type WsSender = Box<dyn Fn(ClientMessage)>;
|
||||
|
||||
/// Local stored value type for the sender (non-Send, WASM-compatible).
|
||||
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;
|
||||
|
||||
/// Hook to manage WebSocket connection for a channel.
|
||||
///
|
||||
/// Returns a tuple of:
|
||||
/// - `Signal<WsState>` - The current connection state
|
||||
/// - `WsSenderStorage` - A stored sender function to send messages
|
||||
#[cfg(feature = "hydrate")]
|
||||
pub fn use_channel_websocket(
|
||||
realm_slug: Signal<String>,
|
||||
channel_id: Signal<Option<uuid::Uuid>>,
|
||||
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
) -> (Signal<WsState>, WsSenderStorage) {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||
|
||||
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
|
||||
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
|
||||
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
// Create a stored sender function (using new_local for WASM single-threaded environment)
|
||||
let ws_ref_for_send = ws_ref.clone();
|
||||
let sender: WsSenderStorage = StoredValue::new_local(Some(Box::new(
|
||||
move |msg: ClientMessage| {
|
||||
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
|
||||
if ws.ready_state() == WebSocket::OPEN {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&format!("[WS->Server] {}", json).into());
|
||||
let _ = ws.send_with_str(&json);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)));
|
||||
|
||||
// Effect to manage WebSocket lifecycle
|
||||
let ws_ref_clone = ws_ref.clone();
|
||||
let members_clone = members.clone();
|
||||
|
||||
Effect::new(move |_| {
|
||||
let slug = realm_slug.get();
|
||||
let ch_id = channel_id.get();
|
||||
|
||||
// Cleanup previous connection
|
||||
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
|
||||
let _ = old_ws.close();
|
||||
}
|
||||
|
||||
let Some(ch_id) = ch_id else {
|
||||
set_ws_state.set(WsState::Disconnected);
|
||||
return;
|
||||
};
|
||||
|
||||
if slug.is_empty() {
|
||||
set_ws_state.set(WsState::Disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct WebSocket URL
|
||||
let window = web_sys::window().unwrap();
|
||||
let location = window.location();
|
||||
let protocol = if location.protocol().unwrap_or_default() == "https:" {
|
||||
"wss:"
|
||||
} else {
|
||||
"ws:"
|
||||
};
|
||||
let host = location.host().unwrap_or_default();
|
||||
let url = format!(
|
||||
"{}//{}/api/realms/{}/channels/{}/ws",
|
||||
protocol, host, slug, ch_id
|
||||
);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&format!("[WS] Connecting to: {}", url).into());
|
||||
|
||||
set_ws_state.set(WsState::Connecting);
|
||||
|
||||
let ws = match WebSocket::new(&url) {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::error_1(&format!("[WS] Failed to create: {:?}", e).into());
|
||||
set_ws_state.set(WsState::Error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// onopen
|
||||
let set_ws_state_open = set_ws_state;
|
||||
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&"[WS] Connected".into());
|
||||
set_ws_state_open.set(WsState::Connected);
|
||||
}) as Box<dyn FnMut(web_sys::Event)>);
|
||||
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
|
||||
onopen.forget();
|
||||
|
||||
// onmessage
|
||||
let members_for_msg = members_clone.clone();
|
||||
let on_members_update_clone = on_members_update.clone();
|
||||
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
||||
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
||||
let text: String = text.into();
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
|
||||
|
||||
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||
handle_server_message(msg, &members_for_msg, &on_members_update_clone);
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(MessageEvent)>);
|
||||
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
|
||||
onmessage.forget();
|
||||
|
||||
// onerror
|
||||
let set_ws_state_err = set_ws_state;
|
||||
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
|
||||
set_ws_state_err.set(WsState::Error);
|
||||
}) as Box<dyn FnMut(ErrorEvent)>);
|
||||
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
|
||||
onerror.forget();
|
||||
|
||||
// onclose
|
||||
let set_ws_state_close = set_ws_state;
|
||||
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(
|
||||
&format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(),
|
||||
);
|
||||
set_ws_state_close.set(WsState::Disconnected);
|
||||
}) as Box<dyn FnMut(CloseEvent)>);
|
||||
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
|
||||
onclose.forget();
|
||||
|
||||
*ws_ref_clone.borrow_mut() = Some(ws);
|
||||
});
|
||||
|
||||
(Signal::derive(move || ws_state.get()), sender)
|
||||
}
|
||||
|
||||
/// Handle a message received from the server.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn handle_server_message(
|
||||
msg: ServerMessage,
|
||||
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
||||
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
) {
|
||||
let mut members_vec = members.borrow_mut();
|
||||
|
||||
match msg {
|
||||
ServerMessage::Welcome {
|
||||
member: _,
|
||||
members: initial_members,
|
||||
} => {
|
||||
*members_vec = initial_members;
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::MemberJoined { member } => {
|
||||
// Remove if exists (rejoin case), then add
|
||||
members_vec.retain(|m| {
|
||||
m.member.user_id != member.member.user_id
|
||||
|| m.member.guest_session_id != member.member.guest_session_id
|
||||
});
|
||||
members_vec.push(member);
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::MemberLeft {
|
||||
user_id,
|
||||
guest_session_id,
|
||||
} => {
|
||||
members_vec.retain(|m| {
|
||||
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
|
||||
});
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::PositionUpdated {
|
||||
user_id,
|
||||
guest_session_id,
|
||||
x,
|
||||
y,
|
||||
} => {
|
||||
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||
}) {
|
||||
m.member.position_x = x;
|
||||
m.member.position_y = y;
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::EmotionUpdated {
|
||||
user_id,
|
||||
guest_session_id,
|
||||
emotion,
|
||||
emotion_layer,
|
||||
} => {
|
||||
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||
}) {
|
||||
m.member.current_emotion = emotion as i16;
|
||||
m.avatar.emotion_layer = emotion_layer;
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::Pong => {
|
||||
// Heartbeat acknowledged - nothing to do
|
||||
}
|
||||
ServerMessage::Error { code, message } => {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub implementation for SSR (server-side rendering).
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn use_channel_websocket(
|
||||
_realm_slug: Signal<String>,
|
||||
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
) -> (Signal<WsState>, WsSenderStorage) {
|
||||
let (ws_state, _) = signal(WsState::Disconnected);
|
||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||
(Signal::derive(move || ws_state.get()), sender)
|
||||
}
|
||||
36
crates/chattyness-user-ui/src/lib.rs
Normal file
36
crates/chattyness-user-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#![recursion_limit = "256"]
|
||||
//! User UI components for chattyness.
|
||||
//!
|
||||
//! This crate provides the public user-facing interface including:
|
||||
//! - Login and signup pages
|
||||
//! - Realm browsing and viewing
|
||||
//! - Scene editor for realm builders
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! For standalone use:
|
||||
//! ```ignore
|
||||
//! use chattyness_user_ui::App;
|
||||
//! // App includes its own Router
|
||||
//! ```
|
||||
//!
|
||||
//! For embedding in a combined app (e.g., chattyness-app):
|
||||
//! ```ignore
|
||||
//! use chattyness_user_ui::UserRoutes;
|
||||
//! // UserRoutes can be placed inside an existing Router
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod auth;
|
||||
pub mod components;
|
||||
pub mod pages;
|
||||
pub mod routes;
|
||||
|
||||
pub use app::{shell, App};
|
||||
pub use routes::UserRoutes;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub use app::AppState;
|
||||
15
crates/chattyness-user-ui/src/pages.rs
Normal file
15
crates/chattyness-user-ui/src/pages.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! Page components for user UI.
|
||||
//!
|
||||
//! Note: Editor pages and NewRealmPage have been moved to admin-ui.
|
||||
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod password_reset;
|
||||
pub mod realm;
|
||||
pub mod signup;
|
||||
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
pub use password_reset::*;
|
||||
pub use realm::*;
|
||||
pub use signup::*;
|
||||
96
crates/chattyness-user-ui/src/pages/home.rs
Normal file
96
crates/chattyness-user-ui/src/pages/home.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Home page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, PageLayout};
|
||||
|
||||
/// Home page.
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
view! {
|
||||
<PageLayout>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
// Hero section
|
||||
<section class="text-center mb-16">
|
||||
<h1 class="text-4xl md:text-6xl font-bold text-white mb-6">
|
||||
"Welcome to "
|
||||
<span class="text-blue-400">"Chattyness"</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-300 max-w-2xl mx-auto mb-8">
|
||||
"Create and explore virtual spaces where communities come alive. "
|
||||
"Build your own realm, customize it, and invite others to join."
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a href="/realms/new" class="btn-primary text-lg px-8 py-4">
|
||||
"Create Your Realm"
|
||||
</a>
|
||||
<a href="/realms" class="btn-secondary text-lg px-8 py-4">
|
||||
"Explore Realms"
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// Features section
|
||||
<section class="mb-16">
|
||||
<h2 class="text-2xl font-bold text-white text-center mb-8">"Why Chattyness?"</h2>
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<FeatureCard
|
||||
icon="castle"
|
||||
title="Create Realms"
|
||||
description="Build themed virtual spaces with multiple scenes, customizable spots, and interactive elements."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="users"
|
||||
title="Build Communities"
|
||||
description="Invite friends, manage members, and create a thriving community around your interests."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="palette"
|
||||
title="Customize Everything"
|
||||
description="Props, avatars, scenes, and scripts - make your realm truly unique."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// CTA section
|
||||
<section class="text-center">
|
||||
<Card class="p-8 max-w-2xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">"Ready to get started?"</h2>
|
||||
<p class="text-gray-300 mb-6">
|
||||
"Create your first realm in just a few clicks. No experience needed."
|
||||
</p>
|
||||
<a href="/realms/new" class="btn-primary inline-block">
|
||||
"Create a Realm"
|
||||
</a>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature card component.
|
||||
#[component]
|
||||
fn FeatureCard(icon: &'static str, title: &'static str, description: &'static str) -> impl IntoView {
|
||||
let icon_symbol = match icon {
|
||||
"castle" => "castle",
|
||||
"users" => "users",
|
||||
"palette" => "palette",
|
||||
_ => "star",
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card class="p-6 text-center">
|
||||
<div class="text-4xl mb-4" aria-hidden="true">
|
||||
<img
|
||||
src=format!("/icons/{}.svg", icon_symbol)
|
||||
alt=""
|
||||
class="w-12 h-12 mx-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{title}</h3>
|
||||
<p class="text-gray-400">{description}</p>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
497
crates/chattyness-user-ui/src/pages/login.rs
Normal file
497
crates/chattyness-user-ui/src/pages/login.rs
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
//! Login page for realm users.
|
||||
|
||||
use leptos::ev::SubmitEvent;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
use crate::components::{Card, CenteredLayout, ErrorAlert, JoinRealmModal, SubmitButton};
|
||||
use chattyness_db::models::RealmSummary;
|
||||
|
||||
/// Main login page component.
|
||||
#[component]
|
||||
pub fn LoginPage() -> impl IntoView {
|
||||
view! {
|
||||
<CenteredLayout>
|
||||
<div class="w-full max-w-lg">
|
||||
// Logo and title
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">
|
||||
<span aria-hidden="true">"Chattyness"</span>
|
||||
</h1>
|
||||
<p class="text-gray-400">"Sign in to explore virtual community spaces"</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<RealmLoginForm />
|
||||
</Card>
|
||||
</div>
|
||||
</CenteredLayout>
|
||||
}
|
||||
}
|
||||
|
||||
/// Realm login form component.
|
||||
#[component]
|
||||
fn RealmLoginForm() -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate = use_navigate();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_submit = navigate.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_join = navigate.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate_for_guest = navigate.clone();
|
||||
|
||||
// Form state
|
||||
let (username, set_username) = signal(String::new());
|
||||
let (password, set_password) = signal(String::new());
|
||||
let (selected_realm, set_selected_realm) = signal(Option::<String>::None);
|
||||
let (private_realm, set_private_realm) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (guest_pending, set_guest_pending) = signal(false);
|
||||
|
||||
// Join modal state
|
||||
let (show_join_modal, set_show_join_modal) = signal(false);
|
||||
let (join_pending, set_join_pending) = signal(false);
|
||||
let (pending_realm, set_pending_realm) = signal(Option::<RealmSummary>::None);
|
||||
|
||||
// Fetch public realms
|
||||
let realms = LocalResource::new(move || async move {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListResponse {
|
||||
realms: Vec<RealmSummary>,
|
||||
}
|
||||
let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
None::<Vec<RealmSummary>>
|
||||
}
|
||||
});
|
||||
|
||||
// Get the realm slug to use
|
||||
let realm_slug = Signal::derive(move || {
|
||||
if !private_realm.get().is_empty() {
|
||||
Some(private_realm.get())
|
||||
} else {
|
||||
selected_realm.get()
|
||||
}
|
||||
});
|
||||
|
||||
// Handle login submission
|
||||
let on_submit = move |ev: SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(None);
|
||||
|
||||
let slug = realm_slug.get();
|
||||
if slug.is_none() {
|
||||
set_error.set(Some(
|
||||
"Please select a realm or enter a private realm name".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let slug = slug.unwrap();
|
||||
let uname = username.get();
|
||||
let pwd = password.get();
|
||||
|
||||
if uname.is_empty() || pwd.is_empty() {
|
||||
set_error.set(Some("Username and password are required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::{LoginRequest, LoginResponse, LoginType};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let navigate = navigate_for_submit.clone();
|
||||
spawn_local(async move {
|
||||
let request = LoginRequest {
|
||||
username: uname,
|
||||
password: pwd,
|
||||
login_type: LoginType::Realm,
|
||||
realm_slug: Some(slug),
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/login")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(login_resp) = resp.json::<LoginResponse>().await {
|
||||
if login_resp.requires_pw_reset {
|
||||
navigate(&login_resp.redirect_url, Default::default());
|
||||
} else if login_resp.is_member == Some(false) {
|
||||
if let Some(realm) = login_resp.realm {
|
||||
set_pending_realm.set(Some(realm));
|
||||
set_show_join_modal.set(true);
|
||||
}
|
||||
} else {
|
||||
navigate(&login_resp.redirect_url, Default::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status == 401 {
|
||||
set_error.set(Some("Invalid username or password".to_string()));
|
||||
} else if status == 403 {
|
||||
set_error.set(Some("Your account is suspended or banned".to_string()));
|
||||
} else {
|
||||
set_error.set(Some("Login failed. Please try again.".to_string()));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle join confirmation
|
||||
let on_join_confirm = move |_| {
|
||||
if pending_realm.get().is_some() {
|
||||
set_join_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::JoinRealmRequest;
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let realm = pending_realm.get().unwrap();
|
||||
let navigate = navigate_for_join.clone();
|
||||
let realm_id = realm.id;
|
||||
let realm_slug = realm.slug.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let request = JoinRealmRequest { realm_id };
|
||||
|
||||
let response = Request::post("/api/auth/join-realm")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_join_pending.set(false);
|
||||
set_show_join_modal.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
navigate(&format!("/realms/{}", realm_slug), Default::default());
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status == 403 {
|
||||
set_error.set(Some("Cannot join this realm".to_string()));
|
||||
} else {
|
||||
set_error.set(Some(
|
||||
"Failed to join realm. Please try again.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_join_cancel = move |_| {
|
||||
set_show_join_modal.set(false);
|
||||
set_pending_realm.set(None);
|
||||
};
|
||||
|
||||
// Handle guest login
|
||||
let on_guest_click = move |_| {
|
||||
set_error.set(None);
|
||||
|
||||
let slug = realm_slug.get();
|
||||
if slug.is_none() {
|
||||
set_error.set(Some("Please select a realm first".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_guest_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::{GuestLoginRequest, GuestLoginResponse};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let navigate = navigate_for_guest.clone();
|
||||
let realm_slug_val = slug.unwrap();
|
||||
|
||||
spawn_local(async move {
|
||||
let request = GuestLoginRequest {
|
||||
realm_slug: realm_slug_val,
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/guest")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_guest_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(guest_resp) = resp.json::<GuestLoginResponse>().await {
|
||||
navigate(&guest_resp.redirect_url, Default::default());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_error.set(Some(err.error));
|
||||
} else {
|
||||
set_error.set(Some(
|
||||
"Guest access not available for this realm".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<form on:submit=on_submit class="space-y-6">
|
||||
// Realm selection
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-gray-300 mb-3">"Choose a Realm"</legend>
|
||||
|
||||
// Public realm list
|
||||
<Suspense fallback=move || {
|
||||
view! { <p class="text-gray-400">"Loading realms..."</p> }
|
||||
}>
|
||||
{move || {
|
||||
realms
|
||||
.get()
|
||||
.map(|maybe_realms: Option<Vec<RealmSummary>>| {
|
||||
match maybe_realms {
|
||||
Some(realms) if !realms.is_empty() => {
|
||||
view! {
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto mb-4">
|
||||
{realms
|
||||
.into_iter()
|
||||
.map(|realm| {
|
||||
let slug = realm.slug.clone();
|
||||
let slug_for_click = slug.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
selected_realm.get() == Some(slug.clone())
|
||||
});
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "w-full text-left p-3 rounded-lg border transition-colors";
|
||||
if is_selected.get() {
|
||||
format!("{} border-blue-500 bg-blue-500/10", base)
|
||||
} else {
|
||||
format!(
|
||||
"{} border-gray-700 hover:border-gray-600 hover:bg-gray-700/50",
|
||||
base,
|
||||
)
|
||||
}
|
||||
}
|
||||
on:click=move |_| {
|
||||
set_selected_realm.set(Some(slug_for_click.clone()));
|
||||
set_private_realm.set(String::new());
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-white font-medium">
|
||||
{realm.name.clone()}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm ml-2">
|
||||
{format!("/{}", realm.slug)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-gray-400 text-sm">
|
||||
{realm.current_user_count}
|
||||
" online"
|
||||
</span>
|
||||
</div>
|
||||
{realm
|
||||
.tagline
|
||||
.as_ref()
|
||||
.map(|t: &String| {
|
||||
view! {
|
||||
<p class="text-gray-400 text-sm mt-1">{t.clone()}</p>
|
||||
}
|
||||
})}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
_ => {
|
||||
view! {
|
||||
<p class="text-gray-400 mb-4">"No public realms available"</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
|
||||
// Private realm input
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span class="text-gray-500">"/"</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Or enter a private realm name"
|
||||
class="input-base pl-6"
|
||||
prop:value=move || private_realm.get()
|
||||
on:input=move |ev| {
|
||||
set_private_realm.set(event_target_value(&ev));
|
||||
set_selected_realm.set(None);
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
// Credentials
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
"Username"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required=true
|
||||
autocomplete="username"
|
||||
class="input-base"
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
"Password"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required=true
|
||||
autocomplete="current-password"
|
||||
class="input-base"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error message
|
||||
<ErrorAlert message=Signal::derive(move || error.get()) />
|
||||
|
||||
// Submit button
|
||||
<SubmitButton
|
||||
text="Enter Realm"
|
||||
loading_text="Signing in..."
|
||||
pending=Signal::derive(move || pending.get())
|
||||
/>
|
||||
|
||||
// Divider
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-700"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-gray-800 text-gray-400">"or"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Guest button
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-3 px-4 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 hover:border-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=move || guest_pending.get() || realm_slug.get().is_none()
|
||||
on:click=on_guest_click
|
||||
>
|
||||
{move || {
|
||||
if guest_pending.get() { "Joining as guest..." } else { "Continue as Guest" }
|
||||
}}
|
||||
</button>
|
||||
|
||||
// Sign up link
|
||||
<p class="text-center text-gray-400 text-sm">
|
||||
"Don't have an account? "
|
||||
<a href="/signup" class="text-blue-400 hover:underline">
|
||||
"Sign up"
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
// Join modal
|
||||
{
|
||||
let on_join_confirm = on_join_confirm.clone();
|
||||
let on_join_cancel = on_join_cancel.clone();
|
||||
move || {
|
||||
let on_join_confirm = on_join_confirm.clone();
|
||||
let on_join_cancel = on_join_cancel.clone();
|
||||
pending_realm
|
||||
.get()
|
||||
.map(|realm| {
|
||||
view! {
|
||||
<JoinRealmModal
|
||||
open=Signal::derive(move || show_join_modal.get())
|
||||
realm_name=realm.name.clone()
|
||||
realm_slug=realm.slug.clone()
|
||||
pending=Signal::derive(move || join_pending.get())
|
||||
on_confirm=Callback::new(on_join_confirm.clone())
|
||||
on_cancel=Callback::new(on_join_cancel.clone())
|
||||
/>
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
216
crates/chattyness-user-ui/src/pages/password_reset.rs
Normal file
216
crates/chattyness-user-ui/src/pages/password_reset.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! Password reset page for users with force_pw_reset flag.
|
||||
|
||||
use leptos::ev::SubmitEvent;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos_router::hooks::use_navigate;
|
||||
|
||||
use crate::components::{Card, CenteredLayout, ErrorAlert, SubmitButton};
|
||||
|
||||
/// Password reset page component.
|
||||
#[component]
|
||||
pub fn PasswordResetPage() -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate = use_navigate();
|
||||
|
||||
let (new_password, set_new_password) = signal(String::new());
|
||||
let (confirm_password, set_confirm_password) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
|
||||
let password_valid = Signal::derive(move || new_password.get().len() >= 8);
|
||||
|
||||
let passwords_match = Signal::derive(move || {
|
||||
let pwd = new_password.get();
|
||||
let confirm = confirm_password.get();
|
||||
!pwd.is_empty() && pwd == confirm
|
||||
});
|
||||
|
||||
let on_submit = move |ev: SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(None);
|
||||
|
||||
let pwd = new_password.get();
|
||||
let confirm = confirm_password.get();
|
||||
|
||||
if pwd.len() < 8 {
|
||||
set_error.set(Some("Password must be at least 8 characters".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if pwd != confirm {
|
||||
set_error.set(Some("Passwords do not match".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::{PasswordResetRequest, PasswordResetResponse};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let navigate = navigate.clone();
|
||||
spawn_local(async move {
|
||||
let request = PasswordResetRequest {
|
||||
new_password: pwd,
|
||||
confirm_password: confirm,
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/reset-password")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(reset_resp) = resp.json::<PasswordResetResponse>().await {
|
||||
navigate(&reset_resp.redirect_url, Default::default());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status == 401 {
|
||||
set_error.set(Some("Session expired. Please log in again.".to_string()));
|
||||
} else if status == 400 {
|
||||
set_error.set(Some("Invalid password. Please try again.".to_string()));
|
||||
} else {
|
||||
set_error.set(Some(
|
||||
"Failed to reset password. Please try again.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<CenteredLayout>
|
||||
<div class="w-full max-w-md">
|
||||
<Card class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto w-16 h-16 rounded-full bg-yellow-600/20 flex items-center justify-center mb-4">
|
||||
<img src="/icons/key.svg" alt="" class="w-8 h-8" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white mb-2">"Reset Your Password"</h1>
|
||||
<p class="text-gray-400">"Please create a new password to continue"</p>
|
||||
</div>
|
||||
|
||||
<form on:submit=on_submit class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="new-password"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"New Password"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="new-password"
|
||||
name="new_password"
|
||||
required=true
|
||||
minlength=8
|
||||
autocomplete="new-password"
|
||||
class="input-base"
|
||||
prop:value=move || new_password.get()
|
||||
on:input=move |ev| set_new_password.set(event_target_value(&ev))
|
||||
/>
|
||||
<div class="mt-2 flex items-center space-x-2">
|
||||
<div class=move || {
|
||||
let base = "h-1 flex-1 rounded";
|
||||
if password_valid.get() {
|
||||
format!("{} bg-green-500", base)
|
||||
} else if new_password.get().len() >= 4 {
|
||||
format!("{} bg-yellow-500", base)
|
||||
} else {
|
||||
format!("{} bg-gray-600", base)
|
||||
}
|
||||
} />
|
||||
</div>
|
||||
<p class=move || {
|
||||
let base = "text-sm mt-1";
|
||||
if password_valid.get() {
|
||||
format!("{} text-green-400", base)
|
||||
} else {
|
||||
format!("{} text-gray-400", base)
|
||||
}
|
||||
}>
|
||||
{move || {
|
||||
if password_valid.get() {
|
||||
"Password meets requirements"
|
||||
} else {
|
||||
"Minimum 8 characters"
|
||||
}
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"Confirm Password"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
required=true
|
||||
autocomplete="new-password"
|
||||
class="input-base"
|
||||
prop:value=move || confirm_password.get()
|
||||
on:input=move |ev| set_confirm_password.set(event_target_value(&ev))
|
||||
/>
|
||||
{move || {
|
||||
let confirm = confirm_password.get();
|
||||
if !confirm.is_empty() {
|
||||
if passwords_match.get() {
|
||||
view! {
|
||||
<p class="text-sm text-green-400 mt-1">"Passwords match"</p>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<p class="text-sm text-red-400 mt-1">
|
||||
"Passwords do not match"
|
||||
</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<ErrorAlert message=Signal::derive(move || error.get()) />
|
||||
|
||||
<SubmitButton
|
||||
text="Reset Password"
|
||||
loading_text="Resetting..."
|
||||
pending=Signal::derive(move || pending.get())
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p class="text-sm text-gray-500 text-center mt-6">
|
||||
"After resetting your password, you'll be redirected to your destination."
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</CenteredLayout>
|
||||
}
|
||||
}
|
||||
349
crates/chattyness-user-ui/src/pages/realm.rs
Normal file
349
crates/chattyness-user-ui/src/pages/realm.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! Realm landing page after login.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos_router::hooks::use_navigate;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::components::use_channel_websocket;
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, RealmRole, RealmWithUserRole, Scene};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
/// Realm landing page component.
|
||||
#[component]
|
||||
pub fn RealmPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate = use_navigate();
|
||||
|
||||
let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default());
|
||||
|
||||
// Channel member state
|
||||
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
|
||||
let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None);
|
||||
|
||||
let realm_data = LocalResource::new(move || {
|
||||
let slug = slug.get();
|
||||
async move {
|
||||
if slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
let response = Request::get(&format!("/api/realms/{}", slug)).send().await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => resp.json::<RealmWithUserRole>().await.ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
let _ = slug;
|
||||
None::<RealmWithUserRole>
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch entry scene for the realm
|
||||
let entry_scene = LocalResource::new(move || {
|
||||
let slug = slug.get();
|
||||
async move {
|
||||
if slug.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
let response = Request::get(&format!("/api/realms/{}/entry-scene", slug))
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => resp.json::<Scene>().await.ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
let _ = slug;
|
||||
None::<Scene>
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket connection for real-time updates
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
|
||||
set_members.set(new_members);
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let (_ws_state, ws_sender) = use_channel_websocket(
|
||||
slug,
|
||||
Signal::derive(move || channel_id.get()),
|
||||
on_members_update,
|
||||
);
|
||||
|
||||
// Set channel ID when scene loads (triggers WebSocket connection)
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
Effect::new(move |_| {
|
||||
let Some(scene) = entry_scene.get().flatten() else {
|
||||
return;
|
||||
};
|
||||
set_channel_id.set(Some(scene.id));
|
||||
});
|
||||
}
|
||||
|
||||
// Handle position update via WebSocket
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_move = Callback::new(move |(x, y): (f64, f64)| {
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::UpdatePosition { x, y });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
|
||||
|
||||
// Handle emotion change via keyboard (e then 0-9)
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
let closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
||||
Rc::new(RefCell::new(None));
|
||||
let closure_holder_clone = closure_holder.clone();
|
||||
|
||||
Effect::new(move |_| {
|
||||
// Cleanup previous closure if any
|
||||
if let Some(old_closure) = closure_holder_clone.borrow_mut().take() {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback(
|
||||
"keydown",
|
||||
old_closure.as_ref().unchecked_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let current_slug = slug.get();
|
||||
if current_slug.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track if 'e' was pressed (for e+0-9 emotion sequence)
|
||||
let e_pressed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
|
||||
let e_pressed_clone = e_pressed.clone();
|
||||
|
||||
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
|
||||
let key = ev.key();
|
||||
|
||||
// Check if 'e' key was pressed
|
||||
if key == "e" || key == "E" {
|
||||
*e_pressed_clone.borrow_mut() = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for 0-9 after 'e' was pressed
|
||||
if *e_pressed_clone.borrow() {
|
||||
*e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome
|
||||
if key.len() == 1 {
|
||||
if let Ok(emotion) = key.parse::<u8>() {
|
||||
if emotion <= 9 {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(
|
||||
&format!("[Emotion] Sending emotion {}", emotion).into(),
|
||||
);
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::UpdateEmotion { emotion });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Any other key resets the 'e' state
|
||||
*e_pressed_clone.borrow_mut() = false;
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window
|
||||
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||||
}
|
||||
|
||||
// Store the closure for cleanup
|
||||
*closure_holder_clone.borrow_mut() = Some(closure);
|
||||
});
|
||||
}
|
||||
|
||||
// Create logout callback (WebSocket disconnects automatically)
|
||||
let on_logout = Callback::new(move |_: ()| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
let navigate = navigate.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
// WebSocket close handles channel leave automatically
|
||||
let _: Result<gloo_net::http::Response, gloo_net::Error> =
|
||||
Request::post("/api/auth/logout").send().await;
|
||||
navigate("/", Default::default());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="h-screen bg-gray-900 text-white flex flex-col overflow-hidden">
|
||||
<Suspense fallback=move || {
|
||||
view! {
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<p class="text-gray-400">"Loading realm..."</p>
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
{move || {
|
||||
let on_logout = on_logout.clone();
|
||||
let on_move = on_move.clone();
|
||||
realm_data
|
||||
.get()
|
||||
.map(|maybe_data| {
|
||||
match maybe_data {
|
||||
Some(data) => {
|
||||
let realm = data.realm;
|
||||
let user_role = data.user_role;
|
||||
|
||||
// Determine if user can access admin
|
||||
// Admin visible for: Owner, Moderator, or staff
|
||||
let can_admin = matches!(
|
||||
user_role,
|
||||
Some(RealmRole::Owner) | Some(RealmRole::Moderator)
|
||||
);
|
||||
|
||||
// Get scene name and description for header
|
||||
let scene_info = entry_scene
|
||||
.get()
|
||||
.flatten()
|
||||
.map(|s| (s.name.clone(), s.description.clone()))
|
||||
.unwrap_or_else(|| ("Loading...".to_string(), None));
|
||||
|
||||
let realm_name = realm.name.clone();
|
||||
let realm_slug_val = realm.slug.clone();
|
||||
let realm_description = realm.tagline.clone();
|
||||
let online_count = realm.current_user_count;
|
||||
let total_members = realm.member_count;
|
||||
let max_capacity = realm.max_users;
|
||||
let scene_name = scene_info.0;
|
||||
let scene_description = scene_info.1;
|
||||
|
||||
view! {
|
||||
<RealmHeader
|
||||
realm_name=realm_name
|
||||
realm_slug=realm_slug_val.clone()
|
||||
realm_description=realm_description
|
||||
scene_name=scene_name
|
||||
scene_description=scene_description
|
||||
online_count=online_count
|
||||
total_members=total_members
|
||||
max_capacity=max_capacity
|
||||
can_admin=can_admin
|
||||
on_logout=on_logout.clone()
|
||||
/>
|
||||
<main class="flex-1 w-full">
|
||||
// Scene viewer - full width
|
||||
<Suspense fallback=move || {
|
||||
view! {
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<p class="text-gray-400">"Loading scene..."</p>
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
{move || {
|
||||
let on_move = on_move.clone();
|
||||
let realm_slug_for_viewer = realm_slug_val.clone();
|
||||
entry_scene
|
||||
.get()
|
||||
.map(|maybe_scene| {
|
||||
match maybe_scene {
|
||||
Some(scene) => {
|
||||
let members_signal = Signal::derive(move || members.get());
|
||||
view! {
|
||||
<div class="relative w-full">
|
||||
<RealmSceneViewer
|
||||
scene=scene
|
||||
realm_slug=realm_slug_for_viewer.clone()
|
||||
members=members_signal
|
||||
on_move=on_move.clone()
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4">
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
None => {
|
||||
view! {
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<Card class="p-8 text-center">
|
||||
<p class="text-gray-400">
|
||||
"No scenes have been created for this realm yet."
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</main>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
None => {
|
||||
view! {
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<Card class="p-8 text-center max-w-md">
|
||||
<div class="mx-auto w-20 h-20 rounded-full bg-red-900/20 flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/icons/x.svg"
|
||||
alt=""
|
||||
class="w-10 h-10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">
|
||||
"Realm Not Found"
|
||||
</h2>
|
||||
<p class="text-gray-400 mb-6">
|
||||
"The realm you're looking for doesn't exist or you don't have access."
|
||||
</p>
|
||||
<a href="/" class="btn-primary inline-block">
|
||||
"Back to Home"
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
412
crates/chattyness-user-ui/src/pages/signup.rs
Normal file
412
crates/chattyness-user-ui/src/pages/signup.rs
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
//! Sign-up page for new user registration.
|
||||
|
||||
use leptos::ev::SubmitEvent;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos_router::hooks::use_navigate;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::components::{Card, CenteredLayout, ErrorAlert, SubmitButton};
|
||||
use chattyness_db::models::RealmSummary;
|
||||
|
||||
/// Sign-up page component.
|
||||
#[component]
|
||||
pub fn SignupPage() -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let navigate = use_navigate();
|
||||
let query = use_query_map();
|
||||
|
||||
// Form state
|
||||
let (username, set_username) = signal(String::new());
|
||||
let (display_name, set_display_name) = signal(String::new());
|
||||
let (email, set_email) = signal(String::new());
|
||||
let (password, set_password) = signal(String::new());
|
||||
let (confirm_password, set_confirm_password) = signal(String::new());
|
||||
let (selected_realm, set_selected_realm) = signal(Option::<String>::None);
|
||||
let (private_realm, set_private_realm) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
|
||||
// Read query param for pre-filled realm
|
||||
Effect::new(move |_| {
|
||||
if let Some(realm) = query.read().get("realm") {
|
||||
set_private_realm.set(realm.to_string());
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch public realms
|
||||
let realms = LocalResource::new(move || async move {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListResponse {
|
||||
realms: Vec<RealmSummary>,
|
||||
}
|
||||
let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
None::<Vec<RealmSummary>>
|
||||
}
|
||||
});
|
||||
|
||||
// Get the realm slug to use
|
||||
let realm_slug = Signal::derive(move || {
|
||||
let private = private_realm.get();
|
||||
if !private.is_empty() {
|
||||
Some(private)
|
||||
} else {
|
||||
selected_realm.get()
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
let on_submit = move |ev: SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(None);
|
||||
|
||||
let slug = realm_slug.get();
|
||||
if slug.is_none() {
|
||||
set_error.set(Some("Please select a realm or enter a realm name".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let uname = username.get();
|
||||
if uname.len() < 3 || uname.len() > 30 {
|
||||
set_error.set(Some("Username must be 3-30 characters".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let dname = display_name.get();
|
||||
if dname.trim().is_empty() {
|
||||
set_error.set(Some("Display name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let pwd = password.get();
|
||||
if pwd.len() < 8 {
|
||||
set_error.set(Some("Password must be at least 8 characters".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let confirm_pwd = confirm_password.get();
|
||||
if pwd != confirm_pwd {
|
||||
set_error.set(Some("Passwords do not match".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use chattyness_db::models::{SignupRequest, SignupResponse};
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let navigate = navigate.clone();
|
||||
let email_val = email.get();
|
||||
let email_opt = if email_val.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(email_val)
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
let request = SignupRequest {
|
||||
username: uname,
|
||||
email: email_opt,
|
||||
display_name: dname,
|
||||
password: pwd,
|
||||
confirm_password: confirm_pwd,
|
||||
realm_slug: slug.unwrap(),
|
||||
};
|
||||
|
||||
let response = Request::post("/api/auth/signup")
|
||||
.json(&request)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(signup_resp) = resp.json::<SignupResponse>().await {
|
||||
navigate(&signup_resp.redirect_url, Default::default());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
let status = resp.status();
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_error.set(Some(err.error));
|
||||
} else if status == 409 {
|
||||
set_error.set(Some("Username or email already taken".to_string()));
|
||||
} else if status == 404 {
|
||||
set_error.set(Some("Realm not found".to_string()));
|
||||
} else {
|
||||
set_error.set(Some("Sign up failed. Please try again.".to_string()));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some(
|
||||
"Network error. Please check your connection.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<CenteredLayout>
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">"Join Chattyness"</h1>
|
||||
<p class="text-gray-400">"Create your account and join a realm"</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-6">
|
||||
<form on:submit=on_submit class="space-y-6">
|
||||
// Realm selection
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-gray-300 mb-3">
|
||||
"Choose a Realm"
|
||||
</legend>
|
||||
|
||||
<Suspense fallback=move || {
|
||||
view! { <p class="text-gray-400">"Loading realms..."</p> }
|
||||
}>
|
||||
{move || {
|
||||
realms
|
||||
.get()
|
||||
.map(|maybe_realms: Option<Vec<RealmSummary>>| {
|
||||
match maybe_realms {
|
||||
Some(realms) if !realms.is_empty() => {
|
||||
view! {
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto mb-4">
|
||||
{realms
|
||||
.into_iter()
|
||||
.map(|realm| {
|
||||
let slug = realm.slug.clone();
|
||||
let slug_for_click = slug.clone();
|
||||
let is_selected = Signal::derive(move || {
|
||||
selected_realm.get() == Some(slug.clone())
|
||||
});
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "w-full text-left p-3 rounded-lg border transition-colors";
|
||||
if is_selected.get() {
|
||||
format!("{} border-blue-500 bg-blue-500/10", base)
|
||||
} else {
|
||||
format!(
|
||||
"{} border-gray-700 hover:border-gray-600 hover:bg-gray-700/50",
|
||||
base,
|
||||
)
|
||||
}
|
||||
}
|
||||
on:click=move |_| {
|
||||
set_selected_realm
|
||||
.set(Some(slug_for_click.clone()));
|
||||
set_private_realm.set(String::new());
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-white font-medium">
|
||||
{realm.name.clone()}
|
||||
</span>
|
||||
<span class="text-gray-500 text-sm ml-2">
|
||||
{format!("/{}", realm.slug)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-gray-400 text-sm">
|
||||
{realm.member_count}
|
||||
" members"
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
_ => {
|
||||
view! {
|
||||
<p class="text-gray-400 mb-4">
|
||||
"No public realms available"
|
||||
</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span class="text-gray-500">"/"</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Or enter a realm name"
|
||||
class="input-base pl-6"
|
||||
prop:value=move || private_realm.get()
|
||||
on:input=move |ev| {
|
||||
set_private_realm.set(event_target_value(&ev));
|
||||
set_selected_realm.set(None);
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
// Account details
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"Username"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required=true
|
||||
autocomplete="username"
|
||||
minlength=3
|
||||
maxlength=30
|
||||
pattern="[a-z][a-z0-9_]*"
|
||||
class="input-base"
|
||||
placeholder="lowercase letters, numbers, underscores"
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
/>
|
||||
<p class="text-gray-500 text-xs mt-1">
|
||||
"3-30 characters, starts with a letter"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="display_name"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"Display Name"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
required=true
|
||||
maxlength=50
|
||||
class="input-base"
|
||||
placeholder="How others will see you"
|
||||
prop:value=move || display_name.get()
|
||||
on:input=move |ev| set_display_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"Email"
|
||||
<span class="text-gray-500 text-xs ml-1">"(optional)"</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
class="input-base"
|
||||
placeholder="your@email.com"
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| set_email.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"Password"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required=true
|
||||
minlength=8
|
||||
autocomplete="new-password"
|
||||
class="input-base"
|
||||
placeholder="At least 8 characters"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirm_password"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>
|
||||
"Confirm Password"
|
||||
<span class="text-red-400" aria-hidden="true">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
required=true
|
||||
minlength=8
|
||||
autocomplete="new-password"
|
||||
class="input-base"
|
||||
prop:value=move || confirm_password.get()
|
||||
on:input=move |ev| {
|
||||
set_confirm_password.set(event_target_value(&ev))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorAlert message=Signal::derive(move || error.get()) />
|
||||
|
||||
<SubmitButton
|
||||
text="Create Account"
|
||||
loading_text="Creating account..."
|
||||
pending=Signal::derive(move || pending.get())
|
||||
/>
|
||||
|
||||
<p class="text-center text-gray-400 text-sm">
|
||||
"Already have an account? "
|
||||
<a href="/" class="text-blue-400 hover:underline">
|
||||
"Sign in"
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</CenteredLayout>
|
||||
}
|
||||
}
|
||||
36
crates/chattyness-user-ui/src/routes.rs
Normal file
36
crates/chattyness-user-ui/src/routes.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! User routes without Router wrapper (for embedding in combined apps).
|
||||
//!
|
||||
//! This module provides the `UserRoutes` component which contains all user
|
||||
//! route definitions without a Router wrapper. This allows the routes to be
|
||||
//! embedded in a parent Router (e.g., CombinedApp in chattyness-app).
|
||||
//!
|
||||
//! For standalone use, use `App` which wraps these routes with a Router.
|
||||
//!
|
||||
//! Note: Editor routes and NewRealmPage have been removed.
|
||||
//! All create/edit functionality is now in the admin-ui.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::{
|
||||
components::{Route, Routes},
|
||||
ParamSegment, StaticSegment,
|
||||
};
|
||||
|
||||
use crate::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
|
||||
|
||||
/// User routes that can be embedded in a parent Router.
|
||||
///
|
||||
/// All paths are relative to the mount point. When used in:
|
||||
/// - `App`: Routes are at root (e.g., `/`, `/signup`, `/home`)
|
||||
/// - `CombinedApp`: Routes are at root (same paths)
|
||||
#[component]
|
||||
pub fn UserRoutes() -> impl IntoView {
|
||||
view! {
|
||||
<Routes fallback=|| "Page not found.".into_view()>
|
||||
<Route path=StaticSegment("") view=LoginPage />
|
||||
<Route path=StaticSegment("signup") view=SignupPage />
|
||||
<Route path=StaticSegment("home") view=HomePage />
|
||||
<Route path=StaticSegment("password-reset") view=PasswordResetPage />
|
||||
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage />
|
||||
</Routes>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue