add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View file

@ -0,0 +1,72 @@
[package]
name = "chattyness-admin-ui"
version.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
chattyness-db = { workspace = true }
chattyness-error = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
tracing = { workspace = true, optional = true }
# Leptos
leptos = { workspace = true }
leptos_meta = { workspace = true }
leptos_router = { workspace = true }
# SSR-only dependencies
axum = { workspace = true, optional = true }
axum-extra = { workspace = true, optional = true }
sqlx = { workspace = true, optional = true }
tower-sessions = { workspace = true, optional = true }
tower-sessions-sqlx-store = { workspace = true, optional = true }
argon2 = { workspace = true, optional = true }
image = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
sha2 = { workspace = true, optional = true }
hex = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
# Hydrate-only dependencies
gloo-net = { workspace = true, optional = true }
web-sys = { workspace = true, optional = true }
wasm-bindgen = { workspace = true, optional = true }
console_error_panic_hook = { workspace = true, optional = true }
urlencoding = { workspace = true, optional = true }
[features]
default = []
ssr = [
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"chattyness-db/ssr",
"chattyness-error/ssr",
"dep:chattyness-error",
"dep:axum",
"dep:axum-extra",
"dep:sqlx",
"dep:tracing",
"dep:tower-sessions",
"dep:tower-sessions-sqlx-store",
"dep:argon2",
"dep:image",
"dep:reqwest",
"dep:sha2",
"dep:hex",
"dep:tokio",
]
hydrate = [
"leptos/hydrate",
"dep:gloo-net",
"dep:web-sys",
"dep:wasm-bindgen",
"dep:console_error_panic_hook",
"dep:urlencoding",
]

View file

@ -0,0 +1,25 @@
//! Admin API module.
#[cfg(feature = "ssr")]
pub mod auth;
#[cfg(feature = "ssr")]
pub mod config;
#[cfg(feature = "ssr")]
pub mod dashboard;
#[cfg(feature = "ssr")]
pub mod props;
#[cfg(feature = "ssr")]
pub mod realms;
#[cfg(feature = "ssr")]
pub mod routes;
#[cfg(feature = "ssr")]
pub mod scenes;
#[cfg(feature = "ssr")]
pub mod spots;
#[cfg(feature = "ssr")]
pub mod staff;
#[cfg(feature = "ssr")]
pub mod users;
#[cfg(feature = "ssr")]
pub use routes::admin_api_router;

View file

@ -0,0 +1,296 @@
//! Admin authentication API handlers.
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use tower_sessions::Session;
use crate::auth::ADMIN_SESSION_STAFF_ID_KEY;
/// Login request body.
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
/// Login response body.
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub success: bool,
pub username: String,
pub display_name: String,
}
/// Error response body.
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
}
/// Staff member row for login lookup.
#[derive(Debug, sqlx::FromRow)]
struct StaffLoginRow {
user_id: uuid::Uuid,
username: String,
display_name: String,
}
/// Login handler for server staff.
///
/// Authenticates staff member and creates a session.
pub async fn login(
State(pool): State<PgPool>,
session: Session,
Json(request): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
// Look up the staff member
let staff: Option<StaffLoginRow> = sqlx::query_as(
r#"
SELECT
u.id as user_id,
u.username,
u.display_name
FROM auth.users u
JOIN server.staff s ON s.user_id = u.id
WHERE u.username = $1
"#,
)
.bind(&request.username)
.fetch_optional(&pool)
.await
.map_err(|e| {
tracing::error!("Database error during login: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Internal server error".to_string(),
}),
)
})?;
let staff = match staff {
Some(s) => s,
None => {
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Invalid username or password".to_string(),
}),
));
}
};
// Verify password using Argon2
let password_hash: Option<String> = sqlx::query_scalar(
r#"
SELECT password_hash
FROM auth.users
WHERE id = $1
"#,
)
.bind(staff.user_id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database error during password check: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Internal server error".to_string(),
}),
)
})?;
let password_hash = match password_hash {
Some(h) => h,
None => {
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Invalid username or password".to_string(),
}),
));
}
};
// Verify password with Argon2
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let parsed_hash = PasswordHash::new(&password_hash).map_err(|e| {
tracing::error!("Password hash parse error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Internal server error".to_string(),
}),
)
})?;
if Argon2::default()
.verify_password(request.password.as_bytes(), &parsed_hash)
.is_err()
{
return Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Invalid username or password".to_string(),
}),
));
}
// Check if user is suspended or banned
let status: String = sqlx::query_scalar(r#"SELECT status::text FROM auth.users WHERE id = $1"#)
.bind(staff.user_id)
.fetch_one(&pool)
.await
.map_err(|e| {
tracing::error!("Database error during status check: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Internal server error".to_string(),
}),
)
})?;
if status != "active" {
return Err((
StatusCode::FORBIDDEN,
Json(ErrorResponse {
error: format!("Account is {}", status),
}),
));
}
// Create session
session
.insert(ADMIN_SESSION_STAFF_ID_KEY, staff.user_id)
.await
.map_err(|e| {
tracing::error!("Session error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Session error".to_string(),
}),
)
})?;
Ok(Json(LoginResponse {
success: true,
username: staff.username,
display_name: staff.display_name,
}))
}
/// Logout handler.
///
/// Clears the session.
pub async fn logout(session: Session) -> Result<Json<serde_json::Value>, StatusCode> {
session.flush().await.map_err(|e| {
tracing::error!("Session flush error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "success": true })))
}
// =============================================================================
// Auth Context Types (shared between SSR and hydrate)
// =============================================================================
/// Realm info for auth context.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ManagedRealm {
pub slug: String,
pub name: String,
}
/// Auth context response for the frontend.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuthContextResponse {
pub is_server_staff: bool,
pub managed_realms: Vec<ManagedRealm>,
}
/// Get auth context endpoint.
///
/// Returns the current user's permissions for rendering the sidebar.
pub async fn get_auth_context(
State(pool): State<PgPool>,
session: Session,
) -> Result<Json<AuthContextResponse>, (StatusCode, Json<ErrorResponse>)> {
// Try to get staff_id from session (server staff)
let staff_id: Option<uuid::Uuid> = session
.get(ADMIN_SESSION_STAFF_ID_KEY)
.await
.ok()
.flatten();
if let Some(staff_id) = staff_id {
// Check if this is actually a staff member
let is_staff: Option<bool> = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM server.staff WHERE user_id = $1)",
)
.bind(staff_id)
.fetch_one(&pool)
.await
.ok();
if is_staff == Some(true) {
return Ok(Json(AuthContextResponse {
is_server_staff: true,
managed_realms: vec![],
}));
}
}
// Try to get user_id from session (realm admin)
let user_id: Option<uuid::Uuid> = session
.get(crate::auth::SESSION_USER_ID_KEY)
.await
.ok()
.flatten();
if let Some(user_id) = user_id {
// Get realms where this user has admin privileges (owner, moderator, builder)
let realms: Vec<ManagedRealm> = sqlx::query_as::<_, (String, String)>(
r#"
SELECT r.slug, r.name
FROM realm.realms r
JOIN realm.memberships m ON m.realm_id = r.id
WHERE m.user_id = $1
AND m.role IN ('owner', 'moderator', 'builder')
ORDER BY r.name
"#,
)
.bind(user_id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Database error fetching managed realms: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Internal server error".to_string(),
}),
)
})?
.into_iter()
.map(|(slug, name)| ManagedRealm { slug, name })
.collect();
return Ok(Json(AuthContextResponse {
is_server_staff: false,
managed_realms: realms,
}));
}
// No valid session
Err((
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "Not authenticated".to_string(),
}),
))
}

View file

@ -0,0 +1,27 @@
//! Server config API handlers.
use axum::{extract::State, Json};
use chattyness_db::{
models::{ServerConfig, UpdateServerConfigRequest},
queries::owner as queries,
};
use chattyness_error::AppError;
use sqlx::PgPool;
/// Get server config.
pub async fn get_config(
State(pool): State<PgPool>,
) -> Result<Json<ServerConfig>, AppError> {
let config = queries::get_server_config(&pool).await?;
Ok(Json(config))
}
/// Update server config.
pub async fn update_config(
State(pool): State<PgPool>,
Json(req): Json<UpdateServerConfigRequest>,
) -> Result<Json<ServerConfig>, AppError> {
req.validate()?;
let config = queries::update_server_config(&pool, &req).await?;
Ok(Json(config))
}

View file

@ -0,0 +1,53 @@
//! Dashboard API handlers.
use axum::{extract::State, Json};
use chattyness_error::AppError;
use serde::Serialize;
use sqlx::PgPool;
/// Dashboard stats response.
#[derive(Debug, Serialize)]
pub struct DashboardStats {
pub total_users: i64,
pub active_users: i64,
pub total_realms: i64,
pub online_users: i64,
pub staff_count: i64,
}
/// Get dashboard stats.
pub async fn get_stats(
State(pool): State<PgPool>,
) -> Result<Json<DashboardStats>, AppError> {
// Total users
let total_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM auth.users")
.fetch_one(&pool)
.await?;
// Active users
let active_users: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM auth.users WHERE status = 'active'")
.fetch_one(&pool)
.await?;
// Total realms
let total_realms: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM realm.realms")
.fetch_one(&pool)
.await?;
// Staff count
let staff_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server.staff")
.fetch_one(&pool)
.await?;
// Online users would require presence tracking - hardcoded to 0 for now
let online_users = 0;
Ok(Json(DashboardStats {
total_users,
active_users,
total_realms,
online_users,
staff_count,
}))
}

View file

@ -0,0 +1,220 @@
//! Props management API handlers for admin UI.
use axum::extract::State;
use axum::Json;
use axum_extra::extract::Multipart;
use chattyness_db::{
models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
queries::props,
};
use chattyness_error::AppError;
use serde::Serialize;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::path::PathBuf;
use uuid::Uuid;
// =============================================================================
// API Types
// =============================================================================
/// Response for prop creation.
#[derive(Debug, Serialize)]
pub struct CreatePropResponse {
pub id: Uuid,
pub name: String,
pub slug: String,
pub asset_path: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<ServerProp> for CreatePropResponse {
fn from(prop: ServerProp) -> Self {
Self {
id: prop.id,
name: prop.name,
slug: prop.slug,
asset_path: prop.asset_path,
created_at: prop.created_at,
}
}
}
// =============================================================================
// File Handling
// =============================================================================
/// Validate and get file extension from filename.
fn validate_file_extension(filename: &str) -> Result<&'static str, AppError> {
let ext = filename
.rsplit('.')
.next()
.map(|e| e.to_lowercase())
.unwrap_or_default();
match ext.as_str() {
"svg" => Ok("svg"),
"png" => Ok("png"),
_ => Err(AppError::Validation(
"File must be SVG or PNG".to_string(),
)),
}
}
/// Store uploaded file and return the asset path.
async fn store_prop_file(bytes: &[u8], extension: &str) -> Result<String, AppError> {
// Compute SHA256 hash of the file content
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hex::encode(hasher.finalize());
// Create directory structure: /srv/chattyness/assets/server/
let dir_path = PathBuf::from("/srv/chattyness/assets/server");
tokio::fs::create_dir_all(&dir_path)
.await
.map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?;
// Write the file with SHA256 hash as filename
let filename = format!("{}.{}", hash, extension);
let file_path = dir_path.join(&filename);
tokio::fs::write(&file_path, bytes)
.await
.map_err(|e| AppError::Internal(format!("Failed to write file: {}", e)))?;
// Return the relative path for database storage
Ok(format!("server/{}", filename))
}
// =============================================================================
// API Handlers
// =============================================================================
/// List all server props.
pub async fn list_props(State(pool): State<PgPool>) -> Result<Json<Vec<ServerPropSummary>>, AppError> {
let prop_list = props::list_server_props(&pool).await?;
Ok(Json(prop_list))
}
/// Create a new server prop via multipart upload.
///
/// Expects multipart form with:
/// - `metadata`: JSON object with prop details (CreateServerPropRequest)
/// - `file`: Binary SVG or PNG file
pub async fn create_prop(
State(pool): State<PgPool>,
mut multipart: Multipart,
) -> Result<Json<CreatePropResponse>, AppError> {
let mut metadata: Option<CreateServerPropRequest> = None;
let mut file_data: Option<(Vec<u8>, String)> = None; // (bytes, extension)
// Parse multipart fields
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::Validation(format!("Failed to read multipart field: {}", e)))?
{
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"metadata" => {
let text = field
.text()
.await
.map_err(|e| AppError::Validation(format!("Failed to read metadata: {}", e)))?;
metadata = Some(serde_json::from_str(&text).map_err(|e| {
AppError::Validation(format!("Invalid metadata JSON: {}", e))
})?);
}
"file" => {
let filename = field
.file_name()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown.png".to_string());
let extension = validate_file_extension(&filename)?;
let bytes = field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("Failed to read file: {}", e)))?;
if bytes.is_empty() {
return Err(AppError::Validation("File is empty".to_string()));
}
file_data = Some((bytes.to_vec(), extension.to_string()));
}
_ => {
// Ignore unknown fields
}
}
}
// Validate we have both required fields
let metadata = metadata.ok_or_else(|| {
AppError::Validation("Missing 'metadata' field in multipart form".to_string())
})?;
let (file_bytes, extension) = file_data.ok_or_else(|| {
AppError::Validation("Missing 'file' field in multipart form".to_string())
})?;
// Validate the request
metadata.validate()?;
// Check slug availability
let slug = metadata.slug_or_generate();
let available = props::is_prop_slug_available(&pool, &slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Prop slug '{}' is already taken",
slug
)));
}
// Store the file
let asset_path = store_prop_file(&file_bytes, &extension).await?;
// Create the prop in database
let prop = props::create_server_prop(&pool, &metadata, &asset_path, None).await?;
tracing::info!("Created server prop: {} ({})", prop.name, prop.id);
Ok(Json(CreatePropResponse::from(prop)))
}
/// Get a server prop by ID.
pub async fn get_prop(
State(pool): State<PgPool>,
axum::extract::Path(prop_id): axum::extract::Path<Uuid>,
) -> Result<Json<ServerProp>, AppError> {
let prop = props::get_server_prop_by_id(&pool, prop_id)
.await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
Ok(Json(prop))
}
/// Delete a server prop.
pub async fn delete_prop(
State(pool): State<PgPool>,
axum::extract::Path(prop_id): axum::extract::Path<Uuid>,
) -> Result<Json<()>, AppError> {
// Get the prop first to get the asset path
let prop = props::get_server_prop_by_id(&pool, prop_id)
.await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
// Delete from database
props::delete_server_prop(&pool, prop_id).await?;
// Try to delete the file (don't fail if file doesn't exist)
let file_path = PathBuf::from("/srv/chattyness/assets").join(&prop.asset_path);
tokio::fs::remove_file(&file_path).await.ok();
tracing::info!("Deleted server prop: {} ({})", prop.name, prop_id);
Ok(Json(()))
}

View file

@ -0,0 +1,133 @@
//! Realm management API handlers.
use axum::{
extract::{Path, Query, State},
Json,
};
use chattyness_db::{
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
queries::owner as queries,
};
use chattyness_error::AppError;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Create realm response.
#[derive(Debug, Serialize)]
pub struct CreateRealmResponse {
pub realm_id: Uuid,
pub slug: String,
pub owner_id: Uuid,
pub owner_temporary_password: Option<String>,
}
/// Transfer ownership request.
#[derive(Debug, Deserialize)]
pub struct TransferOwnershipRequest {
pub new_owner_id: Uuid,
}
/// List query params.
#[derive(Debug, Deserialize)]
pub struct ListRealmsQuery {
pub q: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List realms with optional search.
pub async fn list_realms(
State(pool): State<PgPool>,
Query(query): Query<ListRealmsQuery>,
) -> Result<Json<Vec<RealmListItem>>, AppError> {
let limit = query.limit.unwrap_or(25).min(100);
let offset = query.offset.unwrap_or(0);
let realms = if let Some(ref q) = query.q {
queries::search_realms(&pool, q, limit).await?
} else {
queries::list_realms_with_owner(&pool, limit, offset).await?
};
Ok(Json(realms))
}
/// Get a realm by slug.
pub async fn get_realm(
State(pool): State<PgPool>,
Path(slug): Path<String>,
) -> Result<Json<RealmDetail>, AppError> {
let realm = queries::get_realm_by_slug(&pool, &slug).await?;
Ok(Json(realm))
}
/// Create a new realm.
pub async fn create_realm(
State(pool): State<PgPool>,
Json(req): Json<OwnerCreateRealmRequest>,
) -> Result<Json<CreateRealmResponse>, AppError> {
req.validate()?;
// If owner_id is provided, create realm with existing user
if let Some(owner_id) = req.owner_id {
let realm_id = queries::create_realm(
&pool,
owner_id,
&req.name,
&req.slug,
req.description.as_deref(),
req.tagline.as_deref(),
req.privacy,
req.is_nsfw,
req.max_users,
req.allow_guest_access,
req.theme_color.as_deref(),
)
.await?;
Ok(Json(CreateRealmResponse {
realm_id,
slug: req.slug,
owner_id,
owner_temporary_password: None,
}))
} else {
// Create realm with new user as owner
let (realm_id, user_id, temporary_password) =
queries::create_realm_with_new_owner(&pool, &req).await?;
Ok(Json(CreateRealmResponse {
realm_id,
slug: req.slug,
owner_id: user_id,
owner_temporary_password: Some(temporary_password),
}))
}
}
/// Update a realm.
pub async fn update_realm(
State(pool): State<PgPool>,
Path(slug): Path<String>,
Json(req): Json<UpdateRealmRequest>,
) -> Result<Json<RealmDetail>, AppError> {
req.validate()?;
// First get the realm to find its ID
let existing = queries::get_realm_by_slug(&pool, &slug).await?;
let realm = queries::update_realm(&pool, existing.id, &req).await?;
Ok(Json(realm))
}
/// Transfer realm ownership.
pub async fn transfer_ownership(
State(pool): State<PgPool>,
Path(slug): Path<String>,
Json(req): Json<TransferOwnershipRequest>,
) -> Result<Json<()>, AppError> {
// First get the realm to find its ID
let existing = queries::get_realm_by_slug(&pool, &slug).await?;
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
Ok(Json(()))
}

View file

@ -0,0 +1,96 @@
//! Admin API routes.
use axum::{
routing::{delete, get, post, put},
Router,
};
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
use crate::app::AdminAppState;
/// Create the admin API router.
///
/// Note: HTML pages are handled by Leptos - this router only contains API endpoints.
pub fn admin_api_router() -> Router<AdminAppState> {
Router::new()
// API - Health
.route("/health", get(health_check))
// API - Dashboard
.route("/dashboard/stats", get(dashboard::get_stats))
// API - Auth
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/context", get(auth::get_auth_context))
// API - Config
.route(
"/config",
get(config::get_config).put(config::update_config),
)
// API - Staff
.route("/staff", get(staff::list_staff).post(staff::create_staff))
.route("/staff/{user_id}", delete(staff::delete_staff))
// API - Users
.route("/users", get(users::list_users).post(users::create_user))
.route("/users/search", get(users::search_users))
.route("/users/{user_id}", get(users::get_user))
.route("/users/{user_id}/status", put(users::update_status))
.route(
"/users/{user_id}/reset-password",
post(users::reset_password),
)
.route(
"/users/{user_id}/realms",
get(users::get_user_realms).post(users::add_to_realm),
)
.route(
"/users/{user_id}/realms/{realm_id}",
delete(users::remove_from_realm),
)
// API - Realms
.route(
"/realms",
get(realms::list_realms).post(realms::create_realm),
)
.route("/realms/simple", get(users::list_realms))
.route(
"/realms/{slug}",
get(realms::get_realm).put(realms::update_realm),
)
.route(
"/realms/{slug}/transfer",
post(realms::transfer_ownership),
)
// API - Scenes
.route(
"/realms/{slug}/scenes",
get(scenes::list_scenes).post(scenes::create_scene),
)
.route(
"/scenes/{scene_id}",
get(scenes::get_scene)
.put(scenes::update_scene)
.delete(scenes::delete_scene),
)
// API - Spots
.route(
"/scenes/{scene_id}/spots",
get(spots::list_spots).post(spots::create_spot),
)
.route(
"/spots/{spot_id}",
get(spots::get_spot)
.put(spots::update_spot)
.delete(spots::delete_spot),
)
// API - Props (server-wide)
.route("/props", get(props::list_props).post(props::create_prop))
.route(
"/props/{prop_id}",
get(props::get_prop).delete(props::delete_prop),
)
}
/// Health check endpoint.
async fn health_check() -> &'static str {
"Admin API OK"
}

View file

@ -0,0 +1,317 @@
//! Scene management API handlers for admin UI.
use axum::{
extract::{Path, State},
Json,
};
use chattyness_db::{
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
queries::{realms, scenes},
};
use chattyness_error::AppError;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::path::PathBuf;
use uuid::Uuid;
// =============================================================================
// Image Processing Helpers
// =============================================================================
/// Result of downloading and storing a background image.
struct ImageDownloadResult {
/// The local path to the stored image (relative to static root, for URL).
local_path: String,
/// Image dimensions if requested.
dimensions: Option<(u32, u32)>,
}
/// Download an image from a URL and store it locally.
///
/// Returns the local path and optionally the dimensions.
/// Path format: /static/realm/{realm_id}/scene/{scene_id}/{sha256}.{ext}
async fn download_and_store_image(
url: &str,
realm_id: Uuid,
scene_id: Uuid,
extract_dimensions: bool,
) -> Result<ImageDownloadResult, AppError> {
use sha2::{Digest, Sha256};
// Validate URL
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(AppError::Validation(
"Image URL must start with http:// or https://".to_string(),
));
}
// Download the image
let client = reqwest::Client::new();
let response = client
.get(url)
.header(
reqwest::header::USER_AGENT,
"Chattyness/1.0 (Background image downloader)",
)
.header(reqwest::header::ACCEPT, "image/*")
.send()
.await
.map_err(|e| AppError::Internal(format!("Failed to fetch image: {}", e)))?;
// Check content type
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// Determine extension from content type
let ext = match content_type {
t if t.starts_with("image/jpeg") => "jpg",
t if t.starts_with("image/png") => "png",
t if t.starts_with("image/gif") => "gif",
t if t.starts_with("image/webp") => "webp",
_ => {
// Try to infer from URL
if url.contains(".jpg") || url.contains(".jpeg") {
"jpg"
} else if url.contains(".png") {
"png"
} else if url.contains(".gif") {
"gif"
} else if url.contains(".webp") {
"webp"
} else {
return Err(AppError::Validation(format!(
"Unsupported image type: {}",
content_type
)));
}
}
};
// Get the image bytes
let bytes = response
.bytes()
.await
.map_err(|e| AppError::Internal(format!("Failed to read image data: {}", e)))?;
// Compute SHA256 hash of the image content
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = hex::encode(hasher.finalize());
// Extract dimensions if requested
let dimensions = if extract_dimensions {
let reader = image::ImageReader::new(std::io::Cursor::new(&bytes))
.with_guessed_format()
.map_err(|e| AppError::Internal(format!("Failed to detect image format: {}", e)))?;
let dims = reader
.into_dimensions()
.map_err(|e| AppError::Internal(format!("Failed to read image dimensions: {}", e)))?;
Some(dims)
} else {
None
};
// Create directory structure: /srv/chattyness/assets/realm/{realm_id}/scene/{scene_id}/
let dir_path = PathBuf::from("/srv/chattyness/assets")
.join("realm")
.join(realm_id.to_string())
.join("scene")
.join(scene_id.to_string());
tokio::fs::create_dir_all(&dir_path)
.await
.map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?;
// Write the file with SHA256 hash as filename
let filename = format!("{}.{}", hash, ext);
let file_path = dir_path.join(&filename);
tokio::fs::write(&file_path, &bytes)
.await
.map_err(|e| AppError::Internal(format!("Failed to write image file: {}", e)))?;
// Return the URL path (relative to server root)
let local_path = format!(
"/static/realm/{}/scene/{}/{}",
realm_id, scene_id, filename
);
Ok(ImageDownloadResult {
local_path,
dimensions,
})
}
/// Delete all image files for a scene.
async fn delete_scene_images(realm_id: Uuid, scene_id: Uuid) -> Result<(), AppError> {
let dir_path = PathBuf::from("/srv/chattyness/assets")
.join("realm")
.join(realm_id.to_string())
.join("scene")
.join(scene_id.to_string());
// Try to remove all files in the directory
if let Ok(mut entries) = tokio::fs::read_dir(&dir_path).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.is_file() {
tokio::fs::remove_file(&path).await.ok();
}
}
}
Ok(())
}
// =============================================================================
// API Types
// =============================================================================
/// Query parameters for scene list.
#[derive(Debug, Deserialize)]
pub struct ListScenesQuery {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List all scenes for a realm.
pub async fn list_scenes(
State(pool): State<PgPool>,
Path(slug): Path<String>,
) -> Result<Json<Vec<SceneSummary>>, AppError> {
// Get the realm
let realm = realms::get_realm_by_slug(&pool, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
let scene_list = scenes::list_scenes_for_realm(&pool, realm.id).await?;
Ok(Json(scene_list))
}
/// Get a scene by ID.
pub async fn get_scene(
State(pool): State<PgPool>,
Path(scene_id): Path<Uuid>,
) -> Result<Json<Scene>, AppError> {
let scene = scenes::get_scene_by_id(&pool, scene_id)
.await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
Ok(Json(scene))
}
/// Create scene response.
#[derive(Debug, Serialize)]
pub struct CreateSceneResponse {
pub id: Uuid,
pub slug: String,
}
/// Create a new scene in a realm.
pub async fn create_scene(
State(pool): State<PgPool>,
Path(slug): Path<String>,
Json(mut req): Json<CreateSceneRequest>,
) -> Result<Json<CreateSceneResponse>, AppError> {
// Get the realm
let realm = realms::get_realm_by_slug(&pool, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
// Check if slug is available
let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Scene slug '{}' is already taken in this realm",
req.slug
)));
}
// Generate a temporary scene ID for image storage path
let scene_id = Uuid::new_v4();
// Handle background image URL - download and store locally
if let Some(ref url) = req.background_image_url {
if !url.is_empty() {
let result = download_and_store_image(
url,
realm.id,
scene_id,
req.infer_dimensions_from_image,
)
.await?;
req.background_image_path = Some(result.local_path);
if let Some((width, height)) = result.dimensions {
req.bounds_wkt = Some(format!(
"POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))",
width, width, height, height
));
}
}
}
let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?;
Ok(Json(CreateSceneResponse {
id: scene.id,
slug: scene.slug,
}))
}
/// Update a scene.
pub async fn update_scene(
State(pool): State<PgPool>,
Path(scene_id): Path<Uuid>,
Json(mut req): Json<UpdateSceneRequest>,
) -> Result<Json<Scene>, AppError> {
// Get the existing scene to get realm_id
let existing_scene = scenes::get_scene_by_id(&pool, scene_id)
.await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
// Handle clear background image
if req.clear_background_image {
delete_scene_images(existing_scene.realm_id, scene_id).await?;
req.background_image_path = Some(String::new());
}
// Handle new background image URL - download and store locally
else if let Some(ref url) = req.background_image_url {
if !url.is_empty() {
delete_scene_images(existing_scene.realm_id, scene_id).await?;
let result = download_and_store_image(
url,
existing_scene.realm_id,
scene_id,
req.infer_dimensions_from_image,
)
.await?;
req.background_image_path = Some(result.local_path);
if let Some((width, height)) = result.dimensions {
req.bounds_wkt = Some(format!(
"POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))",
width, width, height, height
));
}
}
}
let scene = scenes::update_scene(&pool, scene_id, &req).await?;
Ok(Json(scene))
}
/// Delete a scene.
pub async fn delete_scene(
State(pool): State<PgPool>,
Path(scene_id): Path<Uuid>,
) -> Result<Json<()>, AppError> {
scenes::delete_scene(&pool, scene_id).await?;
Ok(Json(()))
}

View file

@ -0,0 +1,97 @@
//! Spot management API handlers for admin UI.
use axum::{
extract::{Path, State},
Json,
};
use chattyness_db::{
models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest},
queries::spots,
};
use chattyness_error::AppError;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
/// List all spots for a scene.
pub async fn list_spots(
State(pool): State<PgPool>,
Path(scene_id): Path<Uuid>,
) -> Result<Json<Vec<SpotSummary>>, AppError> {
let spot_list = spots::list_spots_for_scene(&pool, scene_id).await?;
Ok(Json(spot_list))
}
/// Get a spot by ID.
pub async fn get_spot(
State(pool): State<PgPool>,
Path(spot_id): Path<Uuid>,
) -> Result<Json<Spot>, AppError> {
let spot = spots::get_spot_by_id(&pool, spot_id)
.await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
Ok(Json(spot))
}
/// Create spot response.
#[derive(Debug, Serialize)]
pub struct CreateSpotResponse {
pub id: Uuid,
}
/// Create a new spot in a scene.
pub async fn create_spot(
State(pool): State<PgPool>,
Path(scene_id): Path<Uuid>,
Json(req): Json<CreateSpotRequest>,
) -> Result<Json<CreateSpotResponse>, AppError> {
// Check if slug is available (if provided)
if let Some(ref slug) = req.slug {
let available = spots::is_spot_slug_available(&pool, scene_id, slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Spot slug '{}' is already taken in this scene",
slug
)));
}
}
let spot = spots::create_spot(&pool, scene_id, &req).await?;
Ok(Json(CreateSpotResponse { id: spot.id }))
}
/// Update a spot.
pub async fn update_spot(
State(pool): State<PgPool>,
Path(spot_id): Path<Uuid>,
Json(req): Json<UpdateSpotRequest>,
) -> Result<Json<Spot>, AppError> {
// If updating slug, check availability
if let Some(ref new_slug) = req.slug {
let existing = spots::get_spot_by_id(&pool, spot_id)
.await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
if Some(new_slug.clone()) != existing.slug {
let available = spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Spot slug '{}' is already taken in this scene",
new_slug
)));
}
}
}
let spot = spots::update_spot(&pool, spot_id, &req).await?;
Ok(Json(spot))
}
/// Delete a spot.
pub async fn delete_spot(
State(pool): State<PgPool>,
Path(spot_id): Path<Uuid>,
) -> Result<Json<()>, AppError> {
spots::delete_spot(&pool, spot_id).await?;
Ok(Json(()))
}

View file

@ -0,0 +1,71 @@
//! Staff management API handlers.
use axum::{
extract::{Path, State},
Json,
};
use chattyness_db::{
models::{CreateStaffRequest, StaffMember},
queries::owner as queries,
};
use chattyness_error::AppError;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
/// Create staff response.
#[derive(Debug, Serialize)]
pub struct CreateStaffResponse {
pub staff: StaffMember,
pub temporary_password: Option<String>,
}
/// List all staff members.
pub async fn list_staff(
State(pool): State<PgPool>,
) -> Result<Json<Vec<StaffMember>>, AppError> {
let staff = queries::get_all_staff(&pool).await?;
Ok(Json(staff))
}
/// Create a new staff member.
pub async fn create_staff(
State(pool): State<PgPool>,
Json(req): Json<CreateStaffRequest>,
) -> Result<Json<CreateStaffResponse>, AppError> {
req.validate()?;
// If user_id is provided, promote existing user
if let Some(user_id) = req.user_id {
let staff = queries::create_staff(&pool, user_id, req.role, None).await?;
Ok(Json(CreateStaffResponse {
staff,
temporary_password: None,
}))
} else if let Some(ref new_user) = req.new_user {
// Create new user and promote to staff
let (user_id, temporary_password) = queries::create_user(
&pool,
new_user,
)
.await?;
let staff = queries::create_staff(&pool, user_id, req.role, None).await?;
Ok(Json(CreateStaffResponse {
staff,
temporary_password: Some(temporary_password),
}))
} else {
Err(AppError::Validation(
"Must provide either user_id or new_user".to_string(),
))
}
}
/// Delete a staff member.
pub async fn delete_staff(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<()>, AppError> {
queries::delete_staff(&pool, user_id).await?;
Ok(Json(()))
}

View file

@ -0,0 +1,161 @@
//! User management API handlers.
use axum::{
extract::{Path, Query, State},
Json,
};
use chattyness_db::{
models::{
AccountStatus, CreateUserRequest, RealmRole, RealmSummary, UserDetail, UserListItem,
UserRealmMembership,
},
queries::owner as queries,
};
use chattyness_error::AppError;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Response for user creation.
#[derive(Debug, Serialize)]
pub struct CreateUserResponse {
pub id: Uuid,
pub username: String,
pub temporary_password: String,
}
/// Response for password reset.
#[derive(Debug, Serialize)]
pub struct PasswordResetResponse {
pub user_id: Uuid,
pub temporary_password: String,
}
/// Query parameters for user list.
#[derive(Debug, Deserialize)]
pub struct ListUsersQuery {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// Query parameters for user search.
#[derive(Debug, Deserialize)]
pub struct SearchUsersQuery {
pub q: String,
pub limit: Option<i64>,
}
/// List all users with pagination.
pub async fn list_users(
State(pool): State<PgPool>,
Query(query): Query<ListUsersQuery>,
) -> Result<Json<Vec<UserListItem>>, AppError> {
let limit = query.limit.unwrap_or(25).min(100);
let offset = query.offset.unwrap_or(0);
let users = queries::list_users(&pool, limit, offset).await?;
Ok(Json(users))
}
/// Create a new user (optionally with staff role).
pub async fn create_user(
State(pool): State<PgPool>,
Json(req): Json<CreateUserRequest>,
) -> Result<Json<CreateUserResponse>, AppError> {
req.validate()?;
let (user_id, temporary_password) = queries::create_user_with_staff(&pool, &req).await?;
Ok(Json(CreateUserResponse {
id: user_id,
username: req.username,
temporary_password,
}))
}
/// Reset user's password to a random token.
pub async fn reset_password(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<PasswordResetResponse>, AppError> {
let temporary_password = queries::reset_user_password(&pool, user_id).await?;
Ok(Json(PasswordResetResponse {
user_id,
temporary_password,
}))
}
/// Search users by username, email, or display name.
pub async fn search_users(
State(pool): State<PgPool>,
Query(query): Query<SearchUsersQuery>,
) -> Result<Json<Vec<UserListItem>>, AppError> {
let limit = query.limit.unwrap_or(10).min(50);
let users = queries::search_users(&pool, &query.q, limit).await?;
Ok(Json(users))
}
/// Get user detail by ID.
pub async fn get_user(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<UserDetail>, AppError> {
let user = queries::get_user_detail(&pool, user_id).await?;
Ok(Json(user))
}
/// Update status request.
#[derive(Debug, Deserialize)]
pub struct UpdateStatusRequest {
pub status: AccountStatus,
}
/// Update user's account status.
pub async fn update_status(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
Json(req): Json<UpdateStatusRequest>,
) -> Result<Json<UserDetail>, AppError> {
let user = queries::update_user_status(&pool, user_id, req.status).await?;
Ok(Json(user))
}
/// Get user's realm memberships.
pub async fn get_user_realms(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<UserRealmMembership>>, AppError> {
let memberships = queries::get_user_realms(&pool, user_id).await?;
Ok(Json(memberships))
}
/// Add to realm request.
#[derive(Debug, Deserialize)]
pub struct AddToRealmRequestBody {
pub realm_id: Uuid,
pub role: RealmRole,
}
/// Add user to a realm.
pub async fn add_to_realm(
State(pool): State<PgPool>,
Path(user_id): Path<Uuid>,
Json(req): Json<AddToRealmRequestBody>,
) -> Result<Json<()>, AppError> {
queries::add_user_to_realm(&pool, user_id, req.realm_id, req.role).await?;
Ok(Json(()))
}
/// Remove user from a realm.
pub async fn remove_from_realm(
State(pool): State<PgPool>,
Path((user_id, realm_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<()>, AppError> {
queries::remove_user_from_realm(&pool, user_id, realm_id).await?;
Ok(Json(()))
}
/// List all realms (for dropdown).
pub async fn list_realms(
State(pool): State<PgPool>,
) -> Result<Json<Vec<RealmSummary>>, AppError> {
let realms = queries::list_all_realms(&pool).await?;
Ok(Json(realms))
}

View file

@ -0,0 +1,78 @@
//! Admin Leptos application root and router.
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
use leptos_router::components::Router;
use crate::routes::AdminRoutes;
/// Application state for the admin app.
///
/// Note: We intentionally don't derive `FromRef` because both pools are
/// the same type (`PgPool`), which would cause a conflicting implementation.
/// Instead, handlers should use Extension extractors for the pools.
#[cfg(feature = "ssr")]
#[derive(Clone)]
pub struct AdminAppState {
/// The primary database pool for this admin instance.
/// For Owner App: chattyness_owner pool (no RLS)
/// For Admin App: chattyness_app pool (RLS enforced)
pub pool: sqlx::PgPool,
pub leptos_options: LeptosOptions,
}
#[cfg(feature = "ssr")]
impl axum::extract::FromRef<AdminAppState> for LeptosOptions {
fn from_ref(state: &AdminAppState) -> Self {
state.leptos_options.clone()
}
}
#[cfg(feature = "ssr")]
impl axum::extract::FromRef<AdminAppState> for sqlx::PgPool {
fn from_ref(state: &AdminAppState) -> Self {
state.pool.clone()
}
}
/// Shell component for SSR.
///
/// The `data-app="admin"` attribute tells the WASM hydration script to mount
/// AdminApp.
pub fn admin_shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body class="admin-app" data-app="admin">
<AdminApp />
</body>
</html>
}
}
/// Main admin application component.
///
/// This wraps `AdminRoutes` with a `Router` for standalone use (e.g., chattyness-owner).
/// Routes are nested under `/admin` to match the link paths used in page components.
/// For embedding in a combined app (e.g., chattyness-app), use `AdminRoutes` directly.
#[component]
pub fn AdminApp() -> impl IntoView {
// Provide meta context for title and meta tags
provide_meta_context();
view! {
<Stylesheet id="admin-styles" href="/static/css/admin.css" />
<Title text="Chattyness Admin Panel" />
<Router base="/admin">
<AdminRoutes />
</Router>
}
}

View file

@ -0,0 +1,195 @@
//! Admin authentication module.
//!
//! Dual-mode authentication for the admin interface:
//! - Server staff: Uses chattyness_owner pool (bypasses RLS, full access)
//! - Realm admins: Uses chattyness_app pool (RLS enforces permissions)
#[cfg(feature = "ssr")]
use axum::{
http::StatusCode,
response::{IntoResponse, Redirect, Response},
};
#[cfg(feature = "ssr")]
use sqlx::PgPool;
#[cfg(feature = "ssr")]
use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer};
#[cfg(feature = "ssr")]
use tower_sessions_sqlx_store::PostgresStore;
#[cfg(feature = "ssr")]
use uuid::Uuid;
#[cfg(feature = "ssr")]
use chattyness_db::models::{RealmRole, ServerRole, StaffMember, User};
#[cfg(feature = "ssr")]
use chattyness_error::AppError;
// =============================================================================
// Session Constants
// =============================================================================
/// Admin session cookie name.
pub const ADMIN_SESSION_COOKIE_NAME: &str = "chattyness_admin_session";
/// Staff ID key in admin session.
pub const ADMIN_SESSION_STAFF_ID_KEY: &str = "staff_id";
/// User ID key in session (for realm admins coming from app).
pub const SESSION_USER_ID_KEY: &str = "user_id";
// =============================================================================
// Admin Authentication
// =============================================================================
/// Realm admin role information.
#[cfg(feature = "ssr")]
#[derive(Debug, Clone)]
pub struct RealmAdminRole {
pub realm_id: Uuid,
pub realm_slug: String,
pub realm_name: String,
pub role: RealmRole,
}
/// Authenticated admin - either server staff or realm admin.
///
/// This enum determines which database pool to use:
/// - ServerStaff: Uses owner_pool (bypasses RLS)
/// - RealmAdmin: Uses app_pool (RLS enforces permissions)
#[cfg(feature = "ssr")]
#[derive(Debug, Clone)]
pub enum AdminAuth {
/// Server staff member - full access via owner pool.
ServerStaff { staff: StaffMember },
/// Realm admin (owner, moderator, or builder) - scoped access via app pool with RLS.
RealmAdmin {
user: User,
/// Realms where this user has admin privileges.
realm_roles: Vec<RealmAdminRole>,
},
}
#[cfg(feature = "ssr")]
impl AdminAuth {
/// Get the display name for the authenticated admin.
pub fn display_name(&self) -> &str {
match self {
AdminAuth::ServerStaff { staff } => &staff.display_name,
AdminAuth::RealmAdmin { user, .. } => &user.display_name,
}
}
/// Get the username for the authenticated admin.
pub fn username(&self) -> &str {
match self {
AdminAuth::ServerStaff { staff } => &staff.username,
AdminAuth::RealmAdmin { user, .. } => &user.username,
}
}
/// Check if this is a server staff member.
pub fn is_server_staff(&self) -> bool {
matches!(self, AdminAuth::ServerStaff { .. })
}
/// Check if this admin can access server-wide settings.
pub fn can_access_server_config(&self) -> bool {
match self {
AdminAuth::ServerStaff { staff } => {
matches!(staff.role, ServerRole::Owner | ServerRole::Admin)
}
AdminAuth::RealmAdmin { .. } => false,
}
}
/// Check if this admin can manage server staff.
pub fn can_manage_staff(&self) -> bool {
match self {
AdminAuth::ServerStaff { staff } => matches!(staff.role, ServerRole::Owner),
AdminAuth::RealmAdmin { .. } => false,
}
}
/// Check if this admin can view all users.
pub fn can_view_all_users(&self) -> bool {
matches!(self, AdminAuth::ServerStaff { .. })
}
/// Get the realms this admin can manage (empty for server staff who see all).
pub fn managed_realms(&self) -> &[RealmAdminRole] {
match self {
AdminAuth::ServerStaff { .. } => &[],
AdminAuth::RealmAdmin { realm_roles, .. } => realm_roles,
}
}
}
// =============================================================================
// Admin Auth Error
// =============================================================================
/// Admin authentication errors.
#[cfg(feature = "ssr")]
#[derive(Debug)]
pub enum AdminAuthError {
Unauthorized,
SessionError,
InternalError,
}
#[cfg(feature = "ssr")]
impl IntoResponse for AdminAuthError {
fn into_response(self) -> Response {
match self {
AdminAuthError::Unauthorized => {
// Redirect to login page instead of returning 401
Redirect::to("/admin/login").into_response()
}
AdminAuthError::SessionError | AdminAuthError::InternalError => {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
}
}
}
}
#[cfg(feature = "ssr")]
impl From<AdminAuthError> for AppError {
fn from(err: AdminAuthError) -> Self {
match err {
AdminAuthError::Unauthorized => AppError::Unauthorized,
AdminAuthError::SessionError => AppError::Internal("Session error".to_string()),
AdminAuthError::InternalError => AppError::Internal("Internal error".to_string()),
}
}
}
// =============================================================================
// Session Layer
// =============================================================================
/// Create the session management layer for admin interface.
#[cfg(feature = "ssr")]
pub async fn create_admin_session_layer(
pool: PgPool,
secure: bool,
) -> SessionManagerLayer<PostgresStore> {
let session_store = PostgresStore::new(pool)
.with_schema_name("auth")
.expect("Invalid schema name for session store")
.with_table_name("tower_sessions")
.expect("Invalid table name for session store");
// Create session table if it doesn't exist
if let Err(e) = session_store.migrate().await {
tracing::warn!(
"Admin session table migration failed (may already exist): {}",
e
);
}
SessionManagerLayer::new(session_store)
.with_name(ADMIN_SESSION_COOKIE_NAME)
.with_secure(secure)
.with_same_site(SameSite::Lax)
.with_http_only(true)
.with_expiry(Expiry::OnInactivity(Duration::hours(4)))
}

View file

@ -0,0 +1,729 @@
//! Admin-specific Leptos components.
use leptos::prelude::*;
// =============================================================================
// Auth Context Types (for sidebar rendering)
// These are duplicated from api/auth.rs because api is SSR-only
// =============================================================================
/// Realm info for auth context.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ManagedRealm {
pub slug: String,
pub name: String,
}
/// Auth context response for the frontend.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuthContextResponse {
pub is_server_staff: bool,
pub managed_realms: Vec<ManagedRealm>,
}
/// Admin layout with sidebar navigation.
///
/// Note: CSS must be loaded by the parent app:
/// - chattyness-owner: Loads `/static/chattyness-owner.css` in AdminApp
/// - chattyness-app: Loads `/admin.css` in lazy wrapper functions
#[component]
pub fn AdminLayout(
/// Current page identifier for nav highlighting
current_page: &'static str,
/// Base path for navigation links (e.g., "/admin")
#[prop(default = "/admin")]
base_path: &'static str,
/// Whether the user is server staff (shows all server-level options)
#[prop(default = false)]
is_server_staff: bool,
/// Realms this user can manage (slug, name pairs)
#[prop(default = vec![])]
managed_realms: Vec<(String, String)>,
/// Page content
children: Children,
) -> impl IntoView {
view! {
<div class="admin-layout">
<Sidebar
current_page=current_page
base_path=base_path
is_server_staff=is_server_staff
managed_realms=managed_realms
/>
<main class="admin-content">
{children()}
</main>
</div>
}
}
/// Login page layout (no sidebar).
#[component]
pub fn LoginLayout(children: Children) -> impl IntoView {
view! {
<div class="login-layout">
{children()}
</div>
}
}
/// Fetch auth context from API (for client-side use).
///
/// The API path is determined dynamically based on the current URL:
/// - If at `/admin/...`, uses `/api/admin/auth/context`
/// - If at root, uses `/api/auth/context`
pub fn use_auth_context() -> LocalResource<Option<AuthContextResponse>> {
LocalResource::new(move || async move {
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
// Determine API base path from current URL
let api_path = web_sys::window()
.and_then(|w| w.location().pathname().ok())
.map(|path| {
if path.starts_with("/admin") {
"/api/admin/auth/context".to_string()
} else {
"/api/auth/context".to_string()
}
})
.unwrap_or_else(|| "/api/auth/context".to_string());
let resp = Request::get(&api_path).send().await;
match resp {
Ok(r) if r.ok() => r.json::<AuthContextResponse>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
None::<AuthContextResponse>
}
})
}
/// Authenticated admin layout that fetches auth context.
///
/// This wrapper fetches the current user's auth context and passes it to
/// AdminLayout for proper sidebar rendering.
#[component]
pub fn AuthenticatedLayout(
current_page: &'static str,
#[prop(default = "/admin")]
base_path: &'static str,
children: ChildrenFn,
) -> impl IntoView {
let auth_context = use_auth_context();
view! {
<Suspense fallback=move || view! {
<AdminLayout current_page=current_page base_path=base_path>
<div class="loading-container">
<p>"Loading..."</p>
</div>
</AdminLayout>
}>
{move || {
let children = children.clone();
auth_context.get().map(move |maybe_ctx| {
let children = children.clone();
match maybe_ctx {
Some(ctx) => {
let managed_realms: Vec<(String, String)> = ctx.managed_realms
.into_iter()
.map(|r| (r.slug, r.name))
.collect();
view! {
<AdminLayout
current_page=current_page
base_path=base_path
is_server_staff=ctx.is_server_staff
managed_realms=managed_realms
>
{children()}
</AdminLayout>
}.into_any()
}
None => {
// Fallback: show layout with default props (server staff view)
view! {
<AdminLayout current_page=current_page base_path=base_path is_server_staff=true>
{children()}
</AdminLayout>
}.into_any()
}
}
})
}}
</Suspense>
}
}
/// Sidebar navigation component.
#[component]
fn Sidebar(
current_page: &'static str,
base_path: &'static str,
#[prop(default = false)]
is_server_staff: bool,
#[prop(default = vec![])]
managed_realms: Vec<(String, String)>,
) -> impl IntoView {
// Build hrefs with base path
let dashboard_href = base_path.to_string();
let config_href = format!("{}/config", base_path);
let users_href = format!("{}/users", base_path);
let users_new_href = format!("{}/users/new", base_path);
let staff_href = format!("{}/staff", base_path);
let realms_href = format!("{}/realms", base_path);
let realms_new_href = format!("{}/realms/new", base_path);
let props_href = format!("{}/props", base_path);
let props_new_href = format!("{}/props/new", base_path);
view! {
<nav class="sidebar">
<div class="sidebar-header">
<a href="/admin" class="sidebar-brand">"Chattyness"</a>
<span class="sidebar-badge">"Admin"</span>
</div>
<ul class="nav-list">
// Server staff: show all server-level options
{if is_server_staff {
view! {
<NavItem
href=dashboard_href.clone()
label="Dashboard"
active=current_page == "dashboard"
/>
<NavItem
href=config_href.clone()
label="Server Config"
active=current_page == "config"
/>
<li class="nav-section">
<span class="nav-section-title">"User Management"</span>
<ul class="nav-sublist">
<NavItem
href=users_href.clone()
label="All Users"
active=current_page == "users"
sub=true
/>
<NavItem
href=users_new_href.clone()
label="Create User"
active=current_page == "users_new"
sub=true
/>
<NavItem
href=staff_href.clone()
label="Staff"
active=current_page == "staff"
sub=true
/>
</ul>
</li>
<li class="nav-section">
<span class="nav-section-title">"Realm Management"</span>
<ul class="nav-sublist">
<NavItem
href=realms_href.clone()
label="All Realms"
active=current_page == "realms"
sub=true
/>
<NavItem
href=realms_new_href.clone()
label="Create Realm"
active=current_page == "realms_new"
sub=true
/>
</ul>
</li>
<li class="nav-section">
<span class="nav-section-title">"Props"</span>
<ul class="nav-sublist">
<NavItem
href=props_href.clone()
label="All Props"
active=current_page == "props"
sub=true
/>
<NavItem
href=props_new_href.clone()
label="Create Prop"
active=current_page == "props_new"
sub=true
/>
</ul>
</li>
}.into_any()
} else {
// Realm admin: show realm-specific options only
view! {
{managed_realms.into_iter().map(|(slug, name)| {
let scenes_href = format!("{}/realms/{}/scenes", base_path, slug);
let scenes_new_href = format!("{}/realms/{}/scenes/new", base_path, slug);
let realm_settings_href = format!("{}/realms/{}", base_path, slug);
view! {
<li class="nav-section">
<span class="nav-section-title">{name}</span>
<ul class="nav-sublist">
<NavItem
href=scenes_href
label="Scenes"
active=current_page == "scenes"
sub=true
/>
<NavItem
href=scenes_new_href
label="Create Scene"
active=current_page == "scenes_new"
sub=true
/>
<NavItem
href=realm_settings_href
label="Realm Settings"
active=current_page == "realms"
sub=true
/>
</ul>
</li>
}
}).collect::<Vec<_>>()}
}.into_any()
}}
</ul>
<div class="sidebar-footer">
<button type="button" class="sidebar-logout" id="logout-btn">
"Logout"
</button>
</div>
</nav>
}
}
/// Navigation item component.
///
/// Supports both static and dynamic hrefs via `#[prop(into)]`.
#[component]
fn NavItem(
#[prop(into)] href: String,
label: &'static str,
#[prop(default = false)] active: bool,
/// Whether this is a sub-item (indented)
#[prop(default = false)] sub: bool,
) -> impl IntoView {
let link_class = match (active, sub) {
(true, false) => "block w-full px-6 py-2 bg-violet-600 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! {
<li class="my-0.5">
<a href=href class=link_class>{label}</a>
</li>
}
}
/// Page header component.
#[component]
pub fn PageHeader(
/// Page title
title: &'static str,
/// Optional subtitle (accepts String or &str)
#[prop(optional, into)]
subtitle: String,
/// Optional action buttons
#[prop(optional)]
children: Option<Children>,
) -> impl IntoView {
let has_subtitle = !subtitle.is_empty();
view! {
<header class="page-header">
<div class="page-header-text">
<h1 class="page-title">{title}</h1>
{if has_subtitle {
view! { <p class="page-subtitle">{subtitle}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
{if let Some(children) = children {
view! {
<div class="page-header-actions">
{children()}
</div>
}.into_any()
} else {
view! {}.into_any()
}}
</header>
}
}
/// Card component.
#[component]
pub fn Card(
#[prop(optional)] title: &'static str,
#[prop(optional)] class: &'static str,
children: Children,
) -> impl IntoView {
let has_title = !title.is_empty();
let card_class = if class.is_empty() {
"card".to_string()
} else {
format!("card {}", class)
};
view! {
<div class=card_class>
{if has_title {
view! { <h2 class="card-title">{title}</h2> }.into_any()
} else {
view! {}.into_any()
}}
{children()}
</div>
}
}
/// Detail grid for key-value display.
#[component]
pub fn DetailGrid(children: Children) -> impl IntoView {
view! {
<div class="detail-grid">
{children()}
</div>
}
}
/// Detail item within a detail grid.
#[component]
pub fn DetailItem(label: &'static str, children: Children) -> impl IntoView {
view! {
<div class="detail-item">
<div class="detail-label">{label}</div>
<div class="detail-value">{children()}</div>
</div>
}
}
/// Status badge component.
#[component]
pub fn StatusBadge(
/// Status text
status: String,
) -> impl IntoView {
let class = format!("status-badge status-{}", status.to_lowercase());
view! {
<span class=class>{status}</span>
}
}
/// Privacy badge component.
#[component]
pub fn PrivacyBadge(
/// Privacy level
privacy: String,
) -> impl IntoView {
let class = format!("privacy-badge privacy-{}", privacy.to_lowercase());
view! {
<span class=class>{privacy}</span>
}
}
/// NSFW badge component.
#[component]
pub fn NsfwBadge() -> impl IntoView {
view! {
<span class="nsfw-badge">"NSFW"</span>
}
}
/// Empty state placeholder.
#[component]
pub fn EmptyState(
message: &'static str,
#[prop(optional)] action_href: &'static str,
#[prop(optional)] action_text: &'static str,
) -> impl IntoView {
let has_action = !action_href.is_empty() && !action_text.is_empty();
view! {
<div class="empty-state">
<p>{message}</p>
{if has_action {
view! {
<a href=action_href class="btn btn-primary">{action_text}</a>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Alert message component.
#[component]
pub fn Alert(
/// Alert variant: success, error, warning, info
variant: &'static str,
/// Alert message
message: String,
) -> impl IntoView {
let class = format!("alert alert-{}", variant);
view! {
<div class=class role="alert">
<p>{message}</p>
</div>
}
}
/// Message alert that shows/hides based on signal state.
///
/// This component reduces the boilerplate for showing form feedback messages.
/// The message signal contains `Option<(String, bool)>` where bool is `is_success`.
#[component]
pub fn MessageAlert(message: ReadSignal<Option<(String, bool)>>) -> impl IntoView {
view! {
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
}
}
/// Message alert that works with RwSignal.
#[component]
pub fn MessageAlertRw(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
view! {
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
}
}
/// Temporary password display component.
///
/// Shows the temporary password with a warning to copy it.
#[component]
pub fn TempPasswordDisplay(
/// The temporary password signal
password: ReadSignal<Option<String>>,
/// Optional label (default: "Temporary Password:")
#[prop(default = "Temporary Password:")]
label: &'static str,
) -> impl IntoView {
view! {
<Show when=move || password.get().is_some()>
<div class="alert alert-info">
<p><strong>{label}</strong></p>
<code class="temp-password">{move || password.get().unwrap_or_default()}</code>
<p class="text-muted">"Copy this password now - it will not be shown again!"</p>
</div>
</Show>
}
}
/// Delete confirmation component with danger zone styling.
///
/// Shows a button that reveals a confirmation dialog when clicked.
#[component]
pub fn DeleteConfirmation(
/// Warning message to show
message: &'static str,
/// Button text (default: "Delete")
#[prop(default = "Delete")]
button_text: &'static str,
/// Confirm button text (default: "Yes, Delete")
#[prop(default = "Yes, Delete")]
confirm_text: &'static str,
/// Pending state signal
pending: ReadSignal<bool>,
/// Callback when delete is confirmed
on_confirm: impl Fn() + Clone + Send + Sync + 'static,
) -> impl IntoView {
let (show_confirm, set_show_confirm) = signal(false);
let on_confirm_clone = on_confirm.clone();
view! {
<Show
when=move || !show_confirm.get()
fallback=move || {
let on_confirm = on_confirm_clone.clone();
view! {
<div class="alert alert-warning">
<p>{message}</p>
<div class="action-buttons">
<button
type="button"
class="btn btn-danger"
disabled=move || pending.get()
on:click=move |_| on_confirm()
>
{move || if pending.get() { "Deleting..." } else { confirm_text }}
</button>
<button
type="button"
class="btn btn-secondary"
on:click=move |_| set_show_confirm.set(false)
>
"Cancel"
</button>
</div>
</div>
}
}
>
<button
type="button"
class="btn btn-danger"
on:click=move |_| set_show_confirm.set(true)
>
{button_text}
</button>
</Show>
}
}
/// Submit button with loading state.
#[component]
pub fn SubmitButton(
/// Button text when not pending
text: &'static str,
/// Button text when pending (default adds "...")
#[prop(optional)]
pending_text: Option<&'static str>,
/// Whether the button is in pending state
pending: ReadSignal<bool>,
/// Additional CSS classes
#[prop(default = "btn btn-primary")]
class: &'static str,
) -> impl IntoView {
let loading_text = pending_text.unwrap_or_else(|| {
// Can't do string manipulation at compile time, so use a simple approach
text
});
view! {
<button
type="submit"
class=class
disabled=move || pending.get()
>
{move || if pending.get() { loading_text } else { text }}
</button>
}
}
/// Loading spinner.
#[component]
pub fn LoadingSpinner(#[prop(optional)] message: &'static str) -> impl IntoView {
view! {
<div class="loading-spinner">
<div class="spinner"></div>
{if !message.is_empty() {
view! { <span class="loading-message">{message}</span> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Role badge component.
#[component]
pub fn RoleBadge(role: String) -> impl IntoView {
let class = format!("role-badge role-{}", role.to_lowercase());
view! {
<span class=class>{role}</span>
}
}
/// Pagination component.
#[component]
pub fn Pagination(current_page: i64, base_url: String, query: String) -> impl IntoView {
let prev_page = current_page - 1;
let next_page = current_page + 1;
let prev_url = if query.is_empty() {
format!("{}?page={}", base_url, prev_page)
} else {
format!("{}?q={}&page={}", base_url, query, prev_page)
};
let next_url = if query.is_empty() {
format!("{}?page={}", base_url, next_page)
} else {
format!("{}?q={}&page={}", base_url, query, next_page)
};
view! {
<nav class="pagination">
{if current_page > 1 {
view! {
<a href=prev_url class="btn btn-secondary">"Previous"</a>
}.into_any()
} else {
view! {
<span class="btn btn-secondary btn-disabled">"Previous"</span>
}.into_any()
}}
<span class="pagination-info">"Page " {current_page}</span>
<a href=next_url class="btn btn-secondary">"Next"</a>
</nav>
}
}
/// Search form component for list pages.
#[component]
pub fn SearchForm(
/// Form action URL (e.g., "/admin/users")
action: &'static str,
/// Placeholder text
placeholder: &'static str,
/// Current search value signal
search_input: RwSignal<String>,
) -> impl IntoView {
view! {
<form method="get" action=action class="search-form">
<div class="search-box">
<input
type="search"
name="q"
placeholder=placeholder
class="form-input search-input"
prop:value=move || search_input.get()
on:input=move |ev| search_input.set(event_target_value(&ev))
/>
<button type="submit" class="btn btn-primary">"Search"</button>
</div>
</form>
}
}

View file

@ -0,0 +1,226 @@
//! Reusable hooks for the admin UI.
//!
//! These hooks provide common patterns for data fetching, pagination,
//! and form submissions to reduce duplication across pages.
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
/// A hook for fetching data from an API endpoint.
///
/// Handles the `#[cfg(feature = "hydrate")]` boilerplate and provides
/// a consistent pattern for client-side data fetching.
///
/// # Example
/// ```rust
/// let users = use_fetch::<Vec<User>>(move || format!("/api/users?page={}", page()));
/// ```
pub fn use_fetch<T>(url_fn: impl Fn() -> String + Send + Sync + 'static) -> LocalResource<Option<T>>
where
T: serde::de::DeserializeOwned + Send + 'static,
{
LocalResource::new(move || {
let url = url_fn();
async move {
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
match Request::get(&url).send().await {
Ok(r) if r.ok() => r.json::<T>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = url;
None::<T>
}
}
})
}
/// A hook for fetching data with a condition.
///
/// Similar to `use_fetch` but allows skipping the fetch if a condition is false.
///
/// # Example
/// ```rust
/// let user = use_fetch_if::<UserDetail>(
/// move || !user_id().is_empty(),
/// move || format!("/api/users/{}", user_id())
/// );
/// ```
pub fn use_fetch_if<T>(
condition: impl Fn() -> bool + Send + Sync + 'static,
url_fn: impl Fn() -> String + Send + Sync + 'static,
) -> LocalResource<Option<T>>
where
T: serde::de::DeserializeOwned + Send + 'static,
{
LocalResource::new(move || {
let should_fetch = condition();
let url = url_fn();
async move {
if !should_fetch {
return None;
}
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
match Request::get(&url).send().await {
Ok(r) if r.ok() => r.json::<T>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = url;
None::<T>
}
}
})
}
/// Pagination state extracted from URL query parameters.
pub struct PaginationState {
/// The current search query (from `?q=...`).
pub search_query: Signal<String>,
/// The current page number (from `?page=...`, defaults to 1).
pub page: Signal<i64>,
/// Signal for the search input value (for controlled input).
pub search_input: RwSignal<String>,
}
/// A hook for extracting pagination state from URL query parameters.
///
/// Returns search query, page number, and a controlled search input signal.
///
/// # Example
/// ```rust
/// let pagination = use_pagination();
/// let url = format!("/api/users?q={}&page={}", pagination.search_query.get(), pagination.page.get());
/// ```
pub fn use_pagination() -> PaginationState {
let query = use_query_map();
let search_query = Signal::derive(move || query.get().get("q").unwrap_or_default());
let page = Signal::derive(move || {
query
.get()
.get("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1i64)
});
// Use get_untracked for initial value to avoid reactive tracking warning
let initial_search = query.get_untracked().get("q").unwrap_or_default();
let search_input = RwSignal::new(initial_search);
PaginationState {
search_query,
page,
search_input,
}
}
/// Message state for form feedback (message text, is_success).
pub type MessageSignal = RwSignal<Option<(String, bool)>>;
/// Creates a message signal for form feedback.
pub fn use_message() -> MessageSignal {
RwSignal::new(None)
}
/// A helper for making POST/PUT/DELETE requests with JSON body.
///
/// Returns the response or error message.
#[cfg(feature = "hydrate")]
pub async fn api_request<T>(
method: &str,
url: &str,
body: Option<&serde_json::Value>,
) -> Result<T, String>
where
T: serde::de::DeserializeOwned,
{
use gloo_net::http::Request;
let request = match method {
"POST" => Request::post(url),
"PUT" => Request::put(url),
"DELETE" => Request::delete(url),
_ => Request::get(url),
};
let response = if let Some(body) = body {
request
.json(body)
.map_err(|e| e.to_string())?
.send()
.await
} else {
request.send().await
}
.map_err(|_| "Network error".to_string())?;
if response.ok() {
response
.json::<T>()
.await
.map_err(|_| "Failed to parse response".to_string())
} else {
// Try to parse error response
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = response.json::<ErrorResp>().await {
Err(err.error)
} else {
Err("Request failed".to_string())
}
}
}
/// A helper for making POST/PUT/DELETE requests that return success/failure.
#[cfg(feature = "hydrate")]
pub async fn api_request_simple(
method: &str,
url: &str,
body: Option<&serde_json::Value>,
) -> Result<(), String> {
use gloo_net::http::Request;
let request = match method {
"POST" => Request::post(url),
"PUT" => Request::put(url),
"DELETE" => Request::delete(url),
_ => Request::get(url),
};
let response = if let Some(body) = body {
request
.json(body)
.map_err(|e| e.to_string())?
.send()
.await
} else {
request.send().await
}
.map_err(|_| "Network error".to_string())?;
if response.ok() {
Ok(())
} else {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = response.json::<ErrorResp>().await {
Err(err.error)
} else {
Err("Request failed".to_string())
}
}
}

View file

@ -0,0 +1,51 @@
#![recursion_limit = "256"]
//! Admin UI crate for Chattyness.
//!
//! This crate provides the Leptos-based admin interface that can be used by:
//! - The Owner App (:3001) with `chattyness_owner` DB role (no RLS)
//! - The Admin App (:3000/admin) with `chattyness_app` DB role (RLS enforced)
//!
//! The UI components are the same; only the database connection differs.
//!
//! ## Usage
//!
//! For standalone use (e.g., chattyness-owner):
//! ```ignore
//! use chattyness_admin_ui::AdminApp;
//! // AdminApp includes its own Router
//! ```
//!
//! For embedding in a combined app (e.g., chattyness-app):
//! ```ignore
//! use chattyness_admin_ui::AdminRoutes;
//! // AdminRoutes can be placed inside an existing Router
//! ```
pub mod api;
pub mod app;
pub mod auth;
pub mod components;
pub mod hooks;
pub mod models;
pub mod pages;
pub mod routes;
pub mod utils;
pub use app::{admin_shell, AdminApp};
pub use routes::AdminRoutes;
// Re-export commonly used items for convenience
pub use components::{
Alert, Card, DeleteConfirmation, DetailGrid, DetailItem, EmptyState, LoadingSpinner,
MessageAlert, MessageAlertRw, NsfwBadge, PageHeader, Pagination, PrivacyBadge, RoleBadge,
SearchForm, StatusBadge, SubmitButton, TempPasswordDisplay,
};
pub use hooks::{use_fetch, use_fetch_if, use_message, use_pagination, PaginationState};
pub use models::*;
pub use utils::{build_bounds_wkt, build_paginated_url, get_api_base, parse_bounds_wkt};
#[cfg(feature = "hydrate")]
pub use utils::{confirm, navigate_to, reload_page};
#[cfg(feature = "ssr")]
pub use app::AdminAppState;

View file

@ -0,0 +1,258 @@
//! Shared model types for the admin UI.
//!
//! These are client-side DTOs (Data Transfer Objects) for API responses.
//! They are separate from the database models in `chattyness_db`.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// =============================================================================
// User Models
// =============================================================================
/// User summary for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserSummary {
pub id: String,
pub username: String,
pub display_name: String,
pub email: Option<String>,
pub status: String,
pub created_at: String,
}
/// User detail from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserDetail {
pub id: String,
pub username: String,
pub display_name: String,
pub email: Option<String>,
pub status: String,
pub server_role: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Response for user creation.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CreateUserResponse {
pub id: String,
pub username: String,
pub temporary_password: String,
}
/// Response for password reset.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PasswordResetResponse {
pub user_id: String,
pub temporary_password: String,
}
// =============================================================================
// Staff Models
// =============================================================================
/// Staff member for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StaffMemberSummary {
pub user_id: String,
pub username: String,
pub display_name: String,
pub email: Option<String>,
pub role: String,
pub appointed_at: String,
}
// =============================================================================
// Realm Models
// =============================================================================
/// Realm summary for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RealmSummary {
pub id: String,
pub slug: String,
pub name: String,
pub tagline: Option<String>,
pub privacy: String,
pub is_nsfw: bool,
pub owner_id: String,
pub owner_username: String,
pub member_count: i64,
pub current_user_count: i64,
pub created_at: String,
}
/// Realm detail from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RealmDetail {
pub id: String,
pub slug: String,
pub name: String,
pub tagline: Option<String>,
pub description: Option<String>,
pub privacy: String,
pub is_nsfw: bool,
pub allow_guest_access: bool,
pub max_users: i32,
pub theme_color: Option<String>,
pub owner_id: String,
pub owner_username: String,
pub owner_display_name: String,
pub member_count: i64,
pub current_user_count: i64,
pub created_at: String,
pub updated_at: String,
}
/// Response for realm creation.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CreateRealmResponse {
pub realm_id: String,
pub slug: String,
pub owner_id: String,
pub owner_temporary_password: Option<String>,
}
// =============================================================================
// Scene Models
// =============================================================================
/// Scene summary for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SceneSummary {
pub id: Uuid,
pub name: String,
pub slug: String,
pub sort_order: i32,
pub is_entry_point: bool,
pub is_hidden: bool,
pub background_color: Option<String>,
pub background_image_path: Option<String>,
}
/// Scene detail from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SceneDetail {
pub id: Uuid,
pub realm_id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub background_image_path: Option<String>,
pub background_color: Option<String>,
pub bounds_wkt: String,
pub dimension_mode: String,
pub sort_order: i32,
pub is_entry_point: bool,
pub is_hidden: bool,
pub created_at: String,
pub updated_at: String,
}
/// Response for image dimensions.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ImageDimensionsResponse {
pub width: u32,
pub height: u32,
}
// =============================================================================
// Dashboard Models
// =============================================================================
/// Dashboard stats from server.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DashboardStats {
pub total_users: i64,
pub active_users: i64,
pub total_realms: i64,
pub online_users: i64,
pub staff_count: i64,
}
// =============================================================================
// Server Config Models
// =============================================================================
/// Server configuration from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub id: String,
pub name: String,
pub description: Option<String>,
pub welcome_message: Option<String>,
pub max_users_per_channel: i32,
pub message_rate_limit: i32,
pub message_rate_window_seconds: i32,
pub allow_guest_access: bool,
pub allow_user_uploads: bool,
pub require_email_verification: bool,
pub created_at: String,
pub updated_at: String,
}
// =============================================================================
// Common Response Types
// =============================================================================
/// Generic error response from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ErrorResponse {
pub error: String,
}
/// Generic success response.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SuccessResponse {
pub success: bool,
}
// =============================================================================
// Prop Models
// =============================================================================
/// Prop summary for list display.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PropSummary {
pub id: String,
pub name: String,
pub slug: String,
pub asset_path: String,
pub default_layer: Option<String>,
pub is_active: bool,
pub created_at: String,
}
/// Prop detail from API.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PropDetail {
pub id: String,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub tags: Vec<String>,
pub asset_path: String,
pub thumbnail_path: Option<String>,
pub default_layer: Option<String>,
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
pub default_position: Option<i16>,
pub is_unique: bool,
pub is_transferable: bool,
pub is_portable: bool,
pub is_active: bool,
pub available_from: Option<String>,
pub available_until: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Response for prop creation.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CreatePropResponse {
pub id: String,
pub name: String,
pub slug: String,
pub asset_path: String,
}

View file

@ -0,0 +1,35 @@
//! Admin interface Leptos page components.
mod config;
mod dashboard;
mod login;
mod props;
mod props_detail;
mod props_new;
mod realm_detail;
mod realm_new;
mod realms;
mod scene_detail;
mod scene_new;
mod scenes;
mod staff;
mod user_detail;
mod user_new;
mod users;
pub use config::ConfigPage;
pub use dashboard::DashboardPage;
pub use login::LoginPage;
pub use props::PropsPage;
pub use props_detail::PropsDetailPage;
pub use props_new::PropsNewPage;
pub use realm_detail::RealmDetailPage;
pub use realm_new::RealmNewPage;
pub use realms::RealmsPage;
pub use scene_detail::SceneDetailPage;
pub use scene_new::SceneNewPage;
pub use scenes::ScenesPage;
pub use staff::StaffPage;
pub use user_detail::UserDetailPage;
pub use user_new::UserNewPage;
pub use users::UsersPage;

View file

@ -0,0 +1,252 @@
//! Server config page component.
use leptos::prelude::*;
use crate::components::{Card, MessageAlert, PageHeader};
use crate::hooks::use_fetch;
use crate::models::ServerConfig;
/// Config page component.
#[component]
pub fn ConfigPage() -> impl IntoView {
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let config = use_fetch::<ServerConfig>(|| "/api/admin/config".to_string());
view! {
<PageHeader
title="Server Configuration"
subtitle="Manage global server settings"
/>
<Suspense fallback=|| view! { <p>"Loading configuration..."</p> }>
{move || {
config.get().map(|maybe_config| {
match maybe_config {
Some(cfg) => view! {
<ConfigForm config=cfg message=message set_message=set_message pending=pending set_pending=set_pending />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Failed to load configuration. You may not have permission to access this page."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
/// Config form component.
#[component]
#[allow(unused_variables)]
fn ConfigForm(
config: ServerConfig,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
pending: ReadSignal<bool>,
set_pending: WriteSignal<bool>,
) -> impl IntoView {
let (name, set_name) = signal(config.name.clone());
let (description, set_description) = signal(config.description.clone().unwrap_or_default());
let (welcome_message, set_welcome_message) =
signal(config.welcome_message.clone().unwrap_or_default());
let (max_users_per_channel, set_max_users_per_channel) = signal(config.max_users_per_channel);
let (message_rate_limit, set_message_rate_limit) = signal(config.message_rate_limit);
let (message_rate_window_seconds, set_message_rate_window_seconds) =
signal(config.message_rate_window_seconds);
let (allow_guest_access, set_allow_guest_access) = signal(config.allow_guest_access);
let (allow_user_uploads, set_allow_user_uploads) = signal(config.allow_user_uploads);
let (require_email_verification, set_require_email_verification) =
signal(config.require_email_verification);
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
use leptos::task::spawn_local;
let data = serde_json::json!({
"name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"welcome_message": if welcome_message.get().is_empty() { None::<String> } else { Some(welcome_message.get()) },
"max_users_per_channel": max_users_per_channel.get(),
"message_rate_limit": message_rate_limit.get(),
"message_rate_window_seconds": message_rate_window_seconds.get(),
"allow_guest_access": allow_guest_access.get(),
"allow_user_uploads": allow_user_uploads.get(),
"require_email_verification": require_email_verification.get()
});
spawn_local(async move {
let response = Request::put("/api/admin/config")
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some((
"Configuration saved successfully!".to_string(),
true,
)));
}
Ok(_) => {
set_message.set(Some(("Failed to save configuration".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<Card>
<form on:submit=on_submit class="config-form">
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">"Server Name"</label>
<input
type="text"
id="name"
required=true
class="form-input"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</div>
</div>
<div class="form-group">
<label for="description" class="form-label">"Server Description"</label>
<textarea
id="description"
class="form-textarea"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-group">
<label for="welcome_message" class="form-label">"Welcome Message"</label>
<textarea
id="welcome_message"
class="form-textarea"
prop:value=move || welcome_message.get()
on:input=move |ev| set_welcome_message.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="max_users_per_channel" class="form-label">"Max Users per Channel"</label>
<input
type="number"
id="max_users_per_channel"
min="1"
max="1000"
class="form-input"
prop:value=move || max_users_per_channel.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_max_users_per_channel.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="message_rate_limit" class="form-label">"Message Rate Limit"</label>
<input
type="number"
id="message_rate_limit"
min="1"
max="100"
class="form-input"
prop:value=move || message_rate_limit.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_message_rate_limit.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="message_rate_window_seconds" class="form-label">"Rate Window (seconds)"</label>
<input
type="number"
id="message_rate_window_seconds"
min="1"
max="300"
class="form-input"
prop:value=move || message_rate_window_seconds.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_message_rate_window_seconds.set(v);
}
}
/>
</div>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || allow_guest_access.get()
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
/>
"Allow Guest Access"
</label>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || allow_user_uploads.get()
on:change=move |ev| set_allow_user_uploads.set(event_target_checked(&ev))
/>
"Allow User Uploads"
</label>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || require_email_verification.get()
on:change=move |ev| set_require_email_verification.set(event_target_checked(&ev))
/>
"Require Email Verification"
</label>
</div>
<MessageAlert message=message />
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Saving..." } else { "Save Configuration" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,71 @@
//! Dashboard page component.
use leptos::prelude::*;
use crate::components::{Card, PageHeader};
use crate::hooks::use_fetch;
use crate::models::DashboardStats;
/// Dashboard page component.
#[component]
pub fn DashboardPage() -> impl IntoView {
let stats = use_fetch::<DashboardStats>(|| "/api/admin/dashboard/stats".to_string());
view! {
<PageHeader
title="Dashboard"
subtitle="Server overview and quick stats"
/>
<div class="dashboard-grid">
<Suspense fallback=|| view! { <p>"Loading stats..."</p> }>
{move || {
stats.get().map(|maybe_stats| {
match maybe_stats {
Some(s) => view! {
<StatCard title="Total Users" value=s.total_users.to_string() />
<StatCard title="Active Users" value=s.active_users.to_string() />
<StatCard title="Total Realms" value=s.total_realms.to_string() />
<StatCard title="Online Now" value=s.online_users.to_string() />
<StatCard title="Staff Members" value=s.staff_count.to_string() />
}.into_any(),
None => view! {
<StatCard title="Total Users" value="-".to_string() />
<StatCard title="Active Users" value="-".to_string() />
<StatCard title="Total Realms" value="-".to_string() />
<StatCard title="Online Now" value="-".to_string() />
<StatCard title="Staff Members" value="-".to_string() />
}.into_any()
}
})
}}
</Suspense>
</div>
<div class="dashboard-sections">
<Card title="Quick Actions">
<div class="quick-actions">
<a href="/admin/users/new" class="btn btn-primary">"Create User"</a>
<a href="/admin/realms/new" class="btn btn-primary">"Create Realm"</a>
<a href="/admin/staff" class="btn btn-secondary">"Manage Staff"</a>
<a href="/admin/config" class="btn btn-secondary">"Server Config"</a>
</div>
</Card>
<Card title="Recent Activity">
<p class="text-muted">"Activity feed coming soon..."</p>
</Card>
</div>
}
}
/// Stat card component.
#[component]
fn StatCard(title: &'static str, value: String) -> impl IntoView {
view! {
<div class="stat-card">
<div class="stat-value">{value}</div>
<div class="stat-title">{title}</div>
</div>
}
}

View file

@ -0,0 +1,137 @@
//! Login page component.
use leptos::ev::SubmitEvent;
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::Card;
/// Login page component.
#[component]
pub fn LoginPage() -> impl IntoView {
let (username, set_username) = signal(String::new());
let (password, set_password) = signal(String::new());
let (error, set_error) = signal(Option::<String>::None);
let (pending, set_pending) = signal(false);
let on_submit = move |ev: SubmitEvent| {
ev.prevent_default();
set_error.set(None);
let uname = username.get();
let pwd = password.get();
if uname.is_empty() || pwd.is_empty() {
set_error.set(Some("Username and password are required".to_string()));
return;
}
set_pending.set(true);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
spawn_local(async move {
let response = Request::post("/api/admin/auth/login")
.json(&serde_json::json!({
"username": uname,
"password": pwd
}))
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
// Redirect to dashboard
if let Some(window) = web_sys::window() {
let _ = window.location().set_href("/admin");
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_error.set(Some(err.error));
} else {
set_error.set(Some("Invalid username or password".to_string()));
}
}
Err(_) => {
set_error.set(Some("Network error. Please try again.".to_string()));
}
}
});
}
};
view! {
<div class="login-container">
<div class="login-header">
<h1>"Chattyness"</h1>
<span class="login-badge">"Admin Panel"</span>
<p>"Administration interface"</p>
</div>
<Card class="login-card">
<form on:submit=on_submit class="login-form">
<div class="form-group">
<label for="username" class="form-label">
"Username"
<span class="required">"*"</span>
</label>
<input
type="text"
id="username"
name="username"
required=true
autocomplete="username"
placeholder="Enter your username"
class="form-input"
prop:value=move || username.get()
on:input=move |ev| set_username.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="password" class="form-label">
"Password"
<span class="required">"*"</span>
</label>
<input
type="password"
id="password"
name="password"
required=true
autocomplete="current-password"
placeholder="Enter your password"
class="form-input"
prop:value=move || password.get()
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error" role="alert">
<p>{move || error.get().unwrap_or_default()}</p>
</div>
</Show>
<button
type="submit"
class="btn btn-primary btn-full"
disabled=move || pending.get()
>
{move || if pending.get() { "Signing in..." } else { "Sign In" }}
</button>
</form>
</Card>
</div>
}
}

View file

@ -0,0 +1,165 @@
//! Props list page component.
use leptos::prelude::*;
use crate::components::{Card, EmptyState, PageHeader};
use crate::hooks::use_fetch;
use crate::models::PropSummary;
/// View mode for props listing.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Table,
Grid,
}
/// Props page component with table and grid views.
#[component]
pub fn PropsPage() -> impl IntoView {
let (view_mode, set_view_mode) = signal(ViewMode::Table);
let props = use_fetch::<Vec<PropSummary>>(|| "/api/admin/props".to_string());
view! {
<PageHeader title="All Props" subtitle="Manage server props and avatar items">
<div class="flex gap-2">
<button
type="button"
class=move || if view_mode.get() == ViewMode::Table {
"btn btn-primary"
} else {
"btn btn-secondary"
}
on:click=move |_| set_view_mode.set(ViewMode::Table)
>
"Table"
</button>
<button
type="button"
class=move || if view_mode.get() == ViewMode::Grid {
"btn btn-primary"
} else {
"btn btn-secondary"
}
on:click=move |_| set_view_mode.set(ViewMode::Grid)
>
"Grid"
</button>
<a href="/admin/props/new" class="btn btn-primary">"Create Prop"</a>
</div>
</PageHeader>
<Card>
<Suspense fallback=|| view! { <p>"Loading props..."</p> }>
{move || {
props.get().map(|maybe_props: Option<Vec<PropSummary>>| {
match maybe_props {
Some(prop_list) if !prop_list.is_empty() => {
if view_mode.get() == ViewMode::Table {
view! { <PropsTable props=prop_list.clone() /> }.into_any()
} else {
view! { <PropsGrid props=prop_list.clone() /> }.into_any()
}
}
_ => view! {
<EmptyState
message="No props found."
action_href="/admin/props/new"
action_text="Create Prop"
/>
}.into_any()
}
})
}}
</Suspense>
</Card>
}
}
/// Table view for props.
#[component]
fn PropsTable(props: Vec<PropSummary>) -> impl IntoView {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Preview"</th>
<th>"Name"</th>
<th>"Slug"</th>
<th>"Layer"</th>
<th>"Active"</th>
<th>"Created"</th>
</tr>
</thead>
<tbody>
{props.into_iter().map(|prop| {
let asset_url = format!("/assets/{}", prop.asset_path);
view! {
<tr>
<td>
<img
src=asset_url
alt=prop.name.clone()
class="prop-thumbnail"
style="width: 32px; height: 32px; object-fit: contain;"
/>
</td>
<td>
<a href=format!("/admin/props/{}", prop.id) class="table-link">
{prop.name}
</a>
</td>
<td><code>{prop.slug}</code></td>
<td>
{prop.default_layer.map(|l| l.to_string()).unwrap_or_else(|| "-".to_string())}
</td>
<td>
{if prop.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
}}
</td>
<td>{prop.created_at}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}
}
/// Grid view for props with 64x64 thumbnails.
#[component]
fn PropsGrid(props: Vec<PropSummary>) -> impl IntoView {
view! {
<div class="props-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 16px; padding: 16px;">
{props.into_iter().map(|prop| {
let asset_url = format!("/assets/{}", prop.asset_path);
let prop_url = format!("/admin/props/{}", prop.id);
let prop_name_for_title = prop.name.clone();
let prop_name_for_alt = prop.name.clone();
let prop_name_for_label = prop.name;
view! {
<a
href=prop_url
class="props-grid-item"
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 8px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s;"
title=prop_name_for_title
>
<img
src=asset_url
alt=prop_name_for_alt
style="width: 64px; height: 64px; object-fit: contain;"
/>
<span style="font-size: 0.75rem; margin-top: 4px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
{prop_name_for_label}
</span>
</a>
}
}).collect_view()}
</div>
}
}

View file

@ -0,0 +1,139 @@
//! Prop detail page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
use crate::hooks::use_fetch_if;
use crate::models::PropDetail;
/// Prop detail page component.
#[component]
pub fn PropsDetailPage() -> impl IntoView {
let params = use_params_map();
let prop_id = move || params.get().get("prop_id").unwrap_or_default();
let initial_prop_id = params.get_untracked().get("prop_id").unwrap_or_default();
let prop = use_fetch_if::<PropDetail>(
move || !prop_id().is_empty(),
move || format!("/api/admin/props/{}", prop_id()),
);
view! {
<PageHeader title="Prop Details" subtitle=initial_prop_id>
<a href="/admin/props" class="btn btn-secondary">"Back to Props"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading prop..."</p> }>
{move || {
prop.get().map(|maybe_prop| {
match maybe_prop {
Some(p) => view! {
<PropDetailView prop=p />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Prop not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
fn PropDetailView(prop: PropDetail) -> impl IntoView {
let asset_url = format!("/assets/{}", prop.asset_path);
let tags_display = if prop.tags.is_empty() {
"None".to_string()
} else {
prop.tags.join(", ")
};
view! {
<Card>
<div class="prop-header" style="display: flex; gap: 24px; align-items: flex-start;">
<div class="prop-preview" style="flex-shrink: 0;">
<img
src=asset_url
alt=prop.name.clone()
style="width: 128px; height: 128px; object-fit: contain; border: 1px solid var(--color-border, #334155); border-radius: 8px; background: var(--color-bg-tertiary, #0f172a);"
/>
</div>
<div class="prop-info" style="flex: 1;">
<h2 style="margin: 0 0 8px 0;">{prop.name.clone()}</h2>
<p class="text-muted" style="margin: 0;"><code>{prop.slug.clone()}</code></p>
{prop.description.clone().map(|desc| view! {
<p style="margin-top: 12px; color: var(--color-text-secondary, #94a3b8);">{desc}</p>
})}
</div>
</div>
</Card>
<Card title="Details">
<DetailGrid>
<DetailItem label="Prop ID">
<code>{prop.id.clone()}</code>
</DetailItem>
<DetailItem label="Tags">
{tags_display}
</DetailItem>
<DetailItem label="Default Layer">
{prop.default_layer.clone().unwrap_or_else(|| "Not set".to_string())}
</DetailItem>
<DetailItem label="Default Position">
{match prop.default_position {
Some(pos) => {
let labels = ["Top-Left", "Top-Center", "Top-Right",
"Middle-Left", "Center", "Middle-Right",
"Bottom-Left", "Bottom-Center", "Bottom-Right"];
labels.get(pos as usize).map(|s| s.to_string())
.unwrap_or_else(|| format!("{}", pos))
},
None => "Not set".to_string(),
}}
</DetailItem>
<DetailItem label="Status">
{if prop.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
} else {
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
}}
</DetailItem>
</DetailGrid>
</Card>
<Card title="Properties">
<DetailGrid>
<DetailItem label="Unique">
{if prop.is_unique { "Yes" } else { "No" }}
</DetailItem>
<DetailItem label="Transferable">
{if prop.is_transferable { "Yes" } else { "No" }}
</DetailItem>
<DetailItem label="Portable">
{if prop.is_portable { "Yes" } else { "No" }}
</DetailItem>
</DetailGrid>
</Card>
<Card title="Availability">
<DetailGrid>
<DetailItem label="Available From">
{prop.available_from.clone().unwrap_or_else(|| "Always".to_string())}
</DetailItem>
<DetailItem label="Available Until">
{prop.available_until.clone().unwrap_or_else(|| "No end date".to_string())}
</DetailItem>
<DetailItem label="Created">
{prop.created_at.clone()}
</DetailItem>
<DetailItem label="Updated">
{prop.updated_at.clone()}
</DetailItem>
</DetailGrid>
</Card>
}
}

View file

@ -0,0 +1,332 @@
//! Create new prop page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, PageHeader};
/// Prop new page component with file upload.
#[component]
pub fn PropsNewPage() -> impl IntoView {
// Form state
let (name, set_name) = signal(String::new());
let (slug, set_slug) = signal(String::new());
let (description, set_description) = signal(String::new());
let (tags, set_tags) = signal(String::new());
let (default_layer, set_default_layer) = signal("clothes".to_string());
let (default_position, set_default_position) = signal(4i16); // Center position
// UI state
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let (created_id, _set_created_id) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let set_created_id = _set_created_id;
let (slug_auto, set_slug_auto) = signal(true);
let (file_name, _set_file_name) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let set_file_name = _set_file_name;
let update_name = move |ev: leptos::ev::Event| {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
}
};
let on_file_change = move |ev: leptos::ev::Event| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let target = ev.target().unwrap();
let input: web_sys::HtmlInputElement = target.dyn_into().unwrap();
if let Some(files) = input.files() {
if files.length() > 0 {
if let Some(file) = files.get(0) {
set_file_name.set(Some(file.name()));
}
}
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = ev;
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
// Get the form element
let target = ev.target().unwrap();
let form: web_sys::HtmlFormElement = target.dyn_into().unwrap();
// Get the file input
let file_input = form
.query_selector("input[type='file']")
.unwrap()
.unwrap()
.dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
let files = file_input.files();
if files.is_none() || files.as_ref().unwrap().length() == 0 {
set_message.set(Some(("Please select a file".to_string(), false)));
set_pending.set(false);
return;
}
let file = files.unwrap().get(0).unwrap();
// Build tags array from comma-separated string
let tags_vec: Vec<String> = tags
.get()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Create metadata JSON
let metadata = serde_json::json!({
"name": name.get(),
"slug": if slug.get().is_empty() { None::<String> } else { Some(slug.get()) },
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"tags": tags_vec,
"default_layer": default_layer.get(),
"default_position": default_position.get()
});
// Create FormData
let form_data = web_sys::FormData::new().unwrap();
form_data
.append_with_str("metadata", &metadata.to_string())
.unwrap();
form_data.append_with_blob("file", &file).unwrap();
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::post("/api/admin/props")
.body(form_data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
struct CreateResponse {
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
slug: String,
}
if let Ok(result) = resp.json::<CreateResponse>().await {
set_created_id.set(Some(result.id));
set_message.set(Some(("Prop created successfully!".to_string(), true)));
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to create prop".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<PageHeader title="Create New Prop" subtitle="Upload a new server prop image">
<a href="/admin/props" class="btn btn-secondary">"Back to Props"</a>
</PageHeader>
<Card>
<form on:submit=on_submit>
<h3 class="section-title">"Prop Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">
"Name" <span class="required">"*"</span>
</label>
<input
type="text"
id="name"
required=true
class="form-input"
placeholder="Smile Expression"
prop:value=move || name.get()
on:input=update_name
/>
</div>
<div class="form-group">
<label for="slug" class="form-label">"Slug (URL)"</label>
<input
type="text"
id="slug"
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
class="form-input"
placeholder="smile-expression"
prop:value=move || slug.get()
on:input=move |ev| {
set_slug_auto.set(false);
set_slug.set(event_target_value(&ev));
}
/>
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
</div>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
placeholder="A happy smile expression for avatars"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-group">
<label for="tags" class="form-label">"Tags"</label>
<input
type="text"
id="tags"
class="form-input"
placeholder="expression, face, happy"
prop:value=move || tags.get()
on:input=move |ev| set_tags.set(event_target_value(&ev))
/>
<small class="form-help">"Comma-separated list of tags"</small>
</div>
<h3 class="section-title">"Image File"</h3>
<div class="form-group">
<label for="file" class="form-label">
"Image File" <span class="required">"*"</span>
</label>
<input
type="file"
id="file"
required=true
accept=".svg,.png,image/svg+xml,image/png"
class="form-input"
on:change=on_file_change
/>
<small class="form-help">"SVG or PNG image file (64x64 recommended)"</small>
<Show when=move || file_name.get().is_some()>
<p class="text-muted">"Selected: " {move || file_name.get().unwrap_or_default()}</p>
</Show>
</div>
<h3 class="section-title">"Default Positioning"</h3>
<div class="form-row">
<div class="form-group">
<label for="default_layer" class="form-label">"Layer"</label>
<select
id="default_layer"
class="form-select"
on:change=move |ev| set_default_layer.set(event_target_value(&ev))
>
<option value="skin" selected=move || default_layer.get() == "skin">"Skin (behind)"</option>
<option value="clothes" selected=move || default_layer.get() == "clothes">"Clothes (with)"</option>
<option value="accessories" selected=move || default_layer.get() == "accessories">"Accessories (front)"</option>
</select>
<small class="form-help">"Z-depth layer for prop placement"</small>
</div>
<div class="form-group">
<label for="default_position" class="form-label">"Position"</label>
<select
id="default_position"
class="form-select"
on:change=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_default_position.set(v);
}
}
>
<option value="0" selected=move || default_position.get() == 0>"Top-Left (0)"</option>
<option value="1" selected=move || default_position.get() == 1>"Top-Center (1)"</option>
<option value="2" selected=move || default_position.get() == 2>"Top-Right (2)"</option>
<option value="3" selected=move || default_position.get() == 3>"Middle-Left (3)"</option>
<option value="4" selected=move || default_position.get() == 4>"Center (4)"</option>
<option value="5" selected=move || default_position.get() == 5>"Middle-Right (5)"</option>
<option value="6" selected=move || default_position.get() == 6>"Bottom-Left (6)"</option>
<option value="7" selected=move || default_position.get() == 7>"Bottom-Center (7)"</option>
<option value="8" selected=move || default_position.get() == 8>"Bottom-Right (8)"</option>
</select>
<small class="form-help">"Grid position (3x3 grid)"</small>
</div>
</div>
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
<Show when=move || created_id.get().is_some()>
{move || {
let id = created_id.get().unwrap_or_default();
view! {
<div class="alert alert-info">
<p>
<a href=format!("/admin/props/{}", id)>
"View prop"
</a>
</p>
</div>
}
}}
</Show>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Uploading..." } else { "Create Prop" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,295 @@
//! Realm detail/edit page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{
Card, DetailGrid, DetailItem, MessageAlert, NsfwBadge, PageHeader, PrivacyBadge,
};
use crate::hooks::use_fetch_if;
use crate::models::RealmDetail;
use crate::utils::get_api_base;
/// Realm detail page component.
#[component]
pub fn RealmDetailPage() -> impl IntoView {
let params = use_params_map();
let slug = move || params.get().get("slug").unwrap_or_default();
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
let (message, set_message) = signal(Option::<(String, bool)>::None);
let realm = use_fetch_if::<RealmDetail>(
move || !slug().is_empty(),
move || format!("{}/realms/{}", get_api_base(), slug()),
);
let slug_for_scenes = initial_slug.clone();
view! {
<PageHeader title="Realm Details" subtitle=format!("/{}", initial_slug)>
<a href=format!("/admin/realms/{}/scenes", slug_for_scenes) class="btn btn-primary">"Manage Scenes"</a>
<a href="/admin/realms" class="btn btn-secondary">"Back to Realms"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading realm..."</p> }>
{move || {
realm.get().map(|maybe_realm| {
match maybe_realm {
Some(r) => view! {
<RealmDetailView realm=r message=message set_message=set_message />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Realm not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
#[allow(unused_variables)]
fn RealmDetailView(
realm: RealmDetail,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
) -> impl IntoView {
#[cfg(feature = "hydrate")]
let slug = realm.slug.clone();
let slug_display = realm.slug.clone();
let (pending, set_pending) = signal(false);
// Form state
let (name, set_name) = signal(realm.name.clone());
let (tagline, set_tagline) = signal(realm.tagline.clone().unwrap_or_default());
let (description, set_description) = signal(realm.description.clone().unwrap_or_default());
let (privacy, set_privacy) = signal(realm.privacy.clone());
let (max_users, set_max_users) = signal(realm.max_users);
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
let (theme_color, set_theme_color) =
signal(realm.theme_color.clone().unwrap_or_else(|| "#7c3aed".to_string()));
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let api_base = get_api_base();
let slug = slug.clone();
let data = serde_json::json!({
"name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"tagline": if tagline.get().is_empty() { None::<String> } else { Some(tagline.get()) },
"privacy": privacy.get(),
"is_nsfw": is_nsfw.get(),
"max_users": max_users.get(),
"allow_guest_access": allow_guest_access.get(),
"theme_color": theme_color.get()
});
spawn_local(async move {
let response = Request::put(&format!("{}/realms/{}", api_base, slug))
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some(("Realm updated successfully!".to_string(), true)));
}
Ok(_) => {
set_message.set(Some(("Failed to update realm".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<Card>
<div class="realm-header">
<div class="realm-info">
<h2>{realm.name.clone()}</h2>
<p class="text-muted">{realm.tagline.clone().unwrap_or_default()}</p>
</div>
<div class="realm-badges">
<PrivacyBadge privacy=realm.privacy.clone() />
{if realm.is_nsfw {
view! { <NsfwBadge /> }.into_any()
} else {
view! {}.into_any()
}}
</div>
</div>
<DetailGrid>
<DetailItem label="Owner">
<a href=format!("/admin/users/{}", realm.owner_id) class="table-link">
{realm.owner_display_name.clone()} " (@" {realm.owner_username.clone()} ")"
</a>
</DetailItem>
<DetailItem label="Members">
{realm.member_count.to_string()}
</DetailItem>
<DetailItem label="Current Users">
{realm.current_user_count.to_string()}
</DetailItem>
<DetailItem label="Max Users">
{realm.max_users.to_string()}
</DetailItem>
<DetailItem label="Created">
{realm.created_at.clone()}
</DetailItem>
<DetailItem label="Updated">
{realm.updated_at.clone()}
</DetailItem>
</DetailGrid>
</Card>
<Card title="Edit Realm Settings">
<form on:submit=on_submit>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">"Realm Name"</label>
<input
type="text"
id="name"
required=true
class="form-input"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label class="form-label">"Slug (URL)"</label>
<input
type="text"
value=slug_display
class="form-input"
disabled=true
/>
<small class="form-help">"Slug cannot be changed"</small>
</div>
</div>
<div class="form-group">
<label for="tagline" class="form-label">"Tagline"</label>
<input
type="text"
id="tagline"
class="form-input"
placeholder="A short description"
prop:value=move || tagline.get()
on:input=move |ev| set_tagline.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="privacy" class="form-label">"Privacy"</label>
<select
id="privacy"
class="form-select"
on:change=move |ev| set_privacy.set(event_target_value(&ev))
>
<option value="public" selected=move || privacy.get() == "public">"Public"</option>
<option value="unlisted" selected=move || privacy.get() == "unlisted">"Unlisted"</option>
<option value="private" selected=move || privacy.get() == "private">"Private"</option>
</select>
</div>
<div class="form-group">
<label for="max_users" class="form-label">"Max Users"</label>
<input
type="number"
id="max_users"
min=1
max=10000
class="form-input"
prop:value=move || max_users.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_max_users.set(v);
}
}
/>
</div>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_nsfw.get()
on:change=move |ev| set_is_nsfw.set(event_target_checked(&ev))
/>
"NSFW Content"
</label>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || allow_guest_access.get()
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
/>
"Allow Guest Access"
</label>
</div>
</div>
<div class="form-group">
<label for="theme_color" class="form-label">"Theme Color"</label>
<input
type="color"
id="theme_color"
class="form-color"
prop:value=move || theme_color.get()
on:input=move |ev| set_theme_color.set(event_target_value(&ev))
/>
</div>
<MessageAlert message=message />
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,388 @@
//! Create new realm page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, PageHeader};
/// Realm new page component.
#[component]
pub fn RealmNewPage() -> impl IntoView {
// Form state
let (name, set_name) = signal(String::new());
let (slug, set_slug) = signal(String::new());
let (tagline, set_tagline) = signal(String::new());
let (description, set_description) = signal(String::new());
let (privacy, set_privacy) = signal("public".to_string());
let (max_users, set_max_users) = signal(100i32);
let (is_nsfw, set_is_nsfw) = signal(false);
let (allow_guest_access, set_allow_guest_access) = signal(false);
let (theme_color, set_theme_color) = signal("#7c3aed".to_string());
// Owner selection
let (owner_mode, set_owner_mode) = signal("existing".to_string());
let (owner_id, set_owner_id) = signal(String::new());
let (new_username, set_new_username) = signal(String::new());
let (new_email, set_new_email) = signal(String::new());
let (new_display_name, set_new_display_name) = signal(String::new());
// UI state
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let (created_slug, _set_created_slug) = signal(Option::<String>::None);
let (temp_password, _set_temp_password) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let (set_created_slug, set_temp_password) = (_set_created_slug, _set_temp_password);
let (slug_auto, set_slug_auto) = signal(true);
let update_name = move |ev: leptos::ev::Event| {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let mut data = serde_json::json!({
"name": name.get(),
"slug": slug.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"tagline": if tagline.get().is_empty() { None::<String> } else { Some(tagline.get()) },
"privacy": privacy.get(),
"is_nsfw": is_nsfw.get(),
"max_users": max_users.get(),
"allow_guest_access": allow_guest_access.get(),
"theme_color": theme_color.get()
});
if owner_mode.get() == "existing" {
if owner_id.get().is_empty() {
set_message.set(Some(("Please enter an owner User ID".to_string(), false)));
set_pending.set(false);
return;
}
data["owner_id"] = serde_json::json!(owner_id.get());
} else {
if new_username.get().is_empty() || 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);
return;
}
data["new_owner"] = serde_json::json!({
"username": new_username.get(),
"email": new_email.get(),
"display_name": new_display_name.get()
});
}
spawn_local(async move {
let response = Request::post("/api/admin/realms")
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
struct CreateResponse {
slug: String,
owner_temporary_password: Option<String>,
}
if let Ok(result) = resp.json::<CreateResponse>().await {
set_created_slug.set(Some(result.slug));
set_temp_password.set(result.owner_temporary_password);
set_message.set(Some(("Realm created successfully!".to_string(), true)));
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to create realm".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<PageHeader title="Create New Realm" subtitle="Create a new realm space">
<a href="/admin/realms" class="btn btn-secondary">"Back to Realms"</a>
</PageHeader>
<Card>
<form on:submit=on_submit>
<h3 class="section-title">"Realm Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">
"Realm Name" <span class="required">"*"</span>
</label>
<input
type="text"
id="name"
required=true
class="form-input"
placeholder="My Awesome Realm"
prop:value=move || name.get()
on:input=update_name
/>
</div>
<div class="form-group">
<label for="slug" class="form-label">
"Slug (URL)" <span class="required">"*"</span>
</label>
<input
type="text"
id="slug"
required=true
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
class="form-input"
placeholder="my-realm"
prop:value=move || slug.get()
on:input=move |ev| {
set_slug_auto.set(false);
set_slug.set(event_target_value(&ev));
}
/>
<small class="form-help">"Lowercase letters, numbers, hyphens only"</small>
</div>
</div>
<div class="form-group">
<label for="tagline" class="form-label">"Tagline"</label>
<input
type="text"
id="tagline"
class="form-input"
placeholder="A short description"
prop:value=move || tagline.get()
on:input=move |ev| set_tagline.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
placeholder="Detailed description of the realm"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="privacy" class="form-label">"Privacy"</label>
<select
id="privacy"
class="form-select"
on:change=move |ev| set_privacy.set(event_target_value(&ev))
>
<option value="public" selected=move || privacy.get() == "public">"Public"</option>
<option value="unlisted" selected=move || privacy.get() == "unlisted">"Unlisted"</option>
<option value="private" selected=move || privacy.get() == "private">"Private"</option>
</select>
</div>
<div class="form-group">
<label for="max_users" class="form-label">"Max Users"</label>
<input
type="number"
id="max_users"
min=1
max=10000
class="form-input"
prop:value=move || max_users.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_max_users.set(v);
}
}
/>
</div>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_nsfw.get()
on:change=move |ev| set_is_nsfw.set(event_target_checked(&ev))
/>
"NSFW Content"
</label>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || allow_guest_access.get()
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
/>
"Allow Guest Access"
</label>
</div>
</div>
<div class="form-group">
<label for="theme_color" class="form-label">"Theme Color"</label>
<input
type="color"
id="theme_color"
class="form-color"
prop:value=move || theme_color.get()
on:input=move |ev| set_theme_color.set(event_target_value(&ev))
/>
</div>
<h3 class="section-title">"Realm Owner"</h3>
<div class="tab-buttons">
<button
type="button"
class=move || if owner_mode.get() == "existing" { "btn btn-primary" } else { "btn btn-secondary" }
on:click=move |_| set_owner_mode.set("existing".to_string())
>
"Existing User"
</button>
<button
type="button"
class=move || if owner_mode.get() == "new" { "btn btn-primary" } else { "btn btn-secondary" }
on:click=move |_| set_owner_mode.set("new".to_string())
>
"Create New User"
</button>
</div>
<Show when=move || owner_mode.get() == "existing">
<div class="form-group">
<label for="owner_id" class="form-label">"Owner User ID"</label>
<input
type="text"
id="owner_id"
class="form-input"
placeholder="UUID of existing user"
prop:value=move || owner_id.get()
on:input=move |ev| set_owner_id.set(event_target_value(&ev))
/>
</div>
</Show>
<Show when=move || owner_mode.get() == "new">
<p class="text-muted">"A random temporary password will be generated for the new owner."</p>
<div class="form-row">
<div class="form-group">
<label for="new_username" class="form-label">"Username"</label>
<input
type="text"
id="new_username"
minlength=3
maxlength=32
class="form-input"
placeholder="username"
prop:value=move || new_username.get()
on:input=move |ev| set_new_username.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="new_email" class="form-label">"Email"</label>
<input
type="email"
id="new_email"
class="form-input"
placeholder="user@example.com"
prop:value=move || new_email.get()
on:input=move |ev| set_new_email.set(event_target_value(&ev))
/>
</div>
</div>
<div class="form-group">
<label for="new_display_name" class="form-label">"Display Name"</label>
<input
type="text"
id="new_display_name"
class="form-input"
placeholder="Display Name"
prop:value=move || new_display_name.get()
on:input=move |ev| set_new_display_name.set(event_target_value(&ev))
/>
</div>
</Show>
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
<Show when=move || created_slug.get().is_some()>
<div class="alert alert-info">
<p>
<a href=format!("/admin/realms/{}", created_slug.get().unwrap_or_default())>
"View realm"
</a>
</p>
</div>
</Show>
<Show when=move || temp_password.get().is_some()>
<div class="alert alert-warning">
<p><strong>"New Owner Temporary Password:"</strong></p>
<code class="temp-password">{move || temp_password.get().unwrap_or_default()}</code>
<p class="text-muted">"Copy this password now - it will not be shown again!"</p>
</div>
</Show>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Creating..." } else { "Create Realm" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,111 @@
//! Realms list page component.
use leptos::prelude::*;
use crate::components::{
Card, EmptyState, NsfwBadge, PageHeader, Pagination, PrivacyBadge, SearchForm,
};
use crate::hooks::{use_fetch, use_pagination};
use crate::models::RealmSummary;
use crate::utils::build_paginated_url;
/// Realms page component.
#[component]
pub fn RealmsPage() -> impl IntoView {
let pagination = use_pagination();
let realms = use_fetch::<Vec<RealmSummary>>(move || {
build_paginated_url(
"/api/admin/realms",
pagination.page.get(),
&pagination.search_query.get(),
25,
)
});
view! {
<PageHeader title="All Realms" subtitle="Manage realm spaces">
<a href="/admin/realms/new" class="btn btn-primary">"Create Realm"</a>
</PageHeader>
<Card>
<SearchForm
action="/admin/realms"
placeholder="Search by name or slug..."
search_input=pagination.search_input
/>
<Suspense fallback=|| view! { <p>"Loading realms..."</p> }>
{move || {
realms.get().map(|maybe_realms: Option<Vec<RealmSummary>>| {
match maybe_realms {
Some(realm_list) if !realm_list.is_empty() => {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Name"</th>
<th>"Tagline"</th>
<th>"Privacy"</th>
<th>"NSFW"</th>
<th>"Owner"</th>
<th>"Members"</th>
<th>"Online"</th>
<th>"Created"</th>
</tr>
</thead>
<tbody>
{realm_list.into_iter().map(|realm| {
view! {
<tr>
<td>
<a href=format!("/admin/realms/{}", realm.slug) class="table-link">
{realm.name}
</a>
</td>
<td>{realm.tagline.unwrap_or_default()}</td>
<td><PrivacyBadge privacy=realm.privacy /></td>
<td>
{if realm.is_nsfw {
view! { <NsfwBadge /> }.into_any()
} else {
view! { <span>"-"</span> }.into_any()
}}
</td>
<td>
<a href=format!("/admin/users/{}", realm.owner_id) class="table-link">
{realm.owner_username}
</a>
</td>
<td>{realm.member_count}</td>
<td>{realm.current_user_count}</td>
<td>{realm.created_at}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
<Pagination
current_page=pagination.page.get()
base_url="/admin/realms".to_string()
query=pagination.search_query.get()
/>
}.into_any()
}
_ => view! {
<EmptyState
message="No realms found."
action_href="/admin/realms/new"
action_text="Create Realm"
/>
}.into_any()
}
})
}}
</Suspense>
</Card>
}
}

View file

@ -0,0 +1,783 @@
//! Scene detail/edit page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use uuid::Uuid;
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
#[cfg(feature = "hydrate")]
use crate::utils::fetch_image_dimensions_client;
/// Scene detail from API.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct SceneDetail {
pub id: Uuid,
pub realm_id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub background_image_path: Option<String>,
pub background_color: Option<String>,
pub bounds_wkt: String,
pub dimension_mode: String,
pub sort_order: i32,
pub is_entry_point: bool,
pub is_hidden: bool,
pub created_at: String,
pub updated_at: String,
}
/// Parse width and height from WKT bounds string.
/// Example: "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))" -> (800, 600)
/// Handles both "0 0, 800 0" (with space) and "0 0,800 0" (without space) formats.
fn parse_bounds_wkt(wkt: &str) -> (i32, i32) {
// Extract coordinates from POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x5 y5))
// The second point has (width, 0) and third point has (width, height)
if let Some(start) = wkt.find("((") {
if let Some(end) = wkt.find("))") {
let coords_str = &wkt[start + 2..end];
let points: Vec<&str> = coords_str.split(',').map(|s| s.trim()).collect();
if points.len() >= 3 {
// Second point: "width 0"
let second: Vec<&str> = points[1].split_whitespace().collect();
// Third point: "width height"
let third: Vec<&str> = points[2].split_whitespace().collect();
if !second.is_empty() && third.len() >= 2 {
let width = second[0].parse().unwrap_or(800);
let height = third[1].parse().unwrap_or(600);
return (width, height);
}
}
}
}
(800, 600)
}
/// Scene detail page component.
#[component]
pub fn SceneDetailPage() -> impl IntoView {
let params = use_params_map();
let realm_slug = move || params.get().get("slug").unwrap_or_default();
let scene_id = move || params.get().get("scene_id").unwrap_or_default();
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
let (message, set_message) = signal(Option::<(String, bool)>::None);
let scene = LocalResource::new(move || {
let id = scene_id();
async move {
if id.is_empty() {
return None;
}
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let resp = Request::get(&format!("/api/admin/scenes/{}", id)).send().await;
match resp {
Ok(r) if r.ok() => r.json::<SceneDetail>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = id;
None::<SceneDetail>
}
}
});
let slug_for_back = initial_realm_slug.clone();
view! {
<PageHeader title="Scene Details" subtitle="View and edit scene">
<a href=format!("/admin/realms/{}/scenes", slug_for_back) class="btn btn-secondary">"Back to Scenes"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading scene..."</p> }>
{move || {
let realm_slug_val = realm_slug();
scene.get().map(|maybe_scene| {
match maybe_scene {
Some(s) => view! {
<SceneDetailView scene=s realm_slug=realm_slug_val message=message set_message=set_message />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Scene not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
#[allow(unused_variables)]
fn SceneDetailView(
scene: SceneDetail,
realm_slug: String,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
) -> impl IntoView {
#[cfg(feature = "hydrate")]
let scene_id = scene.id.to_string();
#[cfg(feature = "hydrate")]
let scene_id_for_delete = scene.id.to_string();
#[cfg(feature = "hydrate")]
let realm_slug_for_delete = realm_slug.clone();
let (pending, set_pending) = signal(false);
let (delete_pending, set_delete_pending) = signal(false);
let (show_delete_confirm, set_show_delete_confirm) = signal(false);
let (show_image_modal, set_show_image_modal) = signal(false);
// Parse dimensions from bounds_wkt
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
// Clone scene data for view (to avoid move issues)
let scene_name_display = scene.name.clone();
let scene_slug_display = scene.slug.clone();
let scene_slug_disabled = scene.slug.clone();
let scene_description_display = scene.description.clone();
let scene_background_image_path = scene.background_image_path.clone();
let scene_background_image_path_for_modal = scene.background_image_path.clone();
let scene_background_image_path_for_check = scene.background_image_path.clone();
let scene_background_image_path_for_dimensions = scene.background_image_path.clone();
let scene_background_color_display = scene.background_color.clone();
let scene_created_at = scene.created_at.clone();
let scene_updated_at = scene.updated_at.clone();
// Form state
let (name, set_name) = signal(scene.name.clone());
let (description, set_description) = signal(scene.description.clone().unwrap_or_default());
let (background_color, set_background_color) = signal(
scene.background_color.clone().unwrap_or_else(|| "#1a1a2e".to_string()),
);
let (background_image_url, set_background_image_url) = signal(String::new());
let (clear_background_image, set_clear_background_image) = signal(false);
let (infer_dimensions, set_infer_dimensions) = signal(false);
let (width, set_width) = signal(initial_width);
let (height, set_height) = signal(initial_height);
let (dimension_mode, set_dimension_mode) = signal(scene.dimension_mode.clone());
let (sort_order, set_sort_order) = signal(scene.sort_order);
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
// UI state for dimension fetching
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
let url = background_image_url.get();
if url.is_empty() {
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
return;
}
set_fetching_dimensions.set(true);
set_dimension_message.set(None);
#[cfg(feature = "hydrate")]
{
fetch_image_dimensions_client(
url,
move |w, h| {
set_width.set(w as i32);
set_height.set(h as i32);
set_dimension_message.set(Some((
format!("Dimensions: {}x{}", w, h),
true,
)));
},
move |err| {
set_dimension_message.set(Some((err, false)));
},
set_fetching_dimensions,
);
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let id = scene_id.clone();
// Build bounds WKT from width/height
let w = width.get();
let h = height.get();
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
let mut data = serde_json::json!({
"name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
"bounds_wkt": bounds_wkt,
"dimension_mode": dimension_mode.get(),
"sort_order": sort_order.get(),
"is_entry_point": is_entry_point.get(),
"is_hidden": is_hidden.get()
});
// Only include background_image_url if provided
let bg_url = background_image_url.get();
if !bg_url.is_empty() {
data["background_image_url"] = serde_json::json!(bg_url);
// Include infer dimensions flag when uploading new image
if infer_dimensions.get() {
data["infer_dimensions_from_image"] = serde_json::json!(true);
}
}
// Include clear flag if set
if clear_background_image.get() {
data["clear_background_image"] = serde_json::json!(true);
}
spawn_local(async move {
let response = Request::put(&format!("/api/admin/scenes/{}", id))
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some(("Scene updated successfully!".to_string(), true)));
// Clear the background image URL field after success
set_background_image_url.set(String::new());
set_clear_background_image.set(false);
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to update scene".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<Card>
<div class="realm-header">
<div class="realm-info">
<h2>{scene_name_display}</h2>
<p class="text-muted">"/" {scene_slug_display}</p>
</div>
<div class="realm-badges">
{if scene.is_entry_point {
view! { <span class="badge badge-success">"Entry Point"</span> }.into_any()
} else {
view! {}.into_any()
}}
{if scene.is_hidden {
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
} else {
view! {}.into_any()
}}
</div>
</div>
<DetailGrid>
<DetailItem label="Scene ID">
<code>{scene.id.to_string()}</code>
</DetailItem>
<DetailItem label="Realm ID">
<code>{scene.realm_id.to_string()}</code>
</DetailItem>
<DetailItem label="Dimensions">
{format!("{}x{}", initial_width, initial_height)}
</DetailItem>
<DetailItem label="Sort Order">
{scene.sort_order.to_string()}
</DetailItem>
<DetailItem label="Background">
{if let Some(ref path) = scene_background_image_path {
let path_clone = path.clone();
view! {
<div style="display:inline-flex;align-items:center;gap:0.75rem">
<img
src=path_clone.clone()
alt="Background thumbnail"
style="max-width:100px;max-height:75px;border:1px solid #555;border-radius:4px;cursor:pointer"
title="Click to view full size"
on:click=move |_| set_show_image_modal.set(true)
/>
<span class="text-muted" style="font-size:0.85em">{path_clone}</span>
</div>
}.into_any()
} else if let Some(ref color) = scene_background_color_display {
view! {
<span style=format!("display:inline-flex;align-items:center;gap:0.5rem")>
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
{color.clone()}
</span>
}.into_any()
} else {
view! { <span class="text-muted">"None"</span> }.into_any()
}}
</DetailItem>
<DetailItem label="Created">
{scene_created_at}
</DetailItem>
<DetailItem label="Updated">
{scene_updated_at}
</DetailItem>
</DetailGrid>
{if let Some(ref desc) = scene_description_display {
view! {
<div class="realm-description">
<h4>"Description"</h4>
<p>{desc.clone()}</p>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
</Card>
<Card title="Edit Scene">
<form on:submit=on_submit>
<h3 class="section-title">"Scene Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">"Scene Name"</label>
<input
type="text"
id="name"
required=true
class="form-input"
prop:value=move || name.get()
on:input=move |ev| set_name.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label class="form-label">"Slug (URL)"</label>
<input
type="text"
value=scene_slug_disabled
class="form-input"
disabled=true
/>
<small class="form-help">"Slug cannot be changed"</small>
</div>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<h3 class="section-title">"Background"</h3>
<div class="form-row">
<div class="form-group">
<label for="background_color" class="form-label">"Background Color"</label>
<input
type="color"
id="background_color"
class="form-color"
prop:value=move || background_color.get()
on:input=move |ev| set_background_color.set(event_target_value(&ev))
/>
</div>
<div class="form-group" style="flex: 2">
<label for="background_image_url" class="form-label">"New Background Image URL"</label>
<div style="display: flex; gap: 0.5rem">
<input
type="url"
id="background_image_url"
class="form-input"
style="flex: 1"
placeholder="https://example.com/image.png"
prop:value=move || background_image_url.get()
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
/>
<button
type="button"
class="btn btn-secondary"
disabled=move || fetching_dimensions.get()
on:click=fetch_dimensions
>
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
</button>
</div>
<small class="form-help">"Leave empty to keep current image. Click 'Get Size' to auto-fill dimensions."</small>
</div>
</div>
// Image preview (for new URL)
<Show when=move || !background_image_url.get().is_empty()>
<div class="form-group">
<label class="form-label">"New Image Preview"</label>
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
<img
src=move || background_image_url.get()
alt="New background preview"
style="max-width: 100%; height: auto; display: block"
/>
</div>
</div>
</Show>
// Dimension fetch message
<Show when=move || dimension_message.get().is_some()>
{move || {
let (msg, is_success) = dimension_message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert" style="margin-bottom: 1rem">
<p>{msg}</p>
</div>
}
}}
</Show>
// Infer dimensions checkbox (only shown when new URL is provided)
<Show when=move || !background_image_url.get().is_empty()>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || infer_dimensions.get()
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
/>
"Infer dimensions from image"
</label>
<small class="form-help">"If enabled, server will extract dimensions from the image when saving"</small>
</div>
</Show>
{if scene_background_image_path_for_check.is_some() {
view! {
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || clear_background_image.get()
on:change=move |ev| set_clear_background_image.set(event_target_checked(&ev))
/>
"Remove current background image"
</label>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
<h3 class="section-title">"Dimensions"</h3>
<div class="form-row">
<div class="form-group">
<label for="width" class="form-label">"Width"</label>
<input
type="number"
id="width"
min=100
max=10000
class="form-input"
prop:value=move || width.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_width.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="height" class="form-label">"Height"</label>
<input
type="number"
id="height"
min=100
max=10000
class="form-input"
prop:value=move || height.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_height.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
<select
id="dimension_mode"
class="form-select"
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
>
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
</select>
</div>
</div>
// Button to set dimensions from existing background image
{if let Some(ref path) = scene_background_image_path_for_dimensions {
let path_for_closure = path.clone();
view! {
<div class="form-group">
<button
type="button"
class="btn btn-secondary"
disabled=move || fetching_dimensions.get()
on:click=move |_| {
set_fetching_dimensions.set(true);
set_dimension_message.set(None);
#[cfg(feature = "hydrate")]
{
let path = path_for_closure.clone();
fetch_image_dimensions_client(
path,
move |w, h| {
set_width.set(w as i32);
set_height.set(h as i32);
set_dimension_message.set(Some((
format!("Set from image: {}x{}", w, h),
true,
)));
},
move |err| {
set_dimension_message.set(Some((err, false)));
},
set_fetching_dimensions,
);
}
}
>
{move || if fetching_dimensions.get() { "Fetching..." } else { "Set from background image" }}
</button>
<small class="form-help">"Set dimensions to match the current background image"</small>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
<h3 class="section-title">"Options"</h3>
<div class="form-group">
<label for="sort_order" class="form-label">"Sort Order"</label>
<input
type="number"
id="sort_order"
class="form-input"
prop:value=move || sort_order.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_sort_order.set(v);
}
}
/>
<small class="form-help">"Lower numbers appear first in scene lists"</small>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_entry_point.get()
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
/>
"Entry Point"
</label>
<small class="form-help">"Users spawn here when entering the realm"</small>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_hidden.get()
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
/>
"Hidden"
</label>
<small class="form-help">"Scene won't appear in public listings"</small>
</div>
</div>
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
</button>
</div>
</form>
</Card>
<Card title="Danger Zone">
<p class="text-muted">"Deleting a scene is permanent and cannot be undone. All spots within this scene will also be deleted."</p>
<Show
when=move || !show_delete_confirm.get()
fallback={
#[cfg(feature = "hydrate")]
let id = scene_id_for_delete.clone();
#[cfg(feature = "hydrate")]
let slug = realm_slug_for_delete.clone();
move || {
#[cfg(feature = "hydrate")]
let id = id.clone();
#[cfg(feature = "hydrate")]
let slug = slug.clone();
view! {
<div class="alert alert-warning">
<p>"Are you sure you want to delete this scene? This action cannot be undone."</p>
<div class="action-buttons">
<button
type="button"
class="btn btn-danger"
disabled=move || delete_pending.get()
on:click={
#[cfg(feature = "hydrate")]
let id = id.clone();
#[cfg(feature = "hydrate")]
let slug = slug.clone();
move |_| {
set_delete_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let id = id.clone();
let slug = slug.clone();
spawn_local(async move {
let response = Request::delete(&format!("/api/admin/scenes/{}", id))
.send()
.await;
set_delete_pending.set(false);
set_show_delete_confirm.set(false);
match response {
Ok(resp) if resp.ok() => {
if let Some(window) = web_sys::window() {
let _ = window.location().set_href(&format!("/admin/realms/{}/scenes", slug));
}
}
Ok(_) => {
set_message.set(Some(("Failed to delete scene".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
}
}
>
{move || if delete_pending.get() { "Deleting..." } else { "Yes, Delete Scene" }}
</button>
<button
type="button"
class="btn btn-secondary"
on:click=move |_| set_show_delete_confirm.set(false)
>
"Cancel"
</button>
</div>
</div>
}
}
}
>
<button
type="button"
class="btn btn-danger"
on:click=move |_| set_show_delete_confirm.set(true)
>
"Delete Scene"
</button>
</Show>
</Card>
// Image preview modal
<Show when=move || show_image_modal.get()>
{
let path = scene_background_image_path_for_modal.clone();
view! {
<div class="modal-overlay">
<div
class="modal-backdrop"
on:click=move |_| set_show_image_modal.set(false)
></div>
<div class="modal-content" style="max-width:90vw;max-height:90vh;padding:0;background:transparent">
<button
type="button"
class="modal-close"
style="position:absolute;top:-30px;right:0;background:#333;color:#fff;border:none;padding:0.5rem;border-radius:4px;cursor:pointer"
on:click=move |_| set_show_image_modal.set(false)
>
"x"
</button>
{if let Some(ref img_path) = path {
view! {
<img
src=img_path.clone()
alt="Background image"
style="max-width:90vw;max-height:85vh;object-fit:contain;border-radius:4px"
/>
}.into_any()
} else {
view! { <span>"No image"</span> }.into_any()
}}
</div>
</div>
}
}
</Show>
}
}

View file

@ -0,0 +1,429 @@
//! Create new scene page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, PageHeader};
#[cfg(feature = "hydrate")]
use crate::utils::fetch_image_dimensions_client;
/// Scene new page component.
#[component]
pub fn SceneNewPage() -> impl IntoView {
let params = use_params_map();
let realm_slug = move || params.get().get("slug").unwrap_or_default();
// Form state
let (name, set_name) = signal(String::new());
let (slug, set_slug) = signal(String::new());
let (description, set_description) = signal(String::new());
let (background_color, set_background_color) = signal("#1a1a2e".to_string());
let (background_image_url, set_background_image_url) = signal(String::new());
let (infer_dimensions, set_infer_dimensions) = signal(false);
let (width, set_width) = signal(800i32);
let (height, set_height) = signal(600i32);
let (dimension_mode, set_dimension_mode) = signal("fixed".to_string());
let (sort_order, set_sort_order) = signal(0i32);
let (is_entry_point, set_is_entry_point) = signal(false);
let (is_hidden, set_is_hidden) = signal(false);
// UI state
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let (created_id, _set_created_id) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let set_created_id = _set_created_id;
let (slug_auto, set_slug_auto) = signal(true);
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
let update_name = move |ev: leptos::ev::Event| {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
}
};
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
let url = background_image_url.get();
if url.is_empty() {
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
return;
}
set_fetching_dimensions.set(true);
set_dimension_message.set(None);
#[cfg(feature = "hydrate")]
{
fetch_image_dimensions_client(
url,
move |w, h| {
set_width.set(w as i32);
set_height.set(h as i32);
set_dimension_message.set(Some((
format!("Dimensions: {}x{}", w, h),
true,
)));
},
move |err| {
set_dimension_message.set(Some((err, false)));
},
set_fetching_dimensions,
);
}
};
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
let realm_slug_val = realm_slug();
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
// Build bounds WKT from width/height
let w = width.get();
let h = height.get();
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
let data = serde_json::json!({
"name": name.get(),
"slug": slug.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
"background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) },
"infer_dimensions_from_image": infer_dimensions.get(),
"bounds_wkt": bounds_wkt,
"dimension_mode": dimension_mode.get(),
"sort_order": sort_order.get(),
"is_entry_point": is_entry_point.get(),
"is_hidden": is_hidden.get()
});
spawn_local(async move {
let url = format!("/api/admin/realms/{}/scenes", realm_slug_val);
let response = Request::post(&url)
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct CreateResponse {
id: String,
slug: String,
}
if let Ok(result) = resp.json::<CreateResponse>().await {
set_created_id.set(Some(result.id));
set_message.set(Some(("Scene created successfully!".to_string(), true)));
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to create scene".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
let slug_for_header = realm_slug();
view! {
<PageHeader title="Create New Scene" subtitle="Create a new scene in this realm">
<a href=format!("/admin/realms/{}/scenes", slug_for_header) class="btn btn-secondary">"Back to Scenes"</a>
</PageHeader>
<Card>
<form on:submit=on_submit>
<h3 class="section-title">"Scene Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">
"Scene Name" <span class="required">"*"</span>
</label>
<input
type="text"
id="name"
required=true
class="form-input"
placeholder="Main Lobby"
prop:value=move || name.get()
on:input=update_name
/>
</div>
<div class="form-group">
<label for="slug" class="form-label">
"Slug (URL)" <span class="required">"*"</span>
</label>
<input
type="text"
id="slug"
required=true
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
class="form-input"
placeholder="main-lobby"
prop:value=move || slug.get()
on:input=move |ev| {
set_slug_auto.set(false);
set_slug.set(event_target_value(&ev));
}
/>
<small class="form-help">"Lowercase letters, numbers, hyphens only"</small>
</div>
</div>
<div class="form-group">
<label for="description" class="form-label">"Description"</label>
<textarea
id="description"
class="form-textarea"
placeholder="Description of this scene"
prop:value=move || description.get()
on:input=move |ev| set_description.set(event_target_value(&ev))
></textarea>
</div>
<h3 class="section-title">"Background"</h3>
<div class="form-row">
<div class="form-group">
<label for="background_color" class="form-label">"Background Color"</label>
<input
type="color"
id="background_color"
class="form-color"
prop:value=move || background_color.get()
on:input=move |ev| set_background_color.set(event_target_value(&ev))
/>
</div>
<div class="form-group" style="flex: 2">
<label for="background_image_url" class="form-label">"Background Image URL"</label>
<div style="display: flex; gap: 0.5rem">
<input
type="url"
id="background_image_url"
class="form-input"
style="flex: 1"
placeholder="https://example.com/image.png"
prop:value=move || background_image_url.get()
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
/>
<button
type="button"
class="btn btn-secondary"
disabled=move || fetching_dimensions.get()
on:click=fetch_dimensions
>
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
</button>
</div>
<small class="form-help">"Enter a public image URL and click 'Get Size' to auto-fill dimensions"</small>
</div>
</div>
// Image preview
<Show when=move || !background_image_url.get().is_empty()>
<div class="form-group">
<label class="form-label">"Image Preview"</label>
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
<img
src=move || background_image_url.get()
alt="Background preview"
style="max-width: 100%; height: auto; display: block"
/>
</div>
</div>
</Show>
// Dimension fetch message
<Show when=move || dimension_message.get().is_some()>
{move || {
let (msg, is_success) = dimension_message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert" style="margin-bottom: 1rem">
<p>{msg}</p>
</div>
}
}}
</Show>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || infer_dimensions.get()
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
/>
"Infer dimensions from image"
</label>
<small class="form-help">"If enabled, server will extract dimensions from the image when creating the scene"</small>
</div>
<h3 class="section-title">"Dimensions"</h3>
<div class="form-row">
<div class="form-group">
<label for="width" class="form-label">"Width"</label>
<input
type="number"
id="width"
min=100
max=10000
class="form-input"
prop:value=move || width.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_width.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="height" class="form-label">"Height"</label>
<input
type="number"
id="height"
min=100
max=10000
class="form-input"
prop:value=move || height.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_height.set(v);
}
}
/>
</div>
<div class="form-group">
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
<select
id="dimension_mode"
class="form-select"
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
>
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
</select>
</div>
</div>
<h3 class="section-title">"Options"</h3>
<div class="form-group">
<label for="sort_order" class="form-label">"Sort Order"</label>
<input
type="number"
id="sort_order"
class="form-input"
prop:value=move || sort_order.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_sort_order.set(v);
}
}
/>
<small class="form-help">"Lower numbers appear first in scene lists"</small>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_entry_point.get()
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
/>
"Entry Point"
</label>
<small class="form-help">"Users spawn here when entering the realm"</small>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_hidden.get()
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
/>
"Hidden"
</label>
<small class="form-help">"Scene won't appear in public listings"</small>
</div>
</div>
<Show when=move || message.get().is_some()>
{move || {
let (msg, is_success) = message.get().unwrap_or_default();
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
view! {
<div class=class role="alert">
<p>{msg}</p>
</div>
}
}}
</Show>
<Show when=move || created_id.get().is_some()>
{move || {
let id = created_id.get().unwrap_or_default();
let slug = realm_slug();
view! {
<div class="alert alert-info">
<p>
<a href=format!("/admin/realms/{}/scenes/{}", slug, id)>
"View scene"
</a>
</p>
</div>
}
}}
</Show>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Creating..." } else { "Create Scene" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,116 @@
//! Scenes list page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, EmptyState, PageHeader};
use crate::hooks::use_fetch_if;
use crate::models::SceneSummary;
/// Scenes list page component.
#[component]
pub fn ScenesPage() -> impl IntoView {
let params = use_params_map();
let realm_slug = move || params.get().get("slug").unwrap_or_default();
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
let scenes = use_fetch_if::<Vec<SceneSummary>>(
move || !realm_slug().is_empty(),
move || format!("/api/admin/realms/{}/scenes", realm_slug()),
);
let slug_for_create = initial_slug.clone();
let slug_for_back = initial_slug.clone();
view! {
<PageHeader title="Scenes" subtitle="Manage scenes for realm">
<a href=format!("/admin/realms/{}/scenes/new", slug_for_create) class="btn btn-primary">"Create Scene"</a>
</PageHeader>
<div class="mb-4">
<a href=format!("/admin/realms/{}", slug_for_back) class="btn btn-secondary">"Back to Realm"</a>
</div>
<Card>
<Suspense fallback=|| view! { <p>"Loading scenes..."</p> }>
{move || {
let slug = realm_slug();
scenes.get().map(|maybe_scenes: Option<Vec<SceneSummary>>| {
match maybe_scenes {
Some(scene_list) if !scene_list.is_empty() => {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Name"</th>
<th>"Slug"</th>
<th>"Order"</th>
<th>"Entry Point"</th>
<th>"Hidden"</th>
<th>"Background"</th>
</tr>
</thead>
<tbody>
{scene_list.into_iter().map(|scene| {
let scene_id = scene.id.to_string();
let slug_clone = slug.clone();
view! {
<tr>
<td>
<a href=format!("/admin/realms/{}/scenes/{}", slug_clone, scene_id) class="table-link">
{scene.name}
</a>
</td>
<td>{scene.slug}</td>
<td>{scene.sort_order}</td>
<td>
{if scene.is_entry_point {
view! { <span class="badge badge-success">"Yes"</span> }.into_any()
} else {
view! { <span class="text-muted">"-"</span> }.into_any()
}}
</td>
<td>
{if scene.is_hidden {
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
} else {
view! { <span class="text-muted">"-"</span> }.into_any()
}}
</td>
<td>
{if let Some(color) = scene.background_color {
view! {
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
}.into_any()
} else if scene.background_image_path.is_some() {
view! { <span class="text-muted">"Image"</span> }.into_any()
} else {
view! { <span class="text-muted">"-"</span> }.into_any()
}}
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}
_ => {
let slug_for_empty = slug.clone();
view! {
<EmptyState
message="No scenes found for this realm."
action_href=format!("/admin/realms/{}/scenes/new", slug_for_empty).leak()
action_text="Create Scene"
/>
}.into_any()
}
}
})
}}
</Suspense>
</Card>
}
}

View file

@ -0,0 +1,263 @@
//! Staff management page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, EmptyState, MessageAlertRw, PageHeader, RoleBadge};
use crate::hooks::use_fetch;
use crate::models::StaffMemberSummary;
#[cfg(feature = "hydrate")]
use crate::utils::reload_page;
/// Staff page component.
#[component]
pub fn StaffPage() -> impl IntoView {
let message = RwSignal::new(Option::<(String, bool)>::None);
let staff = use_fetch::<Vec<StaffMemberSummary>>(|| "/api/admin/staff".to_string());
view! {
<PageHeader title="Server Staff" subtitle="Manage server administrators">
<AddStaffButton message=message />
</PageHeader>
<MessageAlertRw message=message />
<Card>
<Suspense fallback=|| view! { <p>"Loading staff..."</p> }>
{move || {
staff.get().map(|maybe_staff| {
match maybe_staff {
Some(staff_list) if !staff_list.is_empty() => {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Username"</th>
<th>"Display Name"</th>
<th>"Email"</th>
<th>"Role"</th>
<th>"Appointed"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
{staff_list.into_iter().map(|member| {
let user_id = member.user_id.clone();
view! {
<tr>
<td>
<a href=format!("/admin/users/{}", member.user_id) class="table-link">
{member.username}
</a>
</td>
<td>{member.display_name}</td>
<td>{member.email.unwrap_or_else(|| "-".to_string())}</td>
<td><RoleBadge role=member.role /></td>
<td>{member.appointed_at}</td>
<td>
<RemoveStaffButton
user_id=user_id
message=message
/>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}
_ => view! {
<EmptyState message="No staff members found." />
}.into_any()
}
})
}}
</Suspense>
</Card>
}
}
#[component]
#[allow(unused_variables)]
fn AddStaffButton(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
let (show_modal, set_show_modal) = signal(false);
let (user_id, set_user_id) = signal(String::new());
let (role, set_role) = signal("moderator".to_string());
let (pending, set_pending) = signal(false);
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let data = serde_json::json!({
"user_id": user_id.get(),
"role": role.get()
});
spawn_local(async move {
let response = Request::post("/api/admin/staff")
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
message.set(Some(("Staff member added!".to_string(), true)));
set_show_modal.set(false);
set_user_id.set(String::new());
reload_page();
}
Ok(_) => {
message.set(Some(("Failed to add staff member".to_string(), false)));
}
Err(_) => {
message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<button
type="button"
class="btn btn-primary"
on:click=move |_| set_show_modal.set(true)
>
"Add Staff Member"
</button>
<Show when=move || show_modal.get()>
<div class="modal-overlay">
<div class="modal-backdrop" on:click=move |_| set_show_modal.set(false)></div>
<div class="modal-content">
<button
type="button"
class="modal-close"
on:click=move |_| set_show_modal.set(false)
>
"x"
</button>
<h3 class="modal-title">"Add Staff Member"</h3>
<form on:submit=on_submit>
<div class="form-group">
<label for="staff_user_id" class="form-label">"User ID"</label>
<input
type="text"
id="staff_user_id"
required=true
class="form-input"
placeholder="UUID of user to make staff"
prop:value=move || user_id.get()
on:input=move |ev| set_user_id.set(event_target_value(&ev))
/>
</div>
<div class="form-group">
<label for="staff_role" class="form-label">"Role"</label>
<select
id="staff_role"
class="form-select"
on:change=move |ev| set_role.set(event_target_value(&ev))
>
<option value="moderator" selected=move || role.get() == "moderator">"Moderator"</option>
<option value="admin" selected=move || role.get() == "admin">"Admin"</option>
<option value="owner" selected=move || role.get() == "owner">"Owner"</option>
</select>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
on:click=move |_| set_show_modal.set(false)
>
"Cancel"
</button>
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Adding..." } else { "Add Staff" }}
</button>
</div>
</form>
</div>
</div>
</Show>
}
}
#[component]
#[allow(unused_variables)]
fn RemoveStaffButton(
user_id: String,
message: RwSignal<Option<(String, bool)>>,
) -> impl IntoView {
let (pending, set_pending) = signal(false);
#[cfg(feature = "hydrate")]
let user_id_for_click = user_id.clone();
let on_click = move |_| {
#[cfg(feature = "hydrate")]
{
use crate::utils::confirm;
if !confirm("Remove this staff member?") {
return;
}
}
set_pending.set(true);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let user_id = user_id_for_click.clone();
spawn_local(async move {
let response = Request::delete(&format!("/api/admin/staff/{}", user_id))
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
message.set(Some(("Staff member removed!".to_string(), true)));
reload_page();
}
_ => {
message.set(Some(("Failed to remove staff member".to_string(), false)));
}
}
});
}
};
view! {
<button
type="button"
class="btn btn-danger btn-sm"
disabled=move || pending.get()
on:click=on_click
>
{move || if pending.get() { "..." } else { "Remove" }}
</button>
}
}

View file

@ -0,0 +1,228 @@
//! User detail page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, DetailGrid, DetailItem, MessageAlert, PageHeader, StatusBadge, TempPasswordDisplay};
use crate::hooks::use_fetch_if;
use crate::models::UserDetail;
#[cfg(feature = "hydrate")]
use crate::utils::reload_page;
/// User detail page component.
#[component]
pub fn UserDetailPage() -> impl IntoView {
let params = use_params_map();
let user_id = move || params.get().get("user_id").unwrap_or_default();
let initial_user_id = params.get_untracked().get("user_id").unwrap_or_default();
let (message, set_message) = signal(Option::<(String, bool)>::None);
let user = use_fetch_if::<UserDetail>(
move || !user_id().is_empty(),
move || format!("/api/admin/users/{}", user_id()),
);
view! {
<PageHeader title="User Details" subtitle=initial_user_id>
<a href="/admin/users" class="btn btn-secondary">"Back to Users"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading user..."</p> }>
{move || {
user.get().map(|maybe_user| {
match maybe_user {
Some(u) => view! {
<UserDetailView user=u message=message set_message=set_message />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"User not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
fn UserDetailView(
user: UserDetail,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
) -> impl IntoView {
#[cfg(feature = "hydrate")]
let user_id = user.id.clone();
#[cfg(feature = "hydrate")]
let user_id_for_status = user_id.clone();
#[cfg(feature = "hydrate")]
let user_id_for_reset = user_id.clone();
let user_status = user.status.clone();
let user_status_for_badge = user_status.clone();
let (pending_status, set_pending_status) = signal(false);
let (pending_reset, set_pending_reset) = signal(false);
let (new_password, set_new_password) = signal(Option::<String>::None);
let update_status = {
#[allow(unused_variables)]
move |new_status: &'static str| {
set_pending_status.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let user_id = user_id_for_status.clone();
let status = new_status.to_string();
spawn_local(async move {
let response = Request::put(&format!("/api/admin/users/{}/status", user_id))
.json(&serde_json::json!({ "status": status }))
.unwrap()
.send()
.await;
set_pending_status.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some(("Status updated!".to_string(), true)));
reload_page();
}
_ => {
set_message.set(Some(("Failed to update status".to_string(), false)));
}
}
});
}
}
};
let reset_password = move |_| {
set_pending_reset.set(true);
set_message.set(None);
set_new_password.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let user_id = user_id_for_reset.clone();
spawn_local(async move {
let response = Request::post(&format!("/api/admin/users/{}/reset-password", user_id))
.send()
.await;
set_pending_reset.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
struct ResetResponse {
temporary_password: String,
}
if let Ok(result) = resp.json::<ResetResponse>().await {
set_new_password.set(Some(result.temporary_password));
set_message.set(Some(("Password reset successfully!".to_string(), true)));
}
}
_ => {
set_message.set(Some(("Failed to reset password".to_string(), false)));
}
}
});
}
};
view! {
<Card>
<div class="user-header">
<div class="user-info">
<h2>{user.display_name.clone()}</h2>
<p class="text-muted">"@" {user.username.clone()}</p>
</div>
<StatusBadge status=user_status_for_badge />
</div>
<DetailGrid>
<DetailItem label="User ID">
<code>{user.id.clone()}</code>
</DetailItem>
<DetailItem label="Email">
{user.email.clone().unwrap_or_else(|| "Not set".to_string())}
</DetailItem>
<DetailItem label="Server Role">
{user.server_role.clone().unwrap_or_else(|| "None".to_string())}
</DetailItem>
<DetailItem label="Created">
{user.created_at.clone()}
</DetailItem>
<DetailItem label="Updated">
{user.updated_at.clone()}
</DetailItem>
</DetailGrid>
</Card>
<Card title="Account Actions">
<MessageAlert message=message />
<TempPasswordDisplay password=new_password label="New Temporary Password:" />
<div class="action-buttons">
<button
type="button"
class="btn btn-secondary"
disabled=move || pending_reset.get()
on:click=reset_password
>
{move || if pending_reset.get() { "Resetting..." } else { "Reset Password" }}
</button>
{if user_status != "suspended" {
let update_status = update_status.clone();
view! {
<button
type="button"
class="btn btn-warning"
disabled=move || pending_status.get()
on:click=move |_| update_status("suspended")
>
"Suspend User"
</button>
}.into_any()
} else {
let update_status = update_status.clone();
view! {
<button
type="button"
class="btn btn-primary"
disabled=move || pending_status.get()
on:click=move |_| update_status("active")
>
"Activate User"
</button>
}.into_any()
}}
{if user_status != "banned" {
view! {
<button
type="button"
class="btn btn-danger"
disabled=move || pending_status.get()
on:click=move |_| update_status("banned")
>
"Ban User"
</button>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
</Card>
}
}

View file

@ -0,0 +1,149 @@
//! Create new user page component.
use leptos::prelude::*;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use crate::components::{Card, MessageAlert, PageHeader, TempPasswordDisplay};
/// User new page component.
#[component]
pub fn UserNewPage() -> impl IntoView {
let (username, set_username) = signal(String::new());
let (email, set_email) = signal(String::new());
let (display_name, set_display_name) = signal(String::new());
let (message, set_message) = signal(Option::<(String, bool)>::None);
let (pending, set_pending) = signal(false);
let (temp_password, _set_temp_password) = signal(Option::<String>::None);
#[cfg(feature = "hydrate")]
let set_temp_password = _set_temp_password;
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let data = serde_json::json!({
"username": username.get(),
"email": if email.get().is_empty() { None::<String> } else { Some(email.get()) },
"display_name": display_name.get()
});
spawn_local(async move {
let response = Request::post("/api/admin/users")
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
#[derive(serde::Deserialize)]
struct CreateResponse {
temporary_password: String,
}
if let Ok(result) = resp.json::<CreateResponse>().await {
set_temp_password.set(Some(result.temporary_password));
set_message.set(Some(("User created successfully!".to_string(), true)));
set_username.set(String::new());
set_email.set(String::new());
set_display_name.set(String::new());
}
}
Ok(resp) => {
#[derive(serde::Deserialize)]
struct ErrorResp {
error: String,
}
if let Ok(err) = resp.json::<ErrorResp>().await {
set_message.set(Some((err.error, false)));
} else {
set_message.set(Some(("Failed to create user".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<PageHeader title="Create New User" subtitle="Add a new user account">
<a href="/admin/users" class="btn btn-secondary">"Back to Users"</a>
</PageHeader>
<Card>
<form on:submit=on_submit>
<div class="form-row">
<div class="form-group">
<label for="username" class="form-label">
"Username" <span class="required">"*"</span>
</label>
<input
type="text"
id="username"
required=true
minlength=3
maxlength=32
pattern="[a-zA-Z][a-zA-Z0-9_]*"
class="form-input"
placeholder="username"
prop:value=move || username.get()
on:input=move |ev| set_username.set(event_target_value(&ev))
/>
<small class="form-help">"Letters, numbers, and underscores only"</small>
</div>
<div class="form-group">
<label for="email" class="form-label">"Email"</label>
<input
type="email"
id="email"
class="form-input"
placeholder="user@example.com"
prop:value=move || email.get()
on:input=move |ev| set_email.set(event_target_value(&ev))
/>
</div>
</div>
<div class="form-group">
<label for="display_name" class="form-label">
"Display Name" <span class="required">"*"</span>
</label>
<input
type="text"
id="display_name"
required=true
minlength=1
maxlength=64
class="form-input"
placeholder="Display Name"
prop:value=move || display_name.get()
on:input=move |ev| set_display_name.set(event_target_value(&ev))
/>
</div>
<MessageAlert message=message />
<TempPasswordDisplay password=temp_password />
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
disabled=move || pending.get()
>
{move || if pending.get() { "Creating..." } else { "Create User" }}
</button>
</div>
</form>
</Card>
}
}

View file

@ -0,0 +1,94 @@
//! Users list page component.
use leptos::prelude::*;
use crate::components::{Card, EmptyState, PageHeader, Pagination, SearchForm, StatusBadge};
use crate::hooks::{use_fetch, use_pagination};
use crate::models::UserSummary;
use crate::utils::build_paginated_url;
/// Users page component.
#[component]
pub fn UsersPage() -> impl IntoView {
let pagination = use_pagination();
// Fetch users using the new hook
let users = use_fetch::<Vec<UserSummary>>(move || {
build_paginated_url(
"/api/admin/users",
pagination.page.get(),
&pagination.search_query.get(),
25,
)
});
view! {
<PageHeader title="All Users" subtitle="Manage user accounts">
<a href="/admin/users/new" class="btn btn-primary">"Create User"</a>
</PageHeader>
<Card>
<SearchForm
action="/admin/users"
placeholder="Search by username or email..."
search_input=pagination.search_input
/>
<Suspense fallback=|| view! { <p>"Loading users..."</p> }>
{move || {
users.get().map(|maybe_users: Option<Vec<UserSummary>>| {
match maybe_users {
Some(user_list) if !user_list.is_empty() => {
view! {
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>"Username"</th>
<th>"Display Name"</th>
<th>"Email"</th>
<th>"Status"</th>
<th>"Created"</th>
</tr>
</thead>
<tbody>
{user_list.into_iter().map(|user| {
view! {
<tr>
<td>
<a href=format!("/admin/users/{}", user.id) class="table-link">
{user.username}
</a>
</td>
<td>{user.display_name}</td>
<td>{user.email.unwrap_or_else(|| "-".to_string())}</td>
<td><StatusBadge status=user.status /></td>
<td>{user.created_at}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
<Pagination
current_page=pagination.page.get()
base_url="/admin/users".to_string()
query=pagination.search_query.get()
/>
}.into_any()
}
_ => view! {
<EmptyState
message="No users found."
action_href="/admin/users/new"
action_text="Create User"
/>
}.into_any()
}
})
}}
</Suspense>
</Card>
}
}

View file

@ -0,0 +1,131 @@
//! Admin routes without Router wrapper (for embedding in combined apps).
//!
//! This module provides the `AdminRoutes` component which contains all admin
//! route definitions without a Router wrapper. This allows the routes to be
//! embedded in a parent Router (e.g., CombinedApp in chattyness-app).
//!
//! For standalone use (e.g., chattyness-owner), use `AdminApp` which wraps
//! these routes with a Router.
use leptos::prelude::*;
use leptos_router::{
components::{Route, Routes},
ParamSegment, StaticSegment,
};
use crate::components::{AuthenticatedLayout, LoginLayout};
use crate::pages::{
ConfigPage, DashboardPage, LoginPage, PropsDetailPage, PropsNewPage, PropsPage,
RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage, ScenesPage,
StaffPage, UserDetailPage, UserNewPage, UsersPage,
};
/// Admin routes that can be embedded in a parent Router.
///
/// All paths are relative to the Router's base path. When used in:
/// - `AdminApp`: The Router is configured with base="/admin"
/// - `CombinedApp`: The Router should be configured with base="/admin"
#[component]
pub fn AdminRoutes() -> impl IntoView {
view! {
<Routes fallback=|| "Page not found.".into_view()>
// Login page (no layout)
<Route path=StaticSegment("login") view=|| view! {
<LoginLayout>
<LoginPage />
</LoginLayout>
} />
// Dashboard
<Route path=StaticSegment("") view=|| view! {
<AuthenticatedLayout current_page="dashboard">
<DashboardPage />
</AuthenticatedLayout>
} />
// Config
<Route path=StaticSegment("config") view=|| view! {
<AuthenticatedLayout current_page="config">
<ConfigPage />
</AuthenticatedLayout>
} />
// Users
<Route path=StaticSegment("users") view=|| view! {
<AuthenticatedLayout current_page="users">
<UsersPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("users"), StaticSegment("new")) view=|| view! {
<AuthenticatedLayout current_page="users_new">
<UserNewPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("users"), ParamSegment("user_id")) view=|| view! {
<AuthenticatedLayout current_page="users">
<UserDetailPage />
</AuthenticatedLayout>
} />
// Staff
<Route path=StaticSegment("staff") view=|| view! {
<AuthenticatedLayout current_page="staff">
<StaffPage />
</AuthenticatedLayout>
} />
// Props
<Route path=StaticSegment("props") view=|| view! {
<AuthenticatedLayout current_page="props">
<PropsPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("props"), StaticSegment("new")) view=|| view! {
<AuthenticatedLayout current_page="props_new">
<PropsNewPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("props"), ParamSegment("prop_id")) view=|| view! {
<AuthenticatedLayout current_page="props">
<PropsDetailPage />
</AuthenticatedLayout>
} />
// Realms
<Route path=StaticSegment("realms") view=|| view! {
<AuthenticatedLayout current_page="realms">
<RealmsPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("realms"), StaticSegment("new")) view=|| view! {
<AuthenticatedLayout current_page="realms_new">
<RealmNewPage />
</AuthenticatedLayout>
} />
// Scenes (nested under realms)
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes")) view=|| view! {
<AuthenticatedLayout current_page="scenes">
<ScenesPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), StaticSegment("new")) view=|| view! {
<AuthenticatedLayout current_page="scenes_new">
<SceneNewPage />
</AuthenticatedLayout>
} />
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), ParamSegment("scene_id")) view=|| view! {
<AuthenticatedLayout current_page="scenes">
<SceneDetailPage />
</AuthenticatedLayout>
} />
// Realm detail (must come after more specific realm routes)
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=|| view! {
<AuthenticatedLayout current_page="realms">
<RealmDetailPage />
</AuthenticatedLayout>
} />
</Routes>
}
}

View file

@ -0,0 +1,204 @@
//! Utility functions for the admin UI.
/// Gets the API base path based on the current URL.
///
/// Returns `/api/admin` if the current path starts with `/admin`,
/// otherwise returns `/api`.
///
/// # Example
/// ```rust
/// let api_base = get_api_base();
/// let url = format!("{}/realms/{}", api_base, slug);
/// ```
#[cfg(feature = "hydrate")]
pub fn get_api_base() -> String {
web_sys::window()
.and_then(|w| w.location().pathname().ok())
.map(|path| {
if path.starts_with("/admin") {
"/api/admin".to_string()
} else {
"/api".to_string()
}
})
.unwrap_or_else(|| "/api".to_string())
}
/// Gets the API base path (SSR fallback - always returns /api).
#[cfg(not(feature = "hydrate"))]
pub fn get_api_base() -> String {
"/api".to_string()
}
/// Reloads the current page.
#[cfg(feature = "hydrate")]
pub fn reload_page() {
if let Some(window) = web_sys::window() {
let _ = window.location().reload();
}
}
/// Reloads the current page (SSR no-op).
#[cfg(not(feature = "hydrate"))]
pub fn reload_page() {}
/// Navigates to a new URL.
#[cfg(feature = "hydrate")]
pub fn navigate_to(url: &str) {
if let Some(window) = web_sys::window() {
let _ = window.location().set_href(url);
}
}
/// Navigates to a new URL (SSR no-op).
#[cfg(not(feature = "hydrate"))]
pub fn navigate_to(_url: &str) {}
/// Shows a browser confirm dialog and returns the result.
#[cfg(feature = "hydrate")]
pub fn confirm(message: &str) -> bool {
web_sys::window()
.and_then(|w| w.confirm_with_message(message).ok())
.unwrap_or(false)
}
/// Shows a browser confirm dialog (SSR fallback - always returns false).
#[cfg(not(feature = "hydrate"))]
pub fn confirm(_message: &str) -> bool {
false
}
/// Builds a paginated URL with optional search query.
pub fn build_paginated_url(base: &str, page: i64, query: &str, limit: i64) -> String {
if query.is_empty() {
format!("{}?page={}&limit={}", base, page, limit)
} else {
format!("{}?q={}&page={}&limit={}", base, query, page, limit)
}
}
/// Parse width and height from WKT bounds string.
///
/// Example: "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))" -> (800, 600)
/// Handles both "0 0, 800 0" (with space) and "0 0,800 0" (without space) formats.
pub fn parse_bounds_wkt(wkt: &str) -> (i32, i32) {
// Extract coordinates from POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x5 y5))
// The second point has (width, 0) and third point has (width, height)
if let Some(start) = wkt.find("((") {
if let Some(end) = wkt.find("))") {
let coords_str = &wkt[start + 2..end];
let points: Vec<&str> = coords_str.split(',').map(|s| s.trim()).collect();
if points.len() >= 3 {
// Second point: "width 0"
let second: Vec<&str> = points[1].split_whitespace().collect();
// Third point: "width height"
let third: Vec<&str> = points[2].split_whitespace().collect();
if !second.is_empty() && third.len() >= 2 {
let width = second[0].parse().unwrap_or(800);
let height = third[1].parse().unwrap_or(600);
return (width, height);
}
}
}
}
(800, 600)
}
/// Builds a WKT polygon string from width and height.
pub fn build_bounds_wkt(width: i32, height: i32) -> String {
format!(
"POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))",
width, width, height, height
)
}
/// Fetch image dimensions client-side using JavaScript Image API.
///
/// This works regardless of CORS since we're only reading dimensions, not pixel data.
/// The key is NOT setting the `crossorigin` attribute on the image element.
///
/// # Arguments
/// * `url` - The image URL to fetch dimensions from
/// * `on_success` - Callback receiving (width, height) on success
/// * `on_error` - Callback receiving error message on failure
/// * `set_loading` - Signal to set loading state
#[cfg(feature = "hydrate")]
pub fn fetch_image_dimensions_client<F, E>(
url: String,
on_success: F,
on_error: E,
set_loading: leptos::prelude::WriteSignal<bool>,
)
where
F: Fn(u32, u32) + 'static,
E: Fn(String) + Clone + 'static,
{
use leptos::prelude::Set;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
let on_error_for_onerror = on_error.clone();
let window = match web_sys::window() {
Some(w) => w,
None => {
on_error("No window object available".to_string());
set_loading.set(false);
return;
}
};
let document = match window.document() {
Some(d) => d,
None => {
on_error("No document object available".to_string());
set_loading.set(false);
return;
}
};
let img: web_sys::HtmlImageElement = match document
.create_element("img")
.ok()
.and_then(|el| el.dyn_into().ok())
{
Some(img) => img,
None => {
on_error("Failed to create image element".to_string());
set_loading.set(false);
return;
}
};
// Note: We intentionally do NOT set crossorigin attribute.
// Without it, we can load images from any URL and read their dimensions.
// The crossorigin attribute would cause CORS errors for external images.
let img_clone = img.clone();
let onload = Closure::wrap(Box::new(move || {
let width = img_clone.natural_width();
let height = img_clone.natural_height();
set_loading.set(false);
if width > 0 && height > 0 {
on_success(width, height);
} else {
on_error("Could not determine image dimensions".to_string());
}
}) as Box<dyn Fn()>);
let onerror = Closure::wrap(Box::new(move || {
set_loading.set(false);
on_error_for_onerror("Failed to load image".to_string());
}) as Box<dyn Fn()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
img.set_onerror(Some(onerror.as_ref().unchecked_ref()));
// Trigger the load
img.set_src(&url);
// Prevent closures from being dropped
onload.forget();
onerror.forget();
}