Silence warnings, run cargo fmt
This commit is contained in:
parent
fe1c1d3655
commit
af1c767f5f
77 changed files with 1904 additions and 903 deletions
|
|
@ -4,16 +4,14 @@
|
||||||
//! with the admin interface lazy-loaded to reduce initial WASM bundle size.
|
//! with the admin interface lazy-loaded to reduce initial WASM bundle size.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||||
use leptos_router::{
|
use leptos_router::{
|
||||||
components::{Route, Router, Routes},
|
|
||||||
ParamSegment, StaticSegment,
|
ParamSegment, StaticSegment,
|
||||||
|
components::{Route, Router, Routes},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export user pages for inline route definitions
|
// Re-export user pages for inline route definitions
|
||||||
use chattyness_user_ui::pages::{
|
use chattyness_user_ui::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
|
||||||
HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Lazy-load admin pages to split WASM bundle
|
// Lazy-load admin pages to split WASM bundle
|
||||||
// Each lazy function includes the admin CSS stylesheet for on-demand loading
|
// Each lazy function includes the admin CSS stylesheet for on-demand loading
|
||||||
|
|
@ -34,7 +32,8 @@ fn lazy_login() -> AnyView {
|
||||||
<chattyness_admin_ui::components::LoginLayout>
|
<chattyness_admin_ui::components::LoginLayout>
|
||||||
<chattyness_admin_ui::pages::LoginPage />
|
<chattyness_admin_ui::pages::LoginPage />
|
||||||
</chattyness_admin_ui::components::LoginLayout>
|
</chattyness_admin_ui::components::LoginLayout>
|
||||||
}.into_any()
|
}
|
||||||
|
.into_any()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[lazy]
|
#[lazy]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
|
|
||||||
pub use app::{combined_shell, CombinedApp};
|
pub use app::{CombinedApp, combined_shell};
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub use app::CombinedAppState;
|
pub use app::CombinedAppState;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ mod server {
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
use leptos_axum::{LeptosRoutes, generate_route_list};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -20,7 +20,7 @@ mod server {
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use chattyness_app::{combined_shell, CombinedApp, CombinedAppState};
|
use chattyness_app::{CombinedApp, CombinedAppState, combined_shell};
|
||||||
use chattyness_shared::AppConfig;
|
use chattyness_shared::AppConfig;
|
||||||
use chattyness_user_ui::api::WebSocketState;
|
use chattyness_user_ui::api::WebSocketState;
|
||||||
|
|
||||||
|
|
@ -57,8 +57,9 @@ mod server {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| "chattyness_app=debug,chattyness_user_ui=debug,tower_http=debug".into()),
|
"chattyness_app=debug,chattyness_user_ui=debug,tower_http=debug".into()
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
@ -100,9 +101,8 @@ mod server {
|
||||||
let cleanup_pool = pool.clone();
|
let cleanup_pool = pool.clone();
|
||||||
let cleanup_config = config.cleanup.clone();
|
let cleanup_config = config.cleanup.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(
|
let mut interval =
|
||||||
cleanup_config.reap_interval_secs,
|
tokio::time::interval(Duration::from_secs(cleanup_config.reap_interval_secs));
|
||||||
));
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
let threshold = cleanup_config.stale_threshold_secs as f64;
|
let threshold = cleanup_config.stale_threshold_secs as f64;
|
||||||
|
|
@ -138,9 +138,11 @@ mod server {
|
||||||
let addr = SocketAddr::new(args.host.parse()?, args.port);
|
let addr = SocketAddr::new(args.host.parse()?, args.port);
|
||||||
|
|
||||||
// Create session layer (shared between user and admin interfaces)
|
// Create session layer (shared between user and admin interfaces)
|
||||||
let session_layer =
|
let session_layer = chattyness_user_ui::auth::session::create_session_layer(
|
||||||
chattyness_user_ui::auth::session::create_session_layer(pool.clone(), args.secure_cookies)
|
pool.clone(),
|
||||||
.await;
|
args.secure_cookies,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Create combined app state
|
// Create combined app state
|
||||||
let app_state = CombinedAppState {
|
let app_state = CombinedAppState {
|
||||||
|
|
@ -183,10 +185,9 @@ mod server {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build nested API routers with their own state
|
// Build nested API routers with their own state
|
||||||
let user_api_router = chattyness_user_ui::api::api_router()
|
let user_api_router = chattyness_user_ui::api::api_router().with_state(user_api_state);
|
||||||
.with_state(user_api_state);
|
let admin_api_router =
|
||||||
let admin_api_router = chattyness_admin_ui::api::admin_api_router()
|
chattyness_admin_ui::api::admin_api_router().with_state(admin_api_state);
|
||||||
.with_state(admin_api_state);
|
|
||||||
|
|
||||||
// Create RLS layer for row-level security
|
// Create RLS layer for row-level security
|
||||||
let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone());
|
let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone());
|
||||||
|
|
@ -216,16 +217,30 @@ mod server {
|
||||||
.with_state(app_state)
|
.with_state(app_state)
|
||||||
// Serve pkg files at /pkg (wasm_split hardcodes /pkg/ imports)
|
// Serve pkg files at /pkg (wasm_split hardcodes /pkg/ imports)
|
||||||
// Fallback to split_pkg_dir for --split mode output
|
// Fallback to split_pkg_dir for --split mode output
|
||||||
.nest_service("/pkg", ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir)))
|
.nest_service(
|
||||||
|
"/pkg",
|
||||||
|
ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir)),
|
||||||
|
)
|
||||||
// Uploaded assets (realm backgrounds, etc.) - must come before /static
|
// Uploaded assets (realm backgrounds, etc.) - must come before /static
|
||||||
.nest_service("/static/realm", ServeDir::new(assets_dir.join("realm")))
|
.nest_service("/static/realm", ServeDir::new(assets_dir.join("realm")))
|
||||||
// Server-level assets (avatar props, etc.)
|
// Server-level assets (avatar props, etc.)
|
||||||
.nest_service("/static/server", ServeDir::new(assets_dir.join("server")))
|
.nest_service("/static/server", ServeDir::new(assets_dir.join("server")))
|
||||||
// Also serve at /static for backwards compatibility
|
// Also serve at /static for backwards compatibility
|
||||||
.nest_service("/static", ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir)))
|
.nest_service(
|
||||||
.nest_service("/favicon.ico", tower_http::services::ServeFile::new(public_dir.join("favicon.ico")))
|
"/static",
|
||||||
|
ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir)),
|
||||||
|
)
|
||||||
|
.nest_service(
|
||||||
|
"/favicon.ico",
|
||||||
|
tower_http::services::ServeFile::new(public_dir.join("favicon.ico")),
|
||||||
|
)
|
||||||
|
// Serve icons from public/icons
|
||||||
|
.nest_service("/icons", ServeDir::new(public_dir.join("icons")))
|
||||||
// Serve admin CSS at standardized path (symlinked from owner build)
|
// Serve admin CSS at standardized path (symlinked from owner build)
|
||||||
.nest_service("/static/css/admin.css", tower_http::services::ServeFile::new(public_dir.join("admin.css")))
|
.nest_service(
|
||||||
|
"/static/css/admin.css",
|
||||||
|
tower_http::services::ServeFile::new(public_dir.join("admin.css")),
|
||||||
|
)
|
||||||
// Apply middleware layers (order: session outer, rls inner)
|
// Apply middleware layers (order: session outer, rls inner)
|
||||||
.layer(rls_layer)
|
.layer(rls_layer)
|
||||||
.layer(session_layer);
|
.layer(session_layer);
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
mod server {
|
mod server {
|
||||||
use axum::{response::Redirect, routing::get, Router};
|
use axum::{Router, response::Redirect, routing::get};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
use leptos_axum::{LeptosRoutes, generate_route_list};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use chattyness_admin_ui::{admin_shell, AdminApp, AdminAppState};
|
use chattyness_admin_ui::{AdminApp, AdminAppState, admin_shell};
|
||||||
|
|
||||||
/// CLI arguments.
|
/// CLI arguments.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
@ -77,9 +77,11 @@ mod server {
|
||||||
let addr = SocketAddr::new(args.host.parse()?, args.port);
|
let addr = SocketAddr::new(args.host.parse()?, args.port);
|
||||||
|
|
||||||
// Create session layer
|
// Create session layer
|
||||||
let session_layer =
|
let session_layer = chattyness_admin_ui::auth::create_admin_session_layer(
|
||||||
chattyness_admin_ui::auth::create_admin_session_layer(pool.clone(), args.secure_cookies)
|
pool.clone(),
|
||||||
.await;
|
args.secure_cookies,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Create app state
|
// Create app state
|
||||||
let app_state = AdminAppState {
|
let app_state = AdminAppState {
|
||||||
|
|
@ -111,15 +113,24 @@ mod server {
|
||||||
// Redirect root to admin
|
// Redirect root to admin
|
||||||
.route("/", get(|| async { Redirect::permanent("/admin") }))
|
.route("/", get(|| async { Redirect::permanent("/admin") }))
|
||||||
// Nest API routes under /api/admin (matches frontend expectations when UI is at /admin)
|
// Nest API routes under /api/admin (matches frontend expectations when UI is at /admin)
|
||||||
.nest("/api/admin", chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone()))
|
.nest(
|
||||||
|
"/api/admin",
|
||||||
|
chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone()),
|
||||||
|
)
|
||||||
// Uploaded assets (realm backgrounds, props, etc.) - must come before /static
|
// Uploaded assets (realm backgrounds, props, etc.) - must come before /static
|
||||||
.nest_service("/assets/server", ServeDir::new(assets_dir.join("server")))
|
.nest_service("/assets/server", ServeDir::new(assets_dir.join("server")))
|
||||||
.nest_service("/static/realm", ServeDir::new(assets_dir.join("realm")))
|
.nest_service("/static/realm", ServeDir::new(assets_dir.join("realm")))
|
||||||
// Static files (build output: JS, CSS, WASM)
|
// Static files (build output: JS, CSS, WASM)
|
||||||
.nest_service("/static", ServeDir::new(&static_dir))
|
.nest_service("/static", ServeDir::new(&static_dir))
|
||||||
.nest_service("/favicon.ico", tower_http::services::ServeFile::new(&favicon_path))
|
.nest_service(
|
||||||
|
"/favicon.ico",
|
||||||
|
tower_http::services::ServeFile::new(&favicon_path),
|
||||||
|
)
|
||||||
// Serve admin CSS at standardized path
|
// Serve admin CSS at standardized path
|
||||||
.nest_service("/static/css/admin.css", tower_http::services::ServeFile::new(&admin_css_path))
|
.nest_service(
|
||||||
|
"/static/css/admin.css",
|
||||||
|
tower_http::services::ServeFile::new(&admin_css_path),
|
||||||
|
)
|
||||||
// Leptos routes
|
// Leptos routes
|
||||||
.leptos_routes(&app_state, routes, {
|
.leptos_routes(&app_state, routes, {
|
||||||
let leptos_options = leptos_options.clone();
|
let leptos_options = leptos_options.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Admin authentication API handlers.
|
//! Admin authentication API handlers.
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
|
@ -221,21 +221,16 @@ pub async fn get_auth_context(
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> Result<Json<AuthContextResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<AuthContextResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
// Try to get staff_id from session (server staff)
|
// Try to get staff_id from session (server staff)
|
||||||
let staff_id: Option<uuid::Uuid> = session
|
let staff_id: Option<uuid::Uuid> = session.get(ADMIN_SESSION_STAFF_ID_KEY).await.ok().flatten();
|
||||||
.get(ADMIN_SESSION_STAFF_ID_KEY)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
if let Some(staff_id) = staff_id {
|
if let Some(staff_id) = staff_id {
|
||||||
// Check if this is actually a staff member
|
// Check if this is actually a staff member
|
||||||
let is_staff: Option<bool> = sqlx::query_scalar(
|
let is_staff: Option<bool> =
|
||||||
"SELECT EXISTS(SELECT 1 FROM server.staff WHERE user_id = $1)",
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server.staff WHERE user_id = $1)")
|
||||||
)
|
.bind(staff_id)
|
||||||
.bind(staff_id)
|
.fetch_one(&pool)
|
||||||
.fetch_one(&pool)
|
.await
|
||||||
.await
|
.ok();
|
||||||
.ok();
|
|
||||||
|
|
||||||
if is_staff == Some(true) {
|
if is_staff == Some(true) {
|
||||||
return Ok(Json(AuthContextResponse {
|
return Ok(Json(AuthContextResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Server config API handlers.
|
//! Server config API handlers.
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{Json, extract::State};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{ServerConfig, UpdateServerConfigRequest},
|
models::{ServerConfig, UpdateServerConfigRequest},
|
||||||
queries::owner as queries,
|
queries::owner as queries,
|
||||||
|
|
@ -9,9 +9,7 @@ use chattyness_error::AppError;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
/// Get server config.
|
/// Get server config.
|
||||||
pub async fn get_config(
|
pub async fn get_config(State(pool): State<PgPool>) -> Result<Json<ServerConfig>, AppError> {
|
||||||
State(pool): State<PgPool>,
|
|
||||||
) -> Result<Json<ServerConfig>, AppError> {
|
|
||||||
let config = queries::get_server_config(&pool).await?;
|
let config = queries::get_server_config(&pool).await?;
|
||||||
Ok(Json(config))
|
Ok(Json(config))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Dashboard API handlers.
|
//! Dashboard API handlers.
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{Json, extract::State};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
@ -16,9 +16,7 @@ pub struct DashboardStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get dashboard stats.
|
/// Get dashboard stats.
|
||||||
pub async fn get_stats(
|
pub async fn get_stats(State(pool): State<PgPool>) -> Result<Json<DashboardStats>, AppError> {
|
||||||
State(pool): State<PgPool>,
|
|
||||||
) -> Result<Json<DashboardStats>, AppError> {
|
|
||||||
// Total users
|
// Total users
|
||||||
let total_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM auth.users")
|
let total_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM auth.users")
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
//! Props management API handlers for admin UI.
|
//! Props management API handlers for admin UI.
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
use axum_extra::extract::Multipart;
|
use axum_extra::extract::Multipart;
|
||||||
use serde::Deserialize;
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
|
models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
|
||||||
queries::props,
|
queries::props,
|
||||||
};
|
};
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
@ -64,9 +64,7 @@ fn validate_file_extension(filename: &str) -> Result<&'static str, AppError> {
|
||||||
match ext.as_str() {
|
match ext.as_str() {
|
||||||
"svg" => Ok("svg"),
|
"svg" => Ok("svg"),
|
||||||
"png" => Ok("png"),
|
"png" => Ok("png"),
|
||||||
_ => Err(AppError::Validation(
|
_ => Err(AppError::Validation("File must be SVG or PNG".to_string())),
|
||||||
"File must be SVG or PNG".to_string(),
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +99,9 @@ async fn store_prop_file(bytes: &[u8], extension: &str) -> Result<String, AppErr
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// List all server props.
|
/// List all server props.
|
||||||
pub async fn list_props(State(pool): State<PgPool>) -> Result<Json<Vec<ServerPropSummary>>, AppError> {
|
pub async fn list_props(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<ServerPropSummary>>, AppError> {
|
||||||
let prop_list = props::list_server_props(&pool).await?;
|
let prop_list = props::list_server_props(&pool).await?;
|
||||||
Ok(Json(prop_list))
|
Ok(Json(prop_list))
|
||||||
}
|
}
|
||||||
|
|
@ -137,9 +137,10 @@ pub async fn create_prop(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Validation(format!("Failed to read metadata: {}", e)))?;
|
.map_err(|e| AppError::Validation(format!("Failed to read metadata: {}", e)))?;
|
||||||
|
|
||||||
metadata = Some(serde_json::from_str(&text).map_err(|e| {
|
metadata =
|
||||||
AppError::Validation(format!("Invalid metadata JSON: {}", e))
|
Some(serde_json::from_str(&text).map_err(|e| {
|
||||||
})?);
|
AppError::Validation(format!("Invalid metadata JSON: {}", e))
|
||||||
|
})?);
|
||||||
}
|
}
|
||||||
"file" => {
|
"file" => {
|
||||||
let filename = field
|
let filename = field
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Realm management API handlers.
|
//! Realm management API handlers.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
};
|
};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
|
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Admin API routes.
|
//! Admin API routes.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{delete, get, post, put},
|
|
||||||
Router,
|
Router,
|
||||||
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
|
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
|
||||||
|
|
@ -56,10 +56,7 @@ pub fn admin_api_router() -> Router<AdminAppState> {
|
||||||
"/realms/{slug}",
|
"/realms/{slug}",
|
||||||
get(realms::get_realm).put(realms::update_realm),
|
get(realms::get_realm).put(realms::update_realm),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/realms/{slug}/transfer", post(realms::transfer_ownership))
|
||||||
"/realms/{slug}/transfer",
|
|
||||||
post(realms::transfer_ownership),
|
|
||||||
)
|
|
||||||
// API - Scenes
|
// API - Scenes
|
||||||
.route(
|
.route(
|
||||||
"/realms/{slug}/scenes",
|
"/realms/{slug}/scenes",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Scene management API handlers for admin UI.
|
//! Scene management API handlers for admin UI.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
|
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
|
||||||
|
|
@ -136,10 +136,7 @@ async fn download_and_store_image(
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to write image file: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to write image file: {}", e)))?;
|
||||||
|
|
||||||
// Return the URL path (relative to server root)
|
// Return the URL path (relative to server root)
|
||||||
let local_path = format!(
|
let local_path = format!("/static/realm/{}/scene/{}/{}", realm_id, scene_id, filename);
|
||||||
"/static/realm/{}/scene/{}/{}",
|
|
||||||
realm_id, scene_id, filename
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(ImageDownloadResult {
|
Ok(ImageDownloadResult {
|
||||||
local_path,
|
local_path,
|
||||||
|
|
@ -237,13 +234,9 @@ pub async fn create_scene(
|
||||||
// Handle background image URL - download and store locally
|
// Handle background image URL - download and store locally
|
||||||
if let Some(ref url) = req.background_image_url {
|
if let Some(ref url) = req.background_image_url {
|
||||||
if !url.is_empty() {
|
if !url.is_empty() {
|
||||||
let result = download_and_store_image(
|
let result =
|
||||||
url,
|
download_and_store_image(url, realm.id, scene_id, req.infer_dimensions_from_image)
|
||||||
realm.id,
|
.await?;
|
||||||
scene_id,
|
|
||||||
req.infer_dimensions_from_image,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
req.background_image_path = Some(result.local_path);
|
req.background_image_path = Some(result.local_path);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Spot management API handlers for admin UI.
|
//! Spot management API handlers for admin UI.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest},
|
models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest},
|
||||||
|
|
@ -73,7 +73,8 @@ pub async fn update_spot(
|
||||||
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||||
|
|
||||||
if Some(new_slug.clone()) != existing.slug {
|
if Some(new_slug.clone()) != existing.slug {
|
||||||
let available = spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
|
let available =
|
||||||
|
spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
|
||||||
if !available {
|
if !available {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
"Spot slug '{}' is already taken in this scene",
|
"Spot slug '{}' is already taken in this scene",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Staff management API handlers.
|
//! Staff management API handlers.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{CreateStaffRequest, StaffMember},
|
models::{CreateStaffRequest, StaffMember},
|
||||||
|
|
@ -21,9 +21,7 @@ pub struct CreateStaffResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all staff members.
|
/// List all staff members.
|
||||||
pub async fn list_staff(
|
pub async fn list_staff(State(pool): State<PgPool>) -> Result<Json<Vec<StaffMember>>, AppError> {
|
||||||
State(pool): State<PgPool>,
|
|
||||||
) -> Result<Json<Vec<StaffMember>>, AppError> {
|
|
||||||
let staff = queries::get_all_staff(&pool).await?;
|
let staff = queries::get_all_staff(&pool).await?;
|
||||||
Ok(Json(staff))
|
Ok(Json(staff))
|
||||||
}
|
}
|
||||||
|
|
@ -44,11 +42,7 @@ pub async fn create_staff(
|
||||||
}))
|
}))
|
||||||
} else if let Some(ref new_user) = req.new_user {
|
} else if let Some(ref new_user) = req.new_user {
|
||||||
// Create new user and promote to staff
|
// Create new user and promote to staff
|
||||||
let (user_id, temporary_password) = queries::create_user(
|
let (user_id, temporary_password) = queries::create_user(&pool, new_user).await?;
|
||||||
&pool,
|
|
||||||
new_user,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let staff = queries::create_staff(&pool, user_id, req.role, None).await?;
|
let staff = queries::create_staff(&pool, user_id, req.role, None).await?;
|
||||||
Ok(Json(CreateStaffResponse {
|
Ok(Json(CreateStaffResponse {
|
||||||
staff,
|
staff,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! User management API handlers.
|
//! User management API handlers.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
};
|
};
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{
|
models::{
|
||||||
|
|
@ -153,9 +153,7 @@ pub async fn remove_from_realm(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all realms (for dropdown).
|
/// List all realms (for dropdown).
|
||||||
pub async fn list_realms(
|
pub async fn list_realms(State(pool): State<PgPool>) -> Result<Json<Vec<RealmSummary>>, AppError> {
|
||||||
State(pool): State<PgPool>,
|
|
||||||
) -> Result<Json<Vec<RealmSummary>>, AppError> {
|
|
||||||
let realms = queries::list_all_realms(&pool).await?;
|
let realms = queries::list_all_realms(&pool).await?;
|
||||||
Ok(Json(realms))
|
Ok(Json(realms))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Admin Leptos application root and router.
|
//! Admin Leptos application root and router.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||||
use leptos_router::components::Router;
|
use leptos_router::components::Router;
|
||||||
|
|
||||||
use crate::routes::AdminRoutes;
|
use crate::routes::AdminRoutes;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use axum::{
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer};
|
use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite, cookie::time::Duration};
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
use tower_sessions_sqlx_store::PostgresStore;
|
use tower_sessions_sqlx_store::PostgresStore;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,7 @@ pub fn use_auth_context() -> LocalResource<Option<AuthContextResponse>> {
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AuthenticatedLayout(
|
pub fn AuthenticatedLayout(
|
||||||
current_page: &'static str,
|
current_page: &'static str,
|
||||||
#[prop(default = "/admin")]
|
#[prop(default = "/admin")] base_path: &'static str,
|
||||||
base_path: &'static str,
|
|
||||||
children: ChildrenFn,
|
children: ChildrenFn,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let auth_context = use_auth_context();
|
let auth_context = use_auth_context();
|
||||||
|
|
@ -165,10 +164,8 @@ pub fn AuthenticatedLayout(
|
||||||
fn Sidebar(
|
fn Sidebar(
|
||||||
current_page: &'static str,
|
current_page: &'static str,
|
||||||
base_path: &'static str,
|
base_path: &'static str,
|
||||||
#[prop(default = false)]
|
#[prop(default = false)] is_server_staff: bool,
|
||||||
is_server_staff: bool,
|
#[prop(default = vec![])] managed_realms: Vec<(String, String)>,
|
||||||
#[prop(default = vec![])]
|
|
||||||
managed_realms: Vec<(String, String)>,
|
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// Build hrefs with base path
|
// Build hrefs with base path
|
||||||
let dashboard_href = base_path.to_string();
|
let dashboard_href = base_path.to_string();
|
||||||
|
|
@ -319,13 +316,22 @@ fn NavItem(
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
#[prop(default = false)] active: bool,
|
#[prop(default = false)] active: bool,
|
||||||
/// Whether this is a sub-item (indented)
|
/// Whether this is a sub-item (indented)
|
||||||
#[prop(default = false)] sub: bool,
|
#[prop(default = false)]
|
||||||
|
sub: bool,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let link_class = match (active, sub) {
|
let link_class = match (active, sub) {
|
||||||
(true, false) => "block w-full px-6 py-2 bg-violet-600 text-white transition-all duration-150",
|
(true, false) => {
|
||||||
(false, false) => "block w-full px-6 py-2 text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150",
|
"block w-full px-6 py-2 bg-violet-600 text-white transition-all duration-150"
|
||||||
(true, true) => "block w-full pl-10 pr-6 py-2 text-sm bg-violet-600 text-white transition-all duration-150",
|
}
|
||||||
(false, true) => "block w-full pl-10 pr-6 py-2 text-sm text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150",
|
(false, false) => {
|
||||||
|
"block w-full px-6 py-2 text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150"
|
||||||
|
}
|
||||||
|
(true, true) => {
|
||||||
|
"block w-full pl-10 pr-6 py-2 text-sm bg-violet-600 text-white transition-all duration-150"
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
"block w-full pl-10 pr-6 py-2 text-sm text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|
|
||||||
|
|
@ -154,11 +154,7 @@ where
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = if let Some(body) = body {
|
let response = if let Some(body) = body {
|
||||||
request
|
request.json(body).map_err(|e| e.to_string())?.send().await
|
||||||
.json(body)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
} else {
|
} else {
|
||||||
request.send().await
|
request.send().await
|
||||||
}
|
}
|
||||||
|
|
@ -200,11 +196,7 @@ pub async fn api_request_simple(
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = if let Some(body) = body {
|
let response = if let Some(body) = body {
|
||||||
request
|
request.json(body).map_err(|e| e.to_string())?.send().await
|
||||||
.json(body)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
} else {
|
} else {
|
||||||
request.send().await
|
request.send().await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ pub mod pages;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
pub use app::{admin_shell, AdminApp};
|
pub use app::{AdminApp, admin_shell};
|
||||||
pub use routes::AdminRoutes;
|
pub use routes::AdminRoutes;
|
||||||
|
|
||||||
// Re-export commonly used items for convenience
|
// Re-export commonly used items for convenience
|
||||||
|
|
@ -40,7 +40,7 @@ pub use components::{
|
||||||
MessageAlert, MessageAlertRw, NsfwBadge, PageHeader, Pagination, PrivacyBadge, RoleBadge,
|
MessageAlert, MessageAlertRw, NsfwBadge, PageHeader, Pagination, PrivacyBadge, RoleBadge,
|
||||||
SearchForm, StatusBadge, SubmitButton, TempPasswordDisplay,
|
SearchForm, StatusBadge, SubmitButton, TempPasswordDisplay,
|
||||||
};
|
};
|
||||||
pub use hooks::{use_fetch, use_fetch_if, use_message, use_pagination, PaginationState};
|
pub use hooks::{PaginationState, use_fetch, use_fetch_if, use_message, use_pagination};
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use utils::{build_bounds_wkt, build_paginated_url, get_api_base, parse_bounds_wkt};
|
pub use utils::{build_bounds_wkt, build_paginated_url, get_api_base, parse_bounds_wkt};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Realm detail/edit page component.
|
//! Realm detail/edit page component.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_params_map;
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
Card, DetailGrid, DetailItem, MessageAlert, NsfwBadge, PageHeader, PrivacyBadge,
|
Card, DetailGrid, DetailItem, MessageAlert, NsfwBadge, PageHeader, PrivacyBadge,
|
||||||
|
|
@ -73,8 +73,12 @@ fn RealmDetailView(
|
||||||
let (max_users, set_max_users) = signal(realm.max_users);
|
let (max_users, set_max_users) = signal(realm.max_users);
|
||||||
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
|
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
|
||||||
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
|
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
|
||||||
let (theme_color, set_theme_color) =
|
let (theme_color, set_theme_color) = signal(
|
||||||
signal(realm.theme_color.clone().unwrap_or_else(|| "#7c3aed".to_string()));
|
realm
|
||||||
|
.theme_color
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "#7c3aed".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,14 @@ pub fn RealmNewPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
data["owner_id"] = serde_json::json!(owner_id.get());
|
data["owner_id"] = serde_json::json!(owner_id.get());
|
||||||
} else {
|
} else {
|
||||||
if new_username.get().is_empty() || new_email.get().is_empty() || new_display_name.get().is_empty() {
|
if new_username.get().is_empty()
|
||||||
set_message.set(Some(("Please fill in all new owner fields".to_string(), false)));
|
|| new_email.get().is_empty()
|
||||||
|
|| new_display_name.get().is_empty()
|
||||||
|
{
|
||||||
|
set_message.set(Some((
|
||||||
|
"Please fill in all new owner fields".to_string(),
|
||||||
|
false,
|
||||||
|
)));
|
||||||
set_pending.set(false);
|
set_pending.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +117,8 @@ pub fn RealmNewPage() -> impl IntoView {
|
||||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||||
set_created_slug.set(Some(result.slug));
|
set_created_slug.set(Some(result.slug));
|
||||||
set_temp_password.set(result.owner_temporary_password);
|
set_temp_password.set(result.owner_temporary_password);
|
||||||
set_message.set(Some(("Realm created successfully!".to_string(), true)));
|
set_message
|
||||||
|
.set(Some(("Realm created successfully!".to_string(), true)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Scene detail/edit page component.
|
//! Scene detail/edit page component.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_params_map;
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
|
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
|
||||||
|
|
@ -74,7 +74,9 @@ pub fn SceneDetailPage() -> impl IntoView {
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
let resp = Request::get(&format!("/api/admin/scenes/{}", id)).send().await;
|
let resp = Request::get(&format!("/api/admin/scenes/{}", id))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
match resp {
|
match resp {
|
||||||
Ok(r) if r.ok() => r.json::<SceneDetail>().await.ok(),
|
Ok(r) if r.ok() => r.json::<SceneDetail>().await.ok(),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -153,7 +155,10 @@ fn SceneDetailView(
|
||||||
let (name, set_name) = signal(scene.name.clone());
|
let (name, set_name) = signal(scene.name.clone());
|
||||||
let (description, set_description) = signal(scene.description.clone().unwrap_or_default());
|
let (description, set_description) = signal(scene.description.clone().unwrap_or_default());
|
||||||
let (background_color, set_background_color) = signal(
|
let (background_color, set_background_color) = signal(
|
||||||
scene.background_color.clone().unwrap_or_else(|| "#1a1a2e".to_string()),
|
scene
|
||||||
|
.background_color
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "#1a1a2e".to_string()),
|
||||||
);
|
);
|
||||||
let (background_image_url, set_background_image_url) = signal(String::new());
|
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||||
let (clear_background_image, set_clear_background_image) = signal(false);
|
let (clear_background_image, set_clear_background_image) = signal(false);
|
||||||
|
|
@ -257,7 +262,6 @@ fn SceneDetailView(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Card>
|
<Card>
|
||||||
<div class="realm-header">
|
<div class="realm-header">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Create new scene page component.
|
//! Create new scene page component.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_params_map;
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
|
||||||
use crate::components::{Card, PageHeader};
|
use crate::components::{Card, PageHeader};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -106,11 +106,7 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let url = format!("/api/admin/realms/{}/scenes", realm_slug_val);
|
let url = format!("/api/admin/realms/{}/scenes", realm_slug_val);
|
||||||
let response = Request::post(&url)
|
let response = Request::post(&url).json(&data).unwrap().send().await;
|
||||||
.json(&data)
|
|
||||||
.unwrap()
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
set_pending.set(false);
|
set_pending.set(false);
|
||||||
|
|
||||||
|
|
@ -124,7 +120,8 @@ pub fn SceneNewPage() -> impl IntoView {
|
||||||
}
|
}
|
||||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||||
set_created_id.set(Some(result.id));
|
set_created_id.set(Some(result.id));
|
||||||
set_message.set(Some(("Scene created successfully!".to_string(), true)));
|
set_message
|
||||||
|
.set(Some(("Scene created successfully!".to_string(), true)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
|
|
||||||
|
|
@ -205,10 +205,7 @@ fn AddStaffButton(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn RemoveStaffButton(
|
fn RemoveStaffButton(user_id: String, message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
|
||||||
user_id: String,
|
|
||||||
message: RwSignal<Option<(String, bool)>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let (pending, set_pending) = signal(false);
|
let (pending, set_pending) = signal(false);
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
let user_id_for_click = user_id.clone();
|
let user_id_for_click = user_id.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
//! User detail page component.
|
//! User detail page component.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::hooks::use_params_map;
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
|
||||||
use crate::components::{Card, DetailGrid, DetailItem, MessageAlert, PageHeader, StatusBadge, TempPasswordDisplay};
|
use crate::components::{
|
||||||
|
Card, DetailGrid, DetailItem, MessageAlert, PageHeader, StatusBadge, TempPasswordDisplay,
|
||||||
|
};
|
||||||
use crate::hooks::use_fetch_if;
|
use crate::hooks::use_fetch_if;
|
||||||
use crate::models::UserDetail;
|
use crate::models::UserDetail;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -114,9 +116,10 @@ fn UserDetailView(
|
||||||
|
|
||||||
let user_id = user_id_for_reset.clone();
|
let user_id = user_id_for_reset.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = Request::post(&format!("/api/admin/users/{}/reset-password", user_id))
|
let response =
|
||||||
.send()
|
Request::post(&format!("/api/admin/users/{}/reset-password", user_id))
|
||||||
.await;
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
set_pending_reset.set(false);
|
set_pending_reset.set(false);
|
||||||
|
|
||||||
|
|
@ -128,7 +131,8 @@ fn UserDetailView(
|
||||||
}
|
}
|
||||||
if let Ok(result) = resp.json::<ResetResponse>().await {
|
if let Ok(result) = resp.json::<ResetResponse>().await {
|
||||||
set_new_password.set(Some(result.temporary_password));
|
set_new_password.set(Some(result.temporary_password));
|
||||||
set_message.set(Some(("Password reset successfully!".to_string(), true)));
|
set_message
|
||||||
|
.set(Some(("Password reset successfully!".to_string(), true)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::{
|
use leptos_router::{
|
||||||
components::{Route, Routes},
|
|
||||||
ParamSegment, StaticSegment,
|
ParamSegment, StaticSegment,
|
||||||
|
components::{Route, Routes},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::components::{AuthenticatedLayout, LoginLayout};
|
use crate::components::{AuthenticatedLayout, LoginLayout};
|
||||||
|
|
|
||||||
|
|
@ -128,14 +128,13 @@ pub fn fetch_image_dimensions_client<F, E>(
|
||||||
on_success: F,
|
on_success: F,
|
||||||
on_error: E,
|
on_error: E,
|
||||||
set_loading: leptos::prelude::WriteSignal<bool>,
|
set_loading: leptos::prelude::WriteSignal<bool>,
|
||||||
)
|
) where
|
||||||
where
|
|
||||||
F: Fn(u32, u32) + 'static,
|
F: Fn(u32, u32) + 'static,
|
||||||
E: Fn(String) + Clone + 'static,
|
E: Fn(String) + Clone + 'static,
|
||||||
{
|
{
|
||||||
use leptos::prelude::Set;
|
use leptos::prelude::Set;
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
let on_error_for_onerror = on_error.clone();
|
let on_error_for_onerror = on_error.clone();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,6 @@ pub mod pool;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use ws_messages::*;
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub use pool::*;
|
pub use pool::*;
|
||||||
|
pub use ws_messages::*;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ use chattyness_shared::validation;
|
||||||
/// Realm privacy setting.
|
/// Realm privacy setting.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "realm_privacy", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "realm_privacy", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RealmPrivacy {
|
pub enum RealmPrivacy {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -64,7 +67,10 @@ impl RealmPrivacy {
|
||||||
/// Server-wide reputation tier.
|
/// Server-wide reputation tier.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "reputation_tier", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "reputation_tier", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ReputationTier {
|
pub enum ReputationTier {
|
||||||
Guest,
|
Guest,
|
||||||
|
|
@ -78,7 +84,10 @@ pub enum ReputationTier {
|
||||||
/// User account status.
|
/// User account status.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "account_status", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "account_status", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AccountStatus {
|
pub enum AccountStatus {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -91,7 +100,10 @@ pub enum AccountStatus {
|
||||||
/// User account tag for feature gating and access control.
|
/// User account tag for feature gating and access control.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "user_tag", rename_all = "snake_case"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "user_tag", rename_all = "snake_case")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum UserTag {
|
pub enum UserTag {
|
||||||
Guest,
|
Guest,
|
||||||
|
|
@ -105,7 +117,10 @@ pub enum UserTag {
|
||||||
/// Authentication provider.
|
/// Authentication provider.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "auth_provider", rename_all = "snake_case"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "auth_provider", rename_all = "snake_case")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthProvider {
|
pub enum AuthProvider {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -118,7 +133,10 @@ pub enum AuthProvider {
|
||||||
/// Server-level staff role.
|
/// Server-level staff role.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "server_role", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "server_role", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ServerRole {
|
pub enum ServerRole {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -153,7 +171,10 @@ impl std::str::FromStr for ServerRole {
|
||||||
/// Realm membership role.
|
/// Realm membership role.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "realm_role", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "realm_role", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RealmRole {
|
pub enum RealmRole {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -191,7 +212,10 @@ impl std::str::FromStr for RealmRole {
|
||||||
/// Scene dimension mode.
|
/// Scene dimension mode.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "dimension_mode", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "dimension_mode", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum DimensionMode {
|
pub enum DimensionMode {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -223,7 +247,10 @@ impl std::str::FromStr for DimensionMode {
|
||||||
/// Interactive spot type.
|
/// Interactive spot type.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "spot_type", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "spot_type", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum SpotType {
|
pub enum SpotType {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -258,7 +285,10 @@ impl std::str::FromStr for SpotType {
|
||||||
/// Avatar layer for prop positioning (z-depth).
|
/// Avatar layer for prop positioning (z-depth).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "avatar_layer", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "avatar_layer", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum AvatarLayer {
|
pub enum AvatarLayer {
|
||||||
Skin,
|
Skin,
|
||||||
|
|
@ -298,7 +328,10 @@ impl std::str::FromStr for AvatarLayer {
|
||||||
/// - e10: sleeping, e11: wink
|
/// - e10: sleeping, e11: wink
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "emotion_state", rename_all = "lowercase"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "emotion_state", rename_all = "lowercase")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum EmotionState {
|
pub enum EmotionState {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -600,7 +633,10 @@ pub struct SpotSummary {
|
||||||
/// Origin source for a prop in inventory.
|
/// Origin source for a prop in inventory.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "prop_origin", rename_all = "snake_case"))]
|
#[cfg_attr(
|
||||||
|
feature = "ssr",
|
||||||
|
sqlx(type_name = "prop_origin", rename_all = "snake_case")
|
||||||
|
)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PropOrigin {
|
pub enum PropOrigin {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -773,7 +809,8 @@ impl CreateServerPropRequest {
|
||||||
&& self.default_position.is_none()
|
&& self.default_position.is_none()
|
||||||
{
|
{
|
||||||
return Err(AppError::Validation(
|
return Err(AppError::Validation(
|
||||||
"default_position is required when default_layer or default_emotion is set".to_string(),
|
"default_position is required when default_layer or default_emotion is set"
|
||||||
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -49,7 +49,10 @@ pub async fn create_guest_session(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a guest session by ID.
|
/// Get a guest session by ID.
|
||||||
pub async fn get_guest_session(pool: &PgPool, session_id: Uuid) -> Result<Option<GuestSession>, AppError> {
|
pub async fn get_guest_session(
|
||||||
|
pool: &PgPool,
|
||||||
|
session_id: Uuid,
|
||||||
|
) -> Result<Option<GuestSession>, AppError> {
|
||||||
let session = sqlx::query_as::<_, GuestSession>(
|
let session = sqlx::query_as::<_, GuestSession>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, guest_name, current_realm_id, expires_at, created_at
|
SELECT id, guest_name, current_realm_id, expires_at, created_at
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,22 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
// Single CTE that checks existence/droppability and performs the operation atomically.
|
// Single CTE that checks existence/droppability and performs the operation atomically.
|
||||||
// Returns status flags plus the LooseProp data (if successful).
|
// Returns status flags plus the LooseProp data (if successful).
|
||||||
let result: Option<(bool, bool, bool, Option<Uuid>, Option<Uuid>, Option<Uuid>, Option<Uuid>, Option<f32>, Option<f32>, Option<Uuid>, Option<chrono::DateTime<chrono::Utc>>, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)> = sqlx::query_as(
|
let result: Option<(
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
bool,
|
||||||
|
Option<Uuid>,
|
||||||
|
Option<Uuid>,
|
||||||
|
Option<Uuid>,
|
||||||
|
Option<Uuid>,
|
||||||
|
Option<f32>,
|
||||||
|
Option<f32>,
|
||||||
|
Option<Uuid>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
WITH item_info AS (
|
WITH item_info AS (
|
||||||
SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ use chattyness_error::AppError;
|
||||||
/// Hash a password using argon2.
|
/// Hash a password using argon2.
|
||||||
pub fn hash_password(password: &str) -> Result<String, AppError> {
|
pub fn hash_password(password: &str) -> Result<String, AppError> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
Argon2,
|
||||||
|
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
|
||||||
|
|
@ -95,25 +95,24 @@ pub async fn create_server_prop<'e>(
|
||||||
|
|
||||||
// Positioning: either content layer OR emotion layer OR neither (all NULL)
|
// Positioning: either content layer OR emotion layer OR neither (all NULL)
|
||||||
// Database constraint enforces mutual exclusivity
|
// Database constraint enforces mutual exclusivity
|
||||||
let (default_layer, default_emotion, default_position) =
|
let (default_layer, default_emotion, default_position) = if req.default_layer.is_some() {
|
||||||
if req.default_layer.is_some() {
|
// Content layer prop
|
||||||
// Content layer prop
|
(
|
||||||
(
|
req.default_layer.map(|l| l.to_string()),
|
||||||
req.default_layer.map(|l| l.to_string()),
|
None,
|
||||||
None,
|
Some(req.default_position.unwrap_or(4)), // Default to center position
|
||||||
Some(req.default_position.unwrap_or(4)), // Default to center position
|
)
|
||||||
)
|
} else if req.default_emotion.is_some() {
|
||||||
} else if req.default_emotion.is_some() {
|
// Emotion layer prop
|
||||||
// Emotion layer prop
|
(
|
||||||
(
|
None,
|
||||||
None,
|
req.default_emotion.map(|e| e.to_string()),
|
||||||
req.default_emotion.map(|e| e.to_string()),
|
Some(req.default_position.unwrap_or(4)), // Default to center position
|
||||||
Some(req.default_position.unwrap_or(4)), // Default to center position
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
// Non-avatar prop
|
||||||
// Non-avatar prop
|
(None, None, None)
|
||||||
(None, None, None)
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let is_droppable = req.droppable.unwrap_or(true);
|
let is_droppable = req.droppable.unwrap_or(true);
|
||||||
let is_public = req.public.unwrap_or(false);
|
let is_public = req.public.unwrap_or(false);
|
||||||
|
|
@ -187,25 +186,24 @@ pub async fn upsert_server_prop<'e>(
|
||||||
|
|
||||||
// Positioning: either content layer OR emotion layer OR neither (all NULL)
|
// Positioning: either content layer OR emotion layer OR neither (all NULL)
|
||||||
// Database constraint enforces mutual exclusivity
|
// Database constraint enforces mutual exclusivity
|
||||||
let (default_layer, default_emotion, default_position) =
|
let (default_layer, default_emotion, default_position) = if req.default_layer.is_some() {
|
||||||
if req.default_layer.is_some() {
|
// Content layer prop
|
||||||
// Content layer prop
|
(
|
||||||
(
|
req.default_layer.map(|l| l.to_string()),
|
||||||
req.default_layer.map(|l| l.to_string()),
|
None,
|
||||||
None,
|
Some(req.default_position.unwrap_or(4)), // Default to center position
|
||||||
Some(req.default_position.unwrap_or(4)), // Default to center position
|
)
|
||||||
)
|
} else if req.default_emotion.is_some() {
|
||||||
} else if req.default_emotion.is_some() {
|
// Emotion layer prop
|
||||||
// Emotion layer prop
|
(
|
||||||
(
|
None,
|
||||||
None,
|
req.default_emotion.map(|e| e.to_string()),
|
||||||
req.default_emotion.map(|e| e.to_string()),
|
Some(req.default_position.unwrap_or(4)), // Default to center position
|
||||||
Some(req.default_position.unwrap_or(4)), // Default to center position
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
// Non-avatar prop
|
||||||
// Non-avatar prop
|
(None, None, None)
|
||||||
(None, None, None)
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let is_droppable = req.droppable.unwrap_or(true);
|
let is_droppable = req.droppable.unwrap_or(true);
|
||||||
let is_public = req.public.unwrap_or(false);
|
let is_public = req.public.unwrap_or(false);
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,11 @@ pub async fn create_realm(
|
||||||
|
|
||||||
/// Check if a realm slug is available.
|
/// Check if a realm slug is available.
|
||||||
pub async fn is_slug_available(pool: &PgPool, slug: &str) -> Result<bool, AppError> {
|
pub async fn is_slug_available(pool: &PgPool, slug: &str) -> Result<bool, AppError> {
|
||||||
let exists: (bool,) = sqlx::query_as(
|
let exists: (bool,) =
|
||||||
r#"SELECT EXISTS(SELECT 1 FROM realm.realms WHERE slug = $1)"#,
|
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.realms WHERE slug = $1)"#)
|
||||||
)
|
.bind(slug)
|
||||||
.bind(slug)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(!exists.0)
|
Ok(!exists.0)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,13 @@ pub async fn is_scene_slug_available<'e>(
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let exists: (bool,) =
|
let exists: (bool,) = sqlx::query_as(
|
||||||
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.scenes WHERE realm_id = $1 AND slug = $2)"#)
|
r#"SELECT EXISTS(SELECT 1 FROM realm.scenes WHERE realm_id = $1 AND slug = $2)"#,
|
||||||
.bind(realm_id)
|
)
|
||||||
.bind(slug)
|
.bind(realm_id)
|
||||||
.fetch_one(executor)
|
.bind(slug)
|
||||||
.await?;
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(!exists.0)
|
Ok(!exists.0)
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +295,10 @@ pub async fn update_scene<'e>(
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
if req.dimension_mode.is_some() {
|
if req.dimension_mode.is_some() {
|
||||||
set_clauses.push(format!("dimension_mode = ${}::realm.dimension_mode", param_idx));
|
set_clauses.push(format!(
|
||||||
|
"dimension_mode = ${}::realm.dimension_mode",
|
||||||
|
param_idx
|
||||||
|
));
|
||||||
param_idx += 1;
|
param_idx += 1;
|
||||||
}
|
}
|
||||||
if req.sort_order.is_some() {
|
if req.sort_order.is_some() {
|
||||||
|
|
@ -317,7 +321,8 @@ pub async fn update_scene<'e>(
|
||||||
s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id
|
s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id
|
||||||
FROM realm.scenes s
|
FROM realm.scenes s
|
||||||
LEFT JOIN scene.instances c ON c.scene_id = s.id AND c.instance_type = 'public'
|
LEFT JOIN scene.instances c ON c.scene_id = s.id AND c.instance_type = 'public'
|
||||||
WHERE s.id = $1"#.to_string()
|
WHERE s.id = $1"#
|
||||||
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
set_clauses.push("updated_at = now()".to_string());
|
set_clauses.push("updated_at = now()".to_string());
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -396,12 +401,11 @@ pub async fn get_next_sort_order<'e>(
|
||||||
executor: impl PgExecutor<'e>,
|
executor: impl PgExecutor<'e>,
|
||||||
realm_id: Uuid,
|
realm_id: Uuid,
|
||||||
) -> Result<i32, AppError> {
|
) -> Result<i32, AppError> {
|
||||||
let result: (Option<i32>,) = sqlx::query_as(
|
let result: (Option<i32>,) =
|
||||||
r#"SELECT MAX(sort_order) FROM realm.scenes WHERE realm_id = $1"#,
|
sqlx::query_as(r#"SELECT MAX(sort_order) FROM realm.scenes WHERE realm_id = $1"#)
|
||||||
)
|
.bind(realm_id)
|
||||||
.bind(realm_id)
|
.fetch_one(executor)
|
||||||
.fetch_one(executor)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(result.0.unwrap_or(0) + 1)
|
Ok(result.0.unwrap_or(0) + 1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,12 +108,13 @@ pub async fn is_spot_slug_available<'e>(
|
||||||
scene_id: Uuid,
|
scene_id: Uuid,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let exists: (bool,) =
|
let exists: (bool,) = sqlx::query_as(
|
||||||
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM scene.spots WHERE scene_id = $1 AND slug = $2)"#)
|
r#"SELECT EXISTS(SELECT 1 FROM scene.spots WHERE scene_id = $1 AND slug = $2)"#,
|
||||||
.bind(scene_id)
|
)
|
||||||
.bind(slug)
|
.bind(scene_id)
|
||||||
.fetch_one(executor)
|
.bind(slug)
|
||||||
.await?;
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(!exists.0)
|
Ok(!exists.0)
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +237,8 @@ pub async fn update_spot<'e>(
|
||||||
ST_AsText(destination_position) as destination_position_wkt,
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
current_state, sort_order, is_visible, is_active,
|
current_state, sort_order, is_visible, is_active,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM scene.spots WHERE id = $1"#.to_string()
|
FROM scene.spots WHERE id = $1"#
|
||||||
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
set_clauses.push("updated_at = now()".to_string());
|
set_clauses.push("updated_at = now()".to_string());
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -293,10 +295,7 @@ pub async fn update_spot<'e>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a spot.
|
/// Delete a spot.
|
||||||
pub async fn delete_spot<'e>(
|
pub async fn delete_spot<'e>(executor: impl PgExecutor<'e>, spot_id: Uuid) -> Result<(), AppError> {
|
||||||
executor: impl PgExecutor<'e>,
|
|
||||||
spot_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let result = sqlx::query(r#"DELETE FROM scene.spots WHERE id = $1"#)
|
let result = sqlx::query(r#"DELETE FROM scene.spots WHERE id = $1"#)
|
||||||
.bind(spot_id)
|
.bind(spot_id)
|
||||||
.execute(executor)
|
.execute(executor)
|
||||||
|
|
|
||||||
|
|
@ -293,8 +293,8 @@ pub async fn update_password(
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, SaltString},
|
|
||||||
Argon2, PasswordHasher,
|
Argon2, PasswordHasher,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
@ -326,8 +326,8 @@ pub async fn update_password_conn(
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, SaltString},
|
|
||||||
Argon2, PasswordHasher,
|
Argon2, PasswordHasher,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
@ -405,8 +405,8 @@ pub async fn create_user(
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<Uuid, AppError> {
|
) -> Result<Uuid, AppError> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, SaltString},
|
|
||||||
Argon2, PasswordHasher,
|
Argon2, PasswordHasher,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
@ -442,8 +442,8 @@ pub async fn create_user_conn(
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<Uuid, AppError> {
|
) -> Result<Uuid, AppError> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, SaltString},
|
|
||||||
Argon2, PasswordHasher,
|
Argon2, PasswordHasher,
|
||||||
|
password_hash::{SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
|
@ -473,7 +473,10 @@ pub async fn create_user_conn(
|
||||||
/// Get a staff member by their user ID.
|
/// Get a staff member by their user ID.
|
||||||
///
|
///
|
||||||
/// Returns the staff member with their user info joined.
|
/// Returns the staff member with their user info joined.
|
||||||
pub async fn get_staff_member(pool: &PgPool, user_id: Uuid) -> Result<Option<StaffMember>, AppError> {
|
pub async fn get_staff_member(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Option<StaffMember>, AppError> {
|
||||||
let staff = sqlx::query_as::<_, StaffMember>(
|
let staff = sqlx::query_as::<_, StaffMember>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,9 @@ impl From<AppError> for ErrorResponse {
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
mod ssr_impl {
|
mod ssr_impl {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use axum::Json;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,12 @@ pub fn validate_non_empty(value: &str, field_name: &str) -> Result<(), AppError>
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// - `Ok(())` if length is within bounds
|
/// - `Ok(())` if length is within bounds
|
||||||
/// - `Err(AppError::Validation)` if too short or too long
|
/// - `Err(AppError::Validation)` if too short or too long
|
||||||
pub fn validate_length(value: &str, field_name: &str, min: usize, max: usize) -> Result<(), AppError> {
|
pub fn validate_length(
|
||||||
|
value: &str,
|
||||||
|
field_name: &str,
|
||||||
|
min: usize,
|
||||||
|
max: usize,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let len = value.len();
|
let len = value.len();
|
||||||
if len < min || len > max {
|
if len < min || len > max {
|
||||||
return Err(AppError::Validation(format!(
|
return Err(AppError::Validation(format!(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
//! Authentication API handlers.
|
//! Authentication API handlers.
|
||||||
|
|
||||||
use axum::{
|
use axum::{Json, extract::State};
|
||||||
extract::State,
|
|
||||||
Json,
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
|
@ -19,11 +16,11 @@ use chattyness_db::{
|
||||||
use chattyness_error::AppError;
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
|
AuthUser, OptionalAuthUser,
|
||||||
session::{
|
session::{
|
||||||
SESSION_CURRENT_REALM_KEY, SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY,
|
SESSION_CURRENT_REALM_KEY, SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY,
|
||||||
SESSION_USER_ID_KEY,
|
SESSION_USER_ID_KEY,
|
||||||
},
|
},
|
||||||
AuthUser, OptionalAuthUser,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get current user info.
|
/// Get current user info.
|
||||||
|
|
@ -66,7 +63,9 @@ pub async fn login(
|
||||||
.ok_or(AppError::InvalidCredentials)?;
|
.ok_or(AppError::InvalidCredentials)?;
|
||||||
|
|
||||||
// Set RLS context to the authenticated user for subsequent operations
|
// Set RLS context to the authenticated user for subsequent operations
|
||||||
rls_conn.set_user_id(user.id).await
|
rls_conn
|
||||||
|
.set_user_id(user.id)
|
||||||
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
||||||
|
|
||||||
// Check account status
|
// Check account status
|
||||||
|
|
@ -285,19 +284,27 @@ pub async fn signup(
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut conn = rls_conn.acquire().await;
|
let mut conn = rls_conn.acquire().await;
|
||||||
let user_id =
|
let user_id = users::create_user_conn(
|
||||||
users::create_user_conn(&mut *conn, &req.username, email_opt, req.display_name.trim(), &req.password)
|
&mut *conn,
|
||||||
.await?;
|
&req.username,
|
||||||
|
email_opt,
|
||||||
|
req.display_name.trim(),
|
||||||
|
&req.password,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Set RLS context to the new user for membership creation
|
// Set RLS context to the new user for membership creation
|
||||||
rls_conn.set_user_id(user_id).await
|
rls_conn
|
||||||
|
.set_user_id(user_id)
|
||||||
|
.await
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
||||||
|
|
||||||
// Create membership using RLS connection (now has user context)
|
// Create membership using RLS connection (now has user context)
|
||||||
let mut conn = rls_conn.acquire().await;
|
let mut conn = rls_conn.acquire().await;
|
||||||
let membership_id =
|
let membership_id =
|
||||||
memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member).await?;
|
memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Set up session (user is logged in)
|
// Set up session (user is logged in)
|
||||||
session
|
session
|
||||||
|
|
@ -421,7 +428,8 @@ pub async fn join_realm(
|
||||||
// Create the membership using RLS connection (policy requires user_id = current_user_id)
|
// Create the membership using RLS connection (policy requires user_id = current_user_id)
|
||||||
let mut conn = rls_conn.acquire().await;
|
let mut conn = rls_conn.acquire().await;
|
||||||
let membership_id =
|
let membership_id =
|
||||||
memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member).await?;
|
memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(JoinRealmResponse {
|
Ok(Json(JoinRealmResponse {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
//! Handles avatar data retrieval and slot updates.
|
//! Handles avatar data retrieval and slot updates.
|
||||||
//! Note: Emotion switching is handled via WebSocket.
|
//! Note: Emotion switching is handled via WebSocket.
|
||||||
|
|
||||||
use axum::extract::Path;
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::Path;
|
||||||
|
|
||||||
use chattyness_db::{
|
use chattyness_db::{
|
||||||
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest},
|
models::{AssignSlotRequest, AvatarWithPaths, ClearSlotRequest},
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
//!
|
//!
|
||||||
//! Handles inventory listing and item management.
|
//! Handles inventory listing and item management.
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Realm API handlers for user UI (READ-ONLY).
|
//! Realm API handlers for user UI (READ-ONLY).
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//! All create/update/delete operations are handled by the admin-ui.
|
//! All create/update/delete operations are handled by the admin-ui.
|
||||||
//! Channel presence is handled via WebSocket.
|
//! Channel presence is handled via WebSocket.
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
use axum::{Router, routing::get};
|
||||||
|
|
||||||
use super::{auth, avatars, inventory, realms, scenes, websocket};
|
use super::{auth, avatars, inventory, realms, scenes, websocket};
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
|
|
@ -30,7 +30,10 @@ pub fn api_router() -> Router<AppState> {
|
||||||
// Realm routes (READ-ONLY)
|
// Realm routes (READ-ONLY)
|
||||||
.route("/realms", get(realms::list_realms))
|
.route("/realms", get(realms::list_realms))
|
||||||
.route("/realms/{slug}", get(realms::get_realm))
|
.route("/realms/{slug}", get(realms::get_realm))
|
||||||
.route("/realms/{slug}/available", get(realms::check_slug_available))
|
.route(
|
||||||
|
"/realms/{slug}/available",
|
||||||
|
get(realms::check_slug_available),
|
||||||
|
)
|
||||||
// Scene routes (READ-ONLY)
|
// Scene routes (READ-ONLY)
|
||||||
.route("/realms/{slug}/entry-scene", get(scenes::get_entry_scene))
|
.route("/realms/{slug}/entry-scene", get(scenes::get_entry_scene))
|
||||||
.route("/realms/{slug}/scenes", get(scenes::list_scenes))
|
.route("/realms/{slug}/scenes", get(scenes::list_scenes))
|
||||||
|
|
@ -63,8 +66,5 @@ pub fn api_router() -> Router<AppState> {
|
||||||
)
|
)
|
||||||
// Public inventory routes (public server/realm props)
|
// Public inventory routes (public server/realm props)
|
||||||
.route("/inventory/server", get(inventory::get_server_props))
|
.route("/inventory/server", get(inventory::get_server_props))
|
||||||
.route(
|
.route("/realms/{slug}/inventory", get(inventory::get_realm_props))
|
||||||
"/realms/{slug}/inventory",
|
|
||||||
get(inventory::get_realm_props),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Scene and Spot API handlers for user UI (READ-ONLY).
|
//! Scene and Spot API handlers for user UI (READ-ONLY).
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},
|
|
||||||
FromRef, Path, State,
|
FromRef, Path, State,
|
||||||
|
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},
|
||||||
},
|
},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
|
@ -109,7 +109,11 @@ impl WebSocketState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a user by display name within a realm.
|
/// Find a user by display name within a realm.
|
||||||
pub fn find_user_by_display_name(&self, realm_id: Uuid, display_name: &str) -> Option<(Uuid, UserConnection)> {
|
pub fn find_user_by_display_name(
|
||||||
|
&self,
|
||||||
|
realm_id: Uuid,
|
||||||
|
display_name: &str,
|
||||||
|
) -> Option<(Uuid, UserConnection)> {
|
||||||
for entry in self.users.iter() {
|
for entry in self.users.iter() {
|
||||||
let (user_id, conn) = entry.pair();
|
let (user_id, conn) = entry.pair();
|
||||||
if conn.realm_id == realm_id && conn.display_name.eq_ignore_ascii_case(display_name) {
|
if conn.realm_id == realm_id && conn.display_name.eq_ignore_ascii_case(display_name) {
|
||||||
|
|
@ -148,11 +152,19 @@ where
|
||||||
"[WS] Connection attempt to {}/channels/{} - auth: {:?}",
|
"[WS] Connection attempt to {}/channels/{} - auth: {:?}",
|
||||||
slug,
|
slug,
|
||||||
channel_id,
|
channel_id,
|
||||||
auth_result.as_ref().map(|a| a.0.id).map_err(|e| format!("{:?}", e))
|
auth_result
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.0.id)
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
);
|
);
|
||||||
|
|
||||||
let AuthUser(user) = auth_result.map_err(|e| {
|
let AuthUser(user) = auth_result.map_err(|e| {
|
||||||
tracing::warn!("[WS] Auth failed for {}/channels/{}: {:?}", slug, channel_id, e);
|
tracing::warn!(
|
||||||
|
"[WS] Auth failed for {}/channels/{}: {:?}",
|
||||||
|
slug,
|
||||||
|
channel_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
AppError::from(e)
|
AppError::from(e)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -181,7 +193,9 @@ where
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(ws.on_upgrade(move |socket| {
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
handle_socket(socket, user, channel_id, realm.id, pool, ws_state, ws_config)
|
handle_socket(
|
||||||
|
socket, user, channel_id, realm.id, pool, ws_state, ws_config,
|
||||||
|
)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +239,11 @@ async fn handle_socket(
|
||||||
|
|
||||||
// Set RLS context on this dedicated connection
|
// Set RLS context on this dedicated connection
|
||||||
if let Err(e) = set_rls_user_id(&mut conn, user.id).await {
|
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);
|
tracing::error!(
|
||||||
|
"[WS] Failed to set RLS context for user {}: {:?}",
|
||||||
|
user.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("[WS] RLS context set on dedicated connection");
|
tracing::info!("[WS] RLS context set on dedicated connection");
|
||||||
|
|
@ -266,8 +284,10 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let member = match channel_members::get_channel_member(&mut *conn, channel_id, user.id, realm_id)
|
let member = match channel_members::get_channel_member(
|
||||||
.await
|
&mut *conn, channel_id, user.id, realm_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(m)) => m,
|
Ok(Some(m)) => m,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
|
@ -389,15 +409,21 @@ async fn handle_socket(
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!("[WS<-Client] {}", text);
|
tracing::debug!("[WS<-Client] {}", text);
|
||||||
|
|
||||||
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) else {
|
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
match client_msg {
|
match client_msg {
|
||||||
ClientMessage::UpdatePosition { x, y } => {
|
ClientMessage::UpdatePosition { x, y } => {
|
||||||
if let Err(e) =
|
if let Err(e) = channel_members::update_position(
|
||||||
channel_members::update_position(&mut *recv_conn, channel_id, user_id, x, y)
|
&mut *recv_conn,
|
||||||
.await
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::error!("[WS] Position update failed: {:?}", e);
|
tracing::error!("[WS] Position update failed: {:?}", e);
|
||||||
|
|
@ -416,7 +442,10 @@ async fn handle_socket(
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::warn!("[WS] Invalid emotion name: {}", emotion);
|
tracing::warn!(
|
||||||
|
"[WS] Invalid emotion name: {}",
|
||||||
|
emotion
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -444,11 +473,19 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
ClientMessage::Ping => {
|
ClientMessage::Ping => {
|
||||||
// Update last_moved_at to keep member alive for cleanup
|
// Update last_moved_at to keep member alive for cleanup
|
||||||
let _ = channel_members::touch_member(&mut *recv_conn, channel_id, user_id).await;
|
let _ = channel_members::touch_member(
|
||||||
|
&mut *recv_conn,
|
||||||
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
// Respond with pong directly (not broadcast)
|
// Respond with pong directly (not broadcast)
|
||||||
let _ = direct_tx.send(ServerMessage::Pong).await;
|
let _ = direct_tx.send(ServerMessage::Pong).await;
|
||||||
}
|
}
|
||||||
ClientMessage::SendChatMessage { content, target_display_name } => {
|
ClientMessage::SendChatMessage {
|
||||||
|
content,
|
||||||
|
target_display_name,
|
||||||
|
} => {
|
||||||
// Validate message
|
// Validate message
|
||||||
if content.is_empty() || content.len() > 500 {
|
if content.is_empty() || content.len() > 500 {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -465,18 +502,20 @@ async fn handle_socket(
|
||||||
|
|
||||||
if let Ok(Some(member)) = member_info {
|
if let Ok(Some(member)) = member_info {
|
||||||
// Convert emotion index to name
|
// Convert emotion index to name
|
||||||
let emotion_name = EmotionState::from_index(member.current_emotion as u8)
|
let emotion_name =
|
||||||
.map(|e| e.to_string())
|
EmotionState::from_index(member.current_emotion as u8)
|
||||||
.unwrap_or_else(|| "neutral".to_string());
|
.map(|e| e.to_string())
|
||||||
|
.unwrap_or_else(|| "neutral".to_string());
|
||||||
|
|
||||||
// Handle whisper (direct message) vs broadcast
|
// Handle whisper (direct message) vs broadcast
|
||||||
if let Some(target_name) = target_display_name {
|
if let Some(target_name) = target_display_name {
|
||||||
// Whisper: send directly to target user
|
// Whisper: send directly to target user
|
||||||
if let Some((_target_user_id, target_conn)) =
|
if let Some((_target_user_id, target_conn)) = ws_state
|
||||||
ws_state.find_user_by_display_name(realm_id, &target_name)
|
.find_user_by_display_name(realm_id, &target_name)
|
||||||
{
|
{
|
||||||
// Determine if same scene
|
// Determine if same scene
|
||||||
let is_same_scene = target_conn.channel_id == channel_id;
|
let is_same_scene =
|
||||||
|
target_conn.channel_id == channel_id;
|
||||||
|
|
||||||
let msg = ServerMessage::ChatMessageReceived {
|
let msg = ServerMessage::ChatMessageReceived {
|
||||||
message_id: Uuid::new_v4(),
|
message_id: Uuid::new_v4(),
|
||||||
|
|
@ -487,29 +526,33 @@ async fn handle_socket(
|
||||||
emotion: emotion_name.clone(),
|
emotion: emotion_name.clone(),
|
||||||
x: member.position_x,
|
x: member.position_x,
|
||||||
y: member.position_y,
|
y: member.position_y,
|
||||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
timestamp: chrono::Utc::now()
|
||||||
|
.timestamp_millis(),
|
||||||
is_whisper: true,
|
is_whisper: true,
|
||||||
is_same_scene,
|
is_same_scene,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send to target user
|
// Send to target user
|
||||||
let _ = target_conn.direct_tx.send(msg.clone()).await;
|
let _ =
|
||||||
|
target_conn.direct_tx.send(msg.clone()).await;
|
||||||
|
|
||||||
// Also send back to sender (so they see their own whisper)
|
// Also send back to sender (so they see their own whisper)
|
||||||
// For sender, is_same_scene is always true (they see it as a bubble)
|
// For sender, is_same_scene is always true (they see it as a bubble)
|
||||||
let sender_msg = ServerMessage::ChatMessageReceived {
|
let sender_msg =
|
||||||
message_id: Uuid::new_v4(),
|
ServerMessage::ChatMessageReceived {
|
||||||
user_id: Some(user_id),
|
message_id: Uuid::new_v4(),
|
||||||
guest_session_id: None,
|
user_id: Some(user_id),
|
||||||
display_name: member.display_name.clone(),
|
guest_session_id: None,
|
||||||
content,
|
display_name: member.display_name.clone(),
|
||||||
emotion: emotion_name,
|
content,
|
||||||
x: member.position_x,
|
emotion: emotion_name,
|
||||||
y: member.position_y,
|
x: member.position_x,
|
||||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
y: member.position_y,
|
||||||
is_whisper: true,
|
timestamp: chrono::Utc::now()
|
||||||
is_same_scene: true, // Sender always sees as bubble
|
.timestamp_millis(),
|
||||||
};
|
is_whisper: true,
|
||||||
|
is_same_scene: true, // Sender always sees as bubble
|
||||||
|
};
|
||||||
let _ = direct_tx.send(sender_msg).await;
|
let _ = direct_tx.send(sender_msg).await;
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
|
|
@ -581,20 +624,26 @@ async fn handle_socket(
|
||||||
pos_x,
|
pos_x,
|
||||||
pos_y
|
pos_y
|
||||||
);
|
);
|
||||||
let _ = tx.send(ServerMessage::PropDropped { prop });
|
let _ =
|
||||||
|
tx.send(ServerMessage::PropDropped { prop });
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
||||||
let (code, message) = match &e {
|
let (code, message) = match &e {
|
||||||
chattyness_error::AppError::Forbidden(msg) => {
|
chattyness_error::AppError::Forbidden(msg) => (
|
||||||
("PROP_NOT_DROPPABLE".to_string(), msg.clone())
|
"PROP_NOT_DROPPABLE".to_string(),
|
||||||
}
|
msg.clone(),
|
||||||
|
),
|
||||||
chattyness_error::AppError::NotFound(msg) => {
|
chattyness_error::AppError::NotFound(msg) => {
|
||||||
("PROP_NOT_FOUND".to_string(), msg.clone())
|
("PROP_NOT_FOUND".to_string(), msg.clone())
|
||||||
}
|
}
|
||||||
_ => ("DROP_FAILED".to_string(), format!("{:?}", e)),
|
_ => (
|
||||||
|
"DROP_FAILED".to_string(),
|
||||||
|
format!("{:?}", e),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let _ = tx.send(ServerMessage::Error { code, message });
|
let _ =
|
||||||
|
tx.send(ServerMessage::Error { code, message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -653,7 +702,10 @@ async fn handle_socket(
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::warn!("[WS] No avatar found for user {} to sync", user_id);
|
tracing::warn!(
|
||||||
|
"[WS] No avatar found for user {} to sync",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[WS] Avatar sync failed: {:?}", e);
|
tracing::error!("[WS] Avatar sync failed: {:?}", e);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Leptos application root and router for public app.
|
//! Leptos application root and router for public app.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
|
||||||
use leptos_router::components::Router;
|
use leptos_router::components::Router;
|
||||||
|
|
||||||
use crate::routes::UserRoutes;
|
use crate::routes::UserRoutes;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
//! Authentication middleware and extractors.
|
//! Authentication middleware and extractors.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRef, FromRequestParts},
|
|
||||||
http::{request::Parts, StatusCode},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::{FromRef, FromRequestParts},
|
||||||
|
http::{StatusCode, request::Parts},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
//! Row-Level Security (RLS) middleware for PostgreSQL.
|
//! Row-Level Security (RLS) middleware for PostgreSQL.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{request::Parts, Request, StatusCode},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
Json,
|
Json,
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{Request, StatusCode, request::Parts},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sqlx::{pool::PoolConnection, postgres::PgConnection, PgPool, Postgres};
|
use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgConnection};
|
||||||
use std::{
|
use std::{
|
||||||
future::Future,
|
future::Future,
|
||||||
ops::{Deref, DerefMut},
|
ops::{Deref, DerefMut},
|
||||||
|
|
@ -148,9 +148,10 @@ impl IntoResponse for RlsError {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"RLS connection not available",
|
"RLS connection not available",
|
||||||
),
|
),
|
||||||
RlsError::DatabaseError(msg) => {
|
RlsError::DatabaseError(msg) => (
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, msg.leak() as &'static str)
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
msg.leak() as &'static str,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = ErrorResponse {
|
let body = ErrorResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Session management using tower-sessions.
|
//! Session management using tower-sessions.
|
||||||
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer};
|
use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite, cookie::time::Duration};
|
||||||
use tower_sessions_sqlx_store::PostgresStore;
|
use tower_sessions_sqlx_store::PostgresStore;
|
||||||
|
|
||||||
/// Session cookie name.
|
/// Session cookie name.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||||
|
|
||||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
||||||
|
|
||||||
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
||||||
const BASE_TEXT_SCALE: f64 = 1.4;
|
const BASE_TEXT_SCALE: f64 = 1.4;
|
||||||
|
|
@ -65,18 +65,47 @@ impl ContentBounds {
|
||||||
let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p));
|
let mid_col = [1, 4, 7].iter().any(|&p| has_content_at(p));
|
||||||
let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p));
|
let right_col = [2, 5, 8].iter().any(|&p| has_content_at(p));
|
||||||
|
|
||||||
let min_col = if left_col { 0 } else if mid_col { 1 } else { 2 };
|
let min_col = if left_col {
|
||||||
let max_col = if right_col { 2 } else if mid_col { 1 } else { 0 };
|
0
|
||||||
|
} else if mid_col {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
2
|
||||||
|
};
|
||||||
|
let max_col = if right_col {
|
||||||
|
2
|
||||||
|
} else if mid_col {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
// Rows: 0 (top), 1 (middle), 2 (bottom)
|
// Rows: 0 (top), 1 (middle), 2 (bottom)
|
||||||
let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p));
|
let top_row = [0, 1, 2].iter().any(|&p| has_content_at(p));
|
||||||
let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p));
|
let mid_row = [3, 4, 5].iter().any(|&p| has_content_at(p));
|
||||||
let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p));
|
let bot_row = [6, 7, 8].iter().any(|&p| has_content_at(p));
|
||||||
|
|
||||||
let min_row = if top_row { 0 } else if mid_row { 1 } else { 2 };
|
let min_row = if top_row {
|
||||||
let max_row = if bot_row { 2 } else if mid_row { 1 } else { 0 };
|
0
|
||||||
|
} else if mid_row {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
2
|
||||||
|
};
|
||||||
|
let max_row = if bot_row {
|
||||||
|
2
|
||||||
|
} else if mid_row {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
Self { min_col, max_col, min_row, max_row }
|
Self {
|
||||||
|
min_col,
|
||||||
|
max_col,
|
||||||
|
min_row,
|
||||||
|
max_row,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content center column (0.0 to 2.0, grid center is 1.0).
|
/// Content center column (0.0 to 2.0, grid center is 1.0).
|
||||||
|
|
@ -158,8 +187,12 @@ impl ScreenBoundaries {
|
||||||
half_width: f64,
|
half_width: f64,
|
||||||
half_height: f64,
|
half_height: f64,
|
||||||
) -> (f64, f64) {
|
) -> (f64, f64) {
|
||||||
let clamped_x = center_x.max(self.min_x + half_width).min(self.max_x - half_width);
|
let clamped_x = center_x
|
||||||
let clamped_y = center_y.max(self.min_y + half_height).min(self.max_y - half_height);
|
.max(self.min_x + half_width)
|
||||||
|
.min(self.max_x - half_width);
|
||||||
|
let clamped_y = center_y
|
||||||
|
.max(self.min_y + half_height)
|
||||||
|
.min(self.max_y - half_height);
|
||||||
(clamped_x, clamped_y)
|
(clamped_x, clamped_y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +343,8 @@ pub fn AvatarCanvas(
|
||||||
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
|
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
|
||||||
|
|
||||||
// Calculate bubble height using actual content (includes tail + gap)
|
// Calculate bubble height using actual content (includes tail + gap)
|
||||||
let estimated_bubble_height = bubble.as_ref()
|
let estimated_bubble_height = bubble
|
||||||
|
.as_ref()
|
||||||
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
|
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
|
@ -363,8 +397,8 @@ pub fn AvatarCanvas(
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use wasm_bindgen::closure::Closure;
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
|
||||||
// Image cache for this avatar (persists across re-renders)
|
// Image cache for this avatar (persists across re-renders)
|
||||||
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
||||||
|
|
@ -426,7 +460,8 @@ pub fn AvatarCanvas(
|
||||||
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
|
let avatar_half_height = avatar_size / 2.0 + y_content_offset;
|
||||||
|
|
||||||
// Calculate bubble height using actual content (includes tail + gap)
|
// Calculate bubble height using actual content (includes tail + gap)
|
||||||
let estimated_bubble_height = bubble.as_ref()
|
let estimated_bubble_height = bubble
|
||||||
|
.as_ref()
|
||||||
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
|
.map(|b| estimate_bubble_height(&b.message.content, text_scale))
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
|
@ -470,7 +505,12 @@ pub fn AvatarCanvas(
|
||||||
|
|
||||||
// Helper to load and draw an image
|
// Helper to load and draw an image
|
||||||
// Images are cached; when loaded, triggers a redraw via signal
|
// Images are cached; when loaded, triggers a redraw via signal
|
||||||
let draw_image = |path: &str, cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>, ctx: &web_sys::CanvasRenderingContext2d, x: f64, y: f64, size: f64| {
|
let draw_image = |path: &str,
|
||||||
|
cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
|
||||||
|
ctx: &web_sys::CanvasRenderingContext2d,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
size: f64| {
|
||||||
let normalized_path = normalize_asset_path(path);
|
let normalized_path = normalize_asset_path(path);
|
||||||
let mut cache_borrow = cache.borrow_mut();
|
let mut cache_borrow = cache.borrow_mut();
|
||||||
|
|
||||||
|
|
@ -526,7 +566,14 @@ pub fn AvatarCanvas(
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
|
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
|
||||||
draw_image(clothes_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size);
|
draw_image(
|
||||||
|
clothes_path,
|
||||||
|
&image_cache,
|
||||||
|
&ctx,
|
||||||
|
cell_cx,
|
||||||
|
cell_cy,
|
||||||
|
cell_size,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -537,7 +584,14 @@ pub fn AvatarCanvas(
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
|
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
|
||||||
draw_image(accessories_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size);
|
draw_image(
|
||||||
|
accessories_path,
|
||||||
|
&image_cache,
|
||||||
|
&ctx,
|
||||||
|
cell_cx,
|
||||||
|
cell_cy,
|
||||||
|
cell_size,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -548,7 +602,14 @@ pub fn AvatarCanvas(
|
||||||
let row = pos / 3;
|
let row = pos / 3;
|
||||||
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
let cell_cx = grid_origin_x + (col as f64 + 0.5) * cell_size;
|
||||||
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
|
let cell_cy = grid_origin_y + (row as f64 + 0.5) * cell_size;
|
||||||
draw_image(emotion_path, &image_cache, &ctx, cell_cx, cell_cy, cell_size);
|
draw_image(
|
||||||
|
emotion_path,
|
||||||
|
&image_cache,
|
||||||
|
&ctx,
|
||||||
|
cell_cx,
|
||||||
|
cell_cy,
|
||||||
|
cell_size,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,7 +621,13 @@ pub fn AvatarCanvas(
|
||||||
let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0;
|
let badge_y = avatar_cy - avatar_size / 2.0 - badge_size / 2.0;
|
||||||
|
|
||||||
ctx.begin_path();
|
ctx.begin_path();
|
||||||
let _ = ctx.arc(badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
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");
|
ctx.set_fill_style_str("#f59e0b");
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
|
|
@ -580,7 +647,8 @@ pub fn AvatarCanvas(
|
||||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
ctx.set_font(&format!("{}px sans-serif", 12.0 * text_scale));
|
||||||
ctx.set_text_align("center");
|
ctx.set_text_align("center");
|
||||||
ctx.set_text_baseline("alphabetic");
|
ctx.set_text_baseline("alphabetic");
|
||||||
let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size) + 15.0 * text_scale;
|
let name_y = avatar_cy + avatar_size / 2.0 - (empty_bottom_rows as f64 * cell_size)
|
||||||
|
+ 15.0 * text_scale;
|
||||||
// Black outline
|
// Black outline
|
||||||
ctx.set_stroke_style_str("#000");
|
ctx.set_stroke_style_str("#000");
|
||||||
ctx.set_line_width(3.0);
|
ctx.set_line_width(3.0);
|
||||||
|
|
@ -630,7 +698,9 @@ pub fn AvatarCanvas(
|
||||||
// Compute data-member-id reactively
|
// Compute data-member-id reactively
|
||||||
let data_member_id = move || {
|
let data_member_id = move || {
|
||||||
let m = member.get();
|
let m = member.get();
|
||||||
m.member.user_id.map(|u| u.to_string())
|
m.member
|
||||||
|
.user_id
|
||||||
|
.map(|u| u.to_string())
|
||||||
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
|
.or_else(|| m.member.guest_session_id.map(|g| g.to_string()))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
|
@ -693,11 +763,19 @@ fn draw_bubble(
|
||||||
let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion);
|
let (bg_color, border_color, text_color) = emotion_bubble_colors(&bubble.message.emotion);
|
||||||
|
|
||||||
// Use italic font for whispers
|
// Use italic font for whispers
|
||||||
let font_style = if bubble.message.is_whisper { "italic " } else { "" };
|
let font_style = if bubble.message.is_whisper {
|
||||||
|
"italic "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
// Measure and wrap text
|
// Measure and wrap text
|
||||||
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
|
ctx.set_font(&format!("{}{}px sans-serif", font_style, font_size));
|
||||||
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
let lines = wrap_text(
|
||||||
|
ctx,
|
||||||
|
&bubble.message.content,
|
||||||
|
max_bubble_width - padding * 2.0,
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate bubble dimensions
|
// Calculate bubble dimensions
|
||||||
let bubble_width = lines
|
let bubble_width = lines
|
||||||
|
|
@ -747,7 +825,14 @@ fn draw_bubble(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw bubble background
|
// Draw bubble background
|
||||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
draw_rounded_rect(
|
||||||
|
ctx,
|
||||||
|
bubble_x,
|
||||||
|
bubble_y,
|
||||||
|
bubble_width,
|
||||||
|
bubble_height,
|
||||||
|
border_radius,
|
||||||
|
);
|
||||||
ctx.set_fill_style_str(bg_color);
|
ctx.set_fill_style_str(bg_color);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.set_stroke_style_str(border_color);
|
ctx.set_stroke_style_str(border_color);
|
||||||
|
|
@ -782,7 +867,11 @@ fn draw_bubble(
|
||||||
ctx.set_text_align("left");
|
ctx.set_text_align("left");
|
||||||
ctx.set_text_baseline("top");
|
ctx.set_text_baseline("top");
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, line) in lines.iter().enumerate() {
|
||||||
let _ = ctx.fill_text(line, bubble_x + padding, bubble_y + padding + (i as f64) * line_height);
|
let _ = ctx.fill_text(
|
||||||
|
line,
|
||||||
|
bubble_x + padding,
|
||||||
|
bubble_y + padding + (i as f64) * line_height,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -800,7 +889,10 @@ fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64
|
||||||
format!("{} {}", current_line, word)
|
format!("{} {}", current_line, word)
|
||||||
};
|
};
|
||||||
|
|
||||||
let width = ctx.measure_text(&test_line).map(|m| m.width()).unwrap_or(0.0);
|
let width = ctx
|
||||||
|
.measure_text(&test_line)
|
||||||
|
.map(|m| m.width())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
if width > max_width && !current_line.is_empty() {
|
if width > max_width && !current_line.is_empty() {
|
||||||
lines.push(current_line);
|
lines.push(current_line);
|
||||||
|
|
@ -834,11 +926,7 @@ fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64
|
||||||
/// Returns true if the alpha channel at the clicked pixel is > 0.
|
/// Returns true if the alpha channel at the clicked pixel is > 0.
|
||||||
/// This enables pixel-perfect hit detection on avatar canvases.
|
/// This enables pixel-perfect hit detection on avatar canvases.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
pub fn hit_test_canvas(
|
pub fn hit_test_canvas(canvas: &web_sys::HtmlCanvasElement, client_x: f64, client_y: f64) -> bool {
|
||||||
canvas: &web_sys::HtmlCanvasElement,
|
|
||||||
client_x: f64,
|
|
||||||
client_y: f64,
|
|
||||||
) -> bool {
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
// Get the canvas bounding rect to transform client coords to canvas coords
|
// Get the canvas bounding rect to transform client coords to canvas coords
|
||||||
|
|
@ -849,7 +937,11 @@ pub fn hit_test_canvas(
|
||||||
let relative_y = client_y - rect.top();
|
let relative_y = client_y - rect.top();
|
||||||
|
|
||||||
// Check if click is within canvas bounds
|
// Check if click is within canvas bounds
|
||||||
if relative_x < 0.0 || relative_y < 0.0 || relative_x >= rect.width() || relative_y >= rect.height() {
|
if relative_x < 0.0
|
||||||
|
|| relative_y < 0.0
|
||||||
|
|| relative_x >= rect.width()
|
||||||
|
|| relative_y >= rect.height()
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,8 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use wasm_bindgen::closure::Closure;
|
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
|
||||||
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
let image_cache: Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>> =
|
||||||
Rc::new(RefCell::new(HashMap::new()));
|
Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
|
@ -134,36 +134,37 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
|
||||||
ctx.fill_rect(0.0, 0.0, canvas_size as f64, canvas_size as f64);
|
ctx.fill_rect(0.0, 0.0, canvas_size as f64, canvas_size as f64);
|
||||||
|
|
||||||
// Helper to load and draw an image at a grid position
|
// Helper to load and draw an image at a grid position
|
||||||
let draw_at_position = |path: &str,
|
let draw_at_position =
|
||||||
pos: usize,
|
|path: &str,
|
||||||
cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
|
pos: usize,
|
||||||
ctx: &web_sys::CanvasRenderingContext2d| {
|
cache: &Rc<RefCell<HashMap<String, web_sys::HtmlImageElement>>>,
|
||||||
let normalized_path = normalize_asset_path(path);
|
ctx: &web_sys::CanvasRenderingContext2d| {
|
||||||
let mut cache_borrow = cache.borrow_mut();
|
let normalized_path = normalize_asset_path(path);
|
||||||
let row = pos / 3;
|
let mut cache_borrow = cache.borrow_mut();
|
||||||
let col = pos % 3;
|
let row = pos / 3;
|
||||||
let x = (col * cell_size) as f64;
|
let col = pos % 3;
|
||||||
let y = (row * cell_size) as f64;
|
let x = (col * cell_size) as f64;
|
||||||
let size = cell_size as f64;
|
let y = (row * cell_size) as f64;
|
||||||
|
let size = cell_size as f64;
|
||||||
|
|
||||||
if let Some(img) = cache_borrow.get(&normalized_path) {
|
if let Some(img) = cache_borrow.get(&normalized_path) {
|
||||||
if img.complete() && img.natural_width() > 0 {
|
if img.complete() && img.natural_width() > 0 {
|
||||||
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
|
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
|
||||||
img, x, y, size, size,
|
img, x, y, size, size,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||||
|
let trigger = set_redraw_trigger;
|
||||||
|
let onload = Closure::once(Box::new(move || {
|
||||||
|
trigger.update(|v| *v += 1);
|
||||||
|
}) as Box<dyn FnOnce()>);
|
||||||
|
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
|
onload.forget();
|
||||||
|
img.set_src(&normalized_path);
|
||||||
|
cache_borrow.insert(normalized_path, img);
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
|
||||||
let trigger = set_redraw_trigger;
|
|
||||||
let onload = Closure::once(Box::new(move || {
|
|
||||||
trigger.update(|v| *v += 1);
|
|
||||||
}) as Box<dyn FnOnce()>);
|
|
||||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
|
||||||
onload.forget();
|
|
||||||
img.set_src(&normalized_path);
|
|
||||||
cache_borrow.insert(normalized_path, img);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Draw layers in order: skin -> clothes -> accessories -> current emotion
|
// Draw layers in order: skin -> clothes -> accessories -> current emotion
|
||||||
for (pos, path) in av.skin_layer.iter().enumerate() {
|
for (pos, path) in av.skin_layer.iter().enumerate() {
|
||||||
|
|
@ -252,7 +253,7 @@ pub fn AvatarEditorPopup(
|
||||||
let (context_menu, set_context_menu) = signal(Option::<ContextMenuState>::None);
|
let (context_menu, set_context_menu) = signal(Option::<ContextMenuState>::None);
|
||||||
|
|
||||||
// Saving state
|
// Saving state
|
||||||
let (saving, set_saving) = signal(false);
|
let (_saving, set_saving) = signal(false);
|
||||||
|
|
||||||
// Helper to get current layer name for API calls
|
// Helper to get current layer name for API calls
|
||||||
let get_current_layer_name = move || -> String {
|
let get_current_layer_name = move || -> String {
|
||||||
|
|
@ -290,8 +291,9 @@ pub fn AvatarEditorPopup(
|
||||||
let response = Request::get("/api/inventory").send().await;
|
let response = Request::get("/api/inventory").send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) =
|
if let Ok(data) = resp
|
||||||
resp.json::<chattyness_db::models::InventoryResponse>().await
|
.json::<chattyness_db::models::InventoryResponse>()
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
set_inventory_items.set(data.items);
|
set_inventory_items.set(data.items);
|
||||||
set_inventory_loaded.set(true);
|
set_inventory_loaded.set(true);
|
||||||
|
|
@ -353,8 +355,14 @@ pub fn AvatarEditorPopup(
|
||||||
item.layer
|
item.layer
|
||||||
.map(|l| match (l, layer) {
|
.map(|l| match (l, layer) {
|
||||||
(chattyness_db::models::AvatarLayer::Skin, BaseLayer::Skin) => true,
|
(chattyness_db::models::AvatarLayer::Skin, BaseLayer::Skin) => true,
|
||||||
(chattyness_db::models::AvatarLayer::Clothes, BaseLayer::Clothes) => true,
|
(
|
||||||
(chattyness_db::models::AvatarLayer::Accessories, BaseLayer::Accessories) => true,
|
chattyness_db::models::AvatarLayer::Clothes,
|
||||||
|
BaseLayer::Clothes,
|
||||||
|
) => true,
|
||||||
|
(
|
||||||
|
chattyness_db::models::AvatarLayer::Accessories,
|
||||||
|
BaseLayer::Accessories,
|
||||||
|
) => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use leptos::prelude::*;
|
||||||
use chattyness_db::models::EmotionAvailability;
|
use chattyness_db::models::EmotionAvailability;
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
use super::emotion_picker::{EmoteListPopup, LabelStyle, EMOTIONS};
|
use super::emotion_picker::{EMOTIONS, EmoteListPopup, LabelStyle};
|
||||||
use super::ws_client::WsSenderStorage;
|
use super::ws_client::WsSenderStorage;
|
||||||
|
|
||||||
/// Command mode state for the chat input.
|
/// Command mode state for the chat input.
|
||||||
|
|
@ -90,13 +90,10 @@ pub fn ChatInput(
|
||||||
emotion_availability: Signal<Option<EmotionAvailability>>,
|
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||||
skin_preview_path: Signal<Option<String>>,
|
skin_preview_path: Signal<Option<String>>,
|
||||||
focus_trigger: Signal<bool>,
|
focus_trigger: Signal<bool>,
|
||||||
#[prop(default = Signal::derive(|| ':'))]
|
#[prop(default = Signal::derive(|| ':'))] focus_prefix: Signal<char>,
|
||||||
focus_prefix: Signal<char>,
|
|
||||||
on_focus_change: Callback<bool>,
|
on_focus_change: Callback<bool>,
|
||||||
#[prop(optional)]
|
#[prop(optional)] on_open_settings: Option<Callback<()>>,
|
||||||
on_open_settings: Option<Callback<()>>,
|
#[prop(optional)] on_open_inventory: Option<Callback<()>>,
|
||||||
#[prop(optional)]
|
|
||||||
on_open_inventory: Option<Callback<()>>,
|
|
||||||
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
/// Signal containing the display name to whisper to. When set, pre-fills the input.
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
whisper_target: Option<Signal<Option<String>>>,
|
whisper_target: Option<Signal<Option<String>>>,
|
||||||
|
|
|
||||||
|
|
@ -96,18 +96,18 @@ pub struct ActiveBubble {
|
||||||
/// Returns (background_color, border_color, text_color).
|
/// Returns (background_color, border_color, text_color).
|
||||||
pub fn emotion_bubble_colors(emotion: &str) -> (&'static str, &'static str, &'static str) {
|
pub fn emotion_bubble_colors(emotion: &str) -> (&'static str, &'static str, &'static str) {
|
||||||
match emotion {
|
match emotion {
|
||||||
"neutral" => ("#374151", "#4B5563", "#F9FAFB"), // gray
|
"neutral" => ("#374151", "#4B5563", "#F9FAFB"), // gray
|
||||||
"happy" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
|
"happy" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
|
||||||
"sad" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
|
"sad" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
|
||||||
"angry" => ("#EF4444", "#DC2626", "#F9FAFB"), // red
|
"angry" => ("#EF4444", "#DC2626", "#F9FAFB"), // red
|
||||||
"surprised" => ("#A855F7", "#9333EA", "#F9FAFB"), // purple
|
"surprised" => ("#A855F7", "#9333EA", "#F9FAFB"), // purple
|
||||||
"thinking" => ("#6366F1", "#4F46E5", "#F9FAFB"), // indigo
|
"thinking" => ("#6366F1", "#4F46E5", "#F9FAFB"), // indigo
|
||||||
"laughing" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
|
"laughing" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
|
||||||
"crying" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
|
"crying" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
|
||||||
"love" => ("#EC4899", "#DB2777", "#F9FAFB"), // pink
|
"love" => ("#EC4899", "#DB2777", "#F9FAFB"), // pink
|
||||||
"confused" => ("#8B5CF6", "#7C3AED", "#F9FAFB"), // violet
|
"confused" => ("#8B5CF6", "#7C3AED", "#F9FAFB"), // violet
|
||||||
"sleeping" => ("#1F2937", "#374151", "#9CA3AF"), // dark gray
|
"sleeping" => ("#1F2937", "#374151", "#9CA3AF"), // dark gray
|
||||||
"wink" => ("#10B981", "#059669", "#F9FAFB"), // emerald
|
"wink" => ("#10B981", "#059669", "#F9FAFB"), // emerald
|
||||||
_ => ("#374151", "#4B5563", "#F9FAFB"), // default - gray
|
_ => ("#374151", "#4B5563", "#F9FAFB"), // default - gray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ pub fn ContextMenu(
|
||||||
// Click outside handler
|
// Click outside handler
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if !open.get() {
|
if !open.get() {
|
||||||
|
|
@ -100,28 +100,35 @@ pub fn ContextMenu(
|
||||||
let menu_el: web_sys::HtmlElement = menu_el.into();
|
let menu_el: web_sys::HtmlElement = menu_el.into();
|
||||||
let menu_el_clone = menu_el.clone();
|
let menu_el_clone = menu_el.clone();
|
||||||
|
|
||||||
let handler = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
let handler =
|
||||||
if let Some(target) = ev.target() {
|
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||||
if let Ok(target_el) = target.dyn_into::<web_sys::Node>() {
|
if let Some(target) = ev.target() {
|
||||||
if !menu_el_clone.contains(Some(&target_el)) {
|
if let Ok(target_el) = target.dyn_into::<web_sys::Node>() {
|
||||||
on_close.run(());
|
if !menu_el_clone.contains(Some(&target_el)) {
|
||||||
|
on_close.run(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let window = web_sys::window().unwrap();
|
let window = web_sys::window().unwrap();
|
||||||
let _ = window.add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref());
|
let _ = window
|
||||||
|
.add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref());
|
||||||
|
|
||||||
// Escape key handler
|
// Escape key handler
|
||||||
let on_close_esc = on_close.clone();
|
let on_close_esc = on_close.clone();
|
||||||
let keydown_handler = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
|
let keydown_handler = Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||||||
if ev.key() == "Escape" {
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
on_close_esc.run(());
|
if ev.key() == "Escape" {
|
||||||
ev.prevent_default();
|
on_close_esc.run(());
|
||||||
}
|
ev.prevent_default();
|
||||||
});
|
}
|
||||||
let _ = window.add_event_listener_with_callback("keydown", keydown_handler.as_ref().unchecked_ref());
|
},
|
||||||
|
);
|
||||||
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
keydown_handler.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
// Store handlers to clean up (they get cleaned up when Effect reruns)
|
// Store handlers to clean up (they get cleaned up when Effect reruns)
|
||||||
handler.forget();
|
handler.forget();
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ pub fn ConversationModal(
|
||||||
if let Some(input) = input_ref.get() {
|
if let Some(input) = input_ref.get() {
|
||||||
let _ = input.focus();
|
let _ = input.focus();
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnOnce()>);
|
})
|
||||||
|
as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ pub fn SceneCanvas(
|
||||||
let canvas_style = Signal::derive(move || {
|
let canvas_style = Signal::derive(move || {
|
||||||
let w = width.get();
|
let w = width.get();
|
||||||
let h = height.get();
|
let h = height.get();
|
||||||
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
let bg_color = background_color
|
||||||
|
.get()
|
||||||
|
.unwrap_or_else(|| "#1a1a2e".to_string());
|
||||||
|
|
||||||
if let Some(img) = background_image.get() {
|
if let Some(img) = background_image.get() {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -93,7 +95,10 @@ pub fn SceneCanvas(
|
||||||
w, h, img
|
w, h, img
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("width: {}px; height: {}px; background-color: {};", w, h, bg_color)
|
format!(
|
||||||
|
"width: {}px; height: {}px; background-color: {};",
|
||||||
|
w, h, bg_color
|
||||||
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -134,7 +139,6 @@ pub fn SceneCanvas(
|
||||||
|
|
||||||
/// Canvas for drawing new spots.
|
/// Canvas for drawing new spots.
|
||||||
#[component]
|
#[component]
|
||||||
#[allow(unused_variables)]
|
|
||||||
pub fn SpotDrawer(
|
pub fn SpotDrawer(
|
||||||
#[prop(into)] width: Signal<u32>,
|
#[prop(into)] width: Signal<u32>,
|
||||||
#[prop(into)] height: Signal<u32>,
|
#[prop(into)] height: Signal<u32>,
|
||||||
|
|
@ -144,17 +148,16 @@ pub fn SpotDrawer(
|
||||||
#[prop(into)] background_image: Signal<Option<String>>,
|
#[prop(into)] background_image: Signal<Option<String>>,
|
||||||
#[prop(into)] existing_spots_wkt: Signal<Vec<String>>,
|
#[prop(into)] existing_spots_wkt: Signal<Vec<String>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let (drawing_points, _set_drawing_points) = signal(Vec::<(f64, f64)>::new());
|
let (drawing_points, set_drawing_points) = signal(Vec::<(f64, f64)>::new());
|
||||||
let (is_drawing, _set_is_drawing) = signal(false);
|
let (is_drawing, set_is_drawing) = signal(false);
|
||||||
let (start_point, _set_start_point) = signal(Option::<(f64, f64)>::None);
|
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 canvas_style = Signal::derive(move || {
|
||||||
let w = width.get();
|
let w = width.get();
|
||||||
let h = height.get();
|
let h = height.get();
|
||||||
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
let bg_color = background_color
|
||||||
|
.get()
|
||||||
|
.unwrap_or_else(|| "#1a1a2e".to_string());
|
||||||
|
|
||||||
if let Some(img) = background_image.get() {
|
if let Some(img) = background_image.get() {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -162,20 +165,21 @@ pub fn SpotDrawer(
|
||||||
w, h, img
|
w, h, img
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("width: {}px; height: {}px; background-color: {}; cursor: crosshair;", w, h, bg_color)
|
format!(
|
||||||
|
"width: {}px; height: {}px; background-color: {}; cursor: crosshair;",
|
||||||
|
w, h, bg_color
|
||||||
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let on_mouse_down = move |ev: leptos::ev::MouseEvent| {
|
let on_mouse_down = move |ev: leptos::ev::MouseEvent| {
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
let rect = ev
|
let rect = ev.target().and_then(|t| {
|
||||||
.target()
|
use wasm_bindgen::JsCast;
|
||||||
.and_then(|t| {
|
t.dyn_ref::<web_sys::HtmlElement>()
|
||||||
use wasm_bindgen::JsCast;
|
.map(|el| el.get_bounding_client_rect())
|
||||||
t.dyn_ref::<web_sys::HtmlElement>()
|
});
|
||||||
.map(|el| el.get_bounding_client_rect())
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(rect) = rect {
|
if let Some(rect) = rect {
|
||||||
let x = ev.client_x() as f64 - rect.left();
|
let x = ev.client_x() as f64 - rect.left();
|
||||||
|
|
@ -202,13 +206,11 @@ pub fn SpotDrawer(
|
||||||
{
|
{
|
||||||
if mode.get() == DrawingMode::Rectangle && is_drawing.get() {
|
if mode.get() == DrawingMode::Rectangle && is_drawing.get() {
|
||||||
if let Some((start_x, start_y)) = start_point.get() {
|
if let Some((start_x, start_y)) = start_point.get() {
|
||||||
let rect = ev
|
let rect = ev.target().and_then(|t| {
|
||||||
.target()
|
use wasm_bindgen::JsCast;
|
||||||
.and_then(|t| {
|
t.dyn_ref::<web_sys::HtmlElement>()
|
||||||
use wasm_bindgen::JsCast;
|
.map(|el| el.get_bounding_client_rect())
|
||||||
t.dyn_ref::<web_sys::HtmlElement>()
|
});
|
||||||
.map(|el| el.get_bounding_client_rect())
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(rect) = rect {
|
if let Some(rect) = rect {
|
||||||
let end_x = ev.client_x() as f64 - rect.left();
|
let end_x = ev.client_x() as f64 - rect.left();
|
||||||
|
|
@ -222,7 +224,16 @@ pub fn SpotDrawer(
|
||||||
if (max_x - min_x) > 10.0 && (max_y - min_y) > 10.0 {
|
if (max_x - min_x) > 10.0 && (max_y - min_y) > 10.0 {
|
||||||
let wkt = format!(
|
let wkt = format!(
|
||||||
"POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))",
|
"POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))",
|
||||||
min_x, min_y, max_x, min_y, max_x, max_y, min_x, max_y, min_x, min_y
|
min_x,
|
||||||
|
min_y,
|
||||||
|
max_x,
|
||||||
|
min_y,
|
||||||
|
max_x,
|
||||||
|
max_y,
|
||||||
|
min_x,
|
||||||
|
max_y,
|
||||||
|
min_x,
|
||||||
|
min_y
|
||||||
);
|
);
|
||||||
on_complete.run(wkt);
|
on_complete.run(wkt);
|
||||||
}
|
}
|
||||||
|
|
@ -324,8 +335,14 @@ fn parse_wkt_to_style(wkt: &str) -> String {
|
||||||
if points.len() >= 4 {
|
if points.len() >= 4 {
|
||||||
let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
|
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 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_x = points
|
||||||
let max_y = points.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max);
|
.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!(
|
return format!(
|
||||||
"left: {}px; top: {}px; width: {}px; height: {}px;",
|
"left: {}px; top: {}px; width: {}px; height: {}px;",
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,7 @@ pub fn EmoteListPopup(
|
||||||
if show_all_emotions {
|
if show_all_emotions {
|
||||||
EMOTIONS
|
EMOTIONS
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|name| {
|
.filter(|name| filter_text.is_empty() || name.starts_with(&filter_text))
|
||||||
filter_text.is_empty() || name.starts_with(&filter_text)
|
|
||||||
})
|
|
||||||
.map(|name| ((*name).to_string(), None, true))
|
.map(|name| ((*name).to_string(), None, true))
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -223,7 +221,7 @@ pub fn EmotionPreview(skin_path: Option<String>, emotion_path: Option<String>) -
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
{
|
{
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
let skin_path_clone = skin_path.clone();
|
let skin_path_clone = skin_path.clone();
|
||||||
let emotion_path_clone = emotion_path.clone();
|
let emotion_path_clone = emotion_path.clone();
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,10 @@ pub fn ColorPicker(
|
||||||
|
|
||||||
/// Color palette component.
|
/// Color palette component.
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ColorPalette(#[prop(into)] value: Signal<String>, on_change: Callback<String>) -> impl IntoView {
|
pub fn ColorPalette(
|
||||||
|
#[prop(into)] value: Signal<String>,
|
||||||
|
on_change: Callback<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
let colors = [
|
let colors = [
|
||||||
"#1a1a2e", "#16213e", "#0f3460", "#e94560", "#533483", "#2c3e50", "#1e8449", "#d35400",
|
"#1a1a2e", "#16213e", "#0f3460", "#e94560", "#533483", "#2c3e50", "#1e8449", "#d35400",
|
||||||
];
|
];
|
||||||
|
|
@ -334,7 +337,10 @@ pub fn ColorPalette(#[prop(into)] value: Signal<String>, on_change: Callback<Str
|
||||||
fn event_target_checked(ev: &leptos::ev::Event) -> bool {
|
fn event_target_checked(ev: &leptos::ev::Event) -> bool {
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
ev.target()
|
ev.target()
|
||||||
.and_then(|t| t.dyn_ref::<web_sys::HtmlInputElement>().map(|el| el.checked()))
|
.and_then(|t| {
|
||||||
|
t.dyn_ref::<web_sys::HtmlInputElement>()
|
||||||
|
.map(|el| el.checked())
|
||||||
|
})
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,9 @@ pub fn InventoryPopup(
|
||||||
let response = Request::get("/api/inventory").send().await;
|
let response = Request::get("/api/inventory").send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) =
|
if let Ok(data) = resp
|
||||||
resp.json::<chattyness_db::models::InventoryResponse>().await
|
.json::<chattyness_db::models::InventoryResponse>()
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
set_items.set(data.items);
|
set_items.set(data.items);
|
||||||
set_my_inventory_loaded.set(true);
|
set_my_inventory_loaded.set(true);
|
||||||
|
|
@ -123,8 +124,9 @@ pub fn InventoryPopup(
|
||||||
let response = Request::get("/api/inventory/server").send().await;
|
let response = Request::get("/api/inventory/server").send().await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) =
|
if let Ok(data) = resp
|
||||||
resp.json::<chattyness_db::models::PublicPropsResponse>().await
|
.json::<chattyness_db::models::PublicPropsResponse>()
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
set_server_props.set(data.props);
|
set_server_props.set(data.props);
|
||||||
set_server_loaded.set(true);
|
set_server_loaded.set(true);
|
||||||
|
|
@ -133,8 +135,10 @@ pub fn InventoryPopup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
set_server_error
|
set_server_error.set(Some(format!(
|
||||||
.set(Some(format!("Failed to load server props: {}", resp.status())));
|
"Failed to load server props: {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
set_server_error.set(Some(format!("Network error: {}", e)));
|
set_server_error.set(Some(format!("Network error: {}", e)));
|
||||||
|
|
@ -171,8 +175,9 @@ pub fn InventoryPopup(
|
||||||
.await;
|
.await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => {
|
Ok(resp) if resp.ok() => {
|
||||||
if let Ok(data) =
|
if let Ok(data) = resp
|
||||||
resp.json::<chattyness_db::models::PublicPropsResponse>().await
|
.json::<chattyness_db::models::PublicPropsResponse>()
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
set_realm_props.set(data.props);
|
set_realm_props.set(data.props);
|
||||||
set_realm_loaded.set(true);
|
set_realm_loaded.set(true);
|
||||||
|
|
@ -181,8 +186,10 @@ pub fn InventoryPopup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
set_realm_error
|
set_realm_error.set(Some(format!(
|
||||||
.set(Some(format!("Failed to load realm props: {}", resp.status())));
|
"Failed to load realm props: {}",
|
||||||
|
resp.status()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
set_realm_error.set(Some(format!("Network error: {}", e)));
|
set_realm_error.set(Some(format!("Network error: {}", e)));
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ use crate::utils::LocalStoragePersist;
|
||||||
|
|
||||||
/// Key slot names for the 12 emotion keybindings.
|
/// Key slot names for the 12 emotion keybindings.
|
||||||
/// Maps to e1, e2, ..., e9, e0, eq, ew
|
/// Maps to e1, e2, ..., e9, e0, eq, ew
|
||||||
pub const KEYBINDING_SLOTS: [&str; 12] = [
|
pub const KEYBINDING_SLOTS: [&str; 12] =
|
||||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "q", "w",
|
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "q", "w"];
|
||||||
];
|
|
||||||
|
|
||||||
/// Default emotion order for keybinding slots.
|
/// Default emotion order for keybinding slots.
|
||||||
/// Slot 0 (e1) is always Happy (locked).
|
/// Slot 0 (e1) is always Happy (locked).
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,10 @@ pub fn Card(#[prop(optional)] class: &'static str, children: Children) -> impl I
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SceneThumbnail(scene: chattyness_db::models::SceneSummary) -> impl IntoView {
|
pub fn SceneThumbnail(scene: chattyness_db::models::SceneSummary) -> impl IntoView {
|
||||||
let background_style = match (&scene.background_image_path, &scene.background_color) {
|
let background_style = match (&scene.background_image_path, &scene.background_color) {
|
||||||
(Some(path), _) => format!("background-image: url('{}'); background-size: cover; background-position: center;", path),
|
(Some(path), _) => format!(
|
||||||
|
"background-image: url('{}'); background-size: cover; background-position: center;",
|
||||||
|
path
|
||||||
|
),
|
||||||
(None, Some(color)) => format!("background-color: {};", color),
|
(None, Some(color)) => format!("background-color: {};", color),
|
||||||
(None, None) => "background-color: #1a1a2e;".to_string(),
|
(None, None) => "background-color: #1a1a2e;".to_string(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,7 @@ pub fn NotificationToast(
|
||||||
|
|
||||||
// Clear any existing timer
|
// Clear any existing timer
|
||||||
if let Some(handle) = timer_handle_effect.borrow_mut().take() {
|
if let Some(handle) = timer_handle_effect.borrow_mut().take() {
|
||||||
web_sys::window()
|
web_sys::window().unwrap().clear_timeout_with_handle(handle);
|
||||||
.unwrap()
|
|
||||||
.clear_timeout_with_handle(handle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start new timer if notification is present
|
// Start new timer if notification is present
|
||||||
|
|
@ -89,13 +87,16 @@ pub fn NotificationToast(
|
||||||
let closure = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
let closure = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||||
on_dismiss.run(id);
|
on_dismiss.run(id);
|
||||||
timer_handle_inner.borrow_mut().take();
|
timer_handle_inner.borrow_mut().take();
|
||||||
}) as Box<dyn FnOnce()>);
|
})
|
||||||
|
as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Ok(handle) = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
if let Ok(handle) = window
|
||||||
closure.as_ref().unchecked_ref(),
|
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
5000, // 5 second auto-dismiss
|
closure.as_ref().unchecked_ref(),
|
||||||
) {
|
5000, // 5 second auto-dismiss
|
||||||
|
)
|
||||||
|
{
|
||||||
*timer_handle_effect.borrow_mut() = Some(handle);
|
*timer_handle_effect.borrow_mut() = Some(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +111,9 @@ pub fn NotificationToast(
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
let closure_holder: Rc<RefCell<Option<wasm_bindgen::closure::Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
let closure_holder: Rc<
|
||||||
Rc::new(RefCell::new(None));
|
RefCell<Option<wasm_bindgen::closure::Closure<dyn Fn(web_sys::KeyboardEvent)>>>,
|
||||||
|
> = Rc::new(RefCell::new(None));
|
||||||
let closure_holder_clone = closure_holder.clone();
|
let closure_holder_clone = closure_holder.clone();
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
|
|
@ -140,37 +142,37 @@ pub fn NotificationToast(
|
||||||
let on_history = on_history.clone();
|
let on_history = on_history.clone();
|
||||||
let on_dismiss = on_dismiss.clone();
|
let on_dismiss = on_dismiss.clone();
|
||||||
|
|
||||||
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
|
let closure = wasm_bindgen::closure::Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(
|
||||||
let key = ev.key();
|
move |ev: web_sys::KeyboardEvent| {
|
||||||
match key.as_str() {
|
let key = ev.key();
|
||||||
"r" | "R" => {
|
match key.as_str() {
|
||||||
ev.prevent_default();
|
"r" | "R" => {
|
||||||
on_reply.run(display_name.clone());
|
ev.prevent_default();
|
||||||
on_dismiss.run(notif_id);
|
on_reply.run(display_name.clone());
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
"c" | "C" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_context.run(display_name.clone());
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
"h" | "H" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_history.run(());
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
"Escape" => {
|
||||||
|
ev.prevent_default();
|
||||||
|
on_dismiss.run(notif_id);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
"c" | "C" => {
|
},
|
||||||
ev.prevent_default();
|
);
|
||||||
on_context.run(display_name.clone());
|
|
||||||
on_dismiss.run(notif_id);
|
|
||||||
}
|
|
||||||
"h" | "H" => {
|
|
||||||
ev.prevent_default();
|
|
||||||
on_history.run(());
|
|
||||||
on_dismiss.run(notif_id);
|
|
||||||
}
|
|
||||||
"Escape" => {
|
|
||||||
ev.prevent_default();
|
|
||||||
on_dismiss.run(notif_id);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let _ = window.add_event_listener_with_callback(
|
let _ = window
|
||||||
"keydown",
|
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||||||
closure.as_ref().unchecked_ref(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store closure for cleanup on next change
|
// Store closure for cleanup on next change
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use super::avatar_canvas::hit_test_canvas;
|
use super::avatar_canvas::hit_test_canvas;
|
||||||
use super::avatar_canvas::{member_key, AvatarCanvas};
|
use super::avatar_canvas::{AvatarCanvas, member_key};
|
||||||
use super::chat_types::ActiveBubble;
|
use super::chat_types::ActiveBubble;
|
||||||
use super::context_menu::{ContextMenu, ContextMenuItem};
|
use super::context_menu::{ContextMenu, ContextMenuItem};
|
||||||
use super::settings::{
|
use super::settings::{
|
||||||
calculate_min_zoom, ViewerSettings, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
|
BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings, calculate_min_zoom,
|
||||||
};
|
};
|
||||||
use super::ws_client::FadingMember;
|
use super::ws_client::FadingMember;
|
||||||
use crate::utils::parse_bounds_dimensions;
|
use crate::utils::parse_bounds_dimensions;
|
||||||
|
|
@ -35,18 +35,12 @@ use crate::utils::parse_bounds_dimensions;
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RealmSceneViewer(
|
pub fn RealmSceneViewer(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
#[allow(unused)]
|
|
||||||
realm_slug: String,
|
realm_slug: String,
|
||||||
#[prop(into)]
|
#[prop(into)] members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||||
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
#[prop(into)] active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
|
||||||
#[prop(into)]
|
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
|
||||||
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
|
#[prop(into)] on_move: Callback<(f64, f64)>,
|
||||||
#[prop(into)]
|
#[prop(into)] on_prop_click: Callback<Uuid>,
|
||||||
loose_props: Signal<Vec<LooseProp>>,
|
|
||||||
#[prop(into)]
|
|
||||||
on_move: Callback<(f64, f64)>,
|
|
||||||
#[prop(into)]
|
|
||||||
on_prop_click: Callback<Uuid>,
|
|
||||||
/// Viewer settings for pan/zoom/enlarge modes.
|
/// Viewer settings for pan/zoom/enlarge modes.
|
||||||
#[prop(optional)]
|
#[prop(optional)]
|
||||||
settings: Option<Signal<ViewerSettings>>,
|
settings: Option<Signal<ViewerSettings>>,
|
||||||
|
|
@ -104,9 +98,7 @@ pub fn RealmSceneViewer(
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "#1a1a2e".to_string());
|
.unwrap_or_else(|| "#1a1a2e".to_string());
|
||||||
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
let has_background_image = scene.background_image_path.is_some();
|
let has_background_image = scene.background_image_path.is_some();
|
||||||
#[allow(unused_variables)]
|
|
||||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||||
|
|
||||||
// Canvas refs for background and props layers
|
// Canvas refs for background and props layers
|
||||||
|
|
@ -217,15 +209,18 @@ pub fn RealmSceneViewer(
|
||||||
// Parse the member ID to determine if it's a user_id or guest_session_id
|
// Parse the member ID to determine if it's a user_id or guest_session_id
|
||||||
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
|
if let Ok(member_id) = member_id_str.parse::<Uuid>() {
|
||||||
// Check if this is the current user's avatar
|
// Check if this is the current user's avatar
|
||||||
let is_current_user = my_user_id == Some(member_id) ||
|
let is_current_user = my_user_id == Some(member_id)
|
||||||
my_guest_session_id == Some(member_id);
|
|| my_guest_session_id == Some(member_id);
|
||||||
|
|
||||||
if !is_current_user {
|
if !is_current_user {
|
||||||
// Find the display name for this member
|
// Find the display name for this member
|
||||||
let display_name = members.get().iter()
|
let display_name = members
|
||||||
|
.get()
|
||||||
|
.iter()
|
||||||
.find(|m| {
|
.find(|m| {
|
||||||
m.member.user_id == Some(member_id) ||
|
m.member.user_id == Some(member_id)
|
||||||
m.member.guest_session_id == Some(member_id)
|
|| m.member.guest_session_id
|
||||||
|
== Some(member_id)
|
||||||
})
|
})
|
||||||
.map(|m| m.member.display_name.clone());
|
.map(|m| m.member.display_name.clone());
|
||||||
|
|
||||||
|
|
@ -256,7 +251,7 @@ pub fn RealmSceneViewer(
|
||||||
{
|
{
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
let image_path_clone = image_path.clone();
|
let image_path_clone = image_path.clone();
|
||||||
let bg_color_clone = bg_color.clone();
|
let bg_color_clone = bg_color.clone();
|
||||||
|
|
@ -395,7 +390,8 @@ pub fn RealmSceneViewer(
|
||||||
canvas_width as f64,
|
canvas_width as f64,
|
||||||
canvas_height as f64,
|
canvas_height as f64,
|
||||||
);
|
);
|
||||||
}) as Box<dyn FnOnce()>);
|
})
|
||||||
|
as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
onload.forget();
|
onload.forget();
|
||||||
|
|
@ -419,7 +415,8 @@ pub fn RealmSceneViewer(
|
||||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||||
let scene_aspect = scene_width_f / scene_height_f;
|
let scene_aspect = scene_width_f / scene_height_f;
|
||||||
|
|
||||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect
|
||||||
|
{
|
||||||
let h = display_height as f64;
|
let h = display_height as f64;
|
||||||
let w = h * scene_aspect;
|
let w = h * scene_aspect;
|
||||||
let x = (display_width as f64 - w) / 2.0;
|
let x = (display_width as f64 - w) / 2.0;
|
||||||
|
|
@ -462,9 +459,14 @@ pub fn RealmSceneViewer(
|
||||||
|
|
||||||
let onload = Closure::once(Box::new(move || {
|
let onload = Closure::once(Box::new(move || {
|
||||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
&img_clone,
|
||||||
|
draw_x,
|
||||||
|
draw_y,
|
||||||
|
draw_width,
|
||||||
|
draw_height,
|
||||||
);
|
);
|
||||||
}) as Box<dyn FnOnce()>);
|
})
|
||||||
|
as Box<dyn FnOnce()>);
|
||||||
|
|
||||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
onload.forget();
|
onload.forget();
|
||||||
|
|
@ -498,6 +500,12 @@ pub fn RealmSceneViewer(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read scale factors inside the Effect (reactive context) before the closure
|
||||||
|
let sx = scale_x.get();
|
||||||
|
let sy = scale_y.get();
|
||||||
|
let ox = offset_x.get();
|
||||||
|
let oy = offset_y.get();
|
||||||
|
|
||||||
let Some(canvas) = props_canvas_ref.get() else {
|
let Some(canvas) = props_canvas_ref.get() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -520,12 +528,6 @@ pub fn RealmSceneViewer(
|
||||||
// Clear with transparency
|
// Clear with transparency
|
||||||
ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
ctx.clear_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
|
||||||
|
|
||||||
// Get stored scale factors
|
|
||||||
let sx = scale_x.get();
|
|
||||||
let sy = scale_y.get();
|
|
||||||
let ox = offset_x.get();
|
|
||||||
let oy = offset_y.get();
|
|
||||||
|
|
||||||
// Calculate prop size based on mode
|
// Calculate prop size based on mode
|
||||||
let prop_size = calculate_prop_size(
|
let prop_size = calculate_prop_size(
|
||||||
current_pan_mode,
|
current_pan_mode,
|
||||||
|
|
@ -582,7 +584,9 @@ pub fn RealmSceneViewer(
|
||||||
if canvas_width > 0 && canvas_height > 0 {
|
if canvas_width > 0 && canvas_height > 0 {
|
||||||
if let Some(canvas) = props_canvas_ref.get() {
|
if let Some(canvas) = props_canvas_ref.get() {
|
||||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||||
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
|
if canvas_el.width() != canvas_width
|
||||||
|
|| canvas_el.height() != canvas_height
|
||||||
|
{
|
||||||
canvas_el.set_width(canvas_width);
|
canvas_el.set_width(canvas_width);
|
||||||
canvas_el.set_height(canvas_height);
|
canvas_el.set_height(canvas_height);
|
||||||
}
|
}
|
||||||
|
|
@ -631,39 +635,44 @@ pub fn RealmSceneViewer(
|
||||||
let last_y_down = last_y.clone();
|
let last_y_down = last_y.clone();
|
||||||
|
|
||||||
// Middle mouse down - start drag
|
// Middle mouse down - start drag
|
||||||
let onmousedown = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
let onmousedown =
|
||||||
// Button 1 is middle mouse button
|
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||||
if ev.button() == 1 {
|
// Button 1 is middle mouse button
|
||||||
is_dragging_down.set(true);
|
if ev.button() == 1 {
|
||||||
last_x_down.set(ev.client_x());
|
is_dragging_down.set(true);
|
||||||
last_y_down.set(ev.client_y());
|
last_x_down.set(ev.client_x());
|
||||||
let _ = container_for_down.style().set_property("cursor", "grabbing");
|
last_y_down.set(ev.client_y());
|
||||||
ev.prevent_default();
|
let _ = container_for_down
|
||||||
}
|
.style()
|
||||||
});
|
.set_property("cursor", "grabbing");
|
||||||
|
ev.prevent_default();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Mouse move - drag scroll
|
// Mouse move - drag scroll
|
||||||
let onmousemove = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
let onmousemove =
|
||||||
if is_dragging_move.get() {
|
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||||
let dx = last_x_move.get() - ev.client_x();
|
if is_dragging_move.get() {
|
||||||
let dy = last_y_move.get() - ev.client_y();
|
let dx = last_x_move.get() - ev.client_x();
|
||||||
last_x_move.set(ev.client_x());
|
let dy = last_y_move.get() - ev.client_y();
|
||||||
last_y_move.set(ev.client_y());
|
last_x_move.set(ev.client_x());
|
||||||
container_for_move.set_scroll_left(container_for_move.scroll_left() + dx);
|
last_y_move.set(ev.client_y());
|
||||||
container_for_move.set_scroll_top(container_for_move.scroll_top() + dy);
|
container_for_move.set_scroll_left(container_for_move.scroll_left() + dx);
|
||||||
}
|
container_for_move.set_scroll_top(container_for_move.scroll_top() + dy);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let container_for_up = container_el.clone();
|
let container_for_up = container_el.clone();
|
||||||
let is_dragging_up = is_dragging.clone();
|
let is_dragging_up = is_dragging.clone();
|
||||||
|
|
||||||
// Mouse up - stop drag
|
// Mouse up - stop drag
|
||||||
let onmouseup = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
let onmouseup =
|
||||||
if is_dragging_up.get() {
|
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
||||||
is_dragging_up.set(false);
|
if is_dragging_up.get() {
|
||||||
let _ = container_for_up.style().set_property("cursor", "");
|
is_dragging_up.set(false);
|
||||||
}
|
let _ = container_for_up.style().set_property("cursor", "");
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add event listeners
|
// Add event listeners
|
||||||
let _ = container_el.add_event_listener_with_callback(
|
let _ = container_el.add_event_listener_with_callback(
|
||||||
|
|
@ -674,21 +683,20 @@ pub fn RealmSceneViewer(
|
||||||
"mousemove",
|
"mousemove",
|
||||||
onmousemove.as_ref().unchecked_ref(),
|
onmousemove.as_ref().unchecked_ref(),
|
||||||
);
|
);
|
||||||
let _ = container_el.add_event_listener_with_callback(
|
let _ = container_el
|
||||||
"mouseup",
|
.add_event_listener_with_callback("mouseup", onmouseup.as_ref().unchecked_ref());
|
||||||
onmouseup.as_ref().unchecked_ref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also listen for mouseup on window (in case mouse released outside container)
|
// Also listen for mouseup on window (in case mouse released outside container)
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let is_dragging_window = is_dragging.clone();
|
let is_dragging_window = is_dragging.clone();
|
||||||
let container_for_window = container_el.clone();
|
let container_for_window = container_el.clone();
|
||||||
let onmouseup_window = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
let onmouseup_window =
|
||||||
if is_dragging_window.get() {
|
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
|
||||||
is_dragging_window.set(false);
|
if is_dragging_window.get() {
|
||||||
let _ = container_for_window.style().set_property("cursor", "");
|
is_dragging_window.set(false);
|
||||||
}
|
let _ = container_for_window.style().set_property("cursor", "");
|
||||||
});
|
}
|
||||||
|
});
|
||||||
let _ = window.add_event_listener_with_callback(
|
let _ = window.add_event_listener_with_callback(
|
||||||
"mouseup",
|
"mouseup",
|
||||||
onmouseup_window.as_ref().unchecked_ref(),
|
onmouseup_window.as_ref().unchecked_ref(),
|
||||||
|
|
@ -697,11 +705,12 @@ pub fn RealmSceneViewer(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent context menu on middle click
|
// Prevent context menu on middle click
|
||||||
let oncontextmenu = Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
let oncontextmenu =
|
||||||
if ev.button() == 1 {
|
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
|
||||||
ev.prevent_default();
|
if ev.button() == 1 {
|
||||||
}
|
ev.prevent_default();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
let _ = container_el.add_event_listener_with_callback(
|
let _ = container_el.add_event_listener_with_callback(
|
||||||
"auxclick",
|
"auxclick",
|
||||||
oncontextmenu.as_ref().unchecked_ref(),
|
oncontextmenu.as_ref().unchecked_ref(),
|
||||||
|
|
@ -713,7 +722,6 @@ pub fn RealmSceneViewer(
|
||||||
onmouseup.forget();
|
onmouseup.forget();
|
||||||
oncontextmenu.forget();
|
oncontextmenu.forget();
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create wheel handler closure for use in view
|
// Create wheel handler closure for use in view
|
||||||
|
|
@ -861,7 +869,8 @@ pub fn RealmSceneViewer(
|
||||||
// Create a map of members by key for efficient lookup
|
// Create a map of members by key for efficient lookup
|
||||||
let members_by_key = Signal::derive(move || {
|
let members_by_key = Signal::derive(move || {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
sorted_members.get()
|
sorted_members
|
||||||
|
.get()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, m)| (member_key(&m), (idx, m)))
|
.map(|(idx, m)| (member_key(&m), (idx, m)))
|
||||||
|
|
@ -871,7 +880,8 @@ pub fn RealmSceneViewer(
|
||||||
// Get the list of member keys - use Memo so it only updates when keys actually change
|
// Get the list of member keys - use Memo so it only updates when keys actually change
|
||||||
// (not when member data like position changes)
|
// (not when member data like position changes)
|
||||||
let member_keys = Memo::new(move |_| {
|
let member_keys = Memo::new(move |_| {
|
||||||
sorted_members.get()
|
sorted_members
|
||||||
|
.get()
|
||||||
.iter()
|
.iter()
|
||||||
.map(member_key)
|
.map(member_key)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|
@ -1111,7 +1121,6 @@ fn draw_loose_props(
|
||||||
offset_y: f64,
|
offset_y: f64,
|
||||||
prop_size: f64,
|
prop_size: f64,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
for prop in props {
|
for prop in props {
|
||||||
let x = prop.position_x * scale_x + offset_x;
|
let x = prop.position_x * scale_x + offset_x;
|
||||||
let y = prop.position_y * scale_y + offset_y;
|
let y = prop.position_y * scale_y + offset_y;
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,7 @@ pub fn calculate_min_zoom(
|
||||||
viewport_width: f64,
|
viewport_width: f64,
|
||||||
viewport_height: f64,
|
viewport_height: f64,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
if scene_width <= 0.0
|
if scene_width <= 0.0 || scene_height <= 0.0 || viewport_width <= 0.0 || viewport_height <= 0.0
|
||||||
|| scene_height <= 0.0
|
|
||||||
|| viewport_width <= 0.0
|
|
||||||
|| viewport_height <= 0.0
|
|
||||||
{
|
{
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ use leptos::ev::MouseEvent;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
use super::modals::Modal;
|
use super::modals::Modal;
|
||||||
use super::settings::{calculate_min_zoom, ViewerSettings, ZOOM_MAX, ZOOM_STEP, TEXT_EM_MIN, TEXT_EM_MAX, TEXT_EM_STEP};
|
use super::settings::{
|
||||||
|
TEXT_EM_MAX, TEXT_EM_MIN, TEXT_EM_STEP, ViewerSettings, ZOOM_MAX, ZOOM_STEP, calculate_min_zoom,
|
||||||
|
};
|
||||||
use crate::utils::LocalStoragePersist;
|
use crate::utils::LocalStoragePersist;
|
||||||
|
|
||||||
/// Settings popup component for scene viewer configuration.
|
/// Settings popup component for scene viewer configuration.
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::reactive::owner::LocalStorage;
|
use leptos::reactive::owner::LocalStorage;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
|
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::models::EmotionState;
|
use chattyness_db::models::EmotionState;
|
||||||
|
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp};
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage, WsConfig};
|
use chattyness_db::ws_messages::{DisconnectReason, ServerMessage};
|
||||||
|
|
||||||
use super::chat_types::ChatMessage;
|
use super::chat_types::ChatMessage;
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ pub fn use_channel_websocket(
|
||||||
) -> (Signal<WsState>, WsSenderStorage) {
|
) -> (Signal<WsState>, WsSenderStorage) {
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||||
|
|
||||||
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
|
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
|
||||||
|
|
@ -100,8 +100,8 @@ pub fn use_channel_websocket(
|
||||||
|
|
||||||
// Create a stored sender function (using new_local for WASM single-threaded environment)
|
// Create a stored sender function (using new_local for WASM single-threaded environment)
|
||||||
let ws_ref_for_send = ws_ref.clone();
|
let ws_ref_for_send = ws_ref.clone();
|
||||||
let sender: WsSenderStorage = StoredValue::new_local(Some(Box::new(
|
let sender: WsSenderStorage =
|
||||||
move |msg: ClientMessage| {
|
StoredValue::new_local(Some(Box::new(move |msg: ClientMessage| {
|
||||||
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
|
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
|
||||||
if ws.ready_state() == WebSocket::OPEN {
|
if ws.ready_state() == WebSocket::OPEN {
|
||||||
if let Ok(json) = serde_json::to_string(&msg) {
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
|
@ -111,8 +111,7 @@ pub fn use_channel_websocket(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
})));
|
||||||
)));
|
|
||||||
|
|
||||||
// Effect to manage WebSocket lifecycle
|
// Effect to manage WebSocket lifecycle
|
||||||
let ws_ref_clone = ws_ref.clone();
|
let ws_ref_clone = ws_ref.clone();
|
||||||
|
|
@ -198,24 +197,38 @@ pub fn use_channel_websocket(
|
||||||
|
|
||||||
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||||
// Check for Welcome message to start heartbeat with server-provided config
|
// Check for Welcome message to start heartbeat with server-provided config
|
||||||
if let ServerMessage::Welcome { ref config, ref member, .. } = msg {
|
if let ServerMessage::Welcome {
|
||||||
|
ref config,
|
||||||
|
ref member,
|
||||||
|
..
|
||||||
|
} = msg
|
||||||
|
{
|
||||||
if !*heartbeat_started_clone.borrow() {
|
if !*heartbeat_started_clone.borrow() {
|
||||||
*heartbeat_started_clone.borrow_mut() = true;
|
*heartbeat_started_clone.borrow_mut() = true;
|
||||||
let ping_interval_ms = config.ping_interval_secs * 1000;
|
let ping_interval_ms = config.ping_interval_secs * 1000;
|
||||||
let ws_ref_ping = ws_ref_for_heartbeat.clone();
|
let ws_ref_ping = ws_ref_for_heartbeat.clone();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
web_sys::console::log_1(
|
web_sys::console::log_1(
|
||||||
&format!("[WS] Starting heartbeat with interval {}ms", ping_interval_ms).into(),
|
&format!(
|
||||||
|
"[WS] Starting heartbeat with interval {}ms",
|
||||||
|
ping_interval_ms
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
);
|
);
|
||||||
let heartbeat = gloo_timers::callback::Interval::new(ping_interval_ms as u32, move || {
|
let heartbeat = gloo_timers::callback::Interval::new(
|
||||||
if let Some(ws) = ws_ref_ping.borrow().as_ref() {
|
ping_interval_ms as u32,
|
||||||
if ws.ready_state() == WebSocket::OPEN {
|
move || {
|
||||||
if let Ok(json) = serde_json::to_string(&ClientMessage::Ping) {
|
if let Some(ws) = ws_ref_ping.borrow().as_ref() {
|
||||||
let _ = ws.send_with_str(&json);
|
if ws.ready_state() == WebSocket::OPEN {
|
||||||
|
if let Ok(json) =
|
||||||
|
serde_json::to_string(&ClientMessage::Ping)
|
||||||
|
{
|
||||||
|
let _ = ws.send_with_str(&json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
});
|
);
|
||||||
std::mem::forget(heartbeat);
|
std::mem::forget(heartbeat);
|
||||||
}
|
}
|
||||||
// Call on_welcome callback with current user info
|
// Call on_welcome callback with current user info
|
||||||
|
|
@ -292,7 +305,7 @@ fn handle_server_message(
|
||||||
ServerMessage::Welcome {
|
ServerMessage::Welcome {
|
||||||
member: _,
|
member: _,
|
||||||
members: initial_members,
|
members: initial_members,
|
||||||
config: _, // Config is handled in the caller for heartbeat setup
|
config: _, // Config is handled in the caller for heartbeat setup
|
||||||
} => {
|
} => {
|
||||||
*members_vec = initial_members;
|
*members_vec = initial_members;
|
||||||
on_update.run(members_vec.clone());
|
on_update.run(members_vec.clone());
|
||||||
|
|
@ -314,7 +327,9 @@ fn handle_server_message(
|
||||||
// Find the member before removing
|
// Find the member before removing
|
||||||
let leaving_member = members_vec
|
let leaving_member = members_vec
|
||||||
.iter()
|
.iter()
|
||||||
.find(|m| m.member.user_id == user_id && m.member.guest_session_id == guest_session_id)
|
.find(|m| {
|
||||||
|
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||||
|
})
|
||||||
.cloned();
|
.cloned();
|
||||||
|
|
||||||
// Always remove from active members list
|
// Always remove from active members list
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
|
// Server builds don't use browser-specific code guarded by #[cfg(feature = "hydrate")]
|
||||||
|
#![cfg_attr(
|
||||||
|
not(feature = "hydrate"),
|
||||||
|
allow(unused_variables, dead_code, unused_imports)
|
||||||
|
)]
|
||||||
//! User UI components for chattyness.
|
//! User UI components for chattyness.
|
||||||
//!
|
//!
|
||||||
//! This crate provides the public user-facing interface including:
|
//! This crate provides the public user-facing interface including:
|
||||||
|
|
@ -30,7 +35,7 @@ pub mod pages;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
pub use app::{shell, App};
|
pub use app::{App, shell};
|
||||||
pub use routes::UserRoutes;
|
pub use routes::UserRoutes;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,11 @@ pub fn HomePage() -> impl IntoView {
|
||||||
|
|
||||||
/// Feature card component.
|
/// Feature card component.
|
||||||
#[component]
|
#[component]
|
||||||
fn FeatureCard(icon: &'static str, title: &'static str, description: &'static str) -> impl IntoView {
|
fn FeatureCard(
|
||||||
|
icon: &'static str,
|
||||||
|
title: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
) -> impl IntoView {
|
||||||
let icon_symbol = match icon {
|
let icon_symbol = match icon {
|
||||||
"castle" => "castle",
|
"castle" => "castle",
|
||||||
"users" => "users",
|
"users" => "users",
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ fn RealmLoginForm() -> impl IntoView {
|
||||||
struct ListResponse {
|
struct ListResponse {
|
||||||
realms: Vec<RealmSummary>,
|
realms: Vec<RealmSummary>,
|
||||||
}
|
}
|
||||||
let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await;
|
let response = Request::get("/api/realms?include_nsfw=false&limit=20")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ pub fn PasswordResetPage() -> impl IntoView {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == 401 {
|
if status == 401 {
|
||||||
set_error.set(Some("Session expired. Please log in again.".to_string()));
|
set_error
|
||||||
|
.set(Some("Session expired. Please log in again.".to_string()));
|
||||||
} else if status == 400 {
|
} else if status == 400 {
|
||||||
set_error.set(Some("Invalid password. Please try again.".to_string()));
|
set_error.set(Some("Invalid password. Please try again.".to_string()));
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ use crate::components::{
|
||||||
};
|
};
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
add_to_history, use_channel_websocket, ChannelMemberInfo, ChatMessage,
|
ChannelMemberInfo, ChatMessage, DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry,
|
||||||
DEFAULT_BUBBLE_TIMEOUT_MS, FADE_DURATION_MS, HistoryEntry, WsError,
|
WsError, add_to_history, use_channel_websocket,
|
||||||
};
|
};
|
||||||
use crate::utils::LocalStoragePersist;
|
use crate::utils::LocalStoragePersist;
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
@ -32,6 +32,7 @@ use chattyness_db::models::{
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
use chattyness_db::ws_messages::ClientMessage;
|
use chattyness_db::ws_messages::ClientMessage;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
use crate::components::ws_client::WsSender;
|
use crate::components::ws_client::WsSender;
|
||||||
|
|
||||||
/// Realm landing page component.
|
/// Realm landing page component.
|
||||||
|
|
@ -102,7 +103,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
|
let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
|
||||||
|
|
||||||
// Notification state for cross-scene whispers
|
// Notification state for cross-scene whispers
|
||||||
let (current_notification, set_current_notification) = signal(Option::<NotificationMessage>::None);
|
let (current_notification, set_current_notification) =
|
||||||
|
signal(Option::<NotificationMessage>::None);
|
||||||
let (history_modal_open, set_history_modal_open) = signal(false);
|
let (history_modal_open, set_history_modal_open) = signal(false);
|
||||||
let (conversation_modal_open, set_conversation_modal_open) = signal(false);
|
let (conversation_modal_open, set_conversation_modal_open) = signal(false);
|
||||||
let (conversation_partner, set_conversation_partner) = signal(String::new());
|
let (conversation_partner, set_conversation_partner) = signal(String::new());
|
||||||
|
|
@ -414,7 +416,7 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
{
|
{
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
let closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
let closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
|
||||||
Rc::new(RefCell::new(None));
|
Rc::new(RefCell::new(None));
|
||||||
|
|
@ -512,7 +514,9 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
if let Some((dx, dy)) = scroll_delta {
|
if let Some((dx, dy)) = scroll_delta {
|
||||||
// Find the scene container and scroll it
|
// Find the scene container and scroll it
|
||||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||||
if let Some(container) = document.query_selector(".scene-container").ok().flatten() {
|
if let Some(container) =
|
||||||
|
document.query_selector(".scene-container").ok().flatten()
|
||||||
|
{
|
||||||
let container_el: web_sys::Element = container;
|
let container_el: web_sys::Element = container;
|
||||||
container_el.scroll_by_with_x_and_y(dx, dy);
|
container_el.scroll_by_with_x_and_y(dx, dy);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ pub fn SignupPage() -> impl IntoView {
|
||||||
struct ListResponse {
|
struct ListResponse {
|
||||||
realms: Vec<RealmSummary>,
|
realms: Vec<RealmSummary>,
|
||||||
}
|
}
|
||||||
let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await;
|
let response = Request::get("/api/realms?include_nsfw=false&limit=20")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
match response {
|
match response {
|
||||||
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -74,7 +76,9 @@ pub fn SignupPage() -> impl IntoView {
|
||||||
|
|
||||||
let slug = realm_slug.get();
|
let slug = realm_slug.get();
|
||||||
if slug.is_none() {
|
if slug.is_none() {
|
||||||
set_error.set(Some("Please select a realm or enter a realm name".to_string()));
|
set_error.set(Some(
|
||||||
|
"Please select a realm or enter a realm name".to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::{
|
use leptos_router::{
|
||||||
components::{Route, Routes},
|
|
||||||
ParamSegment, StaticSegment,
|
ParamSegment, StaticSegment,
|
||||||
|
components::{Route, Routes},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
|
use crate::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//! - localStorage persistence trait
|
//! - localStorage persistence trait
|
||||||
//! - Common hooks (escape key handling)
|
//! - Common hooks (escape key handling)
|
||||||
|
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Geometry Utilities
|
// Geometry Utilities
|
||||||
|
|
@ -163,7 +163,7 @@ pub fn use_escape_key(
|
||||||
on_close: leptos::prelude::Callback<()>,
|
on_close: leptos::prelude::Callback<()>,
|
||||||
) {
|
) {
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if !open.get() {
|
if !open.get() {
|
||||||
|
|
@ -179,8 +179,8 @@ pub fn use_escape_key(
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let _ =
|
let _ = window
|
||||||
window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We intentionally don't clean up the listener here.
|
// Note: We intentionally don't clean up the listener here.
|
||||||
|
|
@ -225,7 +225,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_asset_path() {
|
fn test_normalize_asset_path() {
|
||||||
assert_eq!(normalize_asset_path("/images/foo.png"), "/images/foo.png");
|
assert_eq!(normalize_asset_path("/images/foo.png"), "/images/foo.png");
|
||||||
assert_eq!(normalize_asset_path("images/foo.png"), "/static/images/foo.png");
|
assert_eq!(
|
||||||
|
normalize_asset_path("images/foo.png"),
|
||||||
|
"/static/images/foo.png"
|
||||||
|
);
|
||||||
assert_eq!(normalize_asset_path("foo.png"), "/static/foo.png");
|
assert_eq!(normalize_asset_path("foo.png"), "/static/foo.png");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue