diff --git a/apps/chattyness-app/Cargo.toml b/apps/chattyness-app/Cargo.toml new file mode 100644 index 0000000..18c510b --- /dev/null +++ b/apps/chattyness-app/Cargo.toml @@ -0,0 +1,90 @@ +[package] +name = "chattyness-app" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "chattyness-app" +path = "src/main.rs" + +[dependencies] +chattyness-admin-ui.workspace = true +chattyness-user-ui.workspace = true +chattyness-db.workspace = true +chattyness-error.workspace = true +leptos.workspace = true +leptos_meta.workspace = true +leptos_router.workspace = true +leptos_axum = { workspace = true, optional = true } +axum = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tower-http = { workspace = true, optional = true } +tower-sessions = { workspace = true, optional = true } +tower-sessions-sqlx-store = { workspace = true, optional = true } +sqlx = { workspace = true, optional = true } +clap = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +dotenvy = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +serde.workspace = true +uuid.workspace = true + +# WASM dependencies +console_error_panic_hook = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } + +[features] +default = ["ssr"] +ssr = [ + "leptos/ssr", + "leptos_axum", + "axum", + "tower", + "tower-http", + "tower-sessions", + "tower-sessions-sqlx-store", + "sqlx", + "clap", + "tokio", + "dotenvy", + "tracing", + "tracing-subscriber", + "chattyness-admin-ui/ssr", + "chattyness-user-ui/ssr", + "chattyness-db/ssr", + "chattyness-error/ssr", +] +# Unified hydrate feature - admin routes are lazy-loaded via #[lazy] macro +hydrate = [ + "leptos/hydrate", + "chattyness-user-ui/hydrate", + "chattyness-admin-ui/hydrate", + "console_error_panic_hook", + "wasm-bindgen", +] + +[package.metadata.leptos] +# Project name used for output artifacts +output-name = "chattyness-app" + +# Site configuration (paths relative to workspace root) +site-root = "target/site" +site-pkg-dir = "pkg" +site-addr = "127.0.0.1:3000" +reload-port = 3003 + +# Tailwind CSS (path relative to this Cargo.toml) +tailwind-input-file = "style/tailwind.css" + +# Build settings +bin-features = ["ssr"] +bin-default-features = false +lib-features = ["hydrate"] +lib-default-features = false + +# Environment +env = "DEV" diff --git a/apps/chattyness-app/public/admin.css b/apps/chattyness-app/public/admin.css new file mode 120000 index 0000000..c984cf1 --- /dev/null +++ b/apps/chattyness-app/public/admin.css @@ -0,0 +1 @@ +../../../target/site-owner/static/chattyness-owner.css \ No newline at end of file diff --git a/apps/chattyness-app/public/favicon.ico b/apps/chattyness-app/public/favicon.ico new file mode 100644 index 0000000..473a0f4 diff --git a/apps/chattyness-app/src/app.rs b/apps/chattyness-app/src/app.rs new file mode 100644 index 0000000..75ba5dc --- /dev/null +++ b/apps/chattyness-app/src/app.rs @@ -0,0 +1,326 @@ +//! Combined application for lazy-loading admin interface. +//! +//! This module provides a unified app that serves both user and admin interfaces, +//! with the admin interface lazy-loaded to reduce initial WASM bundle size. + +use leptos::prelude::*; +use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; +use leptos_router::{ + components::{Route, Router, Routes}, + ParamSegment, StaticSegment, +}; + +// Re-export user pages for inline route definitions +use chattyness_user_ui::pages::{ + HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage, +}; + +// Lazy-load admin pages to split WASM bundle +// Each lazy function includes the admin CSS stylesheet for on-demand loading +#[lazy] +fn lazy_dashboard() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_login() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_config() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_users() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_user_new() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_user_detail() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_staff() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_realms() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_realm_new() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_realm_detail() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_scenes() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_scene_new() -> AnyView { + view! { + + + + + }.into_any() +} + +#[lazy] +fn lazy_scene_detail() -> AnyView { + view! { + + + + + }.into_any() +} + +/// Admin loading fallback - shown on both server (SSR) and client until lazy content loads. +#[component] +fn AdminLoading() -> impl IntoView { + view! { +
+
+

"Loading admin panel..."

+
+ } +} + +/// Macro to generate lazy admin route view functions. +/// Both SSR and client render the same Suspense structure initially. +/// On SSR: Suspense child is empty, so fallback always shows. +/// On client: Suspense child is the lazy content, which loads after hydration. +macro_rules! lazy_admin_view { + ($name:ident, $lazy_fn:ident) => { + fn $name() -> impl IntoView { + view! { + + { + // On server: empty content, Suspense shows fallback + // On client: lazy content loads after hydration + #[cfg(feature = "ssr")] + { () } + #[cfg(feature = "hydrate")] + { Suspend::new(async { $lazy_fn().await }) } + } + + } + } + }; +} + +// Generate view functions for each admin route +lazy_admin_view!(admin_login_view, lazy_login); +lazy_admin_view!(admin_dashboard_view, lazy_dashboard); +lazy_admin_view!(admin_config_view, lazy_config); +lazy_admin_view!(admin_users_view, lazy_users); +lazy_admin_view!(admin_user_new_view, lazy_user_new); +lazy_admin_view!(admin_user_detail_view, lazy_user_detail); +lazy_admin_view!(admin_staff_view, lazy_staff); +lazy_admin_view!(admin_realms_view, lazy_realms); +lazy_admin_view!(admin_realm_new_view, lazy_realm_new); +lazy_admin_view!(admin_realm_detail_view, lazy_realm_detail); +lazy_admin_view!(admin_scenes_view, lazy_scenes); +lazy_admin_view!(admin_scene_new_view, lazy_scene_new); +lazy_admin_view!(admin_scene_detail_view, lazy_scene_detail); + +/// Combined app state for unified SSR. +#[cfg(feature = "ssr")] +#[derive(Clone)] +pub struct CombinedAppState { + pub pool: sqlx::PgPool, + pub leptos_options: LeptosOptions, +} + +#[cfg(feature = "ssr")] +impl axum::extract::FromRef for LeptosOptions { + fn from_ref(state: &CombinedAppState) -> Self { + state.leptos_options.clone() + } +} + +#[cfg(feature = "ssr")] +impl axum::extract::FromRef for sqlx::PgPool { + fn from_ref(state: &CombinedAppState) -> Self { + state.pool.clone() + } +} + +/// Combined shell for SSR. +pub fn combined_shell(options: LeptosOptions) -> impl IntoView { + view! { + + + + + + + + + + + + + + } +} + +/// Combined application component with lazy-loaded admin routes. +/// +/// User routes are eagerly loaded, admin routes are lazy-loaded via LocalResource +/// to ensure consistent SSR/hydration (server renders fallback, client loads lazy content). +#[component] +pub fn CombinedApp() -> impl IntoView { + provide_meta_context(); + + view! { + + + + <Router> + <main> + <Routes fallback=|| "Page not found.".into_view()> + // ========================================== + // User routes (eager loading) + // ========================================== + <Route path=StaticSegment("") view=LoginPage /> + <Route path=StaticSegment("signup") view=SignupPage /> + <Route path=StaticSegment("home") view=HomePage /> + <Route path=StaticSegment("password-reset") view=PasswordResetPage /> + <Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage /> + + // ========================================== + // Admin routes (lazy loading) + // Server renders fallback, client loads lazy WASM after hydration. + // ========================================== + <Route + path=(StaticSegment("admin"), StaticSegment("login")) + view=admin_login_view + /> + <Route + path=StaticSegment("admin") + view=admin_dashboard_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("config")) + view=admin_config_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("users")) + view=admin_users_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("users"), StaticSegment("new")) + view=admin_user_new_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("users"), ParamSegment("user_id")) + view=admin_user_detail_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("staff")) + view=admin_staff_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("realms")) + view=admin_realms_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("realms"), StaticSegment("new")) + view=admin_realm_new_view + /> + // Scene routes (must come before realm detail to match first) + <Route + path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes")) + view=admin_scenes_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), StaticSegment("new")) + view=admin_scene_new_view + /> + <Route + path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), ParamSegment("scene_id")) + view=admin_scene_detail_view + /> + // Realm detail (must come after more specific routes) + <Route + path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug")) + view=admin_realm_detail_view + /> + </Routes> + </main> + </Router> + } +} diff --git a/apps/chattyness-app/src/lib.rs b/apps/chattyness-app/src/lib.rs new file mode 100644 index 0000000..96ab28a --- /dev/null +++ b/apps/chattyness-app/src/lib.rs @@ -0,0 +1,20 @@ +#![recursion_limit = "256"] +//! App WASM hydration entry point. +//! +//! This provides unified hydration for the combined app, with lazy-loaded +//! admin routes for optimal bundle size. + +mod app; + +pub use app::{combined_shell, CombinedApp}; + +#[cfg(feature = "ssr")] +pub use app::CombinedAppState; + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + console_error_panic_hook::set_once(); + // Use hydrate_lazy to enable lazy component loading + leptos::mount::hydrate_lazy(CombinedApp); +} diff --git a/apps/chattyness-app/src/main.rs b/apps/chattyness-app/src/main.rs new file mode 100644 index 0000000..d5d5a4a --- /dev/null +++ b/apps/chattyness-app/src/main.rs @@ -0,0 +1,196 @@ +#![recursion_limit = "256"] +//! App server entry point. +//! +//! This server runs on port 3000 and serves both user and admin interfaces +//! using a unified CombinedApp with lazy-loaded admin routes. +//! +//! Both interfaces share the same `chattyness_app` database role with RLS. + +#[cfg(feature = "ssr")] +mod server { + use axum::Router; + use clap::Parser; + use leptos::prelude::*; + use leptos_axum::{generate_route_list, LeptosRoutes}; + use sqlx::postgres::PgPoolOptions; + use std::net::SocketAddr; + use std::path::Path; + use std::sync::Arc; + use tower_http::services::ServeDir; + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + + use chattyness_app::{combined_shell, CombinedApp, CombinedAppState}; + use chattyness_user_ui::api::WebSocketState; + + /// CLI arguments. + #[derive(Parser)] + #[command(name = "chattyness-app")] + #[command(about = "Chattyness App Server (User + Admin UI)")] + struct Args { + /// Host to bind to + #[arg(long, env = "HOST", default_value = "127.0.0.1")] + host: String, + + /// Port to bind to + #[arg(long, env = "APP_PORT", default_value = "3000")] + port: u16, + + /// Database password for chattyness_app role + #[arg(long, env = "DB_CHATTYNESS_APP")] + db_password: String, + + /// Use secure cookies + #[arg(long, env = "SECURE_COOKIES", default_value = "false")] + secure_cookies: bool, + } + + pub async fn main() -> Result<(), Box<dyn std::error::Error>> { + // Load environment variables + dotenvy::dotenv().ok(); + + // Initialize logging + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "chattyness_app=debug,chattyness_user_ui=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Parse arguments + let args = Args::parse(); + + tracing::info!("Starting Chattyness App Server"); + + // Create database pool for app access (fixed connection string, RLS-constrained) + let database_url = format!( + "postgres://chattyness_app:{}@localhost/chattyness", + args.db_password + ); + let pool = PgPoolOptions::new() + .max_connections(20) + .connect(&database_url) + .await?; + + tracing::info!("Connected to database (app role with RLS)"); + + // Configure Leptos + let cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); + let conf = get_configuration(Some(cargo_toml)).unwrap(); + let leptos_options = conf.leptos_options; + let addr = SocketAddr::new(args.host.parse()?, args.port); + + // Create session layer (shared between user and admin interfaces) + let session_layer = + chattyness_user_ui::auth::session::create_session_layer(pool.clone(), args.secure_cookies) + .await; + + // Create combined app state + let app_state = CombinedAppState { + pool: pool.clone(), + leptos_options: leptos_options.clone(), + }; + + // Generate routes for the combined app + let routes = generate_route_list(CombinedApp); + + // Get site paths from Leptos config + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir.parent().unwrap().parent().unwrap(); + let site_root = workspace_root.join(&*leptos_options.site_root); + // site-pkg-dir is now "pkg" to match wasm_split's hardcoded /pkg/ imports + let pkg_dir = site_root.join("pkg"); + let public_dir = manifest_dir.join("public"); + // --split mode puts WASM/JS in target/site-app/pkg/ instead of configured location + let split_pkg_dir = workspace_root.join("target/site-app/pkg"); + + tracing::info!("Serving pkg files from: {}", pkg_dir.display()); + tracing::info!("Serving split WASM from: {}", split_pkg_dir.display()); + + // Shared assets directory for uploaded files (realm images, etc.) + let assets_dir = Path::new("/srv/chattyness/assets"); + + // Create WebSocket state for real-time channel presence + let ws_state = Arc::new(WebSocketState::new()); + + // Create state types for each API router + let user_api_state = chattyness_user_ui::AppState { + pool: pool.clone(), + leptos_options: leptos_options.clone(), + ws_state: ws_state.clone(), + }; + let admin_api_state = chattyness_admin_ui::AdminAppState { + pool: pool.clone(), + leptos_options: leptos_options.clone(), + }; + + // Build nested API routers with their own state + let user_api_router = chattyness_user_ui::api::api_router() + .with_state(user_api_state); + let admin_api_router = chattyness_admin_ui::api::admin_api_router() + .with_state(admin_api_state); + + // Create RLS layer for row-level security + let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone()); + + // Build the unified app + // Layer order (outer to inner): session -> rls -> router + // This ensures session is available when RLS middleware runs + let app = Router::new() + // API routes (with their own state) + .nest("/api", user_api_router) + .nest("/api/admin", admin_api_router) + // Leptos routes with unified shell + .leptos_routes_with_context( + &app_state, + routes, + { + let pool = pool.clone(); + move || { + provide_context(pool.clone()); + } + }, + { + let leptos_options = leptos_options.clone(); + move || combined_shell(leptos_options.clone()) + }, + ) + .with_state(app_state) + // Serve pkg files at /pkg (wasm_split hardcodes /pkg/ imports) + // Fallback to split_pkg_dir for --split mode output + .nest_service("/pkg", ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir))) + // Uploaded assets (realm backgrounds, etc.) - must come before /static + .nest_service("/static/realm", ServeDir::new(assets_dir.join("realm"))) + // Server-level assets (avatar props, etc.) + .nest_service("/static/server", ServeDir::new(assets_dir.join("server"))) + // Also serve at /static for backwards compatibility + .nest_service("/static", ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir))) + .nest_service("/favicon.ico", tower_http::services::ServeFile::new(public_dir.join("favicon.ico"))) + // Serve admin CSS at standardized path (symlinked from owner build) + .nest_service("/static/css/admin.css", tower_http::services::ServeFile::new(public_dir.join("admin.css"))) + // Apply middleware layers (order: session outer, rls inner) + .layer(rls_layer) + .layer(session_layer); + + tracing::info!("Listening on http://{}", addr); + tracing::info!(" User UI: http://{}/", addr); + tracing::info!(" Admin UI: http://{}/admin", addr); + + // Start server + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app.into_make_service()).await?; + + Ok(()) + } +} + +#[cfg(feature = "ssr")] +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + server::main().await +} + +#[cfg(not(feature = "ssr"))] +fn main() { + // This is for WASM build, which is handled by lib.rs +} diff --git a/apps/chattyness-app/style/tailwind.css b/apps/chattyness-app/style/tailwind.css new file mode 100644 index 0000000..a3d3d22 --- /dev/null +++ b/apps/chattyness-app/style/tailwind.css @@ -0,0 +1,78 @@ +@import "tailwindcss"; + +/* Source paths (relative to this CSS file's location) */ +/* Only scan user-ui sources - admin-ui CSS is lazy-loaded from /admin.css */ +@source "../src/**/*.rs"; +@source "../public/**/*.html"; +@source "../../../crates/chattyness-user-ui/src/**/*.rs"; +@source not "../../../target"; + +/* Custom theme extensions */ +@theme { + --color-realm-50: #f0f9ff; + --color-realm-100: #e0f2fe; + --color-realm-200: #bae6fd; + --color-realm-300: #7dd3fc; + --color-realm-400: #38bdf8; + --color-realm-500: #0ea5e9; + --color-realm-600: #0284c7; + --color-realm-700: #0369a1; + --color-realm-800: #075985; + --color-realm-900: #0c4a6e; + --color-realm-950: #082f49; +} + +/* User-specific styles only */ +@import "./user.css"; + +/* Base styles for accessibility */ +@layer base { + /* Focus visible for keyboard navigation */ + :focus-visible { + @apply outline-2 outline-offset-2 outline-blue-500; + } + + /* Reduce motion for users who prefer it */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } +} + +/* Component styles used by user-ui */ +@layer components { + /* Form input base styles */ + .input-base { + @apply w-full px-4 py-3 bg-gray-700 border border-gray-600 + rounded-lg text-white placeholder-gray-400 + focus:ring-2 focus:ring-blue-500 focus:border-transparent + transition-colors duration-200; + } + + /* Button base styles */ + .btn-primary { + @apply px-6 py-3 bg-blue-600 hover:bg-blue-700 + disabled:bg-gray-600 disabled:cursor-not-allowed + text-white font-semibold rounded-lg + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 + transition-colors duration-200; + } + + .btn-secondary { + @apply px-6 py-3 bg-gray-600 hover:bg-gray-500 + disabled:bg-gray-700 disabled:cursor-not-allowed + text-white font-semibold rounded-lg + focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-800 + transition-colors duration-200; + } + + /* Error message styles */ + .error-message { + @apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200; + } +} diff --git a/apps/chattyness-app/style/user.css b/apps/chattyness-app/style/user.css new file mode 100644 index 0000000..a1a72cf --- /dev/null +++ b/apps/chattyness-app/style/user.css @@ -0,0 +1,8 @@ +/** + * User Interface Styles + * + * CSS custom properties and component styles for the user-facing interface. + * This file is imported after admin.css to allow user-specific overrides. + */ + +/* User-specific styles will be added here as needed */ diff --git a/apps/chattyness-app/target/site/pkg/chattyness-app.css b/apps/chattyness-app/target/site/pkg/chattyness-app.css new file mode 100644 index 0000000..cd9b7f0 --- /dev/null +++ b/apps/chattyness-app/target/site/pkg/chattyness-app.css @@ -0,0 +1,2369 @@ +/*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; + +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --color-red-200: oklch(88.5% .062 18.334); + --color-red-300: oklch(80.8% .114 19.571); + --color-red-400: oklch(70.4% .191 22.216); + --color-red-500: oklch(63.7% .237 25.331); + --color-red-600: oklch(57.7% .245 27.325); + --color-red-700: oklch(50.5% .213 27.518); + --color-red-800: oklch(44.4% .177 26.899); + --color-red-900: oklch(39.6% .141 25.723); + --color-orange-600: oklch(64.6% .222 41.116); + --color-yellow-300: oklch(90.5% .182 98.111); + --color-yellow-400: oklch(85.2% .199 91.936); + --color-yellow-500: oklch(79.5% .184 86.047); + --color-yellow-600: oklch(68.1% .162 75.834); + --color-green-200: oklch(92.5% .084 155.995); + --color-green-400: oklch(79.2% .209 151.711); + --color-green-500: oklch(72.3% .219 149.579); + --color-green-600: oklch(62.7% .194 149.214); + --color-green-900: oklch(39.3% .095 152.535); + --color-blue-300: oklch(80.9% .105 251.813); + --color-blue-400: oklch(70.7% .165 254.624); + --color-blue-500: oklch(62.3% .214 259.815); + --color-blue-600: oklch(54.6% .245 262.881); + --color-blue-700: oklch(48.8% .243 264.376); + --color-purple-400: oklch(71.4% .203 305.504); + --color-gray-300: oklch(87.2% .01 258.338); + --color-gray-400: oklch(70.7% .022 261.325); + --color-gray-500: oklch(55.1% .027 264.364); + --color-gray-600: oklch(44.6% .03 256.802); + --color-gray-700: oklch(37.3% .034 259.733); + --color-gray-800: oklch(27.8% .033 256.848); + --color-gray-900: oklch(21% .034 264.665); + --color-black: #000; + --color-white: #fff; + --spacing: .25rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-2xl: 42rem; + --container-4xl: 56rem; + --container-7xl: 80rem; + --text-xs: .75rem; + --text-xs--line-height: calc(1 / .75); + --text-sm: .875rem; + --text-sm--line-height: calc(1.25 / .875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --radius-md: .375rem; + --radius-lg: .5rem; + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1); + --blur-sm: 8px; + --aspect-video: 16 / 9; + --default-transition-duration: .15s; + --default-transition-timing-function: cubic-bezier(.4, 0, .2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} + +@layer base { + *, :after, :before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + + b, strong { + font-weight: bolder; + } + + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + + small { + font-size: 80%; + } + + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + + sub { + bottom: -.25em; + } + + sup { + top: -.5em; + } + + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + + :-moz-focusring { + outline: auto; + } + + progress { + vertical-align: baseline; + } + + summary { + display: list-item; + } + + ol, ul, menu { + list-style: none; + } + + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + + img, video { + max-width: 100%; + height: auto; + } + + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: rgba(0, 0, 0, 0); + opacity: 1; + } + + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + + ::file-selector-button { + margin-inline-end: 4px; + } + + ::placeholder { + opacity: 1; + } + + @supports (not ((-webkit-appearance: -apple-pay-button))) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentColor; + } + + @supports (color: color-mix(in lab, red, red)) { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + + textarea { + resize: vertical; + } + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + + ::-webkit-datetime-edit { + display: inline-flex; + } + + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + + :-moz-ui-invalid { + box-shadow: none; + } + + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} + +@layer utilities { + .pointer-events-none { + pointer-events: none; + } + + .collapse { + visibility: collapse; + } + + .invisible { + visibility: hidden; + } + + .visible { + visibility: visible; + } + + .absolute { + position: absolute; + } + + .fixed { + position: fixed; + } + + .fixed\! { + position: fixed !important; + } + + .relative { + position: relative; + } + + .static { + position: static; + } + + .sticky { + position: sticky; + } + + .inset-0 { + inset: calc(var(--spacing) * 0); + } + + .inset-y-0 { + inset-block: calc(var(--spacing) * 0); + } + + .end-1 { + inset-inline-end: calc(var(--spacing) * 1); + } + + .end-2 { + inset-inline-end: calc(var(--spacing) * 2); + } + + .end-3 { + inset-inline-end: calc(var(--spacing) * 3); + } + + .end-5 { + inset-inline-end: calc(var(--spacing) * 5); + } + + .end-9 { + inset-inline-end: calc(var(--spacing) * 9); + } + + .end-10 { + inset-inline-end: calc(var(--spacing) * 10); + } + + .end-32 { + inset-inline-end: calc(var(--spacing) * 32); + } + + .end-88 { + inset-inline-end: calc(var(--spacing) * 88); + } + + .top-2 { + top: calc(var(--spacing) * 2); + } + + .top-4 { + top: calc(var(--spacing) * 4); + } + + .top-10 { + top: calc(var(--spacing) * 10); + } + + .right-0 { + right: calc(var(--spacing) * 0); + } + + .right-2 { + right: calc(var(--spacing) * 2); + } + + .right-4 { + right: calc(var(--spacing) * 4); + } + + .bottom-0 { + bottom: calc(var(--spacing) * 0); + } + + .bottom-2 { + bottom: calc(var(--spacing) * 2); + } + + .left-0 { + left: calc(var(--spacing) * 0); + } + + .left-1 { + left: calc(var(--spacing) * 1); + } + + .left-2 { + left: calc(var(--spacing) * 2); + } + + .isolate { + isolation: isolate; + } + + .z-4 { + z-index: 4; + } + + .z-50 { + z-index: 50; + } + + .col-span-2 { + grid-column: span 2 / span 2; + } + + .container { + width: 100%; + } + + @media (width >= 40rem) { + .container { + max-width: 40rem; + } + } + + @media (width >= 48rem) { + .container { + max-width: 48rem; + } + } + + @media (width >= 64rem) { + .container { + max-width: 64rem; + } + } + + @media (width >= 80rem) { + .container { + max-width: 80rem; + } + } + + @media (width >= 96rem) { + .container { + max-width: 96rem; + } + } + + .mx-4 { + margin-inline: calc(var(--spacing) * 4); + } + + .mx-auto { + margin-inline: auto; + } + + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + + .mr-2 { + margin-right: calc(var(--spacing) * 2); + } + + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + + .mb-16 { + margin-bottom: calc(var(--spacing) * 16); + } + + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } + + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } + + .ml-6 { + margin-left: calc(var(--spacing) * 6); + } + + .ml-auto { + margin-left: auto; + } + + .block { + display: block; + } + + .block\! { + display: block !important; + } + + .contents { + display: contents; + } + + .flex { + display: flex; + } + + .grid { + display: grid; + } + + .grid\! { + display: grid !important; + } + + .hidden { + display: none; + } + + .inline { + display: inline; + } + + .inline-block { + display: inline-block; + } + + .inline-flex { + display: inline-flex; + } + + .table { + display: table; + } + + .table\! { + display: table !important; + } + + .table-row { + display: table-row; + } + + .aspect-video { + aspect-ratio: var(--aspect-video); + } + + .size-1 { + width: calc(var(--spacing) * 1); + height: calc(var(--spacing) * 1); + } + + .h-1 { + height: calc(var(--spacing) * 1); + } + + .h-2 { + height: calc(var(--spacing) * 2); + } + + .h-4 { + height: calc(var(--spacing) * 4); + } + + .h-6 { + height: calc(var(--spacing) * 6); + } + + .h-8 { + height: calc(var(--spacing) * 8); + } + + .h-10 { + height: calc(var(--spacing) * 10); + } + + .h-12 { + height: calc(var(--spacing) * 12); + } + + .h-16 { + height: calc(var(--spacing) * 16); + } + + .h-20 { + height: calc(var(--spacing) * 20); + } + + .h-32 { + height: calc(var(--spacing) * 32); + } + + .h-64 { + height: calc(var(--spacing) * 64); + } + + .h-auto { + height: auto; + } + + .h-full { + height: 100%; + } + + .max-h-32 { + max-height: calc(var(--spacing) * 32); + } + + .max-h-48 { + max-height: calc(var(--spacing) * 48); + } + + .max-h-64 { + max-height: calc(var(--spacing) * 64); + } + + .min-h-\[50vh\] { + min-height: 50vh; + } + + .min-h-screen { + min-height: 100vh; + } + + .w-3 { + width: calc(var(--spacing) * 3); + } + + .w-4 { + width: calc(var(--spacing) * 4); + } + + .w-6 { + width: calc(var(--spacing) * 6); + } + + .w-8 { + width: calc(var(--spacing) * 8); + } + + .w-10 { + width: calc(var(--spacing) * 10); + } + + .w-12 { + width: calc(var(--spacing) * 12); + } + + .w-16 { + width: calc(var(--spacing) * 16); + } + + .w-20 { + width: calc(var(--spacing) * 20); + } + + .w-24 { + width: calc(var(--spacing) * 24); + } + + .w-64 { + width: calc(var(--spacing) * 64); + } + + .w-full { + width: 100%; + } + + .max-w-2xl { + max-width: var(--container-2xl); + } + + .max-w-4xl { + max-width: var(--container-4xl); + } + + .max-w-7xl { + max-width: var(--container-7xl); + } + + .max-w-lg { + max-width: var(--container-lg); + } + + .max-w-md { + max-width: var(--container-md); + } + + .flex-1 { + flex: 1; + } + + .shrink { + flex-shrink: 1; + } + + .grow { + flex-grow: 1; + } + + .border-collapse { + border-collapse: collapse; + } + + .scale-19 { + --tw-scale-x: 19%; + --tw-scale-y: 19%; + --tw-scale-z: 19%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + + .scale-21 { + --tw-scale-x: 21%; + --tw-scale-y: 21%; + --tw-scale-z: 21%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + + .transform { + transform: var(--tw-rotate-x, ) var(--tw-rotate-y, ) var(--tw-rotate-z, ) var(--tw-skew-x, ) var(--tw-skew-y, ); + } + + .cursor-crosshair { + cursor: crosshair; + } + + .cursor-not-allowed { + cursor: not-allowed; + } + + .cursor-pointer { + cursor: pointer; + } + + .resize { + resize: both; + } + + .resize-y { + resize: vertical; + } + + .appearance-none { + appearance: none; + } + + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .flex-col { + flex-direction: column; + } + + .flex-wrap { + flex-wrap: wrap; + } + + .items-center { + align-items: center; + } + + .items-start { + align-items: flex-start; + } + + .justify-between { + justify-content: space-between; + } + + .justify-center { + justify-content: center; + } + + .gap-1 { + gap: calc(var(--spacing) * 1); + } + + .gap-2 { + gap: calc(var(--spacing) * 2); + } + + .gap-3 { + gap: calc(var(--spacing) * 3); + } + + .gap-4 { + gap: calc(var(--spacing) * 4); + } + + .gap-6 { + gap: calc(var(--spacing) * 6); + } + + .gap-8 { + gap: calc(var(--spacing) * 8); + } + + :where(.space-y-2 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-y-3 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-y-4 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-y-6 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-x-2 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + + :where(.space-x-3 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } + + :where(.space-x-4 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .overflow-auto { + overflow: auto; + } + + .overflow-hidden { + overflow: hidden; + } + + .overflow-y-auto { + overflow-y: auto; + } + + .rounded { + border-radius: .25rem; + } + + .rounded-full { + border-radius: 3.40282e38px; + } + + .rounded-lg { + border-radius: var(--radius-lg); + } + + .rounded-md { + border-radius: var(--radius-md); + } + + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + + .border\! { + border-style: var(--tw-border-style) !important; + border-width: 1px !important; + } + + .border-0 { + border-style: var(--tw-border-style); + border-width: 0; + } + + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } + + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + + .border-blue-500 { + border-color: var(--color-blue-500); + } + + .border-gray-500\/50 { + border-color: rgba(106, 114, 130, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .border-gray-500\/50 { + border-color: color-mix(in oklab, var(--color-gray-500) 50%, transparent); + } + } + + .border-gray-600 { + border-color: var(--color-gray-600); + } + + .border-gray-700 { + border-color: var(--color-gray-700); + } + + .border-green-500 { + border-color: var(--color-green-500); + } + + .border-green-500\/50 { + border-color: rgba(0, 199, 88, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .border-green-500\/50 { + border-color: color-mix(in oklab, var(--color-green-500) 50%, transparent); + } + } + + .border-red-800 { + border-color: var(--color-red-800); + } + + .border-transparent { + border-color: rgba(0, 0, 0, 0); + } + + .border-white { + border-color: var(--color-white); + } + + .bg-black\/70 { + background-color: rgba(0, 0, 0, .7); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-black\/70 { + background-color: color-mix(in oklab, var(--color-black) 70%, transparent); + } + } + + .bg-blue-500\/10 { + background-color: rgba(48, 128, 255, .1); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-500\/10 { + background-color: color-mix(in oklab, var(--color-blue-500) 10%, transparent); + } + } + + .bg-blue-500\/30 { + background-color: rgba(48, 128, 255, .3); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-500\/30 { + background-color: color-mix(in oklab, var(--color-blue-500) 30%, transparent); + } + } + + .bg-blue-600 { + background-color: var(--color-blue-600); + } + + .bg-blue-600\/20 { + background-color: rgba(21, 93, 252, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-600\/20 { + background-color: color-mix(in oklab, var(--color-blue-600) 20%, transparent); + } + } + + .bg-gray-500\/20 { + background-color: rgba(106, 114, 130, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-gray-500\/20 { + background-color: color-mix(in oklab, var(--color-gray-500) 20%, transparent); + } + } + + .bg-gray-600 { + background-color: var(--color-gray-600); + } + + .bg-gray-700 { + background-color: var(--color-gray-700); + } + + .bg-gray-800 { + background-color: var(--color-gray-800); + } + + .bg-gray-900 { + background-color: var(--color-gray-900); + } + + .bg-green-500 { + background-color: var(--color-green-500); + } + + .bg-green-500\/20 { + background-color: rgba(0, 199, 88, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-green-500\/20 { + background-color: color-mix(in oklab, var(--color-green-500) 20%, transparent); + } + } + + .bg-green-600 { + background-color: var(--color-green-600); + } + + .bg-green-900\/50 { + background-color: rgba(13, 84, 43, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-green-900\/50 { + background-color: color-mix(in oklab, var(--color-green-900) 50%, transparent); + } + } + + .bg-orange-600 { + background-color: var(--color-orange-600); + } + + .bg-red-600 { + background-color: var(--color-red-600); + } + + .bg-red-900\/20 { + background-color: rgba(130, 24, 26, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-red-900\/20 { + background-color: color-mix(in oklab, var(--color-red-900) 20%, transparent); + } + } + + .bg-transparent { + background-color: rgba(0, 0, 0, 0); + } + + .bg-yellow-500 { + background-color: var(--color-yellow-500); + } + + .bg-yellow-600\/20 { + background-color: rgba(205, 137, 0, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-yellow-600\/20 { + background-color: color-mix(in oklab, var(--color-yellow-600) 20%, transparent); + } + } + + .bg-gradient-to-t { + --tw-gradient-position: to top in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); + } + + .from-black\/70 { + --tw-gradient-from: rgba(0, 0, 0, .7); + } + + @supports (color: color-mix(in lab, red, red)) { + .from-black\/70 { + --tw-gradient-from: color-mix(in oklab, var(--color-black) 70%, transparent); + } + } + + .from-black\/70 { + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + + .to-transparent { + --tw-gradient-to: transparent; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + + .object-cover { + object-fit: cover; + } + + .p-1 { + padding: calc(var(--spacing) * 1); + } + + .p-2 { + padding: calc(var(--spacing) * 2); + } + + .p-3 { + padding: calc(var(--spacing) * 3); + } + + .p-4 { + padding: calc(var(--spacing) * 4); + } + + .p-6 { + padding: calc(var(--spacing) * 6); + } + + .p-8 { + padding: calc(var(--spacing) * 8); + } + + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + + .py-0\.5 { + padding-block: calc(var(--spacing) * .5); + } + + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + + .py-16 { + padding-block: calc(var(--spacing) * 16); + } + + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } + + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } + + .pl-6 { + padding-left: calc(var(--spacing) * 6); + } + + .text-center { + text-align: center; + } + + .text-left { + text-align: left; + } + + .font-mono { + font-family: var(--font-mono); + } + + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + + .leading-0 { + --tw-leading: calc(var(--spacing) * 0); + line-height: calc(var(--spacing) * 0); + } + + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + + .text-blue-400 { + color: var(--color-blue-400); + } + + .text-blue-500 { + color: var(--color-blue-500); + } + + .text-gray-300 { + color: var(--color-gray-300); + } + + .text-gray-400 { + color: var(--color-gray-400); + } + + .text-gray-500 { + color: var(--color-gray-500); + } + + .text-green-200 { + color: var(--color-green-200); + } + + .text-green-400 { + color: var(--color-green-400); + } + + .text-purple-400 { + color: var(--color-purple-400); + } + + .text-red-300 { + color: var(--color-red-300); + } + + .text-red-400 { + color: var(--color-red-400); + } + + .text-white { + color: var(--color-white); + } + + .text-yellow-400 { + color: var(--color-yellow-400); + } + + .capitalize { + text-transform: capitalize; + } + + .lowercase { + text-transform: lowercase; + } + + .uppercase { + text-transform: uppercase; + } + + .italic { + font-style: italic; + } + + .ordinal { + --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, ); + } + + .line-through { + text-decoration-line: line-through; + } + + .underline { + text-decoration-line: underline; + } + + .antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .accent-blue-500 { + accent-color: var(--color-blue-500); + } + + .opacity-50 { + opacity: .5; + } + + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgba(0, 0, 0, .1)), 0 1px 2px -1px var(--tw-shadow-color, rgba(0, 0, 0, .1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .shadow-2xl { + --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgba(0, 0, 0, .25)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .shadow-xl { + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgba(0, 0, 0, .1)), 0 8px 10px -6px var(--tw-shadow-color, rgba(0, 0, 0, .1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .ring { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .ring-2 { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .ring-blue-500 { + --tw-ring-color: var(--color-blue-500); + } + + .ring-offset-2 { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + + .ring-offset-gray-800 { + --tw-ring-offset-color: var(--color-gray-800); + } + + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + + .blur { + --tw-blur: blur(8px); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .grayscale { + --tw-grayscale: grayscale(100%); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .invert { + --tw-invert: invert(100%); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .filter { + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, ); + backdrop-filter: var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, ); + } + + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .select-all { + -webkit-user-select: all; + user-select: all; + } + + .select-none { + -webkit-user-select: none; + user-select: none; + } + + .\[bad-link\:0\] { + bad-link: 0; + } + + .\[driver\:nssock\] { + driver: nssock; + } + + .\[hexley\:Tcl\/build\/tcl\] { + hexley: Tcl / build / tcl; + } + + .\[hexley\:Tcl\/tk8\.5\.4\/macosx\] { + hexley: Tcl / tk8.5.4 / macosx; + } + + .\[localhost\:\~\/Desktop\/c84bcopy\] { + localhost: ~ / Desktop / c84bcopy; + } + + .\[localhost\:\~\/desktop\/c84bcopy\] { + localhost: ~ / desktop / c84bcopy; + } + + .\[log\:log\] { + log: log; + } + + .\[mailto\:eric\.tse\@intel\.com\] { + mailto: eric. tse@intel. com; + } + + .\[mailto\:hlavana\@cisco\.com\] { + mailto: hlavana@cisco. com; + } + + .\[mailto\:tcl-win-admin\@lists\.sourceforge\.net\] { + mailto: tcl-win-admin@lists. sourceforge. net; + } + + .\[rowen\:\~\/tk8\.4\.6\/unix\] { + rowen: ~ / tk8.4.6 / unix; + } + + .\[tk\:chooseColor\] { + tk: chooseColor; + } + + .\[ttk\:PanedWindow\] { + ttk: PanedWindow; + } + + .\[ttk\:PannedWindow\] { + ttk: PannedWindow; + } + + .\[tz\:gettime\] { + tz: gettime; + } + + @media (hover: hover) { + .group-hover\:text-blue-400:is(:where(.group):hover *) { + color: var(--color-blue-400); + } + } + + @media (hover: hover) { + .hover\:scale-110:hover { + --tw-scale-x: 110%; + --tw-scale-y: 110%; + --tw-scale-z: 110%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + + @media (hover: hover) { + .hover\:border-gray-500:hover { + border-color: var(--color-gray-500); + } + } + + @media (hover: hover) { + .hover\:border-gray-600:hover { + border-color: var(--color-gray-600); + } + } + + @media (hover: hover) { + .hover\:bg-blue-700:hover { + background-color: var(--color-blue-700); + } + } + + @media (hover: hover) { + .hover\:bg-gray-600:hover { + background-color: var(--color-gray-600); + } + } + + @media (hover: hover) { + .hover\:bg-gray-700:hover { + background-color: var(--color-gray-700); + } + } + + @media (hover: hover) { + .hover\:bg-gray-700\/50:hover { + background-color: rgba(54, 65, 83, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-gray-700\/50:hover { + background-color: color-mix(in oklab, var(--color-gray-700) 50%, transparent); + } + } + } + + @media (hover: hover) { + .hover\:bg-green-500\/30:hover { + background-color: rgba(0, 199, 88, .3); + } + + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-green-500\/30:hover { + background-color: color-mix(in oklab, var(--color-green-500) 30%, transparent); + } + } + } + + @media (hover: hover) { + .hover\:bg-red-700:hover { + background-color: var(--color-red-700); + } + } + + @media (hover: hover) { + .hover\:bg-red-900\/20:hover { + background-color: rgba(130, 24, 26, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-red-900\/20:hover { + background-color: color-mix(in oklab, var(--color-red-900) 20%, transparent); + } + } + } + + @media (hover: hover) { + .hover\:text-blue-300:hover { + color: var(--color-blue-300); + } + } + + @media (hover: hover) { + .hover\:text-blue-400:hover { + color: var(--color-blue-400); + } + } + + @media (hover: hover) { + .hover\:text-red-300:hover { + color: var(--color-red-300); + } + } + + @media (hover: hover) { + .hover\:text-white:hover { + color: var(--color-white); + } + } + + @media (hover: hover) { + .hover\:text-yellow-300:hover { + color: var(--color-yellow-300); + } + } + + @media (hover: hover) { + .hover\:underline:hover { + text-decoration-line: underline; + } + } + + @media (hover: hover) { + .hover\:opacity-80:hover { + opacity: .8; + } + } + + @media (hover: hover) { + .hover\:ring-2:hover { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + + @media (hover: hover) { + .hover\:ring-blue-500:hover { + --tw-ring-color: var(--color-blue-500); + } + } + + .focus\:ring-2:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .focus\:ring-blue-500:focus { + --tw-ring-color: var(--color-blue-500); + } + + .disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; + } + + .disabled\:opacity-50:disabled { + opacity: .5; + } + + @media (width >= 40rem) { + .sm\:flex-row { + flex-direction: row; + } + } + + @media (width >= 40rem) { + .sm\:px-6 { + padding-inline: calc(var(--spacing) * 6); + } + } + + @media (width >= 48rem) { + .md\:mt-0 { + margin-top: calc(var(--spacing) * 0); + } + } + + @media (width >= 48rem) { + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (width >= 48rem) { + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @media (width >= 48rem) { + .md\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + + @media (width >= 48rem) { + .md\:flex-row { + flex-direction: row; + } + } + + @media (width >= 48rem) { + .md\:text-6xl { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } + + @media (width >= 64rem) { + .lg\:col-span-2 { + grid-column: span 2 / span 2; + } + } + + @media (width >= 64rem) { + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @media (width >= 64rem) { + .lg\:px-8 { + padding-inline: calc(var(--spacing) * 8); + } + } +} + +@layer base { + :focus-visible { + outline-style: var(--tw-outline-style); + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--color-blue-500); + } + + @media (prefers-reduced-motion: reduce) { + *, :before, :after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } + } +} + +@layer components { + .input-base { + width: 100%; + border-radius: var(--radius-lg); + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-gray-600); + background-color: var(--color-gray-700); + padding-inline: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 3); + color: var(--color-white); + } + + .input-base::placeholder { + color: var(--color-gray-400); + } + + .input-base { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .2s; + transition-duration: .2s; + } + + .input-base:focus { + border-color: rgba(0, 0, 0, 0); + } + + .input-base:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .input-base:focus { + --tw-ring-color: var(--color-blue-500); + } + + .btn-primary { + border-radius: var(--radius-lg); + background-color: var(--color-blue-600); + padding-inline: calc(var(--spacing) * 6); + padding-block: calc(var(--spacing) * 3); + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + color: var(--color-white); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .2s; + transition-duration: .2s; + } + + @media (hover: hover) { + .btn-primary:hover { + background-color: var(--color-blue-700); + } + } + + .btn-primary:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .btn-primary:focus { + --tw-ring-color: var(--color-blue-500); + } + + .btn-primary:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + + .btn-primary:focus { + --tw-ring-offset-color: var(--color-gray-800); + } + + .btn-primary:focus { + --tw-outline-style: none; + outline-style: none; + } + + .btn-primary:disabled { + cursor: not-allowed; + } + + .btn-primary:disabled { + background-color: var(--color-gray-600); + } + + .btn-secondary { + border-radius: var(--radius-lg); + background-color: var(--color-gray-600); + padding-inline: calc(var(--spacing) * 6); + padding-block: calc(var(--spacing) * 3); + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + color: var(--color-white); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .2s; + transition-duration: .2s; + } + + @media (hover: hover) { + .btn-secondary:hover { + background-color: var(--color-gray-500); + } + } + + .btn-secondary:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .btn-secondary:focus { + --tw-ring-color: var(--color-gray-400); + } + + .btn-secondary:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + + .btn-secondary:focus { + --tw-ring-offset-color: var(--color-gray-800); + } + + .btn-secondary:focus { + --tw-outline-style: none; + outline-style: none; + } + + .btn-secondary:disabled { + cursor: not-allowed; + } + + .btn-secondary:disabled { + background-color: var(--color-gray-700); + } + + .error-message { + border-radius: var(--radius-lg); + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-red-500); + background-color: rgba(130, 24, 26, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .error-message { + background-color: color-mix(in oklab, var(--color-red-900) 50%, transparent); + } + } + + .error-message { + padding: calc(var(--spacing) * 4); + color: var(--color-red-200); + } +} + +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --tw-rotate-x { + syntax: "*"; + inherits: false +} + +@property --tw-rotate-y { + syntax: "*"; + inherits: false +} + +@property --tw-rotate-z { + syntax: "*"; + inherits: false +} + +@property --tw-skew-x { + syntax: "*"; + inherits: false +} + +@property --tw-skew-y { + syntax: "*"; + inherits: false +} + +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} + +@property --tw-gradient-position { + syntax: "*"; + inherits: false +} + +@property --tw-gradient-from { + syntax: "<color>"; + inherits: false; + initial-value: rgba(0, 0, 0, 0); +} + +@property --tw-gradient-via { + syntax: "<color>"; + inherits: false; + initial-value: rgba(0, 0, 0, 0); +} + +@property --tw-gradient-to { + syntax: "<color>"; + inherits: false; + initial-value: rgba(0, 0, 0, 0); +} + +@property --tw-gradient-stops { + syntax: "*"; + inherits: false +} + +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false +} + +@property --tw-gradient-from-position { + syntax: "<length-percentage>"; + inherits: false; + initial-value: 0%; +} + +@property --tw-gradient-via-position { + syntax: "<length-percentage>"; + inherits: false; + initial-value: 50%; +} + +@property --tw-gradient-to-position { + syntax: "<length-percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-leading { + syntax: "*"; + inherits: false +} + +@property --tw-font-weight { + syntax: "*"; + inherits: false +} + +@property --tw-ordinal { + syntax: "*"; + inherits: false +} + +@property --tw-slashed-zero { + syntax: "*"; + inherits: false +} + +@property --tw-numeric-figure { + syntax: "*"; + inherits: false +} + +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false +} + +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false +} + +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-shadow-alpha { + syntax: "<percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-inset-shadow-alpha { + syntax: "<percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-ring-color { + syntax: "*"; + inherits: false +} + +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false +} + +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-ring-inset { + syntax: "*"; + inherits: false +} + +@property --tw-ring-offset-width { + syntax: "<length>"; + inherits: false; + initial-value: 0; +} + +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} + +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} + +@property --tw-blur { + syntax: "*"; + inherits: false +} + +@property --tw-brightness { + syntax: "*"; + inherits: false +} + +@property --tw-contrast { + syntax: "*"; + inherits: false +} + +@property --tw-grayscale { + syntax: "*"; + inherits: false +} + +@property --tw-hue-rotate { + syntax: "*"; + inherits: false +} + +@property --tw-invert { + syntax: "*"; + inherits: false +} + +@property --tw-opacity { + syntax: "*"; + inherits: false +} + +@property --tw-saturate { + syntax: "*"; + inherits: false +} + +@property --tw-sepia { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow-alpha { + syntax: "<percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false +} + +@property --tw-duration { + syntax: "*"; + inherits: false +} + +@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: rgba(0, 0, 0, 0); + --tw-gradient-via: rgba(0, 0, 0, 0); + --tw-gradient-to: rgba(0, 0, 0, 0); + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-ordinal: initial; + --tw-slashed-zero: initial; + --tw-numeric-figure: initial; + --tw-numeric-spacing: initial; + --tw-numeric-fraction: initial; + --tw-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; + } + } +} diff --git a/apps/chattyness-owner/Cargo.toml b/apps/chattyness-owner/Cargo.toml new file mode 100644 index 0000000..a9df0e6 --- /dev/null +++ b/apps/chattyness-owner/Cargo.toml @@ -0,0 +1,86 @@ +[package] +name = "chattyness-owner" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "chattyness-owner" +path = "src/main.rs" + +[dependencies] +chattyness-admin-ui.workspace = true +chattyness-db.workspace = true +chattyness-error.workspace = true +leptos.workspace = true +leptos_meta.workspace = true +leptos_router.workspace = true +leptos_axum = { workspace = true, optional = true } +axum = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tower-http = { workspace = true, optional = true } +tower-sessions = { workspace = true, optional = true } +tower-sessions-sqlx-store = { workspace = true, optional = true } +sqlx = { workspace = true, optional = true } +clap = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +dotenvy = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +serde.workspace = true +uuid.workspace = true + +# WASM dependencies +console_error_panic_hook = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } + +[features] +default = ["ssr"] +ssr = [ + "leptos/ssr", + "leptos_axum", + "axum", + "tower", + "tower-http", + "tower-sessions", + "tower-sessions-sqlx-store", + "sqlx", + "clap", + "tokio", + "dotenvy", + "tracing", + "tracing-subscriber", + "chattyness-admin-ui/ssr", + "chattyness-db/ssr", + "chattyness-error/ssr", +] +hydrate = [ + "leptos/hydrate", + "chattyness-admin-ui/hydrate", + "console_error_panic_hook", + "wasm-bindgen", +] + +[package.metadata.leptos] +# Project name used for output artifacts +output-name = "chattyness-owner" + +# Site configuration (paths relative to workspace root) +site-root = "target/site-owner" +site-pkg-dir = "static" +site-addr = "127.0.0.1:3001" +reload-port = 3002 + +# Tailwind CSS (path relative to this Cargo.toml) +tailwind-input-file = "style/tailwind.css" + +# Build settings +bin-features = ["ssr"] +bin-default-features = false +lib-features = ["hydrate"] +lib-default-features = false + +# Environment +env = "DEV" diff --git a/apps/chattyness-owner/public/favicon.ico b/apps/chattyness-owner/public/favicon.ico new file mode 100644 index 0000000..473a0f4 diff --git a/apps/chattyness-owner/src/lib.rs b/apps/chattyness-owner/src/lib.rs new file mode 100644 index 0000000..1e9fe30 --- /dev/null +++ b/apps/chattyness-owner/src/lib.rs @@ -0,0 +1,9 @@ +#![recursion_limit = "256"] +//! Owner app WASM hydration entry point. + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + console_error_panic_hook::set_once(); + leptos::mount::hydrate_body(chattyness_admin_ui::AdminApp); +} diff --git a/apps/chattyness-owner/src/main.rs b/apps/chattyness-owner/src/main.rs new file mode 100644 index 0000000..ecfa53b --- /dev/null +++ b/apps/chattyness-owner/src/main.rs @@ -0,0 +1,151 @@ +#![recursion_limit = "256"] +//! Owner app server entry point. +//! +//! This server runs on port 3001 and serves the admin UI with the +//! `chattyness_owner` database role (no RLS restrictions). + +#[cfg(feature = "ssr")] +mod server { + use axum::{response::Redirect, routing::get, Router}; + use clap::Parser; + use leptos::prelude::*; + use leptos_axum::{generate_route_list, LeptosRoutes}; + use sqlx::postgres::PgPoolOptions; + use std::net::SocketAddr; + use std::path::Path; + use tower_http::services::ServeDir; + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + + use chattyness_admin_ui::{admin_shell, AdminApp, AdminAppState}; + + /// CLI arguments. + #[derive(Parser)] + #[command(name = "chattyness-owner")] + #[command(about = "Chattyness Owner Admin Server")] + struct Args { + /// Host to bind to + #[arg(long, env = "HOST", default_value = "127.0.0.1")] + host: String, + + /// Port to bind to + #[arg(long, env = "OWNER_PORT", default_value = "3001")] + port: u16, + + /// Database password for chattyness_owner role + #[arg(long, env = "DB_CHATTYNESS_OWNER")] + db_password: String, + + /// Use secure cookies + #[arg(long, env = "SECURE_COOKIES", default_value = "false")] + secure_cookies: bool, + } + + pub async fn main() -> Result<(), Box<dyn std::error::Error>> { + // Load environment variables + dotenvy::dotenv().ok(); + + // Initialize logging + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "chattyness_owner=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Parse arguments + let args = Args::parse(); + + tracing::info!("Starting Chattyness Owner Server"); + + // Create database pool for owner access (fixed connection string) + let database_url = format!( + "postgres://chattyness_owner:{}@localhost/chattyness", + args.db_password + ); + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(&database_url) + .await?; + + tracing::info!("Connected to database (owner role)"); + + // Configure Leptos + let cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); + let conf = get_configuration(Some(cargo_toml)).unwrap(); + let leptos_options = conf.leptos_options; + let addr = SocketAddr::new(args.host.parse()?, args.port); + + // Create session layer + let session_layer = + chattyness_admin_ui::auth::create_admin_session_layer(pool.clone(), args.secure_cookies) + .await; + + // Create app state + let app_state = AdminAppState { + pool: pool.clone(), + leptos_options: leptos_options.clone(), + }; + + // Generate routes + let routes = generate_route_list(AdminApp); + + // Get site paths from Leptos config + // site_root is relative to workspace root, make it absolute + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir.parent().unwrap().parent().unwrap(); + let site_root = workspace_root.join(&*leptos_options.site_root); + let static_dir = site_root.join("static"); + let favicon_path = manifest_dir.join("public/favicon.ico"); + + tracing::info!("Serving static files from: {}", site_root.display()); + + // Admin CSS path + let admin_css_path = static_dir.join("chattyness-owner.css"); + + // Shared assets directory for uploaded files (realm images, etc.) + let assets_dir = Path::new("/srv/chattyness/assets"); + + // Build the app + let app = Router::new() + // Redirect root to admin + .route("/", get(|| async { Redirect::permanent("/admin") })) + // Nest API routes under /api/admin (matches frontend expectations when UI is at /admin) + .nest("/api/admin", chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone())) + // Uploaded assets (realm backgrounds, props, etc.) - must come before /static + .nest_service("/assets/server", ServeDir::new(assets_dir.join("server"))) + .nest_service("/static/realm", ServeDir::new(assets_dir.join("realm"))) + // Static files (build output: JS, CSS, WASM) + .nest_service("/static", ServeDir::new(&static_dir)) + .nest_service("/favicon.ico", tower_http::services::ServeFile::new(&favicon_path)) + // Serve admin CSS at standardized path + .nest_service("/static/css/admin.css", tower_http::services::ServeFile::new(&admin_css_path)) + // Leptos routes + .leptos_routes(&app_state, routes, { + let leptos_options = leptos_options.clone(); + move || admin_shell(leptos_options.clone()) + }) + // Apply session middleware + .layer(session_layer) + .with_state(app_state); + + tracing::info!("Listening on http://{}", addr); + + // Start server + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app.into_make_service()).await?; + + Ok(()) + } +} + +#[cfg(feature = "ssr")] +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + server::main().await +} + +#[cfg(not(feature = "ssr"))] +fn main() { + // This is for WASM build, which is handled by lib.rs +} diff --git a/apps/chattyness-owner/style/admin.css b/apps/chattyness-owner/style/admin.css new file mode 100644 index 0000000..f1220b7 --- /dev/null +++ b/apps/chattyness-owner/style/admin.css @@ -0,0 +1,463 @@ +/** + * Owner/Admin Interface Styles + * + * Theme overrides for the owner/admin interface. + * shared.css is imported in tailwind.css before this file. + */ + +/* ========================================= + Owner Theme Overrides + ========================================= */ + +:root { + /* Background colors - darker blue theme */ + --color-bg-primary: #1a1a2e; + --color-bg-secondary: #16213e; + --color-bg-tertiary: #0f3460; + --color-bg-hover: #1e3a5f; + + /* Text colors */ + --color-text-primary: #eee; + --color-text-secondary: #a0aec0; + --color-text-muted: #6b7280; + + /* Border colors */ + --color-border: #374151; + --color-border-focus: #7c3aed; + + /* Accent colors - purple theme */ + --color-accent: #7c3aed; + --color-accent-hover: #6d28d9; + --color-accent-text: #ffffff; +} + +/* ========================================= + Owner-Specific Body Styles + ========================================= */ + +body.admin-app { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--color-bg-primary); + color: var(--color-text-primary); + line-height: 1.6; + min-height: 100vh; + margin: 0; + padding: 0; +} + +/* ========================================= + Owner Layout Styles + ========================================= */ + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + padding: 1.5rem 0; + position: fixed; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.admin-content { + margin-left: 240px; + flex: 1; + padding: 2rem; + min-height: 100vh; + background: var(--color-bg-primary); +} + +/* ========================================= + Owner Sidebar Styles + ========================================= */ + +.sidebar-header { + padding: 0 1.5rem 1.5rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sidebar-brand { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; +} + +.sidebar-brand:hover { + opacity: 0.9; +} + +.sidebar-badge { + font-size: 0.75rem; + padding: 0.125rem 0.5rem; + background: var(--color-accent); + color: white; + border-radius: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Navigation */ +.nav-list { + list-style: none; + padding: 0; + margin: 0; + flex: 1; +} + +.nav-item { + margin: 0.125rem 0; +} + +.nav-link { + display: block; + padding: 0.5rem 1.5rem; + color: var(--color-text-secondary); + text-decoration: none; + transition: all 0.15s ease; +} + +.nav-link:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.nav-item.active .nav-link { + background: var(--color-accent); + color: white; +} + +.nav-section { + margin-top: 1rem; +} + +.nav-section-title { + display: block; + padding: 0.5rem 1.5rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.nav-sublist { + list-style: none; + padding: 0; + margin: 0; +} + +.nav-sublist .nav-link { + padding-left: 2.5rem; + font-size: 0.875rem; +} + +.sidebar-footer { + padding: var(--spacing-md); + border-top: 1px solid var(--color-border); + margin-top: auto; +} + +.sidebar-link { + display: block; + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-sm); + text-align: center; + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.sidebar-link:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.sidebar-logout { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + margin-top: var(--spacing-sm); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + font-size: var(--font-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.sidebar-logout:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +/* ========================================= + Owner Page Header Overrides + ========================================= */ + +.page-header { + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 2rem; +} + +/* ========================================= + Owner Card Overrides + ========================================= */ + +.card { + border: 1px solid var(--color-border); +} + +.card-title { + color: var(--color-accent); +} + +/* ========================================= + Owner Table Overrides + ========================================= */ + +.data-table th { + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.table-link { + font-weight: 500; +} + +/* ========================================= + Owner Dashboard Styles + ========================================= */ + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.stat-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + text-align: center; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--color-accent); +} + +.stat-title { + font-size: var(--font-sm); + color: var(--color-text-secondary); + margin-top: var(--spacing-xs); +} + +.dashboard-sections { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-lg); +} + +.quick-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +/* ========================================= + Owner User/Realm Detail Styles + ========================================= */ + +.user-header, +.realm-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.user-info h2, +.realm-info h2 { + font-size: 1.5rem; + margin-bottom: 0.25rem; +} + +.realm-badges { + display: flex; + gap: var(--spacing-sm); +} + +.realm-description { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.realm-description h4 { + font-size: var(--font-lg); + margin-bottom: var(--spacing-sm); + color: var(--color-text-secondary); +} + +/* ========================================= + Owner Form Styles + ========================================= */ + +.section-title { + font-size: var(--font-lg); + font-weight: 600; + color: var(--color-accent); + margin: var(--spacing-lg) 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); +} + +.tab-buttons { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); +} + +.required { + color: var(--color-text-error); +} + +.temp-password { + display: block; + background: var(--color-bg-tertiary); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-family: monospace; + margin: var(--spacing-sm) 0; + word-break: break-all; +} + +/* ========================================= + Owner Action Buttons + ========================================= */ + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-md); +} + +/* ========================================= + Owner Login Layout + ========================================= */ + +.login-layout { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background: var(--color-bg-primary); +} + +.login-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 2rem; + width: 100%; + max-width: 400px; +} + +.login-header { + text-align: center; + margin-bottom: 2rem; +} + +.login-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: 0.5rem; +} + +.login-subtitle { + color: var(--color-text-muted); +} + +/* ========================================= + Owner Config Page Styles + ========================================= */ + +.config-section { + margin-bottom: var(--spacing-xl); +} + +.config-section h3 { + font-size: var(--font-lg); + font-weight: 600; + margin-bottom: var(--spacing-md); + color: var(--color-accent); +} + +.config-item { + margin-bottom: var(--spacing-md); +} + +.config-item label { + display: block; + font-weight: 500; + margin-bottom: var(--spacing-xs); +} + +.config-item .form-help { + margin-top: var(--spacing-xs); +} + +/* ========================================= + Responsive Styles + ========================================= */ + +@media (max-width: 768px) { + .sidebar { + width: 100%; + position: relative; + height: auto; + } + + .admin-content { + margin-left: 0; + padding: 1rem; + } + + .dashboard-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + flex-direction: column; + } + + .page-header { + flex-direction: column; + align-items: stretch; + } + + .page-header-actions { + justify-content: flex-start; + } +} diff --git a/apps/chattyness-owner/style/shared.css b/apps/chattyness-owner/style/shared.css new file mode 100644 index 0000000..7931f7b --- /dev/null +++ b/apps/chattyness-owner/style/shared.css @@ -0,0 +1,879 @@ +/** + * Shared CSS Variables and Component Styles + * + * This file defines CSS custom properties and base styles for shared components. + * Each theme (owner/user) should define their own values for these variables. + */ + +/* ========================================= + CSS Custom Properties + ========================================= */ + +:root { + /* These are default values - override in theme-specific CSS */ + + /* Text colors */ + --color-text-primary: #ffffff; + --color-text-secondary: #9ca3af; + --color-text-muted: #6b7280; + --color-text-error: #ef4444; + --color-text-success: #22c55e; + --color-text-warning: #f59e0b; + --color-text-info: #3b82f6; + + /* Background colors */ + --color-bg-primary: #111827; + --color-bg-secondary: #1f2937; + --color-bg-tertiary: #374151; + --color-bg-hover: #374151; + + /* Border colors */ + --color-border: #374151; + --color-border-focus: #6366f1; + + /* Accent/action colors */ + --color-accent: #6366f1; + --color-accent-hover: #4f46e5; + --color-accent-text: #ffffff; + + /* Status colors */ + --color-status-active: #22c55e; + --color-status-suspended: #f59e0b; + --color-status-banned: #ef4444; + --color-status-pending: #8b5cf6; + --color-status-inactive: #6b7280; + + /* Role colors */ + --color-role-owner: #f59e0b; + --color-role-admin: #ef4444; + --color-role-moderator: #8b5cf6; + --color-role-member: #3b82f6; + --color-role-guest: #6b7280; + + /* Privacy colors */ + --color-privacy-public: #22c55e; + --color-privacy-unlisted: #f59e0b; + --color-privacy-private: #ef4444; + + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + + /* Border radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-full: 9999px; + + /* Font sizes */ + --font-xs: 0.75rem; + --font-sm: 0.875rem; + --font-base: 1rem; + --font-lg: 1.125rem; + --font-xl: 1.25rem; + --font-2xl: 1.5rem; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --shadow-xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + /* Transitions */ + --transition-fast: 150ms; + --transition-base: 200ms; + --transition-slow: 300ms; +} + +/* ========================================= + Base Styles + ========================================= */ + +* { + box-sizing: border-box; +} + +/* ========================================= + Button Styles + ========================================= */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-sm); + font-weight: 500; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: all var(--transition-base); + text-decoration: none; +} + +.btn:disabled, +.btn-disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--color-accent); + color: var(--color-accent-text); +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--color-accent-hover); +} + +.btn-secondary { + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); + border-color: var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background-color: var(--color-bg-hover); +} + +.btn-danger { + background-color: var(--color-status-banned); + color: white; +} + +.btn-danger:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-warning { + background-color: var(--color-status-suspended); + color: white; +} + +.btn-warning:hover:not(:disabled) { + opacity: 0.9; +} + +/* ========================================= + Form Styles + ========================================= */ + +.form-group { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); +} + +.form-row > .form-group { + flex: 1; + min-width: 200px; +} + +.form-label { + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-primary); +} + +.required-mark { + color: var(--color-text-error); + margin-left: var(--spacing-xs); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-base); + color: var(--color-text-primary); + background-color: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--color-text-muted); +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-color { + width: 100px; + height: 40px; + padding: 2px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-tertiary); +} + +.form-help { + font-size: var(--font-xs); + color: var(--color-text-muted); +} + +.checkbox-group { + display: flex; + flex-direction: column; +} + +.checkbox-label { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + cursor: pointer; +} + +.form-checkbox { + width: 1rem; + height: 1rem; + margin-top: 2px; + accent-color: var(--color-accent); +} + +.checkbox-text { + display: flex; + flex-direction: column; + color: var(--color-text-primary); +} + +.checkbox-description { + font-size: var(--font-sm); + color: var(--color-text-muted); +} + +.form-actions { + display: flex; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); +} + +.search-box { + display: flex; + gap: var(--spacing-sm); +} + +.search-input { + flex: 1; +} + +/* ========================================= + Card Styles + ========================================= */ + +.card { + background-color: var(--color-bg-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + box-shadow: var(--shadow-md); +} + +.card-title { + font-size: var(--font-xl); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--spacing-lg); +} + +/* ========================================= + Page Header Styles + ========================================= */ + +.page-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.page-header-text { + flex: 1; +} + +.page-title { + font-size: var(--font-2xl); + font-weight: 700; + color: var(--color-text-primary); + margin: 0; +} + +.page-subtitle { + font-size: var(--font-base); + color: var(--color-text-secondary); + margin: var(--spacing-xs) 0 0 0; +} + +.page-header-actions { + display: flex; + gap: var(--spacing-sm); +} + +/* ========================================= + Alert Styles + ========================================= */ + +.alert { + padding: var(--spacing-md); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-md); +} + +.alert p { + margin: 0; +} + +.alert-error { + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid var(--color-text-error); + color: var(--color-text-error); +} + +.alert-success { + background-color: rgba(34, 197, 94, 0.1); + border: 1px solid var(--color-text-success); + color: var(--color-text-success); +} + +.alert-warning { + background-color: rgba(245, 158, 11, 0.1); + border: 1px solid var(--color-text-warning); + color: var(--color-text-warning); +} + +.alert-info { + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid var(--color-text-info); + color: var(--color-text-info); +} + +/* ========================================= + Badge Styles + ========================================= */ + +.status-badge, +.role-badge, +.privacy-badge, +.badge, +.nsfw-badge { + display: inline-flex; + align-items: center; + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-xs); + font-weight: 500; + border-radius: var(--radius-full); + text-transform: capitalize; +} + +/* Status badges */ +.status-active { + background-color: rgba(34, 197, 94, 0.2); + color: var(--color-status-active); +} + +.status-suspended { + background-color: rgba(245, 158, 11, 0.2); + color: var(--color-status-suspended); +} + +.status-banned { + background-color: rgba(239, 68, 68, 0.2); + color: var(--color-status-banned); +} + +.status-pending { + background-color: rgba(139, 92, 246, 0.2); + color: var(--color-status-pending); +} + +.status-inactive { + background-color: rgba(107, 114, 128, 0.2); + color: var(--color-status-inactive); +} + +/* Role badges */ +.role-owner { + background-color: rgba(245, 158, 11, 0.2); + color: var(--color-role-owner); +} + +.role-admin { + background-color: rgba(239, 68, 68, 0.2); + color: var(--color-role-admin); +} + +.role-moderator { + background-color: rgba(139, 92, 246, 0.2); + color: var(--color-role-moderator); +} + +.role-member { + background-color: rgba(59, 130, 246, 0.2); + color: var(--color-role-member); +} + +.role-guest { + background-color: rgba(107, 114, 128, 0.2); + color: var(--color-role-guest); +} + +/* Privacy badges */ +.privacy-public { + background-color: rgba(34, 197, 94, 0.2); + color: var(--color-privacy-public); +} + +.privacy-unlisted { + background-color: rgba(245, 158, 11, 0.2); + color: var(--color-privacy-unlisted); +} + +.privacy-private { + background-color: rgba(239, 68, 68, 0.2); + color: var(--color-privacy-private); +} + +/* Generic badges */ +.badge-primary { + background-color: rgba(99, 102, 241, 0.2); + color: var(--color-accent); +} + +.badge-secondary { + background-color: rgba(107, 114, 128, 0.2); + color: var(--color-text-secondary); +} + +.badge-success { + background-color: rgba(34, 197, 94, 0.2); + color: var(--color-text-success); +} + +.badge-warning { + background-color: rgba(245, 158, 11, 0.2); + color: var(--color-text-warning); +} + +.badge-error { + background-color: rgba(239, 68, 68, 0.2); + color: var(--color-text-error); +} + +.nsfw-badge { + background-color: rgba(239, 68, 68, 0.2); + color: var(--color-status-banned); + text-transform: uppercase; +} + +/* ========================================= + Table Styles + ========================================= */ + +.table-container { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: var(--spacing-sm) var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.data-table th { + font-size: var(--font-sm); + font-weight: 600; + color: var(--color-text-secondary); + background-color: var(--color-bg-tertiary); +} + +.data-table td { + font-size: var(--font-sm); + color: var(--color-text-primary); +} + +.data-table tbody tr:hover { + background-color: var(--color-bg-hover); +} + +.table-row-clickable { + cursor: pointer; +} + +.table-link { + color: var(--color-accent); + text-decoration: none; +} + +.table-link:hover { + text-decoration: underline; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); +} + +.pagination-info { + color: var(--color-text-secondary); + font-size: var(--font-sm); +} + +/* ========================================= + Modal Styles + ========================================= */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background-color: var(--color-bg-secondary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + max-width: 28rem; + width: calc(100% - 2rem); + margin: var(--spacing-md); + padding: var(--spacing-lg); + border: 1px solid var(--color-border); +} + +.modal-content-large { + max-width: 42rem; +} + +.modal-close { + position: absolute; + top: var(--spacing-md); + right: var(--spacing-md); + padding: var(--spacing-xs); + color: var(--color-text-muted); + background: none; + border: none; + cursor: pointer; + transition: color var(--transition-fast); +} + +.modal-close:hover { + color: var(--color-text-primary); +} + +.modal-close-icon { + width: 1.5rem; + height: 1.5rem; +} + +.modal-body { + text-align: center; +} + +.modal-title { + font-size: var(--font-xl); + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 var(--spacing-md) 0; +} + +.modal-message { + color: var(--color-text-secondary); + margin: 0 0 var(--spacing-lg) 0; +} + +.modal-actions { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +@media (min-width: 640px) { + .modal-actions { + flex-direction: row; + justify-content: center; + } +} + +.modal-actions-center { + justify-content: center; +} + +/* ========================================= + Admin Layout Styles + ========================================= */ + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + width: 16rem; + background-color: var(--color-bg-primary); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + left: 0; + top: 0; +} + +.admin-content { + flex: 1; + margin-left: 16rem; + padding: var(--spacing-xl); + background-color: var(--color-bg-primary); + min-height: 100vh; +} + +.sidebar-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.sidebar-brand { + font-size: var(--font-xl); + font-weight: 700; + color: var(--color-text-primary); + text-decoration: none; + display: block; +} + +.sidebar-brand:hover { + color: var(--color-accent); +} + +.sidebar-subtitle { + font-size: var(--font-sm); + color: var(--color-text-muted); + display: block; + margin-top: var(--spacing-xs); +} + +.sidebar-nav { + flex: 1; + padding: var(--spacing-md); + overflow-y: auto; +} + +.nav-section { + margin-top: var(--spacing-lg); +} + +.nav-section-title { + display: block; + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: var(--spacing-sm) var(--spacing-md); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-secondary); + text-decoration: none; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + margin-bottom: var(--spacing-xs); +} + +.nav-item:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.nav-item-active { + background-color: var(--color-accent); + color: var(--color-accent-text); +} + +.nav-item-active:hover { + background-color: var(--color-accent-hover); + color: var(--color-accent-text); +} + +.nav-icon { + width: 1.25rem; + height: 1.25rem; +} + +.sidebar-footer { + padding: var(--spacing-md); + border-top: 1px solid var(--color-border); +} + +.sidebar-link { + display: block; + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-sm); + text-align: center; + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.sidebar-link:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +/* ========================================= + Detail Grid Styles + ========================================= */ + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-md); +} + +.detail-item { + padding: var(--spacing-sm); +} + +.detail-label { + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--spacing-xs); +} + +.detail-value { + color: var(--color-text-primary); +} + +/* ========================================= + Empty State Styles + ========================================= */ + +.empty-state { + text-align: center; + padding: var(--spacing-xl); + color: var(--color-text-muted); +} + +.empty-state p { + margin-bottom: var(--spacing-md); +} + +/* ========================================= + Centered Layout Styles + ========================================= */ + +.centered-layout { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background-color: var(--color-bg-primary); +} + +/* ========================================= + Loading Spinner Styles + ========================================= */ + +.loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + padding: var(--spacing-xl); +} + +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--color-border); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-message { + color: var(--color-text-muted); + font-size: var(--font-sm); +} + +/* ========================================= + Utility Classes + ========================================= */ + +.mt-4 { + margin-top: var(--spacing-md); +} + +.mb-4 { + margin-bottom: var(--spacing-md); +} + +.text-muted { + color: var(--color-text-muted); +} + +.text-success { + color: var(--color-text-success); +} + +.text-error { + color: var(--color-text-error); +} diff --git a/apps/chattyness-owner/style/tailwind.css b/apps/chattyness-owner/style/tailwind.css new file mode 100644 index 0000000..d19b752 --- /dev/null +++ b/apps/chattyness-owner/style/tailwind.css @@ -0,0 +1,83 @@ +@import "tailwindcss"; + +/* Source paths (relative to this CSS file's location) */ +@source "../src/**/*.rs"; +@source "../public/**/*.html"; +@source "../../../crates/chattyness-admin-ui/src/**/*.rs"; +@source not "../../../target"; + +/* Custom theme extensions */ +@theme { + --color-realm-50: #f0f9ff; + --color-realm-100: #e0f2fe; + --color-realm-200: #bae6fd; + --color-realm-300: #7dd3fc; + --color-realm-400: #38bdf8; + --color-realm-500: #0ea5e9; + --color-realm-600: #0284c7; + --color-realm-700: #0369a1; + --color-realm-800: #075985; + --color-realm-900: #0c4a6e; + --color-realm-950: #082f49; +} + +/* Import shared component styles, then admin theme overrides */ +@import "./shared.css"; +@import "./admin.css"; + +/* Base styles for accessibility */ +@layer base { + /* Focus visible for keyboard navigation */ + :focus-visible { + @apply outline-2 outline-offset-2 outline-blue-500; + } + + /* Reduce motion for users who prefer it */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } +} + +/* Component styles */ +@layer components { + /* Form input base styles */ + .input-base { + @apply w-full px-4 py-3 bg-gray-700 border border-gray-600 + rounded-lg text-white placeholder-gray-400 + focus:ring-2 focus:ring-blue-500 focus:border-transparent + transition-colors duration-200; + } + + /* Button base styles */ + .btn-primary { + @apply px-6 py-3 bg-blue-600 hover:bg-blue-700 + disabled:bg-gray-600 disabled:cursor-not-allowed + text-white font-semibold rounded-lg + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800 + transition-colors duration-200; + } + + .btn-secondary { + @apply px-6 py-3 bg-gray-600 hover:bg-gray-500 + disabled:bg-gray-700 disabled:cursor-not-allowed + text-white font-semibold rounded-lg + focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-800 + transition-colors duration-200; + } + + /* Error message styles */ + .error-message { + @apply p-4 bg-red-900/50 border border-red-500 rounded-lg text-red-200; + } + + /* Card styles */ + .card { + @apply bg-gray-800 rounded-lg shadow-xl p-6; + } +} diff --git a/apps/chattyness-owner/target/site/pkg/chattyness-owner.css b/apps/chattyness-owner/target/site/pkg/chattyness-owner.css new file mode 100644 index 0000000..eed8774 --- /dev/null +++ b/apps/chattyness-owner/target/site/pkg/chattyness-owner.css @@ -0,0 +1,3543 @@ +/*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; + +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --color-red-200: oklch(88.5% .062 18.334); + --color-red-300: oklch(80.8% .114 19.571); + --color-red-400: oklch(70.4% .191 22.216); + --color-red-500: oklch(63.7% .237 25.331); + --color-red-600: oklch(57.7% .245 27.325); + --color-red-700: oklch(50.5% .213 27.518); + --color-red-800: oklch(44.4% .177 26.899); + --color-red-900: oklch(39.6% .141 25.723); + --color-orange-600: oklch(64.6% .222 41.116); + --color-yellow-300: oklch(90.5% .182 98.111); + --color-yellow-400: oklch(85.2% .199 91.936); + --color-yellow-500: oklch(79.5% .184 86.047); + --color-yellow-600: oklch(68.1% .162 75.834); + --color-green-200: oklch(92.5% .084 155.995); + --color-green-400: oklch(79.2% .209 151.711); + --color-green-500: oklch(72.3% .219 149.579); + --color-green-600: oklch(62.7% .194 149.214); + --color-green-900: oklch(39.3% .095 152.535); + --color-blue-300: oklch(80.9% .105 251.813); + --color-blue-400: oklch(70.7% .165 254.624); + --color-blue-500: oklch(62.3% .214 259.815); + --color-blue-600: oklch(54.6% .245 262.881); + --color-blue-700: oklch(48.8% .243 264.376); + --color-purple-400: oklch(71.4% .203 305.504); + --color-gray-300: oklch(87.2% .01 258.338); + --color-gray-400: oklch(70.7% .022 261.325); + --color-gray-500: oklch(55.1% .027 264.364); + --color-gray-600: oklch(44.6% .03 256.802); + --color-gray-700: oklch(37.3% .034 259.733); + --color-gray-800: oklch(27.8% .033 256.848); + --color-gray-900: oklch(21% .034 264.665); + --color-black: #000; + --color-white: #fff; + --spacing: .25rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-2xl: 42rem; + --container-4xl: 56rem; + --container-7xl: 80rem; + --text-xs: .75rem; + --text-xs--line-height: calc(1 / .75); + --text-sm: .875rem; + --text-sm--line-height: calc(1.25 / .875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --radius-md: .375rem; + --radius-lg: .5rem; + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1); + --blur-sm: 8px; + --aspect-video: 16 / 9; + --default-transition-duration: .15s; + --default-transition-timing-function: cubic-bezier(.4, 0, .2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} + +@layer base { + *, :after, :before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + + b, strong { + font-weight: bolder; + } + + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + + small { + font-size: 80%; + } + + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + + sub { + bottom: -.25em; + } + + sup { + top: -.5em; + } + + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + + :-moz-focusring { + outline: auto; + } + + progress { + vertical-align: baseline; + } + + summary { + display: list-item; + } + + ol, ul, menu { + list-style: none; + } + + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + + img, video { + max-width: 100%; + height: auto; + } + + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: rgba(0, 0, 0, 0); + opacity: 1; + } + + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + + ::file-selector-button { + margin-inline-end: 4px; + } + + ::placeholder { + opacity: 1; + } + + @supports (not ((-webkit-appearance: -apple-pay-button))) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentColor; + } + + @supports (color: color-mix(in lab, red, red)) { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + + textarea { + resize: vertical; + } + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + + ::-webkit-datetime-edit { + display: inline-flex; + } + + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + + :-moz-ui-invalid { + box-shadow: none; + } + + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} + +@layer utilities { + .pointer-events-none { + pointer-events: none; + } + + .collapse { + visibility: collapse; + } + + .invisible { + visibility: hidden; + } + + .visible { + visibility: visible; + } + + .absolute { + position: absolute; + } + + .fixed { + position: fixed; + } + + .fixed\! { + position: fixed !important; + } + + .relative { + position: relative; + } + + .static { + position: static; + } + + .sticky { + position: sticky; + } + + .inset-0 { + inset: calc(var(--spacing) * 0); + } + + .inset-y-0 { + inset-block: calc(var(--spacing) * 0); + } + + .end-1 { + inset-inline-end: calc(var(--spacing) * 1); + } + + .end-2 { + inset-inline-end: calc(var(--spacing) * 2); + } + + .end-3 { + inset-inline-end: calc(var(--spacing) * 3); + } + + .end-5 { + inset-inline-end: calc(var(--spacing) * 5); + } + + .end-9 { + inset-inline-end: calc(var(--spacing) * 9); + } + + .end-10 { + inset-inline-end: calc(var(--spacing) * 10); + } + + .end-32 { + inset-inline-end: calc(var(--spacing) * 32); + } + + .end-88 { + inset-inline-end: calc(var(--spacing) * 88); + } + + .top-2 { + top: calc(var(--spacing) * 2); + } + + .top-4 { + top: calc(var(--spacing) * 4); + } + + .top-10 { + top: calc(var(--spacing) * 10); + } + + .right-0 { + right: calc(var(--spacing) * 0); + } + + .right-2 { + right: calc(var(--spacing) * 2); + } + + .right-4 { + right: calc(var(--spacing) * 4); + } + + .bottom-0 { + bottom: calc(var(--spacing) * 0); + } + + .bottom-2 { + bottom: calc(var(--spacing) * 2); + } + + .left-0 { + left: calc(var(--spacing) * 0); + } + + .left-1 { + left: calc(var(--spacing) * 1); + } + + .left-2 { + left: calc(var(--spacing) * 2); + } + + .isolate { + isolation: isolate; + } + + .z-4 { + z-index: 4; + } + + .z-50 { + z-index: 50; + } + + .col-span-2 { + grid-column: span 2 / span 2; + } + + .container { + width: 100%; + } + + @media (width >= 40rem) { + .container { + max-width: 40rem; + } + } + + @media (width >= 48rem) { + .container { + max-width: 48rem; + } + } + + @media (width >= 64rem) { + .container { + max-width: 64rem; + } + } + + @media (width >= 80rem) { + .container { + max-width: 80rem; + } + } + + @media (width >= 96rem) { + .container { + max-width: 96rem; + } + } + + .mx-4 { + margin-inline: calc(var(--spacing) * 4); + } + + .mx-auto { + margin-inline: auto; + } + + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + + .mr-2 { + margin-right: calc(var(--spacing) * 2); + } + + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + + .mb-16 { + margin-bottom: calc(var(--spacing) * 16); + } + + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } + + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } + + .ml-6 { + margin-left: calc(var(--spacing) * 6); + } + + .ml-auto { + margin-left: auto; + } + + .block { + display: block; + } + + .block\! { + display: block !important; + } + + .contents { + display: contents; + } + + .flex { + display: flex; + } + + .grid { + display: grid; + } + + .grid\! { + display: grid !important; + } + + .hidden { + display: none; + } + + .inline { + display: inline; + } + + .inline-block { + display: inline-block; + } + + .inline-flex { + display: inline-flex; + } + + .table { + display: table; + } + + .table\! { + display: table !important; + } + + .table-row { + display: table-row; + } + + .aspect-video { + aspect-ratio: var(--aspect-video); + } + + .size-1 { + width: calc(var(--spacing) * 1); + height: calc(var(--spacing) * 1); + } + + .h-1 { + height: calc(var(--spacing) * 1); + } + + .h-2 { + height: calc(var(--spacing) * 2); + } + + .h-4 { + height: calc(var(--spacing) * 4); + } + + .h-6 { + height: calc(var(--spacing) * 6); + } + + .h-8 { + height: calc(var(--spacing) * 8); + } + + .h-10 { + height: calc(var(--spacing) * 10); + } + + .h-12 { + height: calc(var(--spacing) * 12); + } + + .h-16 { + height: calc(var(--spacing) * 16); + } + + .h-20 { + height: calc(var(--spacing) * 20); + } + + .h-32 { + height: calc(var(--spacing) * 32); + } + + .h-64 { + height: calc(var(--spacing) * 64); + } + + .h-auto { + height: auto; + } + + .h-full { + height: 100%; + } + + .max-h-32 { + max-height: calc(var(--spacing) * 32); + } + + .max-h-48 { + max-height: calc(var(--spacing) * 48); + } + + .max-h-64 { + max-height: calc(var(--spacing) * 64); + } + + .min-h-\[50vh\] { + min-height: 50vh; + } + + .min-h-screen { + min-height: 100vh; + } + + .w-3 { + width: calc(var(--spacing) * 3); + } + + .w-4 { + width: calc(var(--spacing) * 4); + } + + .w-6 { + width: calc(var(--spacing) * 6); + } + + .w-8 { + width: calc(var(--spacing) * 8); + } + + .w-10 { + width: calc(var(--spacing) * 10); + } + + .w-12 { + width: calc(var(--spacing) * 12); + } + + .w-16 { + width: calc(var(--spacing) * 16); + } + + .w-20 { + width: calc(var(--spacing) * 20); + } + + .w-24 { + width: calc(var(--spacing) * 24); + } + + .w-64 { + width: calc(var(--spacing) * 64); + } + + .w-full { + width: 100%; + } + + .max-w-2xl { + max-width: var(--container-2xl); + } + + .max-w-4xl { + max-width: var(--container-4xl); + } + + .max-w-7xl { + max-width: var(--container-7xl); + } + + .max-w-lg { + max-width: var(--container-lg); + } + + .max-w-md { + max-width: var(--container-md); + } + + .flex-1 { + flex: 1; + } + + .shrink { + flex-shrink: 1; + } + + .grow { + flex-grow: 1; + } + + .border-collapse { + border-collapse: collapse; + } + + .scale-19 { + --tw-scale-x: 19%; + --tw-scale-y: 19%; + --tw-scale-z: 19%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + + .scale-21 { + --tw-scale-x: 21%; + --tw-scale-y: 21%; + --tw-scale-z: 21%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + + .transform { + transform: var(--tw-rotate-x, ) var(--tw-rotate-y, ) var(--tw-rotate-z, ) var(--tw-skew-x, ) var(--tw-skew-y, ); + } + + .cursor-crosshair { + cursor: crosshair; + } + + .cursor-not-allowed { + cursor: not-allowed; + } + + .cursor-pointer { + cursor: pointer; + } + + .resize { + resize: both; + } + + .resize-y { + resize: vertical; + } + + .appearance-none { + appearance: none; + } + + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .flex-col { + flex-direction: column; + } + + .flex-wrap { + flex-wrap: wrap; + } + + .items-center { + align-items: center; + } + + .items-start { + align-items: flex-start; + } + + .justify-between { + justify-content: space-between; + } + + .justify-center { + justify-content: center; + } + + .gap-1 { + gap: calc(var(--spacing) * 1); + } + + .gap-2 { + gap: calc(var(--spacing) * 2); + } + + .gap-3 { + gap: calc(var(--spacing) * 3); + } + + .gap-4 { + gap: calc(var(--spacing) * 4); + } + + .gap-6 { + gap: calc(var(--spacing) * 6); + } + + .gap-8 { + gap: calc(var(--spacing) * 8); + } + + :where(.space-y-2 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-y-3 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-y-4 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-y-6 > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + + :where(.space-x-2 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + + :where(.space-x-3 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } + + :where(.space-x-4 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .overflow-auto { + overflow: auto; + } + + .overflow-hidden { + overflow: hidden; + } + + .overflow-y-auto { + overflow-y: auto; + } + + .rounded { + border-radius: .25rem; + } + + .rounded-full { + border-radius: 3.40282e38px; + } + + .rounded-lg { + border-radius: var(--radius-lg); + } + + .rounded-md { + border-radius: var(--radius-md); + } + + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + + .border\! { + border-style: var(--tw-border-style) !important; + border-width: 1px !important; + } + + .border-0 { + border-style: var(--tw-border-style); + border-width: 0; + } + + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } + + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + + .border-blue-500 { + border-color: var(--color-blue-500); + } + + .border-gray-500\/50 { + border-color: rgba(106, 114, 130, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .border-gray-500\/50 { + border-color: color-mix(in oklab, var(--color-gray-500) 50%, transparent); + } + } + + .border-gray-600 { + border-color: var(--color-gray-600); + } + + .border-gray-700 { + border-color: var(--color-gray-700); + } + + .border-green-500 { + border-color: var(--color-green-500); + } + + .border-green-500\/50 { + border-color: rgba(0, 199, 88, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .border-green-500\/50 { + border-color: color-mix(in oklab, var(--color-green-500) 50%, transparent); + } + } + + .border-red-800 { + border-color: var(--color-red-800); + } + + .border-transparent { + border-color: rgba(0, 0, 0, 0); + } + + .border-white { + border-color: var(--color-white); + } + + .bg-black\/70 { + background-color: rgba(0, 0, 0, .7); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-black\/70 { + background-color: color-mix(in oklab, var(--color-black) 70%, transparent); + } + } + + .bg-blue-500\/10 { + background-color: rgba(48, 128, 255, .1); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-500\/10 { + background-color: color-mix(in oklab, var(--color-blue-500) 10%, transparent); + } + } + + .bg-blue-500\/30 { + background-color: rgba(48, 128, 255, .3); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-500\/30 { + background-color: color-mix(in oklab, var(--color-blue-500) 30%, transparent); + } + } + + .bg-blue-600 { + background-color: var(--color-blue-600); + } + + .bg-blue-600\/20 { + background-color: rgba(21, 93, 252, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-600\/20 { + background-color: color-mix(in oklab, var(--color-blue-600) 20%, transparent); + } + } + + .bg-gray-500\/20 { + background-color: rgba(106, 114, 130, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-gray-500\/20 { + background-color: color-mix(in oklab, var(--color-gray-500) 20%, transparent); + } + } + + .bg-gray-600 { + background-color: var(--color-gray-600); + } + + .bg-gray-700 { + background-color: var(--color-gray-700); + } + + .bg-gray-800 { + background-color: var(--color-gray-800); + } + + .bg-gray-900 { + background-color: var(--color-gray-900); + } + + .bg-green-500 { + background-color: var(--color-green-500); + } + + .bg-green-500\/20 { + background-color: rgba(0, 199, 88, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-green-500\/20 { + background-color: color-mix(in oklab, var(--color-green-500) 20%, transparent); + } + } + + .bg-green-600 { + background-color: var(--color-green-600); + } + + .bg-green-900\/50 { + background-color: rgba(13, 84, 43, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-green-900\/50 { + background-color: color-mix(in oklab, var(--color-green-900) 50%, transparent); + } + } + + .bg-orange-600 { + background-color: var(--color-orange-600); + } + + .bg-red-600 { + background-color: var(--color-red-600); + } + + .bg-red-900\/20 { + background-color: rgba(130, 24, 26, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-red-900\/20 { + background-color: color-mix(in oklab, var(--color-red-900) 20%, transparent); + } + } + + .bg-transparent { + background-color: rgba(0, 0, 0, 0); + } + + .bg-yellow-500 { + background-color: var(--color-yellow-500); + } + + .bg-yellow-600\/20 { + background-color: rgba(205, 137, 0, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .bg-yellow-600\/20 { + background-color: color-mix(in oklab, var(--color-yellow-600) 20%, transparent); + } + } + + .bg-gradient-to-t { + --tw-gradient-position: to top in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); + } + + .from-black\/70 { + --tw-gradient-from: rgba(0, 0, 0, .7); + } + + @supports (color: color-mix(in lab, red, red)) { + .from-black\/70 { + --tw-gradient-from: color-mix(in oklab, var(--color-black) 70%, transparent); + } + } + + .from-black\/70 { + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + + .to-transparent { + --tw-gradient-to: transparent; + --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); + } + + .object-cover { + object-fit: cover; + } + + .p-1 { + padding: calc(var(--spacing) * 1); + } + + .p-2 { + padding: calc(var(--spacing) * 2); + } + + .p-3 { + padding: calc(var(--spacing) * 3); + } + + .p-4 { + padding: calc(var(--spacing) * 4); + } + + .p-6 { + padding: calc(var(--spacing) * 6); + } + + .p-8 { + padding: calc(var(--spacing) * 8); + } + + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + + .py-0\.5 { + padding-block: calc(var(--spacing) * .5); + } + + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + + .py-16 { + padding-block: calc(var(--spacing) * 16); + } + + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } + + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } + + .pl-6 { + padding-left: calc(var(--spacing) * 6); + } + + .text-center { + text-align: center; + } + + .text-left { + text-align: left; + } + + .font-mono { + font-family: var(--font-mono); + } + + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + + .leading-0 { + --tw-leading: calc(var(--spacing) * 0); + line-height: calc(var(--spacing) * 0); + } + + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + + .text-blue-400 { + color: var(--color-blue-400); + } + + .text-blue-500 { + color: var(--color-blue-500); + } + + .text-gray-300 { + color: var(--color-gray-300); + } + + .text-gray-400 { + color: var(--color-gray-400); + } + + .text-gray-500 { + color: var(--color-gray-500); + } + + .text-green-200 { + color: var(--color-green-200); + } + + .text-green-400 { + color: var(--color-green-400); + } + + .text-purple-400 { + color: var(--color-purple-400); + } + + .text-red-300 { + color: var(--color-red-300); + } + + .text-red-400 { + color: var(--color-red-400); + } + + .text-white { + color: var(--color-white); + } + + .text-yellow-400 { + color: var(--color-yellow-400); + } + + .capitalize { + text-transform: capitalize; + } + + .lowercase { + text-transform: lowercase; + } + + .uppercase { + text-transform: uppercase; + } + + .italic { + font-style: italic; + } + + .ordinal { + --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, ); + } + + .line-through { + text-decoration-line: line-through; + } + + .underline { + text-decoration-line: underline; + } + + .antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .accent-blue-500 { + accent-color: var(--color-blue-500); + } + + .opacity-50 { + opacity: .5; + } + + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgba(0, 0, 0, .1)), 0 1px 2px -1px var(--tw-shadow-color, rgba(0, 0, 0, .1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .shadow-2xl { + --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgba(0, 0, 0, .25)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .shadow-xl { + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgba(0, 0, 0, .1)), 0 8px 10px -6px var(--tw-shadow-color, rgba(0, 0, 0, .1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .ring { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .ring-2 { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .ring-blue-500 { + --tw-ring-color: var(--color-blue-500); + } + + .ring-offset-2 { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + + .ring-offset-gray-800 { + --tw-ring-offset-color: var(--color-gray-800); + } + + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + + .blur { + --tw-blur: blur(8px); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .grayscale { + --tw-grayscale: grayscale(100%); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .invert { + --tw-invert: invert(100%); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .filter { + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, ); + backdrop-filter: var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, ); + } + + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .transition-all { + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + + .select-all { + -webkit-user-select: all; + user-select: all; + } + + .select-none { + -webkit-user-select: none; + user-select: none; + } + + .\[bad-link\:0\] { + bad-link: 0; + } + + .\[driver\:nssock\] { + driver: nssock; + } + + .\[hexley\:Tcl\/build\/tcl\] { + hexley: Tcl / build / tcl; + } + + .\[hexley\:Tcl\/tk8\.5\.4\/macosx\] { + hexley: Tcl / tk8.5.4 / macosx; + } + + .\[localhost\:\~\/Desktop\/c84bcopy\] { + localhost: ~ / Desktop / c84bcopy; + } + + .\[localhost\:\~\/desktop\/c84bcopy\] { + localhost: ~ / desktop / c84bcopy; + } + + .\[log\:log\] { + log: log; + } + + .\[mailto\:eric\.tse\@intel\.com\] { + mailto: eric. tse@intel. com; + } + + .\[mailto\:hlavana\@cisco\.com\] { + mailto: hlavana@cisco. com; + } + + .\[mailto\:tcl-win-admin\@lists\.sourceforge\.net\] { + mailto: tcl-win-admin@lists. sourceforge. net; + } + + .\[rowen\:\~\/tk8\.4\.6\/unix\] { + rowen: ~ / tk8.4.6 / unix; + } + + .\[tk\:chooseColor\] { + tk: chooseColor; + } + + .\[ttk\:PanedWindow\] { + ttk: PanedWindow; + } + + .\[ttk\:PannedWindow\] { + ttk: PannedWindow; + } + + .\[tz\:gettime\] { + tz: gettime; + } + + @media (hover: hover) { + .group-hover\:text-blue-400:is(:where(.group):hover *) { + color: var(--color-blue-400); + } + } + + @media (hover: hover) { + .hover\:scale-110:hover { + --tw-scale-x: 110%; + --tw-scale-y: 110%; + --tw-scale-z: 110%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + } + + @media (hover: hover) { + .hover\:border-gray-500:hover { + border-color: var(--color-gray-500); + } + } + + @media (hover: hover) { + .hover\:border-gray-600:hover { + border-color: var(--color-gray-600); + } + } + + @media (hover: hover) { + .hover\:bg-blue-700:hover { + background-color: var(--color-blue-700); + } + } + + @media (hover: hover) { + .hover\:bg-gray-600:hover { + background-color: var(--color-gray-600); + } + } + + @media (hover: hover) { + .hover\:bg-gray-700:hover { + background-color: var(--color-gray-700); + } + } + + @media (hover: hover) { + .hover\:bg-gray-700\/50:hover { + background-color: rgba(54, 65, 83, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-gray-700\/50:hover { + background-color: color-mix(in oklab, var(--color-gray-700) 50%, transparent); + } + } + } + + @media (hover: hover) { + .hover\:bg-green-500\/30:hover { + background-color: rgba(0, 199, 88, .3); + } + + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-green-500\/30:hover { + background-color: color-mix(in oklab, var(--color-green-500) 30%, transparent); + } + } + } + + @media (hover: hover) { + .hover\:bg-red-700:hover { + background-color: var(--color-red-700); + } + } + + @media (hover: hover) { + .hover\:bg-red-900\/20:hover { + background-color: rgba(130, 24, 26, .2); + } + + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-red-900\/20:hover { + background-color: color-mix(in oklab, var(--color-red-900) 20%, transparent); + } + } + } + + @media (hover: hover) { + .hover\:text-blue-300:hover { + color: var(--color-blue-300); + } + } + + @media (hover: hover) { + .hover\:text-blue-400:hover { + color: var(--color-blue-400); + } + } + + @media (hover: hover) { + .hover\:text-red-300:hover { + color: var(--color-red-300); + } + } + + @media (hover: hover) { + .hover\:text-white:hover { + color: var(--color-white); + } + } + + @media (hover: hover) { + .hover\:text-yellow-300:hover { + color: var(--color-yellow-300); + } + } + + @media (hover: hover) { + .hover\:underline:hover { + text-decoration-line: underline; + } + } + + @media (hover: hover) { + .hover\:opacity-80:hover { + opacity: .8; + } + } + + @media (hover: hover) { + .hover\:ring-2:hover { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + + @media (hover: hover) { + .hover\:ring-blue-500:hover { + --tw-ring-color: var(--color-blue-500); + } + } + + .focus\:ring-2:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .focus\:ring-blue-500:focus { + --tw-ring-color: var(--color-blue-500); + } + + .disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; + } + + .disabled\:opacity-50:disabled { + opacity: .5; + } + + @media (width >= 40rem) { + .sm\:flex-row { + flex-direction: row; + } + } + + @media (width >= 40rem) { + .sm\:px-6 { + padding-inline: calc(var(--spacing) * 6); + } + } + + @media (width >= 48rem) { + .md\:mt-0 { + margin-top: calc(var(--spacing) * 0); + } + } + + @media (width >= 48rem) { + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (width >= 48rem) { + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @media (width >= 48rem) { + .md\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + + @media (width >= 48rem) { + .md\:flex-row { + flex-direction: row; + } + } + + @media (width >= 48rem) { + .md\:text-6xl { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } + + @media (width >= 64rem) { + .lg\:col-span-2 { + grid-column: span 2 / span 2; + } + } + + @media (width >= 64rem) { + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @media (width >= 64rem) { + .lg\:px-8 { + padding-inline: calc(var(--spacing) * 8); + } + } +} + +:root { + --color-text-primary: #fff; + --color-text-secondary: #9ca3af; + --color-text-muted: #6b7280; + --color-text-error: #ef4444; + --color-text-success: #22c55e; + --color-text-warning: #f59e0b; + --color-text-info: #3b82f6; + --color-bg-primary: #111827; + --color-bg-secondary: #1f2937; + --color-bg-tertiary: #374151; + --color-bg-hover: #374151; + --color-border: #374151; + --color-border-focus: #6366f1; + --color-accent: #6366f1; + --color-accent-hover: #4f46e5; + --color-accent-text: #fff; + --color-status-active: #22c55e; + --color-status-suspended: #f59e0b; + --color-status-banned: #ef4444; + --color-status-pending: #8b5cf6; + --color-status-inactive: #6b7280; + --color-role-owner: #f59e0b; + --color-role-admin: #ef4444; + --color-role-moderator: #8b5cf6; + --color-role-member: #3b82f6; + --color-role-guest: #6b7280; + --color-privacy-public: #22c55e; + --color-privacy-unlisted: #f59e0b; + --color-privacy-private: #ef4444; + --spacing-xs: .25rem; + --spacing-sm: .5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --radius-sm: .25rem; + --radius-md: .5rem; + --radius-lg: .75rem; + --radius-full: 9999px; + --font-xs: .75rem; + --font-sm: .875rem; + --font-base: 1rem; + --font-lg: 1.125rem; + --font-xl: 1.25rem; + --font-2xl: 1.5rem; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1); + --shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, .25); + --transition-fast: .15s; + --transition-base: .2s; + --transition-slow: .3s; +} + +* { + box-sizing: border-box; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-sm); + font-weight: 500; + border-radius: var(--radius-md); + border: 1px solid rgba(0, 0, 0, 0); + cursor: pointer; + transition: all var(--transition-base); + text-decoration: none; +} + +.btn:disabled, .btn-disabled { + opacity: .5; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--color-accent); + color: var(--color-accent-text); +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--color-accent-hover); +} + +.btn-secondary { + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); + border-color: var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background-color: var(--color-bg-hover); +} + +.btn-danger { + background-color: var(--color-status-banned); + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + opacity: .9; +} + +.btn-warning { + background-color: var(--color-status-suspended); + color: #fff; +} + +.btn-warning:hover:not(:disabled) { + opacity: .9; +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.form-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); +} + +.form-row > .form-group { + flex: 1; + min-width: 200px; +} + +.form-label { + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-primary); +} + +.required-mark { + color: var(--color-text-error); + margin-left: var(--spacing-xs); +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: var(--font-base); + color: var(--color-text-primary); + background-color: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast); +} + +.form-input:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: var(--color-border-focus); + box-shadow: 0 0 0 2px rgba(99, 102, 241, .2); +} + +.form-input::placeholder, .form-textarea::placeholder { + color: var(--color-text-muted); +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-color { + width: 100px; + height: 40px; + padding: 2px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-tertiary); +} + +.form-help { + font-size: var(--font-xs); + color: var(--color-text-muted); +} + +.checkbox-group { + display: flex; + flex-direction: column; +} + +.checkbox-label { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + cursor: pointer; +} + +.form-checkbox { + width: 1rem; + height: 1rem; + margin-top: 2px; + accent-color: var(--color-accent); +} + +.checkbox-text { + display: flex; + flex-direction: column; + color: var(--color-text-primary); +} + +.checkbox-description { + font-size: var(--font-sm); + color: var(--color-text-muted); +} + +.form-actions { + display: flex; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); +} + +.search-box { + display: flex; + gap: var(--spacing-sm); +} + +.search-input { + flex: 1; +} + +.card { + background-color: var(--color-bg-secondary); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + box-shadow: var(--shadow-md); +} + +.card-title { + font-size: var(--font-xl); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--spacing-lg); +} + +.page-header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.page-header-text { + flex: 1; +} + +.page-title { + font-size: var(--font-2xl); + font-weight: 700; + color: var(--color-text-primary); + margin: 0; +} + +.page-subtitle { + font-size: var(--font-base); + color: var(--color-text-secondary); + margin: var(--spacing-xs) 0 0 0; +} + +.page-header-actions { + display: flex; + gap: var(--spacing-sm); +} + +.alert { + padding: var(--spacing-md); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-md); +} + +.alert p { + margin: 0; +} + +.alert-error { + background-color: rgba(239, 68, 68, .1); + border: 1px solid var(--color-text-error); + color: var(--color-text-error); +} + +.alert-success { + background-color: rgba(34, 197, 94, .1); + border: 1px solid var(--color-text-success); + color: var(--color-text-success); +} + +.alert-warning { + background-color: rgba(245, 158, 11, .1); + border: 1px solid var(--color-text-warning); + color: var(--color-text-warning); +} + +.alert-info { + background-color: rgba(59, 130, 246, .1); + border: 1px solid var(--color-text-info); + color: var(--color-text-info); +} + +.status-badge, .role-badge, .privacy-badge, .badge, .nsfw-badge { + display: inline-flex; + align-items: center; + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-xs); + font-weight: 500; + border-radius: var(--radius-full); + text-transform: capitalize; +} + +.status-active { + background-color: rgba(34, 197, 94, .2); + color: var(--color-status-active); +} + +.status-suspended { + background-color: rgba(245, 158, 11, .2); + color: var(--color-status-suspended); +} + +.status-banned { + background-color: rgba(239, 68, 68, .2); + color: var(--color-status-banned); +} + +.status-pending { + background-color: rgba(139, 92, 246, .2); + color: var(--color-status-pending); +} + +.status-inactive { + background-color: rgba(107, 114, 128, .2); + color: var(--color-status-inactive); +} + +.role-owner { + background-color: rgba(245, 158, 11, .2); + color: var(--color-role-owner); +} + +.role-admin { + background-color: rgba(239, 68, 68, .2); + color: var(--color-role-admin); +} + +.role-moderator { + background-color: rgba(139, 92, 246, .2); + color: var(--color-role-moderator); +} + +.role-member { + background-color: rgba(59, 130, 246, .2); + color: var(--color-role-member); +} + +.role-guest { + background-color: rgba(107, 114, 128, .2); + color: var(--color-role-guest); +} + +.privacy-public { + background-color: rgba(34, 197, 94, .2); + color: var(--color-privacy-public); +} + +.privacy-unlisted { + background-color: rgba(245, 158, 11, .2); + color: var(--color-privacy-unlisted); +} + +.privacy-private { + background-color: rgba(239, 68, 68, .2); + color: var(--color-privacy-private); +} + +.badge-primary { + background-color: rgba(99, 102, 241, .2); + color: var(--color-accent); +} + +.badge-secondary { + background-color: rgba(107, 114, 128, .2); + color: var(--color-text-secondary); +} + +.badge-success { + background-color: rgba(34, 197, 94, .2); + color: var(--color-text-success); +} + +.badge-warning { + background-color: rgba(245, 158, 11, .2); + color: var(--color-text-warning); +} + +.badge-error { + background-color: rgba(239, 68, 68, .2); + color: var(--color-text-error); +} + +.nsfw-badge { + background-color: rgba(239, 68, 68, .2); + color: var(--color-status-banned); + text-transform: uppercase; +} + +.table-container { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, .data-table td { + padding: var(--spacing-sm) var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.data-table th { + font-size: var(--font-sm); + font-weight: 600; + color: var(--color-text-secondary); + background-color: var(--color-bg-tertiary); +} + +.data-table td { + font-size: var(--font-sm); + color: var(--color-text-primary); +} + +.data-table tbody tr:hover { + background-color: var(--color-bg-hover); +} + +.table-row-clickable { + cursor: pointer; +} + +.table-link { + color: var(--color-accent); + text-decoration: none; +} + +.table-link:hover { + text-decoration: underline; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); +} + +.pagination-info { + color: var(--color-text-secondary); + font-size: var(--font-sm); +} + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, .7); + backdrop-filter: blur(4px); +} + +.modal-content { + position: relative; + background-color: var(--color-bg-secondary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + max-width: 28rem; + width: calc(100% - 2rem); + margin: var(--spacing-md); + padding: var(--spacing-lg); + border: 1px solid var(--color-border); +} + +.modal-content-large { + max-width: 42rem; +} + +.modal-close { + position: absolute; + top: var(--spacing-md); + right: var(--spacing-md); + padding: var(--spacing-xs); + color: var(--color-text-muted); + background: none; + border: none; + cursor: pointer; + transition: color var(--transition-fast); +} + +.modal-close:hover { + color: var(--color-text-primary); +} + +.modal-close-icon { + width: 1.5rem; + height: 1.5rem; +} + +.modal-body { + text-align: center; +} + +.modal-title { + font-size: var(--font-xl); + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 var(--spacing-md) 0; +} + +.modal-message { + color: var(--color-text-secondary); + margin: 0 0 var(--spacing-lg) 0; +} + +.modal-actions { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +@media (width >= 640px) { + .modal-actions { + flex-direction: row; + justify-content: center; + } +} + +.modal-actions-center { + justify-content: center; +} + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + width: 16rem; + background-color: var(--color-bg-primary); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + left: 0; + top: 0; +} + +.admin-content { + flex: 1; + margin-left: 16rem; + padding: var(--spacing-xl); + background-color: var(--color-bg-primary); + min-height: 100vh; +} + +.sidebar-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--color-border); +} + +.sidebar-brand { + font-size: var(--font-xl); + font-weight: 700; + color: var(--color-text-primary); + text-decoration: none; + display: block; +} + +.sidebar-brand:hover { + color: var(--color-accent); +} + +.sidebar-subtitle { + font-size: var(--font-sm); + color: var(--color-text-muted); + display: block; + margin-top: var(--spacing-xs); +} + +.sidebar-nav { + flex: 1; + padding: var(--spacing-md); + overflow-y: auto; +} + +.nav-section { + margin-top: var(--spacing-lg); +} + +.nav-section-title { + display: block; + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: .05em; + padding: var(--spacing-sm) var(--spacing-md); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-secondary); + text-decoration: none; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + margin-bottom: var(--spacing-xs); +} + +.nav-item:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.nav-item-active { + background-color: var(--color-accent); + color: var(--color-accent-text); +} + +.nav-item-active:hover { + background-color: var(--color-accent-hover); + color: var(--color-accent-text); +} + +.nav-icon { + width: 1.25rem; + height: 1.25rem; +} + +.sidebar-footer { + padding: var(--spacing-md); + border-top: 1px solid var(--color-border); +} + +.sidebar-link { + display: block; + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-sm); + text-align: center; + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.sidebar-link:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-md); +} + +.detail-item { + padding: var(--spacing-sm); +} + +.detail-label { + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: .05em; + margin-bottom: var(--spacing-xs); +} + +.detail-value { + color: var(--color-text-primary); +} + +.empty-state { + text-align: center; + padding: var(--spacing-xl); + color: var(--color-text-muted); +} + +.empty-state p { + margin-bottom: var(--spacing-md); +} + +.centered-layout { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background-color: var(--color-bg-primary); +} + +.loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--spacing-md); + padding: var(--spacing-xl); +} + +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--color-border); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: .8s linear infinite spin; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-message { + color: var(--color-text-muted); + font-size: var(--font-sm); +} + +.mt-4 { + margin-top: var(--spacing-md); +} + +.mb-4 { + margin-bottom: var(--spacing-md); +} + +.text-muted { + color: var(--color-text-muted); +} + +.text-success { + color: var(--color-text-success); +} + +.text-error { + color: var(--color-text-error); +} + +:root { + --color-bg-primary: #1a1a2e; + --color-bg-secondary: #16213e; + --color-bg-tertiary: #0f3460; + --color-bg-hover: #1e3a5f; + --color-text-primary: #eee; + --color-text-secondary: #a0aec0; + --color-text-muted: #6b7280; + --color-border: #374151; + --color-border-focus: #7c3aed; + --color-accent: #7c3aed; + --color-accent-hover: #6d28d9; + --color-accent-text: #fff; +} + +body.owner-app { + font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; + background: var(--color-bg-primary); + color: var(--color-text-primary); + line-height: 1.6; + min-height: 100vh; + margin: 0; + padding: 0; +} + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + padding: 1.5rem 0; + position: fixed; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.admin-content { + margin-left: 240px; + flex: 1; + padding: 2rem; + min-height: 100vh; + background: var(--color-bg-primary); +} + +.sidebar-header { + padding: 0 1.5rem 1.5rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: .5rem; +} + +.sidebar-brand { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; +} + +.sidebar-brand:hover { + opacity: .9; +} + +.sidebar-badge { + font-size: .75rem; + padding: .125rem .5rem; + background: var(--color-accent); + color: #fff; + border-radius: .25rem; + text-transform: uppercase; + letter-spacing: .05em; +} + +.nav-list { + list-style: none; + padding: 0; + margin: 0; + flex: 1; +} + +.nav-item { + margin: .125rem 0; +} + +.nav-link { + display: block; + padding: .5rem 1.5rem; + color: var(--color-text-secondary); + text-decoration: none; + transition: all .15s; +} + +.nav-link:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.nav-item.active .nav-link { + background: var(--color-accent); + color: #fff; +} + +.nav-section { + margin-top: 1rem; +} + +.nav-section-title { + display: block; + padding: .5rem 1.5rem; + font-size: .75rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: .05em; +} + +.nav-sublist { + list-style: none; + padding: 0; + margin: 0; +} + +.nav-sublist .nav-link { + padding-left: 2.5rem; + font-size: .875rem; +} + +.sidebar-footer { + padding: var(--spacing-md); + border-top: 1px solid var(--color-border); + margin-top: auto; +} + +.sidebar-link { + display: block; + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-sm); + text-align: center; + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.sidebar-link:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.sidebar-logout { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + margin-top: var(--spacing-sm); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + font-size: var(--font-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.sidebar-logout:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.page-header { + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 2rem; +} + +.card { + border: 1px solid var(--color-border); +} + +.card-title { + color: var(--color-accent); +} + +.data-table th { + text-transform: uppercase; + letter-spacing: .05em; +} + +.table-link { + font-weight: 500; +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); +} + +.stat-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + text-align: center; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--color-accent); +} + +.stat-title { + font-size: var(--font-sm); + color: var(--color-text-secondary); + margin-top: var(--spacing-xs); +} + +.dashboard-sections { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: var(--spacing-lg); +} + +.quick-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); +} + +.user-header, .realm-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.user-info h2, .realm-info h2 { + font-size: 1.5rem; + margin-bottom: .25rem; +} + +.realm-badges { + display: flex; + gap: var(--spacing-sm); +} + +.realm-description { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.realm-description h4 { + font-size: var(--font-lg); + margin-bottom: var(--spacing-sm); + color: var(--color-text-secondary); +} + +.section-title { + font-size: var(--font-lg); + font-weight: 600; + color: var(--color-accent); + margin: var(--spacing-lg) 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); +} + +.tab-buttons { + display: flex; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); +} + +.required { + color: var(--color-text-error); +} + +.temp-password { + display: block; + background: var(--color-bg-tertiary); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-family: monospace; + margin: var(--spacing-sm) 0; + word-break: break-all; +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-top: var(--spacing-md); +} + +.login-layout { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background: var(--color-bg-primary); +} + +.login-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 2rem; + width: 100%; + max-width: 400px; +} + +.login-header { + text-align: center; + margin-bottom: 2rem; +} + +.login-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: .5rem; +} + +.login-subtitle { + color: var(--color-text-muted); +} + +.config-section { + margin-bottom: var(--spacing-xl); +} + +.config-section h3 { + font-size: var(--font-lg); + font-weight: 600; + margin-bottom: var(--spacing-md); + color: var(--color-accent); +} + +.config-item { + margin-bottom: var(--spacing-md); +} + +.config-item label { + display: block; + font-weight: 500; + margin-bottom: var(--spacing-xs); +} + +.config-item .form-help { + margin-top: var(--spacing-xs); +} + +@media (width <= 768px) { + .sidebar { + width: 100%; + position: relative; + height: auto; + } + + .admin-content { + margin-left: 0; + padding: 1rem; + } + + .dashboard-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + flex-direction: column; + } + + .page-header { + flex-direction: column; + align-items: stretch; + } + + .page-header-actions { + justify-content: flex-start; + } +} + +@layer base { + :focus-visible { + outline-style: var(--tw-outline-style); + outline-width: 2px; + outline-offset: 2px; + outline-color: var(--color-blue-500); + } + + @media (prefers-reduced-motion: reduce) { + *, :before, :after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } + } +} + +@layer components { + .input-base { + width: 100%; + border-radius: var(--radius-lg); + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-gray-600); + background-color: var(--color-gray-700); + padding-inline: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 3); + color: var(--color-white); + } + + .input-base::placeholder { + color: var(--color-gray-400); + } + + .input-base { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .2s; + transition-duration: .2s; + } + + .input-base:focus { + border-color: rgba(0, 0, 0, 0); + } + + .input-base:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .input-base:focus { + --tw-ring-color: var(--color-blue-500); + } + + .btn-primary { + border-radius: var(--radius-lg); + background-color: var(--color-blue-600); + padding-inline: calc(var(--spacing) * 6); + padding-block: calc(var(--spacing) * 3); + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + color: var(--color-white); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .2s; + transition-duration: .2s; + } + + @media (hover: hover) { + .btn-primary:hover { + background-color: var(--color-blue-700); + } + } + + .btn-primary:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .btn-primary:focus { + --tw-ring-color: var(--color-blue-500); + } + + .btn-primary:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + + .btn-primary:focus { + --tw-ring-offset-color: var(--color-gray-800); + } + + .btn-primary:focus { + --tw-outline-style: none; + outline-style: none; + } + + .btn-primary:disabled { + cursor: not-allowed; + } + + .btn-primary:disabled { + background-color: var(--color-gray-600); + } + + .btn-secondary { + border-radius: var(--radius-lg); + background-color: var(--color-gray-600); + padding-inline: calc(var(--spacing) * 6); + padding-block: calc(var(--spacing) * 3); + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + color: var(--color-white); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: .2s; + transition-duration: .2s; + } + + @media (hover: hover) { + .btn-secondary:hover { + background-color: var(--color-gray-500); + } + } + + .btn-secondary:focus { + --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + + .btn-secondary:focus { + --tw-ring-color: var(--color-gray-400); + } + + .btn-secondary:focus { + --tw-ring-offset-width: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + } + + .btn-secondary:focus { + --tw-ring-offset-color: var(--color-gray-800); + } + + .btn-secondary:focus { + --tw-outline-style: none; + outline-style: none; + } + + .btn-secondary:disabled { + cursor: not-allowed; + } + + .btn-secondary:disabled { + background-color: var(--color-gray-700); + } + + .error-message { + border-radius: var(--radius-lg); + border-style: var(--tw-border-style); + border-width: 1px; + border-color: var(--color-red-500); + background-color: rgba(130, 24, 26, .5); + } + + @supports (color: color-mix(in lab, red, red)) { + .error-message { + background-color: color-mix(in oklab, var(--color-red-900) 50%, transparent); + } + } + + .error-message { + padding: calc(var(--spacing) * 4); + color: var(--color-red-200); + } + + .card { + border-radius: var(--radius-lg); + background-color: var(--color-gray-800); + padding: calc(var(--spacing) * 6); + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgba(0, 0, 0, .1)), 0 8px 10px -6px var(--tw-shadow-color, rgba(0, 0, 0, .1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } +} + +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --tw-rotate-x { + syntax: "*"; + inherits: false +} + +@property --tw-rotate-y { + syntax: "*"; + inherits: false +} + +@property --tw-rotate-z { + syntax: "*"; + inherits: false +} + +@property --tw-skew-x { + syntax: "*"; + inherits: false +} + +@property --tw-skew-y { + syntax: "*"; + inherits: false +} + +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} + +@property --tw-gradient-position { + syntax: "*"; + inherits: false +} + +@property --tw-gradient-from { + syntax: "<color>"; + inherits: false; + initial-value: rgba(0, 0, 0, 0); +} + +@property --tw-gradient-via { + syntax: "<color>"; + inherits: false; + initial-value: rgba(0, 0, 0, 0); +} + +@property --tw-gradient-to { + syntax: "<color>"; + inherits: false; + initial-value: rgba(0, 0, 0, 0); +} + +@property --tw-gradient-stops { + syntax: "*"; + inherits: false +} + +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false +} + +@property --tw-gradient-from-position { + syntax: "<length-percentage>"; + inherits: false; + initial-value: 0%; +} + +@property --tw-gradient-via-position { + syntax: "<length-percentage>"; + inherits: false; + initial-value: 50%; +} + +@property --tw-gradient-to-position { + syntax: "<length-percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-leading { + syntax: "*"; + inherits: false +} + +@property --tw-font-weight { + syntax: "*"; + inherits: false +} + +@property --tw-ordinal { + syntax: "*"; + inherits: false +} + +@property --tw-slashed-zero { + syntax: "*"; + inherits: false +} + +@property --tw-numeric-figure { + syntax: "*"; + inherits: false +} + +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false +} + +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false +} + +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-shadow-alpha { + syntax: "<percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-inset-shadow-alpha { + syntax: "<percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-ring-color { + syntax: "*"; + inherits: false +} + +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false +} + +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-ring-inset { + syntax: "*"; + inherits: false +} + +@property --tw-ring-offset-width { + syntax: "<length>"; + inherits: false; + initial-value: 0; +} + +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} + +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 rgba(0, 0, 0, 0); +} + +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} + +@property --tw-blur { + syntax: "*"; + inherits: false +} + +@property --tw-brightness { + syntax: "*"; + inherits: false +} + +@property --tw-contrast { + syntax: "*"; + inherits: false +} + +@property --tw-grayscale { + syntax: "*"; + inherits: false +} + +@property --tw-hue-rotate { + syntax: "*"; + inherits: false +} + +@property --tw-invert { + syntax: "*"; + inherits: false +} + +@property --tw-opacity { + syntax: "*"; + inherits: false +} + +@property --tw-saturate { + syntax: "*"; + inherits: false +} + +@property --tw-sepia { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false +} + +@property --tw-drop-shadow-alpha { + syntax: "<percentage>"; + inherits: false; + initial-value: 100%; +} + +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false +} + +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false +} + +@property --tw-duration { + syntax: "*"; + inherits: false +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: rgba(0, 0, 0, 0); + --tw-gradient-via: rgba(0, 0, 0, 0); + --tw-gradient-to: rgba(0, 0, 0, 0); + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-ordinal: initial; + --tw-slashed-zero: initial; + --tw-numeric-figure: initial; + --tw-numeric-spacing: initial; + --tw-numeric-fraction: initial; + --tw-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 rgba(0, 0, 0, 0); + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; + } + } +} diff --git a/apps/chattyness-owner/target/site/pkg/chattyness-owner.d.ts b/apps/chattyness-owner/target/site/pkg/chattyness-owner.d.ts new file mode 100644 index 0000000..b733796 --- /dev/null +++ b/apps/chattyness-owner/target/site/pkg/chattyness-owner.d.ts @@ -0,0 +1,96 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * The `ReadableStreamType` enum. + * + * *This API requires the following crate features to be activated: `ReadableStreamType`* + */ + +type ReadableStreamType = "bytes"; + +export class IntoUnderlyingByteSource { + private constructor(); + free(): void; + [Symbol.dispose](): void; + pull(controller: ReadableByteStreamController): Promise<any>; + start(controller: ReadableByteStreamController): void; + cancel(): void; + readonly autoAllocateChunkSize: number; + readonly type: ReadableStreamType; +} + +export class IntoUnderlyingSink { + private constructor(); + free(): void; + [Symbol.dispose](): void; + abort(reason: any): Promise<any>; + close(): Promise<any>; + write(chunk: any): Promise<any>; +} + +export class IntoUnderlyingSource { + private constructor(); + free(): void; + [Symbol.dispose](): void; + pull(controller: ReadableStreamDefaultController): Promise<any>; + cancel(): void; +} + +export function hydrate(): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly hydrate: () => void; + readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; + readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; + readonly intounderlyingbytesource_cancel: (a: number) => void; + readonly intounderlyingbytesource_pull: (a: number, b: any) => any; + readonly intounderlyingbytesource_start: (a: number, b: any) => void; + readonly intounderlyingbytesource_type: (a: number) => number; + readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void; + readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void; + readonly intounderlyingsink_abort: (a: number, b: any) => any; + readonly intounderlyingsink_close: (a: number) => any; + readonly intounderlyingsink_write: (a: number, b: any) => any; + readonly intounderlyingsource_cancel: (a: number) => void; + readonly intounderlyingsource_pull: (a: number, b: any) => any; + readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___wasm_bindgen_3ecf883c72d93b1f___JsValue_____: (a: number, b: number, c: any) => void; + readonly wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__wasm_bindgen_3ecf883c72d93b1f___JsValue____Output_______: (a: number, b: number) => void; + readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke______: (a: number, b: number) => void; + readonly wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut_____Output_______: (a: number, b: number) => void; + readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___web_sys_ad13626d47bc89a9___features__gen_Event__Event_____: (a: number, b: number, c: any) => void; + readonly wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__web_sys_ad13626d47bc89a9___features__gen_Event__Event____Output_______: (a: number, b: number) => void; + readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___bool_: (a: number, b: number) => number; + readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___js_sys_21257ab1a865f8ae___Function__js_sys_21257ab1a865f8ae___Function_____: (a: number, b: number, c: any, d: any) => void; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** +* Instantiates the given `module`, which can either be bytes or +* a precompiled `WebAssembly.Module`. +* +* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. +* +* @returns {InitOutput} +*/ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** +* If `module_or_path` is {RequestInfo} or {URL}, makes a request and +* for everything else, calls `WebAssembly.instantiate` directly. +* +* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated. +* +* @returns {Promise<InitOutput>} +*/ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>; diff --git a/apps/chattyness-owner/target/site/pkg/chattyness-owner.js b/apps/chattyness-owner/target/site/pkg/chattyness-owner.js new file mode 100644 index 0000000..95d29f2 --- /dev/null +++ b/apps/chattyness-owner/target/site/pkg/chattyness-owner.js @@ -0,0 +1,1261 @@ +let wasm; + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => state.dtor(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + state.dtor(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + } +} + +let WASM_VECTOR_LEN = 0; + +function wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___wasm_bindgen_3ecf883c72d93b1f___JsValue_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___wasm_bindgen_3ecf883c72d93b1f___JsValue_____(arg0, arg1, arg2); +} + +function wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke______(arg0, arg1) { + wasm.wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke______(arg0, arg1); +} + +function wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___web_sys_ad13626d47bc89a9___features__gen_Event__Event_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___web_sys_ad13626d47bc89a9___features__gen_Event__Event_____(arg0, arg1, arg2); +} + +function wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___bool_(arg0, arg1) { + const ret = wasm.wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___bool_(arg0, arg1); + return ret !== 0; +} + +function wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___js_sys_21257ab1a865f8ae___Function__js_sys_21257ab1a865f8ae___Function_____(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___js_sys_21257ab1a865f8ae___Function__js_sys_21257ab1a865f8ae___Function_____(arg0, arg1, arg2, arg3); +} + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; + +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 1)); + +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr >>> 0, 1)); + +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr >>> 0, 1)); + +export class IntoUnderlyingByteSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise<any>} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, controller); + return ret; + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, controller); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +export class IntoUnderlyingSink { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} reason + * @returns {Promise<any>} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, reason); + return ret; + } + /** + * @returns {Promise<any>} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return ret; + } + /** + * @param {any} chunk + * @returns {Promise<any>} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, chunk); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +export class IntoUnderlyingSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise<any>} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, controller); + return ret; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; + +export function hydrate() { + wasm.hydrate(); +} + +const EXPECTED_RESPONSE_TYPES = new Set(['basic', 'cors', 'default']); + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && EXPECTED_RESPONSE_TYPES.has(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } +} + +function __wbg_get_imports() { + const imports = {}; + imports.wbg = {}; + imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) { + const v = arg0; + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }; + imports.wbg.__wbg___wbindgen_debug_string_adfb662ae34724b6 = function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg___wbindgen_is_falsy_7b9692021c137978 = function(arg0) { + const ret = !arg0; + return ret; + }; + imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }; + imports.wbg.__wbg___wbindgen_is_null_dfda7d66506c95b5 = function(arg0) { + const ret = arg0 === null; + return ret; + }; + imports.wbg.__wbg___wbindgen_is_object_ce774f3490692386 = function(arg0) { + const val = arg0; + const ret = typeof(val) === 'object' && val !== null; + return ret; + }; + imports.wbg.__wbg___wbindgen_is_string_704ef9c8fc131030 = function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }; + imports.wbg.__wbg___wbindgen_is_undefined_f6b95eab589e0269 = function(arg0) { + const ret = arg0 === undefined; + return ret; + }; + imports.wbg.__wbg___wbindgen_jsval_eq_b6101cc9cef1fe36 = function(arg0, arg1) { + const ret = arg0 === arg1; + return ret; + }; + imports.wbg.__wbg___wbindgen_number_get_9619185a74197f95 = function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }; + imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }; + imports.wbg.__wbg__wbg_cb_unref_87dfb5aaa0cbcea7 = function(arg0) { + arg0._wbg_cb_unref(); + }; + imports.wbg.__wbg_addEventListener_6a82629b3d430a48 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments) }; + imports.wbg.__wbg_add_a928536d6ee293f3 = function() { return handleError(function (arg0, arg1, arg2) { + arg0.add(getStringFromWasm0(arg1, arg2)); + }, arguments) }; + imports.wbg.__wbg_altKey_e13fae92dfebca3e = function(arg0) { + const ret = arg0.altKey; + return ret; + }; + imports.wbg.__wbg_appendChild_7465eba84213c75f = function() { return handleError(function (arg0, arg1) { + const ret = arg0.appendChild(arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_body_544738f8b03aef13 = function(arg0) { + const ret = arg0.body; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_buffer_6cb2fecb1f253d71 = function(arg0) { + const ret = arg0.buffer; + return ret; + }; + imports.wbg.__wbg_button_a54acd25bab5d442 = function(arg0) { + const ret = arg0.button; + return ret; + }; + imports.wbg.__wbg_byobRequest_f8e3517f5f8ad284 = function(arg0) { + const ret = arg0.byobRequest; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_byteLength_faa9938885bdeee6 = function(arg0) { + const ret = arg0.byteLength; + return ret; + }; + imports.wbg.__wbg_byteOffset_3868b6a19ba01dea = function(arg0) { + const ret = arg0.byteOffset; + return ret; + }; + imports.wbg.__wbg_call_3020136f7a2d6e44 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments) }; + imports.wbg.__wbg_call_abb4ff46ce38be40 = function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_cancelBubble_3ab876913f65579a = function(arg0) { + const ret = arg0.cancelBubble; + return ret; + }; + imports.wbg.__wbg_checked_3e525f462e60e1bb = function(arg0) { + const ret = arg0.checked; + return ret; + }; + imports.wbg.__wbg_classList_d75bc19322d1b8f4 = function(arg0) { + const ret = arg0.classList; + return ret; + }; + imports.wbg.__wbg_cloneNode_34a31a9eb445b6ad = function() { return handleError(function (arg0, arg1) { + const ret = arg0.cloneNode(arg1 !== 0); + return ret; + }, arguments) }; + imports.wbg.__wbg_cloneNode_c9c45b24b171a776 = function() { return handleError(function (arg0) { + const ret = arg0.cloneNode(); + return ret; + }, arguments) }; + imports.wbg.__wbg_close_0af5661bf3d335f2 = function() { return handleError(function (arg0) { + arg0.close(); + }, arguments) }; + imports.wbg.__wbg_close_3ec111e7b23d94d8 = function() { return handleError(function (arg0) { + arg0.close(); + }, arguments) }; + imports.wbg.__wbg_composedPath_c6de3259e6ae48ad = function(arg0) { + const ret = arg0.composedPath(); + return ret; + }; + imports.wbg.__wbg_confirm_b165cbd0f4493563 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.confirm(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments) }; + imports.wbg.__wbg_content_ad90fa08b8c037c5 = function(arg0) { + const ret = arg0.content; + return ret; + }; + imports.wbg.__wbg_createComment_89db599aa930ef8a = function(arg0, arg1, arg2) { + const ret = arg0.createComment(getStringFromWasm0(arg1, arg2)); + return ret; + }; + imports.wbg.__wbg_createElementNS_e7c12bbd579529e2 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.createElementNS(arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + return ret; + }, arguments) }; + imports.wbg.__wbg_createElement_da4ed2b219560fc6 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments) }; + imports.wbg.__wbg_createTask_432d6d38dc688bee = function() { return handleError(function (arg0, arg1) { + const ret = console.createTask(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments) }; + imports.wbg.__wbg_createTextNode_0cf8168f7646a5d2 = function(arg0, arg1, arg2) { + const ret = arg0.createTextNode(getStringFromWasm0(arg1, arg2)); + return ret; + }; + imports.wbg.__wbg_ctrlKey_b391e5105c3f6e76 = function(arg0) { + const ret = arg0.ctrlKey; + return ret; + }; + imports.wbg.__wbg_decodeURIComponent_4e62713cd03627d4 = function() { return handleError(function (arg0, arg1) { + const ret = decodeURIComponent(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments) }; + imports.wbg.__wbg_decodeURI_b37dbbeac7109c58 = function() { return handleError(function (arg0, arg1) { + const ret = decodeURI(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments) }; + imports.wbg.__wbg_defaultPrevented_656a2f6afcfa3679 = function(arg0) { + const ret = arg0.defaultPrevented; + return ret; + }; + imports.wbg.__wbg_deleteProperty_da180bf2624d16d6 = function() { return handleError(function (arg0, arg1) { + const ret = Reflect.deleteProperty(arg0, arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_documentElement_39f40310398a4cba = function(arg0) { + const ret = arg0.documentElement; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_document_5b745e82ba551ca5 = function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_done_62ea16af4ce34b24 = function(arg0) { + const ret = arg0.done; + return ret; + }; + imports.wbg.__wbg_encodeURIComponent_fe8578929b74aa6c = function(arg0, arg1) { + const ret = encodeURIComponent(getStringFromWasm0(arg0, arg1)); + return ret; + }; + imports.wbg.__wbg_enqueue_a7e6b1ee87963aad = function() { return handleError(function (arg0, arg1) { + arg0.enqueue(arg1); + }, arguments) }; + imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }; + imports.wbg.__wbg_error_7bc7d576a6aaf855 = function(arg0) { + console.error(arg0); + }; + imports.wbg.__wbg_error_85faeb8919b11cc6 = function(arg0, arg1, arg2) { + console.error(arg0, arg1, arg2); + }; + imports.wbg.__wbg_fetch_a9bc66c159c18e19 = function(arg0) { + const ret = fetch(arg0); + return ret; + }; + imports.wbg.__wbg_firstChild_b36b7b9c87d19c20 = function(arg0) { + const ret = arg0.firstChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_firstElementChild_e207b33aaa4a86df = function(arg0) { + const ret = arg0.firstElementChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_getAttribute_80900eec94cb3636 = function(arg0, arg1, arg2, arg3) { + const ret = arg1.getAttribute(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_getElementById_e05488d2143c2b21 = function(arg0, arg1, arg2) { + const ret = arg0.getElementById(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_get_6b7bd52aca3f9671 = function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }; + imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_hasAttribute_af746bd1e7f1b334 = function(arg0, arg1, arg2) { + const ret = arg0.hasAttribute(getStringFromWasm0(arg1, arg2)); + return ret; + }; + imports.wbg.__wbg_hash_2e67a8656ea02800 = function(arg0, arg1) { + const ret = arg1.hash; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_hash_979a7861415bf1f8 = function() { return handleError(function (arg0, arg1) { + const ret = arg1.hash; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments) }; + imports.wbg.__wbg_head_aa354d3e01363673 = function(arg0) { + const ret = arg0.head; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_history_42a0e31617a8f00e = function() { return handleError(function (arg0) { + const ret = arg0.history; + return ret; + }, arguments) }; + imports.wbg.__wbg_host_3f3d16f21f257e93 = function(arg0) { + const ret = arg0.host; + return ret; + }; + imports.wbg.__wbg_href_18222dace6ab46cf = function(arg0, arg1) { + const ret = arg1.href; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_href_fd44bd17290b1611 = function(arg0, arg1) { + const ret = arg1.href; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_insertBefore_93e77c32aeae9657 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.insertBefore(arg1, arg2); + return ret; + }, arguments) }; + imports.wbg.__wbg_instanceof_Comment_9e6cfbd6b7e176b2 = function(arg0) { + let result; + try { + result = arg0 instanceof Comment; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_Element_6f7ba982258cfc0f = function(arg0) { + let result; + try { + result = arg0 instanceof Element; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_Error_3443650560328fa9 = function(arg0) { + let result; + try { + result = arg0 instanceof Error; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_HtmlAnchorElement_2ac07b5cf25eac0c = function(arg0) { + let result; + try { + result = arg0 instanceof HTMLAnchorElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_Response_cd74d1c2ac92cb0b = function(arg0) { + let result; + try { + result = arg0 instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_ShadowRoot_acbbcc2231ef8a7b = function(arg0) { + let result; + try { + result = arg0 instanceof ShadowRoot; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_Text_c45c38ab2fe8c06c = function(arg0) { + let result; + try { + result = arg0 instanceof Text; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_instanceof_Window_b5cf7783caa68180 = function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }; + imports.wbg.__wbg_isArray_51fd9e6422c0a395 = function(arg0) { + const ret = Array.isArray(arg0); + return ret; + }; + imports.wbg.__wbg_iterator_27b7c8b35ab3e86b = function() { + const ret = Symbol.iterator; + return ret; + }; + imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_length_d45040a40c570362 = function(arg0) { + const ret = arg0.length; + return ret; + }; + imports.wbg.__wbg_location_962e75c1c1b3ebed = function(arg0) { + const ret = arg0.location; + return ret; + }; + imports.wbg.__wbg_log_1d990106d99dacb7 = function(arg0) { + console.log(arg0); + }; + imports.wbg.__wbg_message_0305fa7903f4b3d9 = function(arg0) { + const ret = arg0.message; + return ret; + }; + imports.wbg.__wbg_metaKey_448c751accad2eba = function(arg0) { + const ret = arg0.metaKey; + return ret; + }; + imports.wbg.__wbg_name_f33243968228ce95 = function(arg0) { + const ret = arg0.name; + return ret; + }; + imports.wbg.__wbg_new_1ba21ce319a06297 = function() { + const ret = new Object(); + return ret; + }; + imports.wbg.__wbg_new_3205bc992762cf38 = function() { return handleError(function () { + const ret = new URLSearchParams(); + return ret; + }, arguments) }; + imports.wbg.__wbg_new_3c79b3bb1b32b7d3 = function() { return handleError(function () { + const ret = new Headers(); + return ret; + }, arguments) }; + imports.wbg.__wbg_new_79cb6b4c6069a31e = function() { return handleError(function (arg0, arg1) { + const ret = new URL(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments) }; + imports.wbg.__wbg_new_8a6f238a6ece86ea = function() { + const ret = new Error(); + return ret; + }; + imports.wbg.__wbg_new_df1173567d5ff028 = function(arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)); + return ret; + }; + imports.wbg.__wbg_new_ff12d2b041fb48f1 = function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___js_sys_21257ab1a865f8ae___Function__js_sys_21257ab1a865f8ae___Function_____(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = state0.b = 0; + } + }; + imports.wbg.__wbg_new_no_args_cb138f77cf6151ee = function(arg0, arg1) { + const ret = new Function(getStringFromWasm0(arg0, arg1)); + return ret; + }; + imports.wbg.__wbg_new_with_base_7d0307fe97312036 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = new URL(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3)); + return ret; + }, arguments) }; + imports.wbg.__wbg_new_with_byte_offset_and_length_d85c3da1fd8df149 = function(arg0, arg1, arg2) { + const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }; + imports.wbg.__wbg_new_with_str_and_init_c5748f76f5108934 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); + return ret; + }, arguments) }; + imports.wbg.__wbg_new_with_str_dea1b77a8c2f6d7d = function() { return handleError(function (arg0, arg1) { + const ret = new URLSearchParams(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments) }; + imports.wbg.__wbg_new_with_str_e8aac3eec73c239d = function() { return handleError(function (arg0, arg1) { + const ret = new Request(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments) }; + imports.wbg.__wbg_nextSibling_5e609f506d0fadd7 = function(arg0) { + const ret = arg0.nextSibling; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_next_138a17bbf04e926c = function(arg0) { + const ret = arg0.next; + return ret; + }; + imports.wbg.__wbg_next_3cfe5c0fe2a4cc53 = function() { return handleError(function (arg0) { + const ret = arg0.next(); + return ret; + }, arguments) }; + imports.wbg.__wbg_nodeType_927ceb9308a9be24 = function(arg0) { + const ret = arg0.nodeType; + return ret; + }; + imports.wbg.__wbg_ok_dd98ecb60d721e20 = function(arg0) { + const ret = arg0.ok; + return ret; + }; + imports.wbg.__wbg_origin_583b9a7f27a7bc24 = function(arg0, arg1) { + const ret = arg1.origin; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_origin_c4ac149104b9ebad = function() { return handleError(function (arg0, arg1) { + const ret = arg1.origin; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments) }; + imports.wbg.__wbg_parentNode_6caea653ea9f3e23 = function(arg0) { + const ret = arg0.parentNode; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_pathname_7b4426cce3f331cf = function() { return handleError(function (arg0, arg1) { + const ret = arg1.pathname; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments) }; + imports.wbg.__wbg_pathname_891dd78881a6e851 = function(arg0, arg1) { + const ret = arg1.pathname; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_preventDefault_e97663aeeb9709d3 = function(arg0) { + arg0.preventDefault(); + }; + imports.wbg.__wbg_pushState_97ca33be0ff17d82 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5) { + arg0.pushState(arg1, getStringFromWasm0(arg2, arg3), arg4 === 0 ? undefined : getStringFromWasm0(arg4, arg5)); + }, arguments) }; + imports.wbg.__wbg_queueMicrotask_9b549dfce8865860 = function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }; + imports.wbg.__wbg_queueMicrotask_fca69f5bfad613a5 = function(arg0) { + queueMicrotask(arg0); + }; + imports.wbg.__wbg_reload_27ff3c39a5227750 = function() { return handleError(function (arg0) { + arg0.reload(); + }, arguments) }; + imports.wbg.__wbg_removeAttribute_96e791ceeb22d591 = function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeAttribute(getStringFromWasm0(arg1, arg2)); + }, arguments) }; + imports.wbg.__wbg_removeEventListener_565e273024b68b75 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments) }; + imports.wbg.__wbg_remove_32f69ffabcbc4072 = function(arg0) { + arg0.remove(); + }; + imports.wbg.__wbg_remove_c705a65e04542a70 = function() { return handleError(function (arg0, arg1, arg2) { + arg0.remove(getStringFromWasm0(arg1, arg2)); + }, arguments) }; + imports.wbg.__wbg_remove_e0441e385f51d1e9 = function(arg0) { + arg0.remove(); + }; + imports.wbg.__wbg_replaceState_9b53ce8415668210 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5) { + arg0.replaceState(arg1, getStringFromWasm0(arg2, arg3), arg4 === 0 ? undefined : getStringFromWasm0(arg4, arg5)); + }, arguments) }; + imports.wbg.__wbg_requestAnimationFrame_994dc4ebde22b8d9 = function() { return handleError(function (arg0, arg1) { + const ret = arg0.requestAnimationFrame(arg1); + return ret; + }, arguments) }; + imports.wbg.__wbg_resolve_fd5bfbaa4ce36e1e = function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }; + imports.wbg.__wbg_respond_9f7fc54636c4a3af = function() { return handleError(function (arg0, arg1) { + arg0.respond(arg1 >>> 0); + }, arguments) }; + imports.wbg.__wbg_run_51bf644e39739ca6 = function(arg0, arg1, arg2) { + try { + var state0 = {a: arg1, b: arg2}; + var cb0 = () => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___bool_(a, state0.b, ); + } finally { + state0.a = a; + } + }; + const ret = arg0.run(cb0); + return ret; + } finally { + state0.a = state0.b = 0; + } + }; + imports.wbg.__wbg_scrollIntoView_0e467b662eec87a8 = function(arg0) { + arg0.scrollIntoView(); + }; + imports.wbg.__wbg_scrollTo_c18d69ba522ef774 = function(arg0, arg1, arg2) { + arg0.scrollTo(arg1, arg2); + }; + imports.wbg.__wbg_searchParams_bc926163e047442f = function(arg0) { + const ret = arg0.searchParams; + return ret; + }; + imports.wbg.__wbg_search_856af82f9dccb2ef = function() { return handleError(function (arg0, arg1) { + const ret = arg1.search; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments) }; + imports.wbg.__wbg_search_dbf031078dd8e645 = function(arg0, arg1) { + const ret = arg1.search; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_setAttribute_34747dd193f45828 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments) }; + imports.wbg.__wbg_set_169e13b608078b7b = function(arg0, arg1, arg2) { + arg0.set(getArrayU8FromWasm0(arg1, arg2)); + }; + imports.wbg.__wbg_set_425eb8b710d5beee = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.set(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments) }; + imports.wbg.__wbg_set_781438a03c0c3c81 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(arg0, arg1, arg2); + return ret; + }, arguments) }; + imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { + arg0.body = arg1; + }; + imports.wbg.__wbg_set_headers_5671cf088e114d2b = function(arg0, arg1) { + arg0.headers = arg1; + }; + imports.wbg.__wbg_set_href_851b22e9bb498129 = function() { return handleError(function (arg0, arg1, arg2) { + arg0.href = getStringFromWasm0(arg1, arg2); + }, arguments) }; + imports.wbg.__wbg_set_innerHTML_f1d03f780518a596 = function(arg0, arg1, arg2) { + arg0.innerHTML = getStringFromWasm0(arg1, arg2); + }; + imports.wbg.__wbg_set_method_76c69e41b3570627 = function(arg0, arg1, arg2) { + arg0.method = getStringFromWasm0(arg1, arg2); + }; + imports.wbg.__wbg_set_nodeValue_997d7696f2c5d4bd = function(arg0, arg1, arg2) { + arg0.nodeValue = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }; + imports.wbg.__wbg_set_search_cbba29f94329f296 = function(arg0, arg1, arg2) { + arg0.search = getStringFromWasm0(arg1, arg2); + }; + imports.wbg.__wbg_set_title_68ffc586125a93b4 = function(arg0, arg1, arg2) { + arg0.title = getStringFromWasm0(arg1, arg2); + }; + imports.wbg.__wbg_shiftKey_a6df227a917d203b = function(arg0) { + const ret = arg0.shiftKey; + return ret; + }; + imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_static_accessor_GLOBAL_769e6b65d6557335 = function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor_GLOBAL_THIS_60cf02db4de8e1c1 = function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor_SELF_08f5a74c69739274 = function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor_WINDOW_a8924b26aa92d024 = function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_static_accessor___INCOMPLETE_CHUNKS_69295e643f835e34 = function() { + const ret = __INCOMPLETE_CHUNKS; + return ret; + }; + imports.wbg.__wbg_static_accessor___RESOLVED_RESOURCES_64c55267f5301918 = function() { + const ret = __RESOLVED_RESOURCES; + return ret; + }; + imports.wbg.__wbg_static_accessor___SERIALIZED_ERRORS_72a3821d1babc2ee = function() { + const ret = __SERIALIZED_ERRORS; + return ret; + }; + imports.wbg.__wbg_tagName_e36b1c5d14a00d3f = function(arg0, arg1) { + const ret = arg1.tagName; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_target_0e3e05a6263c37a0 = function(arg0) { + const ret = arg0.target; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_target_e0867bf2c5a25124 = function(arg0, arg1) { + const ret = arg1.target; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_textContent_8083fbe3416e42c7 = function(arg0, arg1) { + const ret = arg1.textContent; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_text_51046bb33d257f63 = function() { return handleError(function (arg0) { + const ret = arg0.text(); + return ret; + }, arguments) }; + imports.wbg.__wbg_then_429f7caf1026411d = function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }; + imports.wbg.__wbg_then_4f95312d68691235 = function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }; + imports.wbg.__wbg_toString_14b47ee7542a49ef = function(arg0) { + const ret = arg0.toString(); + return ret; + }; + imports.wbg.__wbg_toString_f07112df359c997f = function(arg0) { + const ret = arg0.toString(); + return ret; + }; + imports.wbg.__wbg_url_87f30c96ceb3baf7 = function(arg0, arg1) { + const ret = arg1.url; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_value_2c75ca481407d038 = function(arg0, arg1) { + const ret = arg1.value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; + imports.wbg.__wbg_value_57b7b035e117f7ee = function(arg0) { + const ret = arg0.value; + return ret; + }; + imports.wbg.__wbg_view_788aaf149deefd2f = function(arg0) { + const ret = arg0.view; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }; + imports.wbg.__wbg_warn_6e567d0d926ff881 = function(arg0) { + console.warn(arg0); + }; + imports.wbg.__wbg_warn_989bed09a6035762 = function(arg0, arg1, arg2) { + console.warn(arg0, arg1, arg2); + }; + imports.wbg.__wbindgen_cast_0d0aa50209f2c2f3 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 2995, function: Function { arguments: [], shim_idx: 2997, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut_____Output_______, wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke______); + return ret; + }; + imports.wbg.__wbindgen_cast_0d56dd6345352bb2 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 3133, function: Function { arguments: [Externref], shim_idx: 3134, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__wasm_bindgen_3ecf883c72d93b1f___JsValue____Output_______, wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___wasm_bindgen_3ecf883c72d93b1f___JsValue_____); + return ret; + }; + imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }; + imports.wbg.__wbindgen_cast_808348a3e404addd = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 2994, function: Function { arguments: [NamedExternref("Event")], shim_idx: 2996, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__web_sys_ad13626d47bc89a9___features__gen_Event__Event____Output_______, wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___web_sys_ad13626d47bc89a9___features__gen_Event__Event_____); + return ret; + }; + imports.wbg.__wbindgen_cast_d6cd19b81560fd6e = function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }; + imports.wbg.__wbindgen_init_externref_table = function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }; + + return imports; +} + +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + __wbg_init.__wbindgen_wasm_module = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + + + wasm.__wbindgen_start(); + return wasm; +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (typeof module !== 'undefined') { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (typeof module_or_path !== 'undefined') { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (typeof module_or_path === 'undefined') { + module_or_path = new URL('chattyness-owner_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync }; +export default __wbg_init; diff --git a/apps/chattyness-owner/target/site/pkg/chattyness-owner.wasm b/apps/chattyness-owner/target/site/pkg/chattyness-owner.wasm new file mode 100644 index 0000000..a610d5b Binary files /dev/null and b/apps/chattyness-owner/target/site/pkg/chattyness-owner.wasm differ diff --git a/apps/chattyness-owner/target/site/pkg/chattyness-owner_bg.wasm.d.ts b/apps/chattyness-owner/target/site/pkg/chattyness-owner_bg.wasm.d.ts new file mode 100644 index 0000000..acac854 --- /dev/null +++ b/apps/chattyness-owner/target/site/pkg/chattyness-owner_bg.wasm.d.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const hydrate: () => void; +export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; +export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; +export const intounderlyingbytesource_cancel: (a: number) => void; +export const intounderlyingbytesource_pull: (a: number, b: any) => any; +export const intounderlyingbytesource_start: (a: number, b: any) => void; +export const intounderlyingbytesource_type: (a: number) => number; +export const __wbg_intounderlyingsink_free: (a: number, b: number) => void; +export const __wbg_intounderlyingsource_free: (a: number, b: number) => void; +export const intounderlyingsink_abort: (a: number, b: any) => any; +export const intounderlyingsink_close: (a: number) => any; +export const intounderlyingsink_write: (a: number, b: any) => any; +export const intounderlyingsource_cancel: (a: number) => void; +export const intounderlyingsource_pull: (a: number, b: any) => any; +export const wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___wasm_bindgen_3ecf883c72d93b1f___JsValue_____: (a: number, b: number, c: any) => void; +export const wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__wasm_bindgen_3ecf883c72d93b1f___JsValue____Output_______: (a: number, b: number) => void; +export const wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke______: (a: number, b: number) => void; +export const wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut_____Output_______: (a: number, b: number) => void; +export const wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___web_sys_ad13626d47bc89a9___features__gen_Event__Event_____: (a: number, b: number, c: any) => void; +export const wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__web_sys_ad13626d47bc89a9___features__gen_Event__Event____Output_______: (a: number, b: number) => void; +export const wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___bool_: (a: number, b: number) => number; +export const wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___js_sys_21257ab1a865f8ae___Function__js_sys_21257ab1a865f8ae___Function_____: (a: number, b: number, c: any, d: any) => void; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_exn_store: (a: number) => void; +export const __externref_table_alloc: () => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_start: () => void; diff --git a/crates/chattyness-admin-ui/Cargo.toml b/crates/chattyness-admin-ui/Cargo.toml new file mode 100644 index 0000000..2627bbe --- /dev/null +++ b/crates/chattyness-admin-ui/Cargo.toml @@ -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", +] diff --git a/crates/chattyness-admin-ui/src/api.rs b/crates/chattyness-admin-ui/src/api.rs new file mode 100644 index 0000000..b457a21 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api.rs @@ -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; diff --git a/crates/chattyness-admin-ui/src/api/auth.rs b/crates/chattyness-admin-ui/src/api/auth.rs new file mode 100644 index 0000000..497c1b6 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/auth.rs @@ -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(), + }), + )) +} diff --git a/crates/chattyness-admin-ui/src/api/config.rs b/crates/chattyness-admin-ui/src/api/config.rs new file mode 100644 index 0000000..2546faa --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/config.rs @@ -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)) +} diff --git a/crates/chattyness-admin-ui/src/api/dashboard.rs b/crates/chattyness-admin-ui/src/api/dashboard.rs new file mode 100644 index 0000000..2dd1ac1 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/dashboard.rs @@ -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, + })) +} diff --git a/crates/chattyness-admin-ui/src/api/props.rs b/crates/chattyness-admin-ui/src/api/props.rs new file mode 100644 index 0000000..a575509 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/props.rs @@ -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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/realms.rs b/crates/chattyness-admin-ui/src/api/realms.rs new file mode 100644 index 0000000..72ad45d --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/realms.rs @@ -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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs new file mode 100644 index 0000000..b8e7055 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/routes.rs @@ -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" +} diff --git a/crates/chattyness-admin-ui/src/api/scenes.rs b/crates/chattyness-admin-ui/src/api/scenes.rs new file mode 100644 index 0000000..62d11e7 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/scenes.rs @@ -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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/spots.rs b/crates/chattyness-admin-ui/src/api/spots.rs new file mode 100644 index 0000000..db83212 --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/spots.rs @@ -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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/staff.rs b/crates/chattyness-admin-ui/src/api/staff.rs new file mode 100644 index 0000000..daf9f9b --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/staff.rs @@ -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(())) +} diff --git a/crates/chattyness-admin-ui/src/api/users.rs b/crates/chattyness-admin-ui/src/api/users.rs new file mode 100644 index 0000000..837645e --- /dev/null +++ b/crates/chattyness-admin-ui/src/api/users.rs @@ -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)) +} diff --git a/crates/chattyness-admin-ui/src/app.rs b/crates/chattyness-admin-ui/src/app.rs new file mode 100644 index 0000000..cac693b --- /dev/null +++ b/crates/chattyness-admin-ui/src/app.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/auth.rs b/crates/chattyness-admin-ui/src/auth.rs new file mode 100644 index 0000000..adefe45 --- /dev/null +++ b/crates/chattyness-admin-ui/src/auth.rs @@ -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))) +} diff --git a/crates/chattyness-admin-ui/src/components.rs b/crates/chattyness-admin-ui/src/components.rs new file mode 100644 index 0000000..0551aed --- /dev/null +++ b/crates/chattyness-admin-ui/src/components.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/hooks.rs b/crates/chattyness-admin-ui/src/hooks.rs new file mode 100644 index 0000000..ef1f864 --- /dev/null +++ b/crates/chattyness-admin-ui/src/hooks.rs @@ -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()) + } + } +} diff --git a/crates/chattyness-admin-ui/src/lib.rs b/crates/chattyness-admin-ui/src/lib.rs new file mode 100644 index 0000000..03ddd05 --- /dev/null +++ b/crates/chattyness-admin-ui/src/lib.rs @@ -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; diff --git a/crates/chattyness-admin-ui/src/models.rs b/crates/chattyness-admin-ui/src/models.rs new file mode 100644 index 0000000..0e2eccc --- /dev/null +++ b/crates/chattyness-admin-ui/src/models.rs @@ -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, +} diff --git a/crates/chattyness-admin-ui/src/pages.rs b/crates/chattyness-admin-ui/src/pages.rs new file mode 100644 index 0000000..dd190b4 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages.rs @@ -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; diff --git a/crates/chattyness-admin-ui/src/pages/config.rs b/crates/chattyness-admin-ui/src/pages/config.rs new file mode 100644 index 0000000..6a255df --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/config.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/dashboard.rs b/crates/chattyness-admin-ui/src/pages/dashboard.rs new file mode 100644 index 0000000..80542b8 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/dashboard.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/login.rs b/crates/chattyness-admin-ui/src/pages/login.rs new file mode 100644 index 0000000..088296b --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/login.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/props.rs b/crates/chattyness-admin-ui/src/pages/props.rs new file mode 100644 index 0000000..76aae76 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/props.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/props_detail.rs b/crates/chattyness-admin-ui/src/pages/props_detail.rs new file mode 100644 index 0000000..2ae1874 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/props_detail.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/props_new.rs b/crates/chattyness-admin-ui/src/pages/props_new.rs new file mode 100644 index 0000000..da961fe --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/props_new.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/realm_detail.rs b/crates/chattyness-admin-ui/src/pages/realm_detail.rs new file mode 100644 index 0000000..31ce4f5 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/realm_detail.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/realm_new.rs b/crates/chattyness-admin-ui/src/pages/realm_new.rs new file mode 100644 index 0000000..effd569 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/realm_new.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/realms.rs b/crates/chattyness-admin-ui/src/pages/realms.rs new file mode 100644 index 0000000..94bd377 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/realms.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/scene_detail.rs b/crates/chattyness-admin-ui/src/pages/scene_detail.rs new file mode 100644 index 0000000..a43c049 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/scene_detail.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/scene_new.rs b/crates/chattyness-admin-ui/src/pages/scene_new.rs new file mode 100644 index 0000000..ff51848 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/scene_new.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/scenes.rs b/crates/chattyness-admin-ui/src/pages/scenes.rs new file mode 100644 index 0000000..2be4ccd --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/scenes.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/staff.rs b/crates/chattyness-admin-ui/src/pages/staff.rs new file mode 100644 index 0000000..dae2c10 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/staff.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/user_detail.rs b/crates/chattyness-admin-ui/src/pages/user_detail.rs new file mode 100644 index 0000000..8ed83c5 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/user_detail.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/user_new.rs b/crates/chattyness-admin-ui/src/pages/user_new.rs new file mode 100644 index 0000000..ccaf8fc --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/user_new.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/pages/users.rs b/crates/chattyness-admin-ui/src/pages/users.rs new file mode 100644 index 0000000..35efd85 --- /dev/null +++ b/crates/chattyness-admin-ui/src/pages/users.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/routes.rs b/crates/chattyness-admin-ui/src/routes.rs new file mode 100644 index 0000000..0cd3d69 --- /dev/null +++ b/crates/chattyness-admin-ui/src/routes.rs @@ -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> + } +} diff --git a/crates/chattyness-admin-ui/src/utils.rs b/crates/chattyness-admin-ui/src/utils.rs new file mode 100644 index 0000000..284f35c --- /dev/null +++ b/crates/chattyness-admin-ui/src/utils.rs @@ -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(); +} diff --git a/crates/chattyness-db/Cargo.toml b/crates/chattyness-db/Cargo.toml new file mode 100644 index 0000000..4a6768d --- /dev/null +++ b/crates/chattyness-db/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "chattyness-db" +version.workspace = true +edition.workspace = true + +[dependencies] +chattyness-error = { workspace = true, optional = true } +chattyness-shared = { workspace = true, optional = true } +serde.workspace = true +uuid.workspace = true +chrono.workspace = true + +# SSR-only dependencies +sqlx = { workspace = true, optional = true } +argon2 = { workspace = true, optional = true } +rand = { workspace = true, optional = true } + +[features] +default = [] +ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"] diff --git a/crates/chattyness-db/src/lib.rs b/crates/chattyness-db/src/lib.rs new file mode 100644 index 0000000..731bc5f --- /dev/null +++ b/crates/chattyness-db/src/lib.rs @@ -0,0 +1,16 @@ +//! Database module for chattyness. +//! +//! Provides SQLx-based database access with runtime queries. + +pub mod models; +pub mod ws_messages; + +#[cfg(feature = "ssr")] +pub mod pool; +#[cfg(feature = "ssr")] +pub mod queries; + +pub use models::*; +pub use ws_messages::*; +#[cfg(feature = "ssr")] +pub use pool::*; diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs new file mode 100644 index 0000000..39c08f6 --- /dev/null +++ b/crates/chattyness-db/src/models.rs @@ -0,0 +1,1697 @@ +//! Database models for chattyness. +//! +//! These structs mirror the database schema and are used for SQLx queries. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[cfg(feature = "ssr")] +use chattyness_error::AppError; +#[cfg(feature = "ssr")] +use chattyness_shared::validation; + +// ============================================================================= +// Enums (matching PostgreSQL ENUMs) +// ============================================================================= + +/// Realm privacy setting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "realm_privacy", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum RealmPrivacy { + #[default] + Public, + Unlisted, + Private, +} + +impl std::fmt::Display for RealmPrivacy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RealmPrivacy::Public => write!(f, "public"), + RealmPrivacy::Unlisted => write!(f, "unlisted"), + RealmPrivacy::Private => write!(f, "private"), + } + } +} + +impl std::str::FromStr for RealmPrivacy { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "public" => Ok(RealmPrivacy::Public), + "unlisted" => Ok(RealmPrivacy::Unlisted), + "private" => Ok(RealmPrivacy::Private), + _ => Err(format!("Invalid privacy setting: {}", s)), + } + } +} + +impl RealmPrivacy { + /// Get the string representation for database storage. + pub fn as_str(&self) -> &'static str { + match self { + RealmPrivacy::Public => "public", + RealmPrivacy::Unlisted => "unlisted", + RealmPrivacy::Private => "private", + } + } +} + +/// Server-wide reputation tier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "reputation_tier", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum ReputationTier { + Guest, + #[default] + Member, + Established, + Trusted, + Elder, +} + +/// User account status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "account_status", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum AccountStatus { + #[default] + Active, + Suspended, + Banned, + Deleted, +} + +/// Authentication provider. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "auth_provider", rename_all = "snake_case"))] +#[serde(rename_all = "snake_case")] +pub enum AuthProvider { + #[default] + Local, + OauthGoogle, + OauthDiscord, + OauthGithub, +} + +/// Server-level staff role. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "server_role", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum ServerRole { + #[default] + Moderator, + Admin, + Owner, +} + +impl std::fmt::Display for ServerRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServerRole::Moderator => write!(f, "moderator"), + ServerRole::Admin => write!(f, "admin"), + ServerRole::Owner => write!(f, "owner"), + } + } +} + +impl std::str::FromStr for ServerRole { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "moderator" => Ok(ServerRole::Moderator), + "admin" => Ok(ServerRole::Admin), + "owner" => Ok(ServerRole::Owner), + _ => Err(format!("Invalid server role: {}", s)), + } + } +} + +/// Realm membership role. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "realm_role", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum RealmRole { + #[default] + Member, + Builder, + Moderator, + Owner, +} + +impl std::fmt::Display for RealmRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RealmRole::Member => write!(f, "member"), + RealmRole::Builder => write!(f, "builder"), + RealmRole::Moderator => write!(f, "moderator"), + RealmRole::Owner => write!(f, "owner"), + } + } +} + +impl std::str::FromStr for RealmRole { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "member" => Ok(RealmRole::Member), + "builder" => Ok(RealmRole::Builder), + "moderator" => Ok(RealmRole::Moderator), + "owner" => Ok(RealmRole::Owner), + _ => Err(format!("Invalid realm role: {}", s)), + } + } +} + +/// Scene dimension mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "dimension_mode", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum DimensionMode { + #[default] + Fixed, + Viewport, +} + +impl std::fmt::Display for DimensionMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DimensionMode::Fixed => write!(f, "fixed"), + DimensionMode::Viewport => write!(f, "viewport"), + } + } +} + +impl std::str::FromStr for DimensionMode { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "fixed" => Ok(DimensionMode::Fixed), + "viewport" => Ok(DimensionMode::Viewport), + _ => Err(format!("Invalid dimension mode: {}", s)), + } + } +} + +/// Interactive spot type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "spot_type", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum SpotType { + #[default] + Normal, + Door, + Trigger, +} + +impl std::fmt::Display for SpotType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpotType::Normal => write!(f, "normal"), + SpotType::Door => write!(f, "door"), + SpotType::Trigger => write!(f, "trigger"), + } + } +} + +impl std::str::FromStr for SpotType { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "normal" => Ok(SpotType::Normal), + "door" => Ok(SpotType::Door), + "trigger" => Ok(SpotType::Trigger), + _ => Err(format!("Invalid spot type: {}", s)), + } + } +} + +/// Avatar layer for prop positioning (z-depth). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "avatar_layer", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum AvatarLayer { + Skin, + #[default] + Clothes, + Accessories, +} + +impl std::fmt::Display for AvatarLayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AvatarLayer::Skin => write!(f, "skin"), + AvatarLayer::Clothes => write!(f, "clothes"), + AvatarLayer::Accessories => write!(f, "accessories"), + } + } +} + +impl std::str::FromStr for AvatarLayer { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "skin" => Ok(AvatarLayer::Skin), + "clothes" => Ok(AvatarLayer::Clothes), + "accessories" => Ok(AvatarLayer::Accessories), + _ => Err(format!("Invalid avatar layer: {}", s)), + } + } +} + +/// Emotion state for avatar emotion overlays. +/// +/// Maps to emotion slots 0-11 in the avatar grid: +/// - e0: neutral, e1: happy, e2: sad, e3: angry, e4: surprised +/// - e5: thinking, e6: laughing, e7: crying, e8: love, e9: confused +/// - e10: sleeping, e11: wink +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "emotion_state", rename_all = "lowercase"))] +#[serde(rename_all = "lowercase")] +pub enum EmotionState { + #[default] + Neutral, + Happy, + Sad, + Angry, + Surprised, + Thinking, + Laughing, + Crying, + Love, + Confused, + Sleeping, + Wink, +} + +impl std::fmt::Display for EmotionState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EmotionState::Neutral => write!(f, "neutral"), + EmotionState::Happy => write!(f, "happy"), + EmotionState::Sad => write!(f, "sad"), + EmotionState::Angry => write!(f, "angry"), + EmotionState::Surprised => write!(f, "surprised"), + EmotionState::Thinking => write!(f, "thinking"), + EmotionState::Laughing => write!(f, "laughing"), + EmotionState::Crying => write!(f, "crying"), + EmotionState::Love => write!(f, "love"), + EmotionState::Confused => write!(f, "confused"), + EmotionState::Sleeping => write!(f, "sleeping"), + EmotionState::Wink => write!(f, "wink"), + } + } +} + +impl std::str::FromStr for EmotionState { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "neutral" => Ok(EmotionState::Neutral), + "happy" => Ok(EmotionState::Happy), + "sad" => Ok(EmotionState::Sad), + "angry" => Ok(EmotionState::Angry), + "surprised" => Ok(EmotionState::Surprised), + "thinking" => Ok(EmotionState::Thinking), + "laughing" => Ok(EmotionState::Laughing), + "crying" => Ok(EmotionState::Crying), + "love" => Ok(EmotionState::Love), + "confused" => Ok(EmotionState::Confused), + "sleeping" => Ok(EmotionState::Sleeping), + "wink" => Ok(EmotionState::Wink), + _ => Err(format!("Invalid emotion state: {}", s)), + } + } +} + +// ============================================================================= +// User Models +// ============================================================================= + +/// A user account. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct User { + pub id: Uuid, + pub username: String, + pub email: Option<String>, + pub display_name: String, + pub bio: Option<String>, + pub avatar_url: Option<String>, + pub reputation_tier: ReputationTier, + pub status: AccountStatus, + pub email_verified: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Minimal user info for display purposes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSummary { + pub id: Uuid, + pub username: String, + pub display_name: String, + pub avatar_url: Option<String>, +} + +// ============================================================================= +// Realm Models +// ============================================================================= + +/// A realm (themed virtual space). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct Realm { + pub id: Uuid, + pub name: String, + pub slug: String, + pub description: Option<String>, + pub tagline: Option<String>, + pub owner_id: Uuid, + pub privacy: RealmPrivacy, + pub is_nsfw: bool, + pub min_reputation_tier: ReputationTier, + pub theme_color: Option<String>, + pub banner_image_path: Option<String>, + pub thumbnail_path: Option<String>, + pub max_users: i32, + pub allow_guest_access: bool, + pub default_scene_id: Option<Uuid>, + pub member_count: i32, + pub current_user_count: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Realm with the current user's role (if authenticated and a member). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RealmWithUserRole { + #[serde(flatten)] + pub realm: Realm, + /// The current user's role in this realm, if they are a member. + pub user_role: Option<RealmRole>, +} + +/// Request to create a new realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRealmRequest { + pub name: String, + pub slug: String, + pub description: Option<String>, + pub tagline: Option<String>, + pub privacy: RealmPrivacy, + pub is_nsfw: bool, + pub max_users: i32, + pub allow_guest_access: bool, + pub theme_color: Option<String>, +} + +#[cfg(feature = "ssr")] +impl CreateRealmRequest { + /// Validate the create realm request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.name, "Realm name")?; + validation::validate_slug(&self.slug)?; + validation::validate_range(self.max_users, "Max users", 1, 10000)?; + validation::validate_optional_hex_color(self.theme_color.as_deref())?; + Ok(()) + } +} + +/// Minimal realm info for listings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct RealmSummary { + pub id: Uuid, + pub name: String, + pub slug: String, + pub tagline: Option<String>, + pub privacy: RealmPrivacy, + pub is_nsfw: bool, + pub thumbnail_path: Option<String>, + pub member_count: i32, + pub current_user_count: i32, +} + +/// Response after creating a realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRealmResponse { + pub id: Uuid, + pub slug: String, + pub redirect_url: String, +} + +// ============================================================================= +// Scene Models +// ============================================================================= + +/// A scene (room within a realm). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct Scene { + 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>, + /// Bounds as WKT string (e.g., "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))") + pub bounds_wkt: String, + pub dimension_mode: DimensionMode, + pub ambient_audio_id: Option<Uuid>, + pub ambient_volume: Option<f32>, + pub sort_order: i32, + pub is_entry_point: bool, + pub is_hidden: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Minimal scene info for listings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +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>, +} + +/// A spot (interactive region within a scene). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct Spot { + pub id: Uuid, + pub scene_id: Uuid, + pub name: Option<String>, + pub slug: Option<String>, + /// Region as WKT string (e.g., "POLYGON((100 100, 200 100, 200 200, 100 200, 100 100))") + pub region_wkt: String, + pub spot_type: SpotType, + pub destination_scene_id: Option<Uuid>, + /// Destination position as WKT string (e.g., "POINT(400 300)") + pub destination_position_wkt: Option<String>, + pub current_state: i16, + pub sort_order: i32, + pub is_visible: bool, + pub is_active: bool, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Minimal spot info for listings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct SpotSummary { + pub id: Uuid, + pub name: Option<String>, + pub slug: Option<String>, + pub spot_type: SpotType, + pub region_wkt: String, + pub sort_order: i32, + pub is_visible: bool, + pub is_active: bool, +} + +// ============================================================================= +// Props Models +// ============================================================================= + +/// A server-wide prop (global library). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ServerProp { + pub id: Uuid, + pub name: String, + pub slug: String, + pub description: Option<String>, + pub tags: Vec<String>, + pub asset_path: String, + pub thumbnail_path: Option<String>, + /// Default content layer (skin/clothes/accessories). Mutually exclusive with default_emotion. + pub default_layer: Option<AvatarLayer>, + /// Default emotion layer (neutral/happy/sad/etc). Mutually exclusive with default_layer. + pub default_emotion: Option<EmotionState>, + /// 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<DateTime<Utc>>, + pub available_until: Option<DateTime<Utc>>, + pub created_by: Option<Uuid>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Minimal server prop info for listings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ServerPropSummary { + pub id: Uuid, + pub name: String, + pub slug: String, + pub asset_path: String, + pub default_layer: Option<AvatarLayer>, + pub is_active: bool, + pub created_at: DateTime<Utc>, +} + +/// Request to create a server prop. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateServerPropRequest { + pub name: String, + #[serde(default)] + pub slug: Option<String>, + #[serde(default)] + pub description: Option<String>, + #[serde(default)] + pub tags: Vec<String>, + /// Default content layer (skin/clothes/accessories). Mutually exclusive with default_emotion. + #[serde(default)] + pub default_layer: Option<AvatarLayer>, + /// Default emotion layer (neutral/happy/sad/etc). Mutually exclusive with default_layer. + #[serde(default)] + pub default_emotion: Option<EmotionState>, + /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 + #[serde(default)] + pub default_position: Option<i16>, +} + +#[cfg(feature = "ssr")] +impl CreateServerPropRequest { + /// Validate the create prop request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.name, "Prop name")?; + if let Some(ref slug) = self.slug { + validation::validate_slug(slug)?; + } + // Validate grid position is 0-8 + if let Some(pos) = self.default_position { + if !(0..=8).contains(&pos) { + return Err(AppError::Validation( + "default_position must be between 0 and 8".to_string(), + )); + } + } + // Validate mutual exclusivity: can't have both default_layer and default_emotion + if self.default_layer.is_some() && self.default_emotion.is_some() { + return Err(AppError::Validation( + "Cannot specify both default_layer and default_emotion - they are mutually exclusive".to_string(), + )); + } + // If either layer or emotion is set, position must also be set + if (self.default_layer.is_some() || self.default_emotion.is_some()) + && self.default_position.is_none() + { + return Err(AppError::Validation( + "default_position is required when default_layer or default_emotion is set".to_string(), + )); + } + Ok(()) + } + + /// Generate a slug from the name if not provided. + pub fn slug_or_generate(&self) -> String { + self.slug.clone().unwrap_or_else(|| { + self.name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::<String>() + .trim_matches('-') + .to_string() + }) + } +} + +/// A saved avatar configuration (up to 10 per user). +/// +/// Contains 117 prop slot references: +/// - 27 content layer slots (3 layers × 9 positions) +/// - 90 emotion layer slots (10 emotions × 9 positions) +/// +/// Grid positions (0-8): +/// ```text +/// ┌───┬───┬───┐ +/// │ 0 │ 1 │ 2 │ top row +/// ├───┼───┼───┤ +/// │ 3 │ 4 │ 5 │ middle row +/// ├───┼───┼───┤ +/// │ 6 │ 7 │ 8 │ bottom row +/// └───┴───┴───┘ +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct Avatar { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + /// Slot number (0-9, keyboard: a0-a9) + pub slot_number: i16, + /// Last used emotion slot (0-9, NULL if none) + pub last_emotion: Option<i16>, + + // Content Layer: Skin (behind user, body/face) + pub l_skin_0: Option<Uuid>, + pub l_skin_1: Option<Uuid>, + pub l_skin_2: Option<Uuid>, + pub l_skin_3: Option<Uuid>, + pub l_skin_4: Option<Uuid>, + pub l_skin_5: Option<Uuid>, + pub l_skin_6: Option<Uuid>, + pub l_skin_7: Option<Uuid>, + pub l_skin_8: Option<Uuid>, + + // Content Layer: Clothes (with user, worn items) + pub l_clothes_0: Option<Uuid>, + pub l_clothes_1: Option<Uuid>, + pub l_clothes_2: Option<Uuid>, + pub l_clothes_3: Option<Uuid>, + pub l_clothes_4: Option<Uuid>, + pub l_clothes_5: Option<Uuid>, + pub l_clothes_6: Option<Uuid>, + pub l_clothes_7: Option<Uuid>, + pub l_clothes_8: Option<Uuid>, + + // Content Layer: Accessories (in front of user, held/attached items) + pub l_accessories_0: Option<Uuid>, + pub l_accessories_1: Option<Uuid>, + pub l_accessories_2: Option<Uuid>, + pub l_accessories_3: Option<Uuid>, + pub l_accessories_4: Option<Uuid>, + pub l_accessories_5: Option<Uuid>, + pub l_accessories_6: Option<Uuid>, + pub l_accessories_7: Option<Uuid>, + pub l_accessories_8: Option<Uuid>, + + // Emotion: Neutral (e0) + pub e_neutral_0: Option<Uuid>, + pub e_neutral_1: Option<Uuid>, + pub e_neutral_2: Option<Uuid>, + pub e_neutral_3: Option<Uuid>, + pub e_neutral_4: Option<Uuid>, + pub e_neutral_5: Option<Uuid>, + pub e_neutral_6: Option<Uuid>, + pub e_neutral_7: Option<Uuid>, + pub e_neutral_8: Option<Uuid>, + + // Emotion: Happy (e1) + pub e_happy_0: Option<Uuid>, + pub e_happy_1: Option<Uuid>, + pub e_happy_2: Option<Uuid>, + pub e_happy_3: Option<Uuid>, + pub e_happy_4: Option<Uuid>, + pub e_happy_5: Option<Uuid>, + pub e_happy_6: Option<Uuid>, + pub e_happy_7: Option<Uuid>, + pub e_happy_8: Option<Uuid>, + + // Emotion: Sad (e2) + pub e_sad_0: Option<Uuid>, + pub e_sad_1: Option<Uuid>, + pub e_sad_2: Option<Uuid>, + pub e_sad_3: Option<Uuid>, + pub e_sad_4: Option<Uuid>, + pub e_sad_5: Option<Uuid>, + pub e_sad_6: Option<Uuid>, + pub e_sad_7: Option<Uuid>, + pub e_sad_8: Option<Uuid>, + + // Emotion: Angry (e3) + pub e_angry_0: Option<Uuid>, + pub e_angry_1: Option<Uuid>, + pub e_angry_2: Option<Uuid>, + pub e_angry_3: Option<Uuid>, + pub e_angry_4: Option<Uuid>, + pub e_angry_5: Option<Uuid>, + pub e_angry_6: Option<Uuid>, + pub e_angry_7: Option<Uuid>, + pub e_angry_8: Option<Uuid>, + + // Emotion: Surprised (e4) + pub e_surprised_0: Option<Uuid>, + pub e_surprised_1: Option<Uuid>, + pub e_surprised_2: Option<Uuid>, + pub e_surprised_3: Option<Uuid>, + pub e_surprised_4: Option<Uuid>, + pub e_surprised_5: Option<Uuid>, + pub e_surprised_6: Option<Uuid>, + pub e_surprised_7: Option<Uuid>, + pub e_surprised_8: Option<Uuid>, + + // Emotion: Thinking (e5) + pub e_thinking_0: Option<Uuid>, + pub e_thinking_1: Option<Uuid>, + pub e_thinking_2: Option<Uuid>, + pub e_thinking_3: Option<Uuid>, + pub e_thinking_4: Option<Uuid>, + pub e_thinking_5: Option<Uuid>, + pub e_thinking_6: Option<Uuid>, + pub e_thinking_7: Option<Uuid>, + pub e_thinking_8: Option<Uuid>, + + // Emotion: Laughing (e6) + pub e_laughing_0: Option<Uuid>, + pub e_laughing_1: Option<Uuid>, + pub e_laughing_2: Option<Uuid>, + pub e_laughing_3: Option<Uuid>, + pub e_laughing_4: Option<Uuid>, + pub e_laughing_5: Option<Uuid>, + pub e_laughing_6: Option<Uuid>, + pub e_laughing_7: Option<Uuid>, + pub e_laughing_8: Option<Uuid>, + + // Emotion: Crying (e7) + pub e_crying_0: Option<Uuid>, + pub e_crying_1: Option<Uuid>, + pub e_crying_2: Option<Uuid>, + pub e_crying_3: Option<Uuid>, + pub e_crying_4: Option<Uuid>, + pub e_crying_5: Option<Uuid>, + pub e_crying_6: Option<Uuid>, + pub e_crying_7: Option<Uuid>, + pub e_crying_8: Option<Uuid>, + + // Emotion: Love (e8) + pub e_love_0: Option<Uuid>, + pub e_love_1: Option<Uuid>, + pub e_love_2: Option<Uuid>, + pub e_love_3: Option<Uuid>, + pub e_love_4: Option<Uuid>, + pub e_love_5: Option<Uuid>, + pub e_love_6: Option<Uuid>, + pub e_love_7: Option<Uuid>, + pub e_love_8: Option<Uuid>, + + // Emotion: Confused (e9) + pub e_confused_0: Option<Uuid>, + pub e_confused_1: Option<Uuid>, + pub e_confused_2: Option<Uuid>, + pub e_confused_3: Option<Uuid>, + pub e_confused_4: Option<Uuid>, + pub e_confused_5: Option<Uuid>, + pub e_confused_6: Option<Uuid>, + pub e_confused_7: Option<Uuid>, + pub e_confused_8: Option<Uuid>, + + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Currently active avatar for a user in a realm. +/// Users can have different active avatars per realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ActiveAvatar { + pub user_id: Uuid, + pub realm_id: Uuid, + pub avatar_id: Uuid, + /// Current emotion slot (0-9, keyboard: e0-e9) + pub current_emotion: i16, + pub updated_at: DateTime<Utc>, +} + +// ============================================================================= +// Server Config Models +// ============================================================================= + +/// Server-wide configuration (singleton). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ServerConfig { + pub id: Uuid, + 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: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to update server configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateServerConfigRequest { + 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, +} + +#[cfg(feature = "ssr")] +impl UpdateServerConfigRequest { + /// Validate the update request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.name, "Server name")?; + validation::validate_range(self.max_users_per_channel, "Max users per channel", 1, 1000)?; + validation::validate_range(self.message_rate_limit, "Message rate limit", 1, i32::MAX)?; + validation::validate_range( + self.message_rate_window_seconds, + "Message rate window", + 1, + i32::MAX, + )?; + Ok(()) + } +} + +// ============================================================================= +// Staff Models +// ============================================================================= + +/// A server staff member (joined with user info). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct StaffMember { + pub user_id: Uuid, + pub username: String, + pub display_name: String, + pub email: Option<String>, + pub role: ServerRole, + pub appointed_by: Option<Uuid>, + pub appointed_at: DateTime<Utc>, +} + +// ============================================================================= +// User Management Models +// ============================================================================= + +/// User listing item (for tables). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserListItem { + pub id: Uuid, + pub username: String, + pub display_name: String, + pub email: Option<String>, + pub status: AccountStatus, + pub reputation_tier: ReputationTier, + pub staff_role: Option<ServerRole>, + pub created_at: DateTime<Utc>, + pub last_seen_at: Option<DateTime<Utc>>, +} + +/// Full user detail (for user detail page). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserDetail { + pub id: Uuid, + pub username: String, + pub email: Option<String>, + pub display_name: String, + pub bio: Option<String>, + pub avatar_url: Option<String>, + pub reputation_tier: ReputationTier, + pub status: AccountStatus, + pub email_verified: bool, + pub staff_role: Option<ServerRole>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + pub last_seen_at: Option<DateTime<Utc>>, +} + +/// User's realm membership (for user detail page). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserRealmMembership { + pub realm_id: Uuid, + pub realm_name: String, + pub realm_slug: String, + pub role: RealmRole, + pub nickname: Option<String>, + pub joined_at: DateTime<Utc>, + pub last_visited_at: Option<DateTime<Utc>>, +} + +// ============================================================================= +// Staff Request Models +// ============================================================================= + +/// Request to create a staff member. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateStaffRequest { + /// Existing user ID to promote to staff. + pub user_id: Option<Uuid>, + /// Or create a new user. + pub new_user: Option<NewUserData>, + /// Role to assign. + pub role: ServerRole, +} + +#[cfg(feature = "ssr")] +impl CreateStaffRequest { + /// Validate the create staff request. + pub fn validate(&self) -> Result<(), AppError> { + // Must have either user_id or new_user, not both + match (&self.user_id, &self.new_user) { + (None, None) => { + return Err(AppError::Validation( + "Must provide either user_id or new_user".to_string(), + )); + } + (Some(_), Some(_)) => { + return Err(AppError::Validation( + "Cannot provide both user_id and new_user".to_string(), + )); + } + _ => {} + } + + // Validate new_user if provided + if let Some(new_user) = &self.new_user { + new_user.validate()?; + } + + Ok(()) + } +} + +/// Data for creating a new user (password is auto-generated). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewUserData { + pub username: String, + pub email: String, + pub display_name: String, +} + +#[cfg(feature = "ssr")] +impl NewUserData { + /// Validate new user data. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.username, "Username")?; + validation::validate_length(&self.username, "Username", 3, 32)?; + validation::validate_email(&self.email)?; + validation::validate_non_empty(&self.display_name, "Display name")?; + Ok(()) + } +} + +/// Request to update a user's account status. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserStatusRequest { + pub status: AccountStatus, +} + +/// Request to add a user to a realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddUserToRealmRequest { + pub realm_id: Uuid, + pub role: RealmRole, +} + +/// Request to create a standalone user (from owner interface). +/// Password is auto-generated as a random token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserRequest { + pub username: String, + pub email: String, + pub display_name: String, + /// Optional: make this user a staff member. + pub staff_role: Option<ServerRole>, +} + +#[cfg(feature = "ssr")] +impl CreateUserRequest { + /// Validate the create user request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.username, "Username")?; + validation::validate_length(&self.username, "Username", 3, 32)?; + validation::validate_email(&self.email)?; + validation::validate_non_empty(&self.display_name, "Display name")?; + Ok(()) + } +} + +/// Request to create a realm from the owner interface. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnerCreateRealmRequest { + /// Realm name. + pub name: String, + /// Realm slug (URL-friendly identifier). + pub slug: String, + /// Optional description. + pub description: Option<String>, + /// Optional tagline. + pub tagline: Option<String>, + /// Privacy setting. + pub privacy: RealmPrivacy, + /// Is this realm NSFW? + pub is_nsfw: bool, + /// Maximum concurrent users. + pub max_users: i32, + /// Allow guest access? + pub allow_guest_access: bool, + /// Optional theme color. + pub theme_color: Option<String>, + /// Existing user ID to make owner. + pub owner_id: Option<Uuid>, + /// Or create a new user as owner. + pub new_owner: Option<NewUserData>, +} + +#[cfg(feature = "ssr")] +impl OwnerCreateRealmRequest { + /// Validate the create realm request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.name, "Realm name")?; + validation::validate_slug(&self.slug)?; + validation::validate_range(self.max_users, "Max users", 1, 10000)?; + validation::validate_optional_hex_color(self.theme_color.as_deref())?; + + // Must have either owner_id or new_owner + match (&self.owner_id, &self.new_owner) { + (None, None) => { + return Err(AppError::Validation( + "Must provide either owner_id or new_owner".to_string(), + )); + } + (Some(_), Some(_)) => { + return Err(AppError::Validation( + "Cannot provide both owner_id and new_owner".to_string(), + )); + } + (None, Some(new_owner)) => { + new_owner.validate()?; + } + _ => {} + } + + Ok(()) + } +} + +/// Realm listing item for owner interface. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct RealmListItem { + pub id: Uuid, + pub name: String, + pub slug: String, + pub tagline: Option<String>, + pub privacy: RealmPrivacy, + pub is_nsfw: bool, + pub owner_id: Uuid, + pub owner_username: String, + pub member_count: i32, + pub current_user_count: i32, + pub created_at: DateTime<Utc>, +} + +/// Full realm detail for owner interface. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct RealmDetail { + pub id: Uuid, + pub name: String, + pub slug: String, + pub description: Option<String>, + pub tagline: Option<String>, + pub owner_id: Uuid, + pub owner_username: String, + pub owner_display_name: String, + pub privacy: RealmPrivacy, + pub is_nsfw: bool, + pub min_reputation_tier: ReputationTier, + pub theme_color: Option<String>, + pub banner_image_path: Option<String>, + pub thumbnail_path: Option<String>, + pub max_users: i32, + pub allow_guest_access: bool, + pub member_count: i32, + pub current_user_count: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to update a realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRealmRequest { + pub name: String, + pub description: Option<String>, + pub tagline: Option<String>, + pub privacy: RealmPrivacy, + pub is_nsfw: bool, + pub max_users: i32, + pub allow_guest_access: bool, + pub theme_color: Option<String>, +} + +#[cfg(feature = "ssr")] +impl UpdateRealmRequest { + /// Validate the update realm request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.name, "Realm name")?; + validation::validate_range(self.max_users, "Max users", 1, 10000)?; + validation::validate_optional_hex_color(self.theme_color.as_deref())?; + Ok(()) + } +} + +// ============================================================================= +// Authentication Models +// ============================================================================= + +/// Login type for authentication. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LoginType { + Staff, + Realm, +} + +/// User with authentication fields for login verification. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct UserWithAuth { + pub id: Uuid, + pub username: String, + pub email: Option<String>, + pub display_name: String, + pub avatar_url: Option<String>, + pub status: AccountStatus, + pub force_pw_reset: bool, + pub password_hash: Option<String>, +} + +/// Request to login. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, + pub login_type: LoginType, + /// Required if login_type is Realm. + pub realm_slug: Option<String>, +} + +#[cfg(feature = "ssr")] +impl LoginRequest { + /// Validate the login request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.username, "Username")?; + validation::validate_non_empty(&self.password, "Password")?; + if self.login_type == LoginType::Realm && self.realm_slug.is_none() { + return Err(AppError::Validation( + "Realm slug is required for realm login".to_string(), + )); + } + Ok(()) + } +} + +/// Response after successful login. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginResponse { + pub user: UserSummary, + pub redirect_url: String, + pub requires_pw_reset: bool, + /// For realm login: whether user is already a member. + pub is_member: Option<bool>, + /// Original destination for redirect after password reset. + pub original_destination: Option<String>, + /// Staff role if logging in as staff. + pub staff_role: Option<ServerRole>, + /// Realm info if logging into a realm (for join confirmation). + pub realm: Option<RealmSummary>, +} + +/// Request to reset password. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasswordResetRequest { + pub new_password: String, + pub confirm_password: String, +} + +#[cfg(feature = "ssr")] +impl PasswordResetRequest { + /// Validate the password reset request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_password(&self.new_password)?; + validation::validate_passwords_match(&self.new_password, &self.confirm_password)?; + Ok(()) + } +} + +/// Response after password reset. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PasswordResetResponse { + pub success: bool, + pub redirect_url: String, +} + +/// Request to sign up (create a new account and join a realm). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignupRequest { + pub username: String, + pub email: Option<String>, + pub display_name: String, + pub password: String, + pub confirm_password: String, + pub realm_slug: String, +} + +#[cfg(feature = "ssr")] +impl SignupRequest { + /// Validate the signup request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_username(&self.username)?; + validation::validate_non_empty(&self.display_name, "Display name")?; + validation::validate_length(self.display_name.trim(), "Display name", 1, 50)?; + + // Email: basic format if provided and non-empty + if let Some(ref email) = self.email { + let email_trimmed = email.trim(); + if !email_trimmed.is_empty() && !validation::is_valid_email(email_trimmed) { + return Err(AppError::Validation("Invalid email address".to_string())); + } + } + + validation::validate_password(&self.password)?; + validation::validate_passwords_match(&self.password, &self.confirm_password)?; + + // Realm slug: required + if self.realm_slug.trim().is_empty() { + return Err(AppError::Validation( + "Please select a realm to join".to_string(), + )); + } + + Ok(()) + } +} + +/// Response after successful signup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignupResponse { + pub user: UserSummary, + pub redirect_url: String, + pub membership_id: Uuid, +} + +/// Request to login as a guest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuestLoginRequest { + pub realm_slug: String, +} + +#[cfg(feature = "ssr")] +impl GuestLoginRequest { + /// Validate the guest login request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.realm_slug, "Realm")?; + Ok(()) + } +} + +/// Response after successful guest login. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuestLoginResponse { + pub guest_name: String, + pub guest_id: Uuid, + pub redirect_url: String, + pub realm: RealmSummary, +} + +/// Request to join a realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinRealmRequest { + pub realm_id: Uuid, +} + +/// Response after joining a realm. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinRealmResponse { + pub success: bool, + pub membership_id: Uuid, + pub redirect_url: String, +} + +/// Current user info (for /api/auth/me endpoint). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrentUserResponse { + pub user: Option<AuthenticatedUser>, +} + +/// Authenticated user info. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthenticatedUser { + pub id: Uuid, + pub username: String, + pub display_name: String, + pub avatar_url: Option<String>, + pub staff_role: Option<ServerRole>, +} + +// ============================================================================= +// Membership Models +// ============================================================================= + +/// A realm membership record. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct Membership { + pub id: Uuid, + pub realm_id: Uuid, + pub user_id: Uuid, + pub role: RealmRole, + pub nickname: Option<String>, + pub joined_at: DateTime<Utc>, + pub last_visited_at: Option<DateTime<Utc>>, +} + +/// Membership with realm info for user's realm list. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct MembershipWithRealm { + pub membership_id: Uuid, + pub realm_id: Uuid, + pub realm_name: String, + pub realm_slug: String, + pub realm_privacy: RealmPrivacy, + pub role: RealmRole, + pub nickname: Option<String>, + pub last_visited_at: Option<DateTime<Utc>>, +} + +// ============================================================================= +// Scene Request/Response Models +// ============================================================================= + +/// Request to create a new scene. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSceneRequest { + pub name: String, + pub slug: String, + pub description: Option<String>, + /// URL to download background image from. Server stores locally and sets background_image_path. + pub background_image_url: Option<String>, + /// If true and background_image_url is provided, extract dimensions from the image. + #[serde(default)] + pub infer_dimensions_from_image: bool, + /// Set directly only if not using background_image_url. + pub background_image_path: Option<String>, + pub background_color: Option<String>, + /// Bounds as WKT string (e.g., "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))") + pub bounds_wkt: Option<String>, + pub dimension_mode: Option<DimensionMode>, + pub sort_order: Option<i32>, + pub is_entry_point: Option<bool>, + pub is_hidden: Option<bool>, +} + +#[cfg(feature = "ssr")] +impl CreateSceneRequest { + /// Validate the create scene request. + pub fn validate(&self) -> Result<(), AppError> { + validation::validate_non_empty(&self.name, "Scene name")?; + validation::validate_slug(&self.slug)?; + validation::validate_optional_hex_color(self.background_color.as_deref())?; + Ok(()) + } +} + +/// Request to update a scene. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSceneRequest { + pub name: Option<String>, + pub description: Option<String>, + /// URL to download background image from. Server stores locally and sets background_image_path. + pub background_image_url: Option<String>, + /// If true and background_image_url is provided, extract dimensions from the image. + #[serde(default)] + pub infer_dimensions_from_image: bool, + /// Set to true to clear the existing background image. + #[serde(default)] + pub clear_background_image: bool, + /// Set directly only if not using background_image_url. + pub background_image_path: Option<String>, + pub background_color: Option<String>, + pub bounds_wkt: Option<String>, + pub dimension_mode: Option<DimensionMode>, + pub sort_order: Option<i32>, + pub is_entry_point: Option<bool>, + pub is_hidden: Option<bool>, +} + +#[cfg(feature = "ssr")] +impl UpdateSceneRequest { + /// Validate the update scene request. + pub fn validate(&self) -> Result<(), AppError> { + // Validate name if provided + if let Some(ref name) = self.name { + validation::validate_non_empty(name, "Scene name")?; + } + + validation::validate_optional_hex_color(self.background_color.as_deref())?; + + Ok(()) + } +} + +/// Response after creating a scene. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSceneResponse { + pub id: Uuid, + pub slug: String, +} + +/// Response for scene list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SceneListResponse { + pub scenes: Vec<SceneSummary>, +} + +// ============================================================================= +// Spot Request/Response Models +// ============================================================================= + +/// Request to create a new spot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSpotRequest { + pub name: Option<String>, + pub slug: Option<String>, + /// Region as WKT string (e.g., "POLYGON((100 100, 200 100, 200 200, 100 200, 100 100))") + pub region_wkt: String, + pub spot_type: Option<SpotType>, + pub destination_scene_id: Option<Uuid>, + /// Destination position as WKT string (e.g., "POINT(400 300)") + pub destination_position_wkt: Option<String>, + pub sort_order: Option<i32>, + pub is_visible: Option<bool>, + pub is_active: Option<bool>, +} + +#[cfg(feature = "ssr")] +impl CreateSpotRequest { + /// Validate the create spot request. + pub fn validate(&self) -> Result<(), AppError> { + // Validate slug if provided + if let Some(ref slug) = self.slug { + validation::validate_slug(slug)?; + } + + validation::validate_non_empty(&self.region_wkt, "Region WKT")?; + + // Validate door type has destination + if self.spot_type == Some(SpotType::Door) && self.destination_scene_id.is_none() { + return Err(AppError::Validation( + "Door spots must have a destination scene".to_string(), + )); + } + + Ok(()) + } +} + +/// Request to update a spot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSpotRequest { + pub name: Option<String>, + pub slug: Option<String>, + pub region_wkt: Option<String>, + pub spot_type: Option<SpotType>, + pub destination_scene_id: Option<Uuid>, + pub destination_position_wkt: Option<String>, + pub current_state: Option<i16>, + pub sort_order: Option<i32>, + pub is_visible: Option<bool>, + pub is_active: Option<bool>, +} + +#[cfg(feature = "ssr")] +impl UpdateSpotRequest { + /// Validate the update spot request. + pub fn validate(&self) -> Result<(), AppError> { + // Validate slug if provided + if let Some(ref slug) = self.slug { + validation::validate_slug(slug)?; + } + + // Validate region_wkt if provided + if let Some(ref wkt) = self.region_wkt { + validation::validate_non_empty(wkt, "Region WKT")?; + } + + Ok(()) + } +} + +/// Response after creating a spot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSpotResponse { + pub id: Uuid, +} + +/// Response for spot list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpotListResponse { + pub spots: Vec<SpotSummary>, +} + +// ============================================================================= +// Channel Member Models +// ============================================================================= + +/// A user's presence in a channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ChannelMember { + pub id: Uuid, + pub channel_id: Uuid, + pub user_id: Option<Uuid>, + pub guest_session_id: Option<Uuid>, + /// X coordinate in scene space + pub position_x: f64, + /// Y coordinate in scene space + pub position_y: f64, + /// Facing direction in degrees (0-359) + pub facing_direction: i16, + pub is_moving: bool, + pub is_afk: bool, + pub joined_at: DateTime<Utc>, + pub last_moved_at: DateTime<Utc>, +} + +/// Channel member with user info for display. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ChannelMemberInfo { + pub id: Uuid, + pub channel_id: Uuid, + pub user_id: Option<Uuid>, + pub guest_session_id: Option<Uuid>, + /// Display name (user's display_name or guest's guest_name) + pub display_name: String, + /// X coordinate in scene space + pub position_x: f64, + /// Y coordinate in scene space + pub position_y: f64, + /// Facing direction in degrees (0-359) + pub facing_direction: i16, + pub is_moving: bool, + pub is_afk: bool, + /// Current emotion slot (0-9) + pub current_emotion: i16, + pub joined_at: DateTime<Utc>, +} + +/// Request to update position in a channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatePositionRequest { + pub x: f64, + pub y: f64, +} + +/// Request to switch emotion. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateEmotionRequest { + /// Emotion slot 0-9 (e0-e9 hotkeys) + pub emotion: u8, +} + +// ============================================================================= +// Avatar Render Data Models +// ============================================================================= + +/// Data needed to render an avatar's current appearance. +/// Contains the asset paths for all equipped props. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvatarRenderData { + pub avatar_id: Uuid, + pub current_emotion: i16, + /// Asset paths for skin layer positions 0-8 (None if slot empty) + pub skin_layer: [Option<String>; 9], + /// Asset paths for clothes layer positions 0-8 + pub clothes_layer: [Option<String>; 9], + /// Asset paths for accessories layer positions 0-8 + pub accessories_layer: [Option<String>; 9], + /// Asset paths for current emotion overlay positions 0-8 + pub emotion_layer: [Option<String>; 9], +} + +impl Default for AvatarRenderData { + fn default() -> Self { + Self { + avatar_id: Uuid::nil(), + current_emotion: 0, + skin_layer: Default::default(), + clothes_layer: Default::default(), + accessories_layer: Default::default(), + emotion_layer: Default::default(), + } + } +} + +/// Channel member with full avatar render data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelMemberWithAvatar { + pub member: ChannelMemberInfo, + pub avatar: AvatarRenderData, +} + +/// Response for channel members list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelMembersResponse { + pub members: Vec<ChannelMemberWithAvatar>, +} + +/// Response after joining a channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinChannelResponse { + pub member: ChannelMemberInfo, + pub members: Vec<ChannelMemberWithAvatar>, +} diff --git a/crates/chattyness-db/src/pool.rs b/crates/chattyness-db/src/pool.rs new file mode 100644 index 0000000..2c70559 --- /dev/null +++ b/crates/chattyness-db/src/pool.rs @@ -0,0 +1,84 @@ +//! Database connection pool and RLS context management. + +use std::time::Duration; + +use sqlx::{postgres::PgPoolOptions, PgPool}; +use uuid::Uuid; + +use chattyness_error::AppError; + +/// Create a new database connection pool for the owner interface. +/// +/// Uses the `chattyness_owner` role which has full database access. +pub async fn create_owner_pool(database_url: &str) -> Result<PgPool, AppError> { + let pool = PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(5)) + .connect(database_url) + .await?; + + Ok(pool) +} + +/// Create a new database connection pool for the public app. +/// +/// Uses the `chattyness_app` role which has Row-Level Security (RLS) policies. +pub async fn create_app_pool(database_url: &str) -> Result<PgPool, AppError> { + let pool = PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(Duration::from_secs(5)) + .connect(database_url) + .await?; + + Ok(pool) +} + +/// Set the current user context for Row-Level Security. +/// +/// This should be called at the start of each request to enable RLS policies +/// that depend on the current user ID. +pub async fn set_user_context(pool: &PgPool, user_id: Option<Uuid>) -> Result<(), AppError> { + if let Some(id) = user_id { + sqlx::query("SELECT public.set_current_user_id($1)") + .bind(id) + .execute(pool) + .await?; + } else { + // Clear the user context for anonymous requests + sqlx::query("SELECT public.clear_current_user_id()") + .execute(pool) + .await?; + } + Ok(()) +} + +/// Clear the current user context. +/// +/// Called at the end of a request to ensure the connection is clean +/// before returning to the pool. +pub async fn clear_user_context(pool: &PgPool) -> Result<(), AppError> { + sqlx::query("SELECT public.clear_current_user_id()") + .execute(pool) + .await?; + Ok(()) +} + +/// Set the current guest session context for Row-Level Security. +/// +/// This should be called for guest users to enable RLS policies +/// that depend on the current guest session ID. +pub async fn set_guest_context(pool: &PgPool, guest_session_id: Uuid) -> Result<(), AppError> { + sqlx::query("SELECT public.set_current_guest_session_id($1)") + .bind(guest_session_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Clear the current guest session context. +pub async fn clear_guest_context(pool: &PgPool) -> Result<(), AppError> { + sqlx::query("SELECT public.set_current_guest_session_id(NULL)") + .execute(pool) + .await?; + Ok(()) +} diff --git a/crates/chattyness-db/src/queries.rs b/crates/chattyness-db/src/queries.rs new file mode 100644 index 0000000..0e50022 --- /dev/null +++ b/crates/chattyness-db/src/queries.rs @@ -0,0 +1,12 @@ +//! Database query modules. + +pub mod avatars; +pub mod channel_members; +pub mod guests; +pub mod memberships; +pub mod owner; +pub mod props; +pub mod realms; +pub mod scenes; +pub mod spots; +pub mod users; diff --git a/crates/chattyness-db/src/queries/avatars.rs b/crates/chattyness-db/src/queries/avatars.rs new file mode 100644 index 0000000..b43b308 --- /dev/null +++ b/crates/chattyness-db/src/queries/avatars.rs @@ -0,0 +1,201 @@ +//! Avatar-related database queries. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::{ActiveAvatar, AvatarRenderData}; +use chattyness_error::AppError; + +/// Get the active avatar for a user in a realm. +pub async fn get_active_avatar<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + realm_id: Uuid, +) -> Result<Option<ActiveAvatar>, AppError> { + let avatar = sqlx::query_as::<_, ActiveAvatar>( + r#" + SELECT user_id, realm_id, avatar_id, current_emotion, updated_at + FROM props.active_avatars + WHERE user_id = $1 AND realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_optional(executor) + .await?; + + Ok(avatar) +} + +/// Set the current emotion for a user in a realm. +/// Returns the full emotion layer (9 asset paths) for the new emotion. +pub async fn set_emotion<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + realm_id: Uuid, + emotion: i16, +) -> Result<[Option<String>; 9], AppError> { + if emotion < 0 || emotion > 9 { + return Err(AppError::Validation("Emotion must be 0-9".to_string())); + } + + // Map emotion index to column prefix + let emotion_prefix = match emotion { + 0 => "e_neutral", + 1 => "e_happy", + 2 => "e_sad", + 3 => "e_angry", + 4 => "e_surprised", + 5 => "e_thinking", + 6 => "e_laughing", + 7 => "e_crying", + 8 => "e_love", + 9 => "e_confused", + _ => return Err(AppError::Validation("Emotion must be 0-9".to_string())), + }; + + // Build dynamic query for the specific emotion's 9 positions + let query = format!( + r#" + WITH updated AS ( + UPDATE props.active_avatars + SET current_emotion = $3, updated_at = now() + WHERE user_id = $1 AND realm_id = $2 + RETURNING avatar_id + ) + SELECT + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_0) as p0, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_1) as p1, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_2) as p2, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_3) as p3, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_4) as p4, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_5) as p5, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_6) as p6, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_7) as p7, + (SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_8) as p8 + FROM updated u + JOIN props.avatars a ON a.id = u.avatar_id + "#, + prefix = emotion_prefix + ); + + let result = sqlx::query_as::<_, EmotionLayerRow>(&query) + .bind(user_id) + .bind(realm_id) + .bind(emotion) + .fetch_optional(executor) + .await?; + + match result { + Some(row) => Ok([ + row.p0, row.p1, row.p2, row.p3, row.p4, row.p5, row.p6, row.p7, row.p8, + ]), + None => Err(AppError::NotFound( + "No active avatar for this user in this realm".to_string(), + )), + } +} + +/// Row type for emotion layer query. +#[derive(Debug, sqlx::FromRow)] +struct EmotionLayerRow { + p0: Option<String>, + p1: Option<String>, + p2: Option<String>, + p3: Option<String>, + p4: Option<String>, + p5: Option<String>, + p6: Option<String>, + p7: Option<String>, + p8: Option<String>, +} + +/// Get render data for a user's avatar in a realm. +/// +/// Returns the asset paths for all equipped props in the avatar's current state. +/// This is a simplified version that only returns the center position (position 4) +/// props for skin, clothes, accessories, and current emotion layers. +pub async fn get_avatar_render_data<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + realm_id: Uuid, +) -> Result<AvatarRenderData, AppError> { + // Simplified query: just get position 4 (center) props for each layer + // This covers the common case of simple face avatars + let render_data = sqlx::query_as::<_, SimplifiedAvatarRow>( + r#" + SELECT + a.id as avatar_id, + aa.current_emotion, + -- Skin layer center + skin.prop_asset_path as skin_center, + -- Clothes layer center + clothes.prop_asset_path as clothes_center, + -- Accessories layer center + acc.prop_asset_path as accessories_center, + -- Current emotion layer center (based on current_emotion) + CASE aa.current_emotion + WHEN 0 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4) + WHEN 1 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4) + WHEN 2 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4) + WHEN 3 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4) + WHEN 4 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4) + WHEN 5 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4) + WHEN 6 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4) + WHEN 7 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4) + WHEN 8 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4) + WHEN 9 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4) + END as emotion_center + FROM props.active_avatars aa + JOIN props.avatars a ON aa.avatar_id = a.id + LEFT JOIN props.inventory skin ON a.l_skin_4 = skin.id + LEFT JOIN props.inventory clothes ON a.l_clothes_4 = clothes.id + LEFT JOIN props.inventory acc ON a.l_accessories_4 = acc.id + WHERE aa.user_id = $1 AND aa.realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_optional(executor) + .await?; + + match render_data { + Some(row) => Ok(row.into()), + None => Ok(AvatarRenderData::default()), + } +} + +/// Simplified avatar row for center-only rendering. +#[derive(Debug, sqlx::FromRow)] +struct SimplifiedAvatarRow { + avatar_id: Uuid, + current_emotion: i16, + skin_center: Option<String>, + clothes_center: Option<String>, + accessories_center: Option<String>, + emotion_center: Option<String>, +} + +impl From<SimplifiedAvatarRow> for AvatarRenderData { + fn from(row: SimplifiedAvatarRow) -> Self { + // For now, only populate position 4 (center) + let mut skin_layer: [Option<String>; 9] = Default::default(); + let mut clothes_layer: [Option<String>; 9] = Default::default(); + let mut accessories_layer: [Option<String>; 9] = Default::default(); + let mut emotion_layer: [Option<String>; 9] = Default::default(); + + skin_layer[4] = row.skin_center; + clothes_layer[4] = row.clothes_center; + accessories_layer[4] = row.accessories_center; + emotion_layer[4] = row.emotion_center; + + Self { + avatar_id: row.avatar_id, + current_emotion: row.current_emotion, + skin_layer, + clothes_layer, + accessories_layer, + emotion_layer, + } + } +} diff --git a/crates/chattyness-db/src/queries/channel_members.rs b/crates/chattyness-db/src/queries/channel_members.rs new file mode 100644 index 0000000..0355c67 --- /dev/null +++ b/crates/chattyness-db/src/queries/channel_members.rs @@ -0,0 +1,233 @@ +//! Channel member queries for user presence in channels. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::{ChannelMember, ChannelMemberInfo}; +use chattyness_error::AppError; + +/// Join a channel as an authenticated user. +/// +/// Creates a channel_members entry with default position (400, 300). +pub async fn join_channel<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + user_id: Uuid, +) -> Result<ChannelMember, AppError> { + let member = sqlx::query_as::<_, ChannelMember>( + r#" + INSERT INTO realm.channel_members (channel_id, user_id, position) + VALUES ($1, $2, ST_SetSRID(ST_MakePoint(400, 300), 0)) + ON CONFLICT (channel_id, user_id) DO UPDATE + SET joined_at = now() + RETURNING + id, + channel_id, + user_id, + guest_session_id, + ST_X(position) as position_x, + ST_Y(position) as position_y, + facing_direction, + is_moving, + is_afk, + joined_at, + last_moved_at + "#, + ) + .bind(channel_id) + .bind(user_id) + .fetch_one(executor) + .await?; + + Ok(member) +} + +/// Ensure an active avatar exists for a user in a realm. +/// Uses the user's default avatar (slot 0) if none exists. +pub async fn ensure_active_avatar<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + realm_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO props.active_avatars (user_id, realm_id, avatar_id, current_emotion) + SELECT $1, $2, id, 0 + FROM props.avatars + WHERE user_id = $1 AND slot_number = 0 + ON CONFLICT (user_id, realm_id) DO NOTHING + "#, + ) + .bind(user_id) + .bind(realm_id) + .execute(executor) + .await?; + + Ok(()) +} + +/// Leave a channel. +pub async fn leave_channel<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + user_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + r#"DELETE FROM realm.channel_members WHERE channel_id = $1 AND user_id = $2"#, + ) + .bind(channel_id) + .bind(user_id) + .execute(executor) + .await?; + + Ok(()) +} + +/// Update a user's position in a channel. +pub async fn update_position<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + user_id: Uuid, + x: f64, + y: f64, +) -> Result<(), AppError> { + let result = sqlx::query( + r#" + UPDATE realm.channel_members + SET position = ST_SetSRID(ST_MakePoint($3, $4), 0), + last_moved_at = now(), + is_moving = true + WHERE channel_id = $1 AND user_id = $2 + "#, + ) + .bind(channel_id) + .bind(user_id) + .bind(x) + .bind(y) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Channel member not found".to_string())); + } + + Ok(()) +} + +/// Get all members in a channel with their display info and current emotion. +pub async fn get_channel_members<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + realm_id: Uuid, +) -> Result<Vec<ChannelMemberInfo>, AppError> { + let members = sqlx::query_as::<_, ChannelMemberInfo>( + r#" + SELECT + cm.id, + cm.channel_id, + cm.user_id, + cm.guest_session_id, + COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name, + ST_X(cm.position) as position_x, + ST_Y(cm.position) as position_y, + cm.facing_direction, + cm.is_moving, + cm.is_afk, + COALESCE(aa.current_emotion, 0::smallint) as current_emotion, + cm.joined_at + FROM realm.channel_members cm + LEFT JOIN auth.users u ON cm.user_id = u.id + LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id + LEFT JOIN props.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2 + WHERE cm.channel_id = $1 + ORDER BY cm.joined_at ASC + "#, + ) + .bind(channel_id) + .bind(realm_id) + .fetch_all(executor) + .await?; + + Ok(members) +} + +/// Get a specific channel member by user ID. +pub async fn get_channel_member<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + user_id: Uuid, + realm_id: Uuid, +) -> Result<Option<ChannelMemberInfo>, AppError> { + let member = sqlx::query_as::<_, ChannelMemberInfo>( + r#" + SELECT + cm.id, + cm.channel_id, + cm.user_id, + cm.guest_session_id, + COALESCE(u.display_name, 'Anonymous') as display_name, + ST_X(cm.position) as position_x, + ST_Y(cm.position) as position_y, + cm.facing_direction, + cm.is_moving, + cm.is_afk, + COALESCE(aa.current_emotion, 0::smallint) as current_emotion, + cm.joined_at + FROM realm.channel_members cm + LEFT JOIN auth.users u ON cm.user_id = u.id + LEFT JOIN props.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3 + WHERE cm.channel_id = $1 AND cm.user_id = $2 + "#, + ) + .bind(channel_id) + .bind(user_id) + .bind(realm_id) + .fetch_optional(executor) + .await?; + + Ok(member) +} + +/// Set a user's moving state to false (called after movement animation completes). +pub async fn set_stopped<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + user_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE realm.channel_members + SET is_moving = false + WHERE channel_id = $1 AND user_id = $2 + "#, + ) + .bind(channel_id) + .bind(user_id) + .execute(executor) + .await?; + + Ok(()) +} + +/// Set a user's AFK state. +pub async fn set_afk<'e>( + executor: impl PgExecutor<'e>, + channel_id: Uuid, + user_id: Uuid, + is_afk: bool, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE realm.channel_members + SET is_afk = $3 + WHERE channel_id = $1 AND user_id = $2 + "#, + ) + .bind(channel_id) + .bind(user_id) + .bind(is_afk) + .execute(executor) + .await?; + + Ok(()) +} diff --git a/crates/chattyness-db/src/queries/guests.rs b/crates/chattyness-db/src/queries/guests.rs new file mode 100644 index 0000000..3433781 --- /dev/null +++ b/crates/chattyness-db/src/queries/guests.rs @@ -0,0 +1,95 @@ +//! Guest session database queries. + +use chrono::{DateTime, TimeDelta, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use chattyness_error::AppError; + +/// Guest session record. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct GuestSession { + pub id: Uuid, + pub guest_name: String, + pub current_realm_id: Option<Uuid>, + pub expires_at: DateTime<Utc>, + pub created_at: DateTime<Utc>, +} + +/// Create a new guest session. +/// +/// Returns the guest session ID. +pub async fn create_guest_session( + pool: &PgPool, + guest_name: &str, + realm_id: Uuid, + token_hash: &str, + user_agent: Option<&str>, + ip_address: Option<&str>, + expires_at: DateTime<Utc>, +) -> Result<Uuid, AppError> { + let (session_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.guest_sessions (guest_name, token_hash, user_agent, ip_address, current_realm_id, expires_at) + VALUES ($1, $2, $3, $4::inet, $5, $6) + RETURNING id + "#, + ) + .bind(guest_name) + .bind(token_hash) + .bind(user_agent) + .bind(ip_address) + .bind(realm_id) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(session_id) +} + +/// Get a guest session by ID. +pub async fn get_guest_session(pool: &PgPool, session_id: Uuid) -> Result<Option<GuestSession>, AppError> { + let session = sqlx::query_as::<_, GuestSession>( + r#" + SELECT id, guest_name, current_realm_id, expires_at, created_at + FROM auth.guest_sessions + WHERE id = $1 AND expires_at > now() + "#, + ) + .bind(session_id) + .fetch_optional(pool) + .await?; + + // Update last activity if session exists + if session.is_some() { + sqlx::query("UPDATE auth.guest_sessions SET last_activity_at = now() WHERE id = $1") + .bind(session_id) + .execute(pool) + .await?; + } + + Ok(session) +} + +/// Delete a guest session. +pub async fn delete_guest_session(pool: &PgPool, session_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM auth.guest_sessions WHERE id = $1") + .bind(session_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Generate a random guest name like "Guest_12345". +pub fn generate_guest_name() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let number: u32 = rng.gen_range(10000..100000); + format!("Guest_{}", number) +} + +/// Calculate guest session expiry (24 hours from now). +pub fn guest_session_expiry() -> DateTime<Utc> { + Utc::now() + TimeDelta::hours(24) +} diff --git a/crates/chattyness-db/src/queries/memberships.rs b/crates/chattyness-db/src/queries/memberships.rs new file mode 100644 index 0000000..ab4ab21 --- /dev/null +++ b/crates/chattyness-db/src/queries/memberships.rs @@ -0,0 +1,201 @@ +//! Membership-related database queries. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{Membership, MembershipWithRealm, RealmRole, ServerRole}; +use chattyness_error::AppError; + +/// Get a user's membership in a specific realm. +pub async fn get_user_membership( + pool: &PgPool, + user_id: Uuid, + realm_id: Uuid, +) -> Result<Option<Membership>, AppError> { + let membership = sqlx::query_as::<_, Membership>( + r#" + SELECT + id, + realm_id, + user_id, + role, + nickname, + created_at AS joined_at, + last_visited_at + FROM realm.memberships + WHERE user_id = $1 AND realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_optional(pool) + .await?; + + Ok(membership) +} + +/// Create a new membership (join a realm). +pub async fn create_membership( + pool: &PgPool, + user_id: Uuid, + realm_id: Uuid, + role: RealmRole, +) -> Result<Uuid, AppError> { + let (membership_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO realm.memberships (realm_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING id + "#, + ) + .bind(realm_id) + .bind(user_id) + .bind(role) + .fetch_one(pool) + .await?; + + // Update member count on the realm + sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1") + .bind(realm_id) + .execute(pool) + .await?; + + Ok(membership_id) +} + +/// Create a new membership using a connection (for RLS support). +pub async fn create_membership_conn( + conn: &mut sqlx::PgConnection, + user_id: Uuid, + realm_id: Uuid, + role: RealmRole, +) -> Result<Uuid, AppError> { + let (membership_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO realm.memberships (realm_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING id + "#, + ) + .bind(realm_id) + .bind(user_id) + .bind(role) + .fetch_one(&mut *conn) + .await?; + + // Update member count on the realm + sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1") + .bind(realm_id) + .execute(&mut *conn) + .await?; + + Ok(membership_id) +} + +/// Get all realm memberships for a user. +pub async fn get_user_memberships( + pool: &PgPool, + user_id: Uuid, +) -> Result<Vec<MembershipWithRealm>, AppError> { + let memberships = sqlx::query_as::<_, MembershipWithRealm>( + r#" + SELECT + m.id AS membership_id, + m.realm_id, + r.name AS realm_name, + r.slug AS realm_slug, + r.privacy AS realm_privacy, + m.role, + m.nickname, + m.last_visited_at + FROM realm.memberships m + JOIN realm.realms r ON m.realm_id = r.id + WHERE m.user_id = $1 + ORDER BY m.last_visited_at DESC NULLS LAST + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(memberships) +} + +/// Get a user's server staff role (if any). +pub async fn get_user_staff_role( + pool: &PgPool, + user_id: Uuid, +) -> Result<Option<ServerRole>, AppError> { + let result: Option<(ServerRole,)> = sqlx::query_as( + r#" + SELECT role + FROM server.staff + WHERE user_id = $1 + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|(role,)| role)) +} + +/// Update last visited timestamp for a membership. +pub async fn update_last_visited( + pool: &PgPool, + user_id: Uuid, + realm_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE realm.memberships + SET last_visited_at = now() + WHERE user_id = $1 AND realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// Update last visited timestamp using a connection (for RLS support). +pub async fn update_last_visited_conn( + conn: &mut sqlx::PgConnection, + user_id: Uuid, + realm_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE realm.memberships + SET last_visited_at = now() + WHERE user_id = $1 AND realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .execute(conn) + .await?; + + Ok(()) +} + +/// Check if a user is a member of a realm. +pub async fn is_member(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result<bool, AppError> { + let exists: (bool,) = sqlx::query_as( + r#" + SELECT EXISTS( + SELECT 1 FROM realm.memberships + WHERE user_id = $1 AND realm_id = $2 + ) + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_one(pool) + .await?; + + Ok(exists.0) +} diff --git a/crates/chattyness-db/src/queries/owner.rs b/crates/chattyness-db/src/queries/owner.rs new file mode 100644 index 0000000..d1d04a1 --- /dev/null +++ b/crates/chattyness-db/src/queries/owner.rs @@ -0,0 +1,25 @@ +//! Owner-related database queries. +//! +//! These queries are used by the owner interface and require the chattyness_owner role. +//! +//! This module is organized into submodules by domain: +//! - `config`: Server configuration queries +//! - `staff`: Staff management queries +//! - `users`: User management queries +//! - `realms`: Realm management queries +//! - `helpers`: Shared helper functions (password hashing, token generation) + +mod config; +mod helpers; +mod realms; +mod staff; +mod users; + +// Re-export all public functions for backwards compatibility +pub use config::*; +pub use realms::*; +pub use staff::*; +pub use users::*; + +// Re-export helpers for use by other modules if needed +pub use helpers::{generate_random_token, hash_password}; diff --git a/crates/chattyness-db/src/queries/owner/config.rs b/crates/chattyness-db/src/queries/owner/config.rs new file mode 100644 index 0000000..bac41bd --- /dev/null +++ b/crates/chattyness-db/src/queries/owner/config.rs @@ -0,0 +1,93 @@ +//! Server configuration database queries. +//! +//! These queries are used by the owner interface and require the chattyness_owner role. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{ServerConfig, UpdateServerConfigRequest}; +use chattyness_error::AppError; + +/// The fixed UUID for the singleton server config row. +pub fn server_config_id() -> Uuid { + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() +} + +/// Get the server configuration. +pub async fn get_server_config(pool: &PgPool) -> Result<ServerConfig, AppError> { + let config = sqlx::query_as::<_, ServerConfig>( + r#" + SELECT + id, + name, + description, + welcome_message, + max_users_per_channel, + message_rate_limit, + message_rate_window_seconds, + allow_guest_access, + allow_user_uploads, + require_email_verification, + created_at, + updated_at + FROM server.config + WHERE id = $1 + "#, + ) + .bind(server_config_id()) + .fetch_one(pool) + .await?; + + Ok(config) +} + +/// Update the server configuration. +pub async fn update_server_config( + pool: &PgPool, + req: &UpdateServerConfigRequest, +) -> Result<ServerConfig, AppError> { + let config = sqlx::query_as::<_, ServerConfig>( + r#" + UPDATE server.config + SET + name = $1, + description = $2, + welcome_message = $3, + max_users_per_channel = $4, + message_rate_limit = $5, + message_rate_window_seconds = $6, + allow_guest_access = $7, + allow_user_uploads = $8, + require_email_verification = $9, + updated_at = now() + WHERE id = $10 + RETURNING + id, + name, + description, + welcome_message, + max_users_per_channel, + message_rate_limit, + message_rate_window_seconds, + allow_guest_access, + allow_user_uploads, + require_email_verification, + created_at, + updated_at + "#, + ) + .bind(&req.name) + .bind(&req.description) + .bind(&req.welcome_message) + .bind(req.max_users_per_channel) + .bind(req.message_rate_limit) + .bind(req.message_rate_window_seconds) + .bind(req.allow_guest_access) + .bind(req.allow_user_uploads) + .bind(req.require_email_verification) + .bind(server_config_id()) + .fetch_one(pool) + .await?; + + Ok(config) +} diff --git a/crates/chattyness-db/src/queries/owner/helpers.rs b/crates/chattyness-db/src/queries/owner/helpers.rs new file mode 100644 index 0000000..21193c0 --- /dev/null +++ b/crates/chattyness-db/src/queries/owner/helpers.rs @@ -0,0 +1,32 @@ +//! Shared helper functions for owner queries. + +use chattyness_error::AppError; + +/// Hash a password using argon2. +pub fn hash_password(password: &str) -> Result<String, AppError> { + use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?; + + Ok(hash.to_string()) +} + +/// Generate a random 50-character alphanumeric token for temporary passwords. +pub fn generate_random_token() -> String { + use rand::Rng; + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + (0..50) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} diff --git a/crates/chattyness-db/src/queries/owner/realms.rs b/crates/chattyness-db/src/queries/owner/realms.rs new file mode 100644 index 0000000..e1e2d95 --- /dev/null +++ b/crates/chattyness-db/src/queries/owner/realms.rs @@ -0,0 +1,385 @@ +//! Realm management database queries. +//! +//! These queries are used by the owner interface and require the chattyness_owner role. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{ + OwnerCreateRealmRequest, RealmDetail, RealmListItem, RealmPrivacy, UpdateRealmRequest, +}; +use chattyness_error::AppError; + +use super::helpers::{generate_random_token, hash_password}; + +/// List all realms with owner info (for admin interface). +pub async fn list_realms_with_owner( + pool: &PgPool, + limit: i64, + offset: i64, +) -> Result<Vec<RealmListItem>, AppError> { + let realms = sqlx::query_as::<_, RealmListItem>( + r#" + SELECT + r.id, + r.name, + r.slug, + r.tagline, + r.privacy, + r.is_nsfw, + r.owner_id, + u.username as owner_username, + r.member_count, + r.current_user_count, + r.created_at + FROM realm.realms r + JOIN auth.users u ON r.owner_id = u.id + ORDER BY r.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(realms) +} + +/// Search realms by name or slug. +pub async fn search_realms( + pool: &PgPool, + query: &str, + limit: i64, +) -> Result<Vec<RealmListItem>, AppError> { + let pattern = format!("%{}%", query); + let realms = sqlx::query_as::<_, RealmListItem>( + r#" + SELECT + r.id, + r.name, + r.slug, + r.tagline, + r.privacy, + r.is_nsfw, + r.owner_id, + u.username as owner_username, + r.member_count, + r.current_user_count, + r.created_at + FROM realm.realms r + JOIN auth.users u ON r.owner_id = u.id + WHERE + r.name ILIKE $1 + OR r.slug ILIKE $1 + ORDER BY + CASE + WHEN r.slug ILIKE $1 THEN 1 + WHEN r.name ILIKE $1 THEN 2 + ELSE 3 + END, + r.name + LIMIT $2 + "#, + ) + .bind(&pattern) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(realms) +} + +/// Create a new realm with an existing user as owner. +#[allow(clippy::too_many_arguments)] +pub async fn create_realm( + pool: &PgPool, + owner_id: Uuid, + name: &str, + slug: &str, + description: Option<&str>, + tagline: Option<&str>, + privacy: RealmPrivacy, + is_nsfw: bool, + max_users: i32, + allow_guest_access: bool, + theme_color: Option<&str>, +) -> Result<Uuid, AppError> { + // Start a transaction + let mut tx = pool.begin().await?; + + // Create the realm + let realm_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO realm.realms ( + name, slug, description, tagline, owner_id, + privacy, is_nsfw, max_users, allow_guest_access, theme_color + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + "#, + ) + .bind(name) + .bind(slug) + .bind(description) + .bind(tagline) + .bind(owner_id) + .bind(privacy) + .bind(is_nsfw) + .bind(max_users) + .bind(allow_guest_access) + .bind(theme_color) + .fetch_one(&mut *tx) + .await?; + + // Add owner as realm member with owner role + sqlx::query( + r#" + INSERT INTO realm.memberships (realm_id, user_id, role) + VALUES ($1, $2, 'owner') + "#, + ) + .bind(realm_id) + .bind(owner_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(realm_id) +} + +/// Create a realm with a new user as owner (atomically). +/// Returns (realm_id, user_id, plaintext_token) - the token should be shown to the server owner. +pub async fn create_realm_with_new_owner( + pool: &PgPool, + req: &OwnerCreateRealmRequest, +) -> Result<(Uuid, Uuid, String), AppError> { + let new_owner = req + .new_owner + .as_ref() + .ok_or_else(|| AppError::Validation("new_owner is required".to_string()))?; + + // Generate a random token as the temporary password + let token = generate_random_token(); + let password_hash = hash_password(&token)?; + + // Start a transaction + let mut tx = pool.begin().await?; + + // Create the user with force_pw_reset = true + let user_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset) + VALUES ($1, $2, $3, $4, true) + RETURNING id + "#, + ) + .bind(&new_owner.username) + .bind(&new_owner.email) + .bind(&new_owner.display_name) + .bind(&password_hash) + .fetch_one(&mut *tx) + .await?; + + // Create the realm + let realm_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO realm.realms ( + name, slug, description, tagline, owner_id, + privacy, is_nsfw, max_users, allow_guest_access, theme_color + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + "#, + ) + .bind(&req.name) + .bind(&req.slug) + .bind(&req.description) + .bind(&req.tagline) + .bind(user_id) + .bind(req.privacy) + .bind(req.is_nsfw) + .bind(req.max_users) + .bind(req.allow_guest_access) + .bind(&req.theme_color) + .fetch_one(&mut *tx) + .await?; + + // Add owner as realm member with owner role + sqlx::query( + r#" + INSERT INTO realm.memberships (realm_id, user_id, role) + VALUES ($1, $2, 'owner') + "#, + ) + .bind(realm_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok((realm_id, user_id, token)) +} + +/// Get a realm by slug with full details for editing. +pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result<RealmDetail, AppError> { + let realm = sqlx::query_as::<_, RealmDetail>( + r#" + SELECT + r.id, + r.name, + r.slug, + r.description, + r.tagline, + r.owner_id, + u.username as owner_username, + u.display_name as owner_display_name, + r.privacy, + r.is_nsfw, + r.min_reputation_tier, + r.theme_color, + r.banner_image_path, + r.thumbnail_path, + r.max_users, + r.allow_guest_access, + r.member_count, + r.current_user_count, + r.created_at, + r.updated_at + FROM realm.realms r + JOIN auth.users u ON r.owner_id = u.id + WHERE r.slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm with slug '{}' not found", slug)))?; + + Ok(realm) +} + +/// Update a realm's settings. +pub async fn update_realm( + pool: &PgPool, + realm_id: Uuid, + req: &UpdateRealmRequest, +) -> Result<RealmDetail, AppError> { + // Update the realm + sqlx::query( + r#" + UPDATE realm.realms + SET + name = $1, + description = $2, + tagline = $3, + privacy = $4, + is_nsfw = $5, + max_users = $6, + allow_guest_access = $7, + theme_color = $8, + updated_at = now() + WHERE id = $9 + "#, + ) + .bind(&req.name) + .bind(&req.description) + .bind(&req.tagline) + .bind(req.privacy) + .bind(req.is_nsfw) + .bind(req.max_users) + .bind(req.allow_guest_access) + .bind(&req.theme_color) + .bind(realm_id) + .execute(pool) + .await?; + + // Fetch and return the updated realm + let realm = sqlx::query_as::<_, RealmDetail>( + r#" + SELECT + r.id, + r.name, + r.slug, + r.description, + r.tagline, + r.owner_id, + u.username as owner_username, + u.display_name as owner_display_name, + r.privacy, + r.is_nsfw, + r.min_reputation_tier, + r.theme_color, + r.banner_image_path, + r.thumbnail_path, + r.max_users, + r.allow_guest_access, + r.member_count, + r.current_user_count, + r.created_at, + r.updated_at + FROM realm.realms r + JOIN auth.users u ON r.owner_id = u.id + WHERE r.id = $1 + "#, + ) + .bind(realm_id) + .fetch_one(pool) + .await?; + + Ok(realm) +} + +/// Transfer realm ownership to a different user. +pub async fn transfer_realm_ownership( + pool: &PgPool, + realm_id: Uuid, + new_owner_id: Uuid, +) -> Result<(), AppError> { + let mut tx = pool.begin().await?; + + // Get current owner_id + let current_owner_id = + sqlx::query_scalar::<_, Uuid>(r#"SELECT owner_id FROM realm.realms WHERE id = $1"#) + .bind(realm_id) + .fetch_one(&mut *tx) + .await?; + + // Update realm owner + sqlx::query(r#"UPDATE realm.realms SET owner_id = $1, updated_at = now() WHERE id = $2"#) + .bind(new_owner_id) + .bind(realm_id) + .execute(&mut *tx) + .await?; + + // Update old owner's membership role to member (or remove?) + sqlx::query( + r#" + UPDATE realm.memberships + SET role = 'moderator' + WHERE realm_id = $1 AND user_id = $2 + "#, + ) + .bind(realm_id) + .bind(current_owner_id) + .execute(&mut *tx) + .await?; + + // Ensure new owner has membership with owner role + sqlx::query( + r#" + INSERT INTO realm.memberships (realm_id, user_id, role) + VALUES ($1, $2, 'owner') + ON CONFLICT (realm_id, user_id) DO UPDATE SET role = 'owner' + "#, + ) + .bind(realm_id) + .bind(new_owner_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} diff --git a/crates/chattyness-db/src/queries/owner/staff.rs b/crates/chattyness-db/src/queries/owner/staff.rs new file mode 100644 index 0000000..74418de --- /dev/null +++ b/crates/chattyness-db/src/queries/owner/staff.rs @@ -0,0 +1,111 @@ +//! Staff management database queries. +//! +//! These queries are used by the owner interface and require the chattyness_owner role. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{ServerRole, StaffMember}; +use chattyness_error::AppError; + +/// Get all staff members. +pub async fn get_all_staff(pool: &PgPool) -> Result<Vec<StaffMember>, AppError> { + let staff = sqlx::query_as::<_, StaffMember>( + r#" + SELECT + s.user_id, + u.username, + u.display_name, + u.email, + s.role, + s.appointed_by, + s.appointed_at + FROM server.staff s + JOIN auth.users u ON s.user_id = u.id + ORDER BY + CASE s.role + WHEN 'owner' THEN 1 + WHEN 'admin' THEN 2 + WHEN 'moderator' THEN 3 + END, + s.appointed_at DESC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(staff) +} + +/// Get staff members by role. +pub async fn get_staff_by_role( + pool: &PgPool, + role: ServerRole, +) -> Result<Vec<StaffMember>, AppError> { + let staff = sqlx::query_as::<_, StaffMember>( + r#" + SELECT + s.user_id, + u.username, + u.display_name, + u.email, + s.role, + s.appointed_by, + s.appointed_at + FROM server.staff s + JOIN auth.users u ON s.user_id = u.id + WHERE s.role = $1 + ORDER BY s.appointed_at DESC + "#, + ) + .bind(role) + .fetch_all(pool) + .await?; + + Ok(staff) +} + +/// Create a staff member (promote existing user). +pub async fn create_staff( + pool: &PgPool, + user_id: Uuid, + role: ServerRole, + appointed_by: Option<Uuid>, +) -> Result<StaffMember, AppError> { + let staff = sqlx::query_as::<_, StaffMember>( + r#" + WITH inserted AS ( + INSERT INTO server.staff (user_id, role, appointed_by) + VALUES ($1, $2, $3) + RETURNING user_id, role, appointed_by, appointed_at + ) + SELECT + i.user_id, + u.username, + u.display_name, + u.email, + i.role, + i.appointed_by, + i.appointed_at + FROM inserted i + JOIN auth.users u ON i.user_id = u.id + "#, + ) + .bind(user_id) + .bind(role) + .bind(appointed_by) + .fetch_one(pool) + .await?; + + Ok(staff) +} + +/// Remove a staff member. +pub async fn delete_staff(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM server.staff WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/crates/chattyness-db/src/queries/owner/users.rs b/crates/chattyness-db/src/queries/owner/users.rs new file mode 100644 index 0000000..6188c3f --- /dev/null +++ b/crates/chattyness-db/src/queries/owner/users.rs @@ -0,0 +1,346 @@ +//! User management database queries. +//! +//! These queries are used by the owner interface and require the chattyness_owner role. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{ + AccountStatus, CreateUserRequest, NewUserData, RealmRole, RealmSummary, UserDetail, + UserListItem, UserRealmMembership, +}; +use chattyness_error::AppError; + +use super::helpers::{generate_random_token, hash_password}; + +/// List all users with pagination. +pub async fn list_users( + pool: &PgPool, + limit: i64, + offset: i64, +) -> Result<Vec<UserListItem>, AppError> { + let users = sqlx::query_as::<_, UserListItem>( + r#" + SELECT + u.id, + u.username, + u.display_name, + u.email, + u.status, + u.reputation_tier, + s.role as staff_role, + u.created_at, + u.last_seen_at + FROM auth.users u + LEFT JOIN server.staff s ON u.id = s.user_id + ORDER BY u.created_at DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(users) +} + +/// Search users by username, email, or display_name. +pub async fn search_users( + pool: &PgPool, + query: &str, + limit: i64, +) -> Result<Vec<UserListItem>, AppError> { + let pattern = format!("%{}%", query); + let users = sqlx::query_as::<_, UserListItem>( + r#" + SELECT + u.id, + u.username, + u.display_name, + u.email, + u.status, + u.reputation_tier, + s.role as staff_role, + u.created_at, + u.last_seen_at + FROM auth.users u + LEFT JOIN server.staff s ON u.id = s.user_id + WHERE + u.username ILIKE $1 + OR u.email ILIKE $1 + OR u.display_name ILIKE $1 + ORDER BY + CASE + WHEN u.username ILIKE $1 THEN 1 + WHEN u.display_name ILIKE $1 THEN 2 + ELSE 3 + END, + u.username + LIMIT $2 + "#, + ) + .bind(&pattern) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(users) +} + +/// Get full user detail by ID. +pub async fn get_user_detail(pool: &PgPool, user_id: Uuid) -> Result<UserDetail, AppError> { + let user = sqlx::query_as::<_, UserDetail>( + r#" + SELECT + u.id, + u.username, + u.email, + u.display_name, + u.bio, + u.avatar_url, + u.reputation_tier, + u.status, + u.email_verified, + s.role as staff_role, + u.created_at, + u.updated_at, + u.last_seen_at + FROM auth.users u + LEFT JOIN server.staff s ON u.id = s.user_id + WHERE u.id = $1 + "#, + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(user) +} + +/// Update a user's account status. +pub async fn update_user_status( + pool: &PgPool, + user_id: Uuid, + status: AccountStatus, +) -> Result<UserDetail, AppError> { + // First update the status + sqlx::query( + r#" + UPDATE auth.users + SET status = $1, updated_at = now() + WHERE id = $2 + "#, + ) + .bind(status) + .bind(user_id) + .execute(pool) + .await?; + + // Then return the updated user detail + get_user_detail(pool, user_id).await +} + +/// Create a new user account with a random temporary password. +/// Returns (user_id, plaintext_token) - the token should be shown to the server owner. +pub async fn create_user(pool: &PgPool, data: &NewUserData) -> Result<(Uuid, String), AppError> { + // Generate a random token as the temporary password + let token = generate_random_token(); + let password_hash = hash_password(&token)?; + + let user_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset) + VALUES ($1, $2, $3, $4, true) + RETURNING id + "#, + ) + .bind(&data.username) + .bind(&data.email) + .bind(&data.display_name) + .bind(&password_hash) + .fetch_one(pool) + .await?; + + Ok((user_id, token)) +} + +/// Reset a user's password to a random token. +/// Returns the plaintext token (to show to the server owner). +pub async fn reset_user_password(pool: &PgPool, user_id: Uuid) -> Result<String, AppError> { + let token = generate_random_token(); + let password_hash = hash_password(&token)?; + + sqlx::query( + r#" + UPDATE auth.users + SET password_hash = $1, force_pw_reset = true, updated_at = now() + WHERE id = $2 + "#, + ) + .bind(&password_hash) + .bind(user_id) + .execute(pool) + .await?; + + Ok(token) +} + +/// Get all realm memberships for a user. +pub async fn get_user_realms( + pool: &PgPool, + user_id: Uuid, +) -> Result<Vec<UserRealmMembership>, AppError> { + let memberships = sqlx::query_as::<_, UserRealmMembership>( + r#" + SELECT + m.realm_id, + r.name as realm_name, + r.slug as realm_slug, + m.role, + m.nickname, + m.created_at as joined_at, + m.last_visited_at + FROM realm.memberships m + JOIN realm.realms r ON m.realm_id = r.id + WHERE m.user_id = $1 + ORDER BY m.last_visited_at DESC NULLS LAST, m.created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(memberships) +} + +/// Add a user to a realm. +pub async fn add_user_to_realm( + pool: &PgPool, + user_id: Uuid, + realm_id: Uuid, + role: RealmRole, +) -> Result<(), AppError> { + sqlx::query( + r#" + INSERT INTO realm.memberships (realm_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (realm_id, user_id) DO UPDATE SET role = $3 + "#, + ) + .bind(realm_id) + .bind(user_id) + .bind(role) + .execute(pool) + .await?; + + Ok(()) +} + +/// Remove a user from a realm. +pub async fn remove_user_from_realm( + pool: &PgPool, + user_id: Uuid, + realm_id: Uuid, +) -> Result<(), AppError> { + sqlx::query( + r#" + DELETE FROM realm.memberships + WHERE realm_id = $1 AND user_id = $2 + "#, + ) + .bind(realm_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// List all realms (for dropdown selection). +pub async fn list_all_realms(pool: &PgPool) -> Result<Vec<RealmSummary>, AppError> { + let realms = sqlx::query_as::<_, RealmSummary>( + r#" + SELECT + id, + name, + slug, + tagline, + privacy, + is_nsfw, + thumbnail_path, + member_count, + current_user_count + FROM realm.realms + ORDER BY name + "#, + ) + .fetch_all(pool) + .await?; + + Ok(realms) +} + +/// Create a new user with optional staff role (atomically). +/// Returns (user_id, plaintext_token) - the token should be shown to the server owner. +pub async fn create_user_with_staff( + pool: &PgPool, + req: &CreateUserRequest, +) -> Result<(Uuid, String), AppError> { + // Generate a random token as the temporary password + let token = generate_random_token(); + let password_hash = hash_password(&token)?; + + // Start a transaction if we need to also create staff record + if let Some(staff_role) = req.staff_role { + let mut tx = pool.begin().await?; + + // Create the user with force_pw_reset = true + let user_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset) + VALUES ($1, $2, $3, $4, true) + RETURNING id + "#, + ) + .bind(&req.username) + .bind(&req.email) + .bind(&req.display_name) + .bind(&password_hash) + .fetch_one(&mut *tx) + .await?; + + // Create the staff record + sqlx::query( + r#" + INSERT INTO server.staff (user_id, role) + VALUES ($1, $2) + "#, + ) + .bind(user_id) + .bind(staff_role) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok((user_id, token)) + } else { + // Just create the user with force_pw_reset = true + let user_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset) + VALUES ($1, $2, $3, $4, true) + RETURNING id + "#, + ) + .bind(&req.username) + .bind(&req.email) + .bind(&req.display_name) + .bind(&password_hash) + .fetch_one(pool) + .await?; + + Ok((user_id, token)) + } +} diff --git a/crates/chattyness-db/src/queries/props.rs b/crates/chattyness-db/src/queries/props.rs new file mode 100644 index 0000000..04d8fa4 --- /dev/null +++ b/crates/chattyness-db/src/queries/props.rs @@ -0,0 +1,180 @@ +//! Props-related database queries. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::{CreateServerPropRequest, ServerProp, ServerPropSummary}; +use chattyness_error::AppError; + +/// List all server props. +pub async fn list_server_props<'e>( + executor: impl PgExecutor<'e>, +) -> Result<Vec<ServerPropSummary>, AppError> { + let props = sqlx::query_as::<_, ServerPropSummary>( + r#" + SELECT + id, + name, + slug, + asset_path, + default_layer, + is_active, + created_at + FROM server.props + ORDER BY name ASC + "#, + ) + .fetch_all(executor) + .await?; + + Ok(props) +} + +/// Get a server prop by ID. +pub async fn get_server_prop_by_id<'e>( + executor: impl PgExecutor<'e>, + prop_id: Uuid, +) -> Result<Option<ServerProp>, AppError> { + let prop = sqlx::query_as::<_, ServerProp>( + r#" + SELECT + id, + name, + slug, + description, + tags, + asset_path, + thumbnail_path, + default_layer, + default_emotion, + default_position, + is_unique, + is_transferable, + is_portable, + is_active, + available_from, + available_until, + created_by, + created_at, + updated_at + FROM server.props + WHERE id = $1 + "#, + ) + .bind(prop_id) + .fetch_optional(executor) + .await?; + + Ok(prop) +} + +/// Check if a prop slug is available. +pub async fn is_prop_slug_available<'e>( + executor: impl PgExecutor<'e>, + slug: &str, +) -> Result<bool, AppError> { + let exists: (bool,) = + sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM server.props WHERE slug = $1)"#) + .bind(slug) + .fetch_one(executor) + .await?; + + Ok(!exists.0) +} + +/// Create a new server prop. +pub async fn create_server_prop<'e>( + executor: impl PgExecutor<'e>, + req: &CreateServerPropRequest, + asset_path: &str, + created_by: Option<Uuid>, +) -> Result<ServerProp, AppError> { + let slug = req.slug_or_generate(); + + // Positioning: either content layer OR emotion layer OR neither (all NULL) + // Database constraint enforces mutual exclusivity + let (default_layer, default_emotion, default_position) = + if req.default_layer.is_some() { + // Content layer prop + ( + req.default_layer.map(|l| l.to_string()), + None, + Some(req.default_position.unwrap_or(4)), // Default to center position + ) + } else if req.default_emotion.is_some() { + // Emotion layer prop + ( + None, + req.default_emotion.map(|e| e.to_string()), + Some(req.default_position.unwrap_or(4)), // Default to center position + ) + } else { + // Non-avatar prop + (None, None, None) + }; + + let prop = sqlx::query_as::<_, ServerProp>( + r#" + INSERT INTO server.props ( + name, slug, description, tags, asset_path, + default_layer, default_emotion, default_position, + created_by + ) + VALUES ( + $1, $2, $3, $4, $5, + $6::props.avatar_layer, $7::props.emotion_state, $8, + $9 + ) + RETURNING + id, + name, + slug, + description, + tags, + asset_path, + thumbnail_path, + default_layer, + default_emotion, + default_position, + is_unique, + is_transferable, + is_portable, + is_active, + available_from, + available_until, + created_by, + created_at, + updated_at + "#, + ) + .bind(&req.name) + .bind(&slug) + .bind(&req.description) + .bind(&req.tags) + .bind(asset_path) + .bind(&default_layer) + .bind(&default_emotion) + .bind(default_position) + .bind(created_by) + .fetch_one(executor) + .await?; + + Ok(prop) +} + +/// Delete a server prop. +pub async fn delete_server_prop<'e>( + executor: impl PgExecutor<'e>, + prop_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query(r#"DELETE FROM server.props WHERE id = $1"#) + .bind(prop_id) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Prop not found".to_string())); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/queries/realms.rs b/crates/chattyness-db/src/queries/realms.rs new file mode 100644 index 0000000..ef5f555 --- /dev/null +++ b/crates/chattyness-db/src/queries/realms.rs @@ -0,0 +1,228 @@ +//! Realm-related database queries. + +use sqlx::{PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::models::{CreateRealmRequest, Realm, RealmSummary}; +use chattyness_error::AppError; + +/// Create a new realm. +pub async fn create_realm( + pool: &PgPool, + owner_id: Uuid, + req: &CreateRealmRequest, +) -> Result<Realm, AppError> { + let privacy_str = req.privacy.as_str(); + let realm = sqlx::query_as::<_, Realm>( + r#" + INSERT INTO realm.realms ( + name, slug, description, tagline, owner_id, + privacy, is_nsfw, max_users, allow_guest_access, theme_color + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING + id, + name, + slug, + description, + tagline, + owner_id, + privacy, + is_nsfw, + min_reputation_tier, + theme_color, + banner_image_path, + thumbnail_path, + max_users, + allow_guest_access, + default_scene_id, + member_count, + current_user_count, + created_at, + updated_at + "#, + ) + .bind(&req.name) + .bind(&req.slug) + .bind(&req.description) + .bind(&req.tagline) + .bind(owner_id) + .bind(privacy_str) + .bind(req.is_nsfw) + .bind(req.max_users) + .bind(req.allow_guest_access) + .bind(&req.theme_color) + .fetch_one(pool) + .await?; + + Ok(realm) +} + +/// Check if a realm slug is available. +pub async fn is_slug_available(pool: &PgPool, slug: &str) -> Result<bool, AppError> { + let exists: (bool,) = sqlx::query_as( + r#"SELECT EXISTS(SELECT 1 FROM realm.realms WHERE slug = $1)"#, + ) + .bind(slug) + .fetch_one(pool) + .await?; + + Ok(!exists.0) +} + +/// Get a realm by its slug. +pub async fn get_realm_by_slug<'e>( + executor: impl PgExecutor<'e>, + slug: &str, +) -> Result<Option<Realm>, AppError> { + let realm = sqlx::query_as::<_, Realm>( + r#" + SELECT + id, + name, + slug, + description, + tagline, + owner_id, + privacy, + is_nsfw, + min_reputation_tier, + theme_color, + banner_image_path, + thumbnail_path, + max_users, + allow_guest_access, + default_scene_id, + member_count, + current_user_count, + created_at, + updated_at + FROM realm.realms + WHERE slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(executor) + .await?; + + Ok(realm) +} + +/// Get a realm by its ID. +pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Realm>, AppError> { + let realm = sqlx::query_as::<_, Realm>( + r#" + SELECT + id, + name, + slug, + description, + tagline, + owner_id, + privacy, + is_nsfw, + min_reputation_tier, + theme_color, + banner_image_path, + thumbnail_path, + max_users, + allow_guest_access, + default_scene_id, + member_count, + current_user_count, + created_at, + updated_at + FROM realm.realms + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(realm) +} + +/// List public realms. +pub async fn list_public_realms( + pool: &PgPool, + include_nsfw: bool, + limit: i64, + offset: i64, +) -> Result<Vec<RealmSummary>, AppError> { + let realms = if include_nsfw { + sqlx::query_as::<_, RealmSummary>( + r#" + SELECT + id, + name, + slug, + tagline, + privacy, + is_nsfw, + thumbnail_path, + member_count, + current_user_count + FROM realm.realms + WHERE privacy = 'public' + ORDER BY current_user_count DESC, member_count DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + } else { + sqlx::query_as::<_, RealmSummary>( + r#" + SELECT + id, + name, + slug, + tagline, + privacy, + is_nsfw, + thumbnail_path, + member_count, + current_user_count + FROM realm.realms + WHERE privacy = 'public' AND is_nsfw = false + ORDER BY current_user_count DESC, member_count DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await? + }; + + Ok(realms) +} + +/// Get realms owned by a user. +pub async fn get_user_realms(pool: &PgPool, user_id: Uuid) -> Result<Vec<RealmSummary>, AppError> { + let realms = sqlx::query_as::<_, RealmSummary>( + r#" + SELECT + id, + name, + slug, + tagline, + privacy, + is_nsfw, + thumbnail_path, + member_count, + current_user_count + FROM realm.realms + WHERE owner_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + + Ok(realms) +} diff --git a/crates/chattyness-db/src/queries/scenes.rs b/crates/chattyness-db/src/queries/scenes.rs new file mode 100644 index 0000000..7363181 --- /dev/null +++ b/crates/chattyness-db/src/queries/scenes.rs @@ -0,0 +1,442 @@ +//! Scene-related database queries. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}; +use chattyness_error::AppError; + +/// List all scenes for a realm. +pub async fn list_scenes_for_realm<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, +) -> Result<Vec<SceneSummary>, AppError> { + let scenes = sqlx::query_as::<_, SceneSummary>( + r#" + SELECT + id, + name, + slug, + sort_order, + is_entry_point, + is_hidden, + background_color, + background_image_path + FROM realm.scenes + WHERE realm_id = $1 + ORDER BY sort_order ASC, name ASC + "#, + ) + .bind(realm_id) + .fetch_all(executor) + .await?; + + Ok(scenes) +} + +/// Get a scene by its ID. +pub async fn get_scene_by_id<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, +) -> Result<Option<Scene>, AppError> { + let scene = sqlx::query_as::<_, Scene>( + r#" + SELECT + id, + realm_id, + name, + slug, + description, + background_image_path, + background_color, + ST_AsText(bounds) as bounds_wkt, + dimension_mode, + ambient_audio_id, + ambient_volume, + sort_order, + is_entry_point, + is_hidden, + created_at, + updated_at + FROM realm.scenes + WHERE id = $1 + "#, + ) + .bind(scene_id) + .fetch_optional(executor) + .await?; + + Ok(scene) +} + +/// Get a scene by realm ID and slug. +pub async fn get_scene_by_slug<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, + slug: &str, +) -> Result<Option<Scene>, AppError> { + let scene = sqlx::query_as::<_, Scene>( + r#" + SELECT + id, + realm_id, + name, + slug, + description, + background_image_path, + background_color, + ST_AsText(bounds) as bounds_wkt, + dimension_mode, + ambient_audio_id, + ambient_volume, + sort_order, + is_entry_point, + is_hidden, + created_at, + updated_at + FROM realm.scenes + WHERE realm_id = $1 AND slug = $2 + "#, + ) + .bind(realm_id) + .bind(slug) + .fetch_optional(executor) + .await?; + + Ok(scene) +} + +/// Check if a scene slug is available within a realm. +pub async fn is_scene_slug_available<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, + slug: &str, +) -> Result<bool, AppError> { + let exists: (bool,) = + sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.scenes WHERE realm_id = $1 AND slug = $2)"#) + .bind(realm_id) + .bind(slug) + .fetch_one(executor) + .await?; + + Ok(!exists.0) +} + +/// Create a new scene. +pub async fn create_scene<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, + req: &CreateSceneRequest, +) -> Result<Scene, AppError> { + let bounds_wkt = req + .bounds_wkt + .as_deref() + .unwrap_or("POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))"); + let dimension_mode = req.dimension_mode.unwrap_or_default().to_string(); + let sort_order = req.sort_order.unwrap_or(0); + let is_entry_point = req.is_entry_point.unwrap_or(false); + let is_hidden = req.is_hidden.unwrap_or(false); + + let scene = sqlx::query_as::<_, Scene>( + r#" + INSERT INTO realm.scenes ( + realm_id, name, slug, description, + background_image_path, background_color, + bounds, dimension_mode, + sort_order, is_entry_point, is_hidden + ) + VALUES ( + $1, $2, $3, $4, + $5, $6, + ST_GeomFromText($7, 0), $8::realm.dimension_mode, + $9, $10, $11 + ) + RETURNING + id, + realm_id, + name, + slug, + description, + background_image_path, + background_color, + ST_AsText(bounds) as bounds_wkt, + dimension_mode, + ambient_audio_id, + ambient_volume, + sort_order, + is_entry_point, + is_hidden, + created_at, + updated_at + "#, + ) + .bind(realm_id) + .bind(&req.name) + .bind(&req.slug) + .bind(&req.description) + .bind(&req.background_image_path) + .bind(&req.background_color) + .bind(bounds_wkt) + .bind(&dimension_mode) + .bind(sort_order) + .bind(is_entry_point) + .bind(is_hidden) + .fetch_one(executor) + .await?; + + Ok(scene) +} + +/// Create a new scene with a specific ID. +/// +/// This is used when we need to know the scene ID before creating it +/// (e.g., for storing background images in the correct path). +pub async fn create_scene_with_id<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, + realm_id: Uuid, + req: &CreateSceneRequest, +) -> Result<Scene, AppError> { + let bounds_wkt = req + .bounds_wkt + .as_deref() + .unwrap_or("POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))"); + let dimension_mode = req.dimension_mode.unwrap_or_default().to_string(); + let sort_order = req.sort_order.unwrap_or(0); + let is_entry_point = req.is_entry_point.unwrap_or(false); + let is_hidden = req.is_hidden.unwrap_or(false); + + let scene = sqlx::query_as::<_, Scene>( + r#" + INSERT INTO realm.scenes ( + id, realm_id, name, slug, description, + background_image_path, background_color, + bounds, dimension_mode, + sort_order, is_entry_point, is_hidden + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, + ST_GeomFromText($8, 0), $9::realm.dimension_mode, + $10, $11, $12 + ) + RETURNING + id, + realm_id, + name, + slug, + description, + background_image_path, + background_color, + ST_AsText(bounds) as bounds_wkt, + dimension_mode, + ambient_audio_id, + ambient_volume, + sort_order, + is_entry_point, + is_hidden, + created_at, + updated_at + "#, + ) + .bind(scene_id) + .bind(realm_id) + .bind(&req.name) + .bind(&req.slug) + .bind(&req.description) + .bind(&req.background_image_path) + .bind(&req.background_color) + .bind(bounds_wkt) + .bind(&dimension_mode) + .bind(sort_order) + .bind(is_entry_point) + .bind(is_hidden) + .fetch_one(executor) + .await?; + + Ok(scene) +} + +/// Update a scene. +pub async fn update_scene<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, + req: &UpdateSceneRequest, +) -> Result<Scene, AppError> { + // Build dynamic update query + let mut set_clauses = Vec::new(); + let mut param_idx = 2; // $1 is scene_id + + if req.name.is_some() { + set_clauses.push(format!("name = ${}", param_idx)); + param_idx += 1; + } + if req.description.is_some() { + set_clauses.push(format!("description = ${}", param_idx)); + param_idx += 1; + } + if req.background_image_path.is_some() { + set_clauses.push(format!("background_image_path = ${}", param_idx)); + param_idx += 1; + } + if req.background_color.is_some() { + set_clauses.push(format!("background_color = ${}", param_idx)); + param_idx += 1; + } + if req.bounds_wkt.is_some() { + set_clauses.push(format!("bounds = ST_GeomFromText(${}, 0)", param_idx)); + param_idx += 1; + } + if req.dimension_mode.is_some() { + set_clauses.push(format!("dimension_mode = ${}::realm.dimension_mode", param_idx)); + param_idx += 1; + } + if req.sort_order.is_some() { + set_clauses.push(format!("sort_order = ${}", param_idx)); + param_idx += 1; + } + if req.is_entry_point.is_some() { + set_clauses.push(format!("is_entry_point = ${}", param_idx)); + param_idx += 1; + } + if req.is_hidden.is_some() { + set_clauses.push(format!("is_hidden = ${}", param_idx)); + } + + // If no updates, just return the current scene + let query = if set_clauses.is_empty() { + r#"SELECT id, realm_id, name, slug, description, background_image_path, + background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode, + ambient_audio_id, ambient_volume, sort_order, is_entry_point, + is_hidden, created_at, updated_at + FROM realm.scenes WHERE id = $1"#.to_string() + } else { + set_clauses.push("updated_at = now()".to_string()); + format!( + r#"UPDATE realm.scenes SET {} + WHERE id = $1 + RETURNING id, realm_id, name, slug, description, background_image_path, + background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode, + ambient_audio_id, ambient_volume, sort_order, is_entry_point, + is_hidden, created_at, updated_at"#, + set_clauses.join(", ") + ) + }; + + let mut query_builder = sqlx::query_as::<_, Scene>(&query).bind(scene_id); + + if let Some(ref name) = req.name { + query_builder = query_builder.bind(name); + } + if let Some(ref description) = req.description { + query_builder = query_builder.bind(description); + } + if let Some(ref background_image_path) = req.background_image_path { + query_builder = query_builder.bind(background_image_path); + } + if let Some(ref background_color) = req.background_color { + query_builder = query_builder.bind(background_color); + } + if let Some(ref bounds_wkt) = req.bounds_wkt { + query_builder = query_builder.bind(bounds_wkt); + } + if let Some(ref dimension_mode) = req.dimension_mode { + query_builder = query_builder.bind(dimension_mode.to_string()); + } + if let Some(sort_order) = req.sort_order { + query_builder = query_builder.bind(sort_order); + } + if let Some(is_entry_point) = req.is_entry_point { + query_builder = query_builder.bind(is_entry_point); + } + if let Some(is_hidden) = req.is_hidden { + query_builder = query_builder.bind(is_hidden); + } + + let scene = query_builder + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; + + Ok(scene) +} + +/// Delete a scene. +pub async fn delete_scene<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query(r#"DELETE FROM realm.scenes WHERE id = $1"#) + .bind(scene_id) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Scene not found".to_string())); + } + + Ok(()) +} + +/// Get the next sort order for a new scene in a realm. +pub async fn get_next_sort_order<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, +) -> Result<i32, AppError> { + let result: (Option<i32>,) = sqlx::query_as( + r#"SELECT MAX(sort_order) FROM realm.scenes WHERE realm_id = $1"#, + ) + .bind(realm_id) + .fetch_one(executor) + .await?; + + Ok(result.0.unwrap_or(0) + 1) +} + +/// Get the entry scene for a realm. +/// +/// Returns the scene in this priority order: +/// 1. The scene specified by `default_scene_id` on the realm (if provided and exists) +/// 2. The first scene marked as `is_entry_point` +/// 3. The first scene by sort_order +pub async fn get_entry_scene_for_realm<'e>( + executor: impl PgExecutor<'e>, + realm_id: Uuid, + default_scene_id: Option<Uuid>, +) -> Result<Option<Scene>, AppError> { + // Use a single query that handles the priority in SQL + let scene = sqlx::query_as::<_, Scene>( + r#" + SELECT + id, + realm_id, + name, + slug, + description, + background_image_path, + background_color, + ST_AsText(bounds) as bounds_wkt, + dimension_mode, + ambient_audio_id, + ambient_volume, + sort_order, + is_entry_point, + is_hidden, + created_at, + updated_at + FROM realm.scenes + WHERE realm_id = $1 AND is_hidden = false + ORDER BY + CASE WHEN id = $2 THEN 0 ELSE 1 END, + is_entry_point DESC, + sort_order ASC + LIMIT 1 + "#, + ) + .bind(realm_id) + .bind(default_scene_id) + .fetch_optional(executor) + .await?; + + Ok(scene) +} diff --git a/crates/chattyness-db/src/queries/spots.rs b/crates/chattyness-db/src/queries/spots.rs new file mode 100644 index 0000000..84bfdb0 --- /dev/null +++ b/crates/chattyness-db/src/queries/spots.rs @@ -0,0 +1,324 @@ +//! Spot-related database queries. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}; +use chattyness_error::AppError; + +/// List all spots for a scene. +pub async fn list_spots_for_scene<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, +) -> Result<Vec<SpotSummary>, AppError> { + let spots = sqlx::query_as::<_, SpotSummary>( + r#" + SELECT + id, + name, + slug, + spot_type, + ST_AsText(region) as region_wkt, + sort_order, + is_visible, + is_active + FROM realm.spots + WHERE scene_id = $1 + ORDER BY sort_order ASC, name ASC NULLS LAST + "#, + ) + .bind(scene_id) + .fetch_all(executor) + .await?; + + Ok(spots) +} + +/// Get a spot by its ID. +pub async fn get_spot_by_id<'e>( + executor: impl PgExecutor<'e>, + spot_id: Uuid, +) -> Result<Option<Spot>, AppError> { + let spot = sqlx::query_as::<_, Spot>( + r#" + SELECT + id, + scene_id, + name, + slug, + ST_AsText(region) as region_wkt, + spot_type, + destination_scene_id, + ST_AsText(destination_position) as destination_position_wkt, + current_state, + sort_order, + is_visible, + is_active, + created_at, + updated_at + FROM realm.spots + WHERE id = $1 + "#, + ) + .bind(spot_id) + .fetch_optional(executor) + .await?; + + Ok(spot) +} + +/// Get a spot by scene ID and slug. +pub async fn get_spot_by_slug<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, + slug: &str, +) -> Result<Option<Spot>, AppError> { + let spot = sqlx::query_as::<_, Spot>( + r#" + SELECT + id, + scene_id, + name, + slug, + ST_AsText(region) as region_wkt, + spot_type, + destination_scene_id, + ST_AsText(destination_position) as destination_position_wkt, + current_state, + sort_order, + is_visible, + is_active, + created_at, + updated_at + FROM realm.spots + WHERE scene_id = $1 AND slug = $2 + "#, + ) + .bind(scene_id) + .bind(slug) + .fetch_optional(executor) + .await?; + + Ok(spot) +} + +/// Check if a spot slug is available within a scene. +pub async fn is_spot_slug_available<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, + slug: &str, +) -> Result<bool, AppError> { + let exists: (bool,) = + sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.spots WHERE scene_id = $1 AND slug = $2)"#) + .bind(scene_id) + .bind(slug) + .fetch_one(executor) + .await?; + + Ok(!exists.0) +} + +/// Create a new spot. +pub async fn create_spot<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, + req: &CreateSpotRequest, +) -> Result<Spot, AppError> { + let spot_type = req.spot_type.unwrap_or_default().to_string(); + let sort_order = req.sort_order.unwrap_or(0); + let is_visible = req.is_visible.unwrap_or(true); + let is_active = req.is_active.unwrap_or(true); + + let spot = sqlx::query_as::<_, Spot>( + r#" + INSERT INTO realm.spots ( + scene_id, name, slug, + region, spot_type, + destination_scene_id, destination_position, + sort_order, is_visible, is_active + ) + VALUES ( + $1, $2, $3, + ST_GeomFromText($4, 0), $5::realm.spot_type, + $6, CASE WHEN $7 IS NOT NULL THEN ST_GeomFromText($7, 0) ELSE NULL END, + $8, $9, $10 + ) + RETURNING + id, + scene_id, + name, + slug, + ST_AsText(region) as region_wkt, + spot_type, + destination_scene_id, + ST_AsText(destination_position) as destination_position_wkt, + current_state, + sort_order, + is_visible, + is_active, + created_at, + updated_at + "#, + ) + .bind(scene_id) + .bind(&req.name) + .bind(&req.slug) + .bind(&req.region_wkt) + .bind(&spot_type) + .bind(req.destination_scene_id) + .bind(&req.destination_position_wkt) + .bind(sort_order) + .bind(is_visible) + .bind(is_active) + .fetch_one(executor) + .await?; + + Ok(spot) +} + +/// Update a spot. +pub async fn update_spot<'e>( + executor: impl PgExecutor<'e>, + spot_id: Uuid, + req: &UpdateSpotRequest, +) -> Result<Spot, AppError> { + // Build dynamic update query + let mut set_clauses = Vec::new(); + let mut param_idx = 2; // $1 is spot_id + + if req.name.is_some() { + set_clauses.push(format!("name = ${}", param_idx)); + param_idx += 1; + } + if req.slug.is_some() { + set_clauses.push(format!("slug = ${}", param_idx)); + param_idx += 1; + } + if req.region_wkt.is_some() { + set_clauses.push(format!("region = ST_GeomFromText(${}, 0)", param_idx)); + param_idx += 1; + } + if req.spot_type.is_some() { + set_clauses.push(format!("spot_type = ${}::realm.spot_type", param_idx)); + param_idx += 1; + } + if req.destination_scene_id.is_some() { + set_clauses.push(format!("destination_scene_id = ${}", param_idx)); + param_idx += 1; + } + if req.destination_position_wkt.is_some() { + set_clauses.push(format!( + "destination_position = CASE WHEN ${} IS NOT NULL THEN ST_GeomFromText(${}, 0) ELSE NULL END", + param_idx, param_idx + )); + param_idx += 1; + } + if req.current_state.is_some() { + set_clauses.push(format!("current_state = ${}", param_idx)); + param_idx += 1; + } + if req.sort_order.is_some() { + set_clauses.push(format!("sort_order = ${}", param_idx)); + param_idx += 1; + } + if req.is_visible.is_some() { + set_clauses.push(format!("is_visible = ${}", param_idx)); + param_idx += 1; + } + if req.is_active.is_some() { + set_clauses.push(format!("is_active = ${}", param_idx)); + } + + // If no updates, just return the current spot via SELECT + let query = if set_clauses.is_empty() { + r#"SELECT id, scene_id, name, slug, ST_AsText(region) as region_wkt, + spot_type, destination_scene_id, + ST_AsText(destination_position) as destination_position_wkt, + current_state, sort_order, is_visible, is_active, + created_at, updated_at + FROM realm.spots WHERE id = $1"#.to_string() + } else { + set_clauses.push("updated_at = now()".to_string()); + format!( + r#"UPDATE realm.spots SET {} + WHERE id = $1 + RETURNING id, scene_id, name, slug, ST_AsText(region) as region_wkt, + spot_type, destination_scene_id, + ST_AsText(destination_position) as destination_position_wkt, + current_state, sort_order, is_visible, is_active, + created_at, updated_at"#, + set_clauses.join(", ") + ) + }; + + let mut query_builder = sqlx::query_as::<_, Spot>(&query).bind(spot_id); + + if let Some(ref name) = req.name { + query_builder = query_builder.bind(name); + } + if let Some(ref slug) = req.slug { + query_builder = query_builder.bind(slug); + } + if let Some(ref region_wkt) = req.region_wkt { + query_builder = query_builder.bind(region_wkt); + } + if let Some(ref spot_type) = req.spot_type { + query_builder = query_builder.bind(spot_type.to_string()); + } + if let Some(destination_scene_id) = req.destination_scene_id { + query_builder = query_builder.bind(destination_scene_id); + } + if let Some(ref destination_position_wkt) = req.destination_position_wkt { + query_builder = query_builder.bind(destination_position_wkt); + } + if let Some(current_state) = req.current_state { + query_builder = query_builder.bind(current_state); + } + if let Some(sort_order) = req.sort_order { + query_builder = query_builder.bind(sort_order); + } + if let Some(is_visible) = req.is_visible { + query_builder = query_builder.bind(is_visible); + } + if let Some(is_active) = req.is_active { + query_builder = query_builder.bind(is_active); + } + + let spot = query_builder + .fetch_optional(executor) + .await? + .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; + + Ok(spot) +} + +/// Delete a spot. +pub async fn delete_spot<'e>( + executor: impl PgExecutor<'e>, + spot_id: Uuid, +) -> Result<(), AppError> { + let result = sqlx::query(r#"DELETE FROM realm.spots WHERE id = $1"#) + .bind(spot_id) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("Spot not found".to_string())); + } + + Ok(()) +} + +/// Get the next sort order for a new spot in a scene. +pub async fn get_next_sort_order<'e>( + executor: impl PgExecutor<'e>, + scene_id: Uuid, +) -> Result<i32, AppError> { + let result: (Option<i32>,) = + sqlx::query_as(r#"SELECT MAX(sort_order) FROM realm.spots WHERE scene_id = $1"#) + .bind(scene_id) + .fetch_one(executor) + .await?; + + Ok(result.0.unwrap_or(0) + 1) +} diff --git a/crates/chattyness-db/src/queries/users.rs b/crates/chattyness-db/src/queries/users.rs new file mode 100644 index 0000000..37b8621 --- /dev/null +++ b/crates/chattyness-db/src/queries/users.rs @@ -0,0 +1,493 @@ +//! User-related database queries. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::{StaffMember, User, UserWithAuth}; +use chattyness_error::AppError; + +/// Get a user by their ID. +pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, AppError> { + let user = sqlx::query_as::<_, User>( + r#" + SELECT + id, + username, + email, + display_name, + bio, + avatar_url, + reputation_tier, + status, + email_verified, + created_at, + updated_at + FROM auth.users + WHERE id = $1 AND status = 'active' + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(user) +} + +/// Get a user by their username. +pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>, AppError> { + let user = sqlx::query_as::<_, User>( + r#" + SELECT + id, + username, + email, + display_name, + bio, + avatar_url, + reputation_tier, + status, + email_verified, + created_at, + updated_at + FROM auth.users + WHERE username = $1 AND status = 'active' + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + Ok(user) +} + +/// Get a user by their email. +pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, AppError> { + let user = sqlx::query_as::<_, User>( + r#" + SELECT + id, + username, + email, + display_name, + bio, + avatar_url, + reputation_tier, + status, + email_verified, + created_at, + updated_at + FROM auth.users + WHERE lower(email) = lower($1) AND status = 'active' + "#, + ) + .bind(email) + .fetch_optional(pool) + .await?; + + Ok(user) +} + +/// Row type for password verification query. +#[derive(sqlx::FromRow)] +struct PasswordRow { + id: Uuid, + password_hash: Option<String>, +} + +/// Verify a user's password and return the user if valid. +pub async fn verify_password( + pool: &PgPool, + username: &str, + password: &str, +) -> Result<Option<User>, AppError> { + // First get the password hash + let row = sqlx::query_as::<_, PasswordRow>( + r#" + SELECT id, password_hash + FROM auth.users + WHERE username = $1 AND status = 'active' AND auth_provider = 'local' + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let Some(ref password_hash) = row.password_hash else { + return Ok(None); + }; + + // Verify the password using argon2 + use argon2::{Argon2, PasswordHash, PasswordVerifier}; + let parsed_hash = PasswordHash::new(password_hash) + .map_err(|e| AppError::Internal(format!("Invalid password hash: {}", e)))?; + + if Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_err() + { + return Ok(None); + } + + // Password is valid, fetch the full user + get_user_by_id(pool, row.id).await +} + +/// Create a new user session. +pub async fn create_session( + pool: &PgPool, + user_id: Uuid, + token_hash: &str, + user_agent: Option<&str>, + ip_address: Option<std::net::IpAddr>, + expires_at: chrono::DateTime<chrono::Utc>, +) -> Result<Uuid, AppError> { + let ip_str = ip_address.map(|ip| ip.to_string()); + + let session_id: (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.sessions (user_id, token_hash, user_agent, ip_address, expires_at) + VALUES ($1, $2, $3, $4::inet, $5) + RETURNING id + "#, + ) + .bind(user_id) + .bind(token_hash) + .bind(user_agent) + .bind(ip_str) + .bind(expires_at) + .fetch_one(pool) + .await?; + + Ok(session_id.0) +} + +/// Get a user by their session token hash. +pub async fn get_user_by_session( + pool: &PgPool, + token_hash: &str, +) -> Result<Option<User>, AppError> { + let user = sqlx::query_as::<_, User>( + r#" + SELECT + u.id, + u.username, + u.email, + u.display_name, + u.bio, + u.avatar_url, + u.reputation_tier, + u.status, + u.email_verified, + u.created_at, + u.updated_at + FROM auth.users u + JOIN auth.sessions s ON u.id = s.user_id + WHERE s.token_hash = $1 + AND s.expires_at > now() + AND u.status = 'active' + "#, + ) + .bind(token_hash) + .fetch_optional(pool) + .await?; + + // Update last activity + if user.is_some() { + sqlx::query("UPDATE auth.sessions SET last_activity_at = now() WHERE token_hash = $1") + .bind(token_hash) + .execute(pool) + .await?; + } + + Ok(user) +} + +/// Delete a session by token hash. +pub async fn delete_session(pool: &PgPool, token_hash: &str) -> Result<(), AppError> { + sqlx::query("DELETE FROM auth.sessions WHERE token_hash = $1") + .bind(token_hash) + .execute(pool) + .await?; + Ok(()) +} + +/// Update a user's last seen timestamp. +pub async fn update_last_seen(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> { + sqlx::query("UPDATE auth.users SET last_seen_at = now() WHERE id = $1") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Get a user with auth fields for login verification. +pub async fn get_user_with_auth( + pool: &PgPool, + username: &str, +) -> Result<Option<UserWithAuth>, AppError> { + let user = sqlx::query_as::<_, UserWithAuth>( + r#" + SELECT + id, + username, + email, + display_name, + avatar_url, + status, + force_pw_reset, + password_hash + FROM auth.users + WHERE username = $1 AND auth_provider = 'local' + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + Ok(user) +} + +/// Verify password and return user with auth info. +pub async fn verify_password_with_reset_flag( + pool: &PgPool, + username: &str, + password: &str, +) -> Result<Option<UserWithAuth>, AppError> { + let user = get_user_with_auth(pool, username).await?; + + let Some(user) = user else { + return Ok(None); + }; + + let Some(ref password_hash) = user.password_hash else { + return Ok(None); + }; + + // Verify the password using argon2 + use argon2::{Argon2, PasswordHash, PasswordVerifier}; + let parsed_hash = PasswordHash::new(password_hash) + .map_err(|e| AppError::Internal(format!("Invalid password hash: {}", e)))?; + + if Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_err() + { + return Ok(None); + } + + Ok(Some(user)) +} + +/// Update a user's password. +pub async fn update_password( + pool: &PgPool, + user_id: Uuid, + new_password: &str, +) -> Result<(), AppError> { + use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(new_password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))? + .to_string(); + + sqlx::query( + r#" + UPDATE auth.users + SET password_hash = $1, force_pw_reset = false, updated_at = now() + WHERE id = $2 + "#, + ) + .bind(&password_hash) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// Update a user's password using a connection (for RLS support). +pub async fn update_password_conn( + conn: &mut sqlx::PgConnection, + user_id: Uuid, + new_password: &str, +) -> Result<(), AppError> { + use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(new_password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))? + .to_string(); + + sqlx::query( + r#" + UPDATE auth.users + SET password_hash = $1, force_pw_reset = false, updated_at = now() + WHERE id = $2 + "#, + ) + .bind(&password_hash) + .bind(user_id) + .execute(conn) + .await?; + + Ok(()) +} + +/// Clear the force_pw_reset flag for a user. +pub async fn clear_force_pw_reset(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> { + sqlx::query( + r#" + UPDATE auth.users + SET force_pw_reset = false, updated_at = now() + WHERE id = $1 + "#, + ) + .bind(user_id) + .execute(pool) + .await?; + + Ok(()) +} + +/// Check if a username already exists. +pub async fn username_exists(pool: &PgPool, username: &str) -> Result<bool, AppError> { + let (exists,): (bool,) = sqlx::query_as( + r#" + SELECT EXISTS(SELECT 1 FROM auth.users WHERE username = $1) + "#, + ) + .bind(username) + .fetch_one(pool) + .await?; + + Ok(exists) +} + +/// Check if an email already exists. +pub async fn email_exists(pool: &PgPool, email: &str) -> Result<bool, AppError> { + let (exists,): (bool,) = sqlx::query_as( + r#" + SELECT EXISTS(SELECT 1 FROM auth.users WHERE lower(email) = lower($1)) + "#, + ) + .bind(email) + .fetch_one(pool) + .await?; + + Ok(exists) +} + +/// Create a new user with hashed password. +pub async fn create_user( + pool: &PgPool, + username: &str, + email: Option<&str>, + display_name: &str, + password: &str, +) -> Result<Uuid, AppError> { + use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))? + .to_string(); + + let (user_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status) + VALUES ($1, $2, $3, $4, 'local', 'active') + RETURNING id + "#, + ) + .bind(username) + .bind(email) + .bind(&password_hash) + .bind(display_name) + .fetch_one(pool) + .await?; + + Ok(user_id) +} + +/// Create a new user using a connection (for RLS support). +pub async fn create_user_conn( + conn: &mut sqlx::PgConnection, + username: &str, + email: Option<&str>, + display_name: &str, + password: &str, +) -> Result<Uuid, AppError> { + use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, + }; + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))? + .to_string(); + + let (user_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status) + VALUES ($1, $2, $3, $4, 'local', 'active') + RETURNING id + "#, + ) + .bind(username) + .bind(email) + .bind(&password_hash) + .bind(display_name) + .fetch_one(conn) + .await?; + + Ok(user_id) +} + +/// Get a staff member by their user ID. +/// +/// Returns the staff member with their user info joined. +pub async fn get_staff_member(pool: &PgPool, user_id: Uuid) -> Result<Option<StaffMember>, AppError> { + let staff = sqlx::query_as::<_, StaffMember>( + r#" + SELECT + s.user_id, + u.username, + u.display_name, + u.email, + s.role, + s.appointed_by, + s.appointed_at + FROM server.staff s + JOIN auth.users u ON s.user_id = u.id + WHERE s.user_id = $1 AND u.status = 'active' + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(staff) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs new file mode 100644 index 0000000..217b080 --- /dev/null +++ b/crates/chattyness-db/src/ws_messages.rs @@ -0,0 +1,92 @@ +//! WebSocket message protocol for channel presence. +//! +//! Shared message types used by both server and WASM client. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar}; + +/// Client-to-server WebSocket messages. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + /// Update position in the channel. + UpdatePosition { + /// X coordinate in scene space. + x: f64, + /// Y coordinate in scene space. + y: f64, + }, + + /// Update emotion (0-9). + UpdateEmotion { + /// Emotion slot (0-9, keyboard: e0-e9). + emotion: u8, + }, + + /// Ping to keep connection alive. + Ping, +} + +/// Server-to-client WebSocket messages. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + /// Welcome message with initial state after connection. + Welcome { + /// This user's member info. + member: ChannelMemberInfo, + /// All current members with avatars. + members: Vec<ChannelMemberWithAvatar>, + }, + + /// A member joined the channel. + MemberJoined { + /// The member that joined. + member: ChannelMemberWithAvatar, + }, + + /// A member left the channel. + MemberLeft { + /// User ID (if authenticated user). + user_id: Option<Uuid>, + /// Guest session ID (if guest). + guest_session_id: Option<Uuid>, + }, + + /// A member updated their position. + PositionUpdated { + /// User ID (if authenticated user). + user_id: Option<Uuid>, + /// Guest session ID (if guest). + guest_session_id: Option<Uuid>, + /// New X coordinate. + x: f64, + /// New Y coordinate. + y: f64, + }, + + /// A member changed their emotion. + EmotionUpdated { + /// User ID (if authenticated user). + user_id: Option<Uuid>, + /// Guest session ID (if guest). + guest_session_id: Option<Uuid>, + /// New emotion slot (0-9). + emotion: u8, + /// Asset paths for all 9 positions of the new emotion layer. + emotion_layer: [Option<String>; 9], + }, + + /// Pong response to client ping. + Pong, + + /// Error message. + Error { + /// Error code. + code: String, + /// Error message. + message: String, + }, +} diff --git a/crates/chattyness-error/Cargo.toml b/crates/chattyness-error/Cargo.toml new file mode 100644 index 0000000..e63fefa --- /dev/null +++ b/crates/chattyness-error/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "chattyness-error" +version.workspace = true +edition.workspace = true + +[dependencies] +thiserror.workspace = true +serde.workspace = true + +# SSR-only dependencies +sqlx = { workspace = true, optional = true } +axum = { workspace = true, optional = true } +http.workspace = true + +[features] +default = [] +ssr = ["sqlx", "axum"] diff --git a/crates/chattyness-error/src/lib.rs b/crates/chattyness-error/src/lib.rs new file mode 100644 index 0000000..a166b71 --- /dev/null +++ b/crates/chattyness-error/src/lib.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Application error types for chattyness. +/// +/// All errors derive From for automatic conversion where applicable. +#[derive(Error, Debug)] +pub enum AppError { + #[cfg(feature = "ssr")] + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[cfg(not(feature = "ssr"))] + #[error("Database error: {0}")] + Database(String), + + #[error("Validation error: {0}")] + Validation(String), + + #[error("Authentication required")] + Unauthorized, + + #[error("Forbidden: {0}")] + Forbidden(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Conflict: {0}")] + Conflict(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error("Invalid credentials")] + InvalidCredentials, + + #[error("Account suspended or banned")] + AccountSuspended, + + #[error("Not a staff member")] + NotStaffMember, + + #[error("Password reset required")] + PasswordResetRequired, +} + +/// API error response for JSON serialization. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option<String>, +} + +impl From<AppError> for ErrorResponse { + fn from(err: AppError) -> Self { + let code = match &err { + AppError::Database(_) => Some("DATABASE_ERROR".to_string()), + AppError::Validation(_) => Some("VALIDATION_ERROR".to_string()), + AppError::Unauthorized => Some("UNAUTHORIZED".to_string()), + AppError::Forbidden(_) => Some("FORBIDDEN".to_string()), + AppError::NotFound(_) => Some("NOT_FOUND".to_string()), + AppError::Conflict(_) => Some("CONFLICT".to_string()), + AppError::Internal(_) => Some("INTERNAL_ERROR".to_string()), + AppError::InvalidCredentials => Some("INVALID_CREDENTIALS".to_string()), + AppError::AccountSuspended => Some("ACCOUNT_SUSPENDED".to_string()), + AppError::NotStaffMember => Some("NOT_STAFF_MEMBER".to_string()), + AppError::PasswordResetRequired => Some("PASSWORD_RESET_REQUIRED".to_string()), + }; + + ErrorResponse { + error: err.to_string(), + code, + } + } +} + +#[cfg(feature = "ssr")] +mod ssr_impl { + use super::*; + use axum::http::StatusCode; + use axum::response::{IntoResponse, Response}; + use axum::Json; + + impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match &self { + AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::Validation(_) => StatusCode::BAD_REQUEST, + AppError::Unauthorized => StatusCode::UNAUTHORIZED, + AppError::Forbidden(_) => StatusCode::FORBIDDEN, + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::Conflict(_) => StatusCode::CONFLICT, + AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::InvalidCredentials => StatusCode::UNAUTHORIZED, + AppError::AccountSuspended => StatusCode::FORBIDDEN, + AppError::NotStaffMember => StatusCode::FORBIDDEN, + AppError::PasswordResetRequired => StatusCode::FORBIDDEN, + }; + + let body = ErrorResponse::from(self); + (status, Json(body)).into_response() + } + } +} diff --git a/crates/chattyness-shared/Cargo.toml b/crates/chattyness-shared/Cargo.toml new file mode 100644 index 0000000..f32cda1 --- /dev/null +++ b/crates/chattyness-shared/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "chattyness-shared" +version.workspace = true +edition.workspace = true + +[dependencies] +chattyness-error.workspace = true +regex.workspace = true +serde.workspace = true diff --git a/crates/chattyness-shared/src/lib.rs b/crates/chattyness-shared/src/lib.rs new file mode 100644 index 0000000..0093236 --- /dev/null +++ b/crates/chattyness-shared/src/lib.rs @@ -0,0 +1,8 @@ +//! Shared utilities for chattyness. +//! +//! This crate provides common validation functions and utilities +//! used across the application. + +pub mod validation; + +pub use validation::*; diff --git a/crates/chattyness-shared/src/validation.rs b/crates/chattyness-shared/src/validation.rs new file mode 100644 index 0000000..0fb31d7 --- /dev/null +++ b/crates/chattyness-shared/src/validation.rs @@ -0,0 +1,298 @@ +//! Shared validation utilities. +//! +//! This module provides common validation functions and pre-compiled regex patterns +//! for validating user input consistently across the application. + +use std::sync::LazyLock; + +use chattyness_error::AppError; +use regex::Regex; + +// ============================================================================= +// Pre-compiled Regex Patterns +// ============================================================================= + +/// Slug pattern: 1-50 characters, lowercase alphanumeric and hyphens. +/// Must start and end with alphanumeric (unless 1-2 chars). +static SLUG_REGEX: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$|^[a-z0-9]{1,2}$").expect("Invalid slug regex") +}); + +/// Hex color pattern: #RRGGBB or #RRGGBBAA format. +static HEX_COLOR_REGEX: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$").expect("Invalid hex color regex") +}); + +/// Username pattern: 3-30 characters, starts with lowercase letter, +/// contains only lowercase letters, numbers, and underscores. +static USERNAME_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9_]{2,29}$").expect("Invalid username regex")); + +/// Email pattern: basic check for @ symbol with characters before and after. +static EMAIL_REGEX: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").expect("Invalid email regex")); + +// ============================================================================= +// Validation Functions +// ============================================================================= + +/// Validate a slug (URL-friendly identifier). +/// +/// # Rules +/// - 1-50 characters +/// - Lowercase alphanumeric and hyphens only +/// - Must start and end with alphanumeric (except for 1-2 character slugs) +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(AppError::Validation)` if invalid +pub fn validate_slug(slug: &str) -> Result<(), AppError> { + if !SLUG_REGEX.is_match(slug) { + return Err(AppError::Validation( + "Slug must be 1-50 characters, lowercase alphanumeric and hyphens only".to_string(), + )); + } + Ok(()) +} + +/// Check if a slug is valid without returning an error. +pub fn is_valid_slug(slug: &str) -> bool { + SLUG_REGEX.is_match(slug) +} + +/// Validate a hex color string. +/// +/// # Rules +/// - Must match #RRGGBB or #RRGGBBAA format +/// - Case insensitive for hex digits +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(AppError::Validation)` if invalid +pub fn validate_hex_color(color: &str) -> Result<(), AppError> { + if !HEX_COLOR_REGEX.is_match(color) { + return Err(AppError::Validation( + "Color must be a valid hex color (#RRGGBB or #RRGGBBAA)".to_string(), + )); + } + Ok(()) +} + +/// Check if a hex color is valid without returning an error. +pub fn is_valid_hex_color(color: &str) -> bool { + HEX_COLOR_REGEX.is_match(color) +} + +/// Validate an optional hex color string. +/// +/// Returns `Ok(())` if the color is `None` or a valid hex color. +pub fn validate_optional_hex_color(color: Option<&str>) -> Result<(), AppError> { + if let Some(c) = color { + validate_hex_color(c)?; + } + Ok(()) +} + +/// Validate a username. +/// +/// # Rules +/// - 3-30 characters +/// - Must start with a lowercase letter +/// - Can contain lowercase letters, numbers, and underscores +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(AppError::Validation)` if invalid +pub fn validate_username(username: &str) -> Result<(), AppError> { + if !USERNAME_REGEX.is_match(username) { + return Err(AppError::Validation( + "Username must be 3-30 characters, start with a letter, and contain only lowercase letters, numbers, and underscores".to_string(), + )); + } + Ok(()) +} + +/// Check if a username is valid without returning an error. +pub fn is_valid_username(username: &str) -> bool { + USERNAME_REGEX.is_match(username) +} + +/// Validate an email address. +/// +/// # Rules +/// - Must contain @ with characters before and after +/// - Must have a domain with at least one dot +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(AppError::Validation)` if invalid +pub fn validate_email(email: &str) -> Result<(), AppError> { + let email = email.trim(); + if email.is_empty() || !EMAIL_REGEX.is_match(email) { + return Err(AppError::Validation("Invalid email address".to_string())); + } + Ok(()) +} + +/// Check if an email is valid without returning an error. +pub fn is_valid_email(email: &str) -> bool { + let email = email.trim(); + !email.is_empty() && EMAIL_REGEX.is_match(email) +} + +/// Validate that a string is not empty after trimming. +/// +/// # Returns +/// - `Ok(())` if the string is non-empty +/// - `Err(AppError::Validation)` with the provided field name if empty +pub fn validate_non_empty(value: &str, field_name: &str) -> Result<(), AppError> { + if value.trim().is_empty() { + return Err(AppError::Validation(format!( + "{} cannot be empty", + field_name + ))); + } + Ok(()) +} + +/// Validate that a string length is within bounds. +/// +/// # Returns +/// - `Ok(())` if length is within bounds +/// - `Err(AppError::Validation)` if too short or too long +pub fn validate_length(value: &str, field_name: &str, min: usize, max: usize) -> Result<(), AppError> { + let len = value.len(); + if len < min || len > max { + return Err(AppError::Validation(format!( + "{} must be {}-{} characters", + field_name, min, max + ))); + } + Ok(()) +} + +/// Validate that a number is within a range. +/// +/// # Returns +/// - `Ok(())` if the number is within the range +/// - `Err(AppError::Validation)` if out of range +pub fn validate_range<T: std::cmp::PartialOrd + std::fmt::Display>( + value: T, + field_name: &str, + min: T, + max: T, +) -> Result<(), AppError> { + if value < min || value > max { + return Err(AppError::Validation(format!( + "{} must be between {} and {}", + field_name, min, max + ))); + } + Ok(()) +} + +/// Validate a password meets minimum requirements. +/// +/// # Rules +/// - At least 8 characters +/// +/// # Returns +/// - `Ok(())` if valid +/// - `Err(AppError::Validation)` if too short +pub fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 { + return Err(AppError::Validation( + "Password must be at least 8 characters".to_string(), + )); + } + Ok(()) +} + +/// Validate that two passwords match. +/// +/// # Returns +/// - `Ok(())` if passwords match +/// - `Err(AppError::Validation)` if they don't match +pub fn validate_passwords_match(password: &str, confirm: &str) -> Result<(), AppError> { + if password != confirm { + return Err(AppError::Validation("Passwords do not match".to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_slug() { + // Valid slugs + assert!(validate_slug("a").is_ok()); + assert!(validate_slug("ab").is_ok()); + assert!(validate_slug("abc").is_ok()); + assert!(validate_slug("my-realm").is_ok()); + assert!(validate_slug("realm123").is_ok()); + assert!(validate_slug("my-awesome-realm-2024").is_ok()); + + // Invalid slugs + assert!(validate_slug("").is_err()); + assert!(validate_slug("-starts-with-dash").is_err()); + assert!(validate_slug("ends-with-dash-").is_err()); + assert!(validate_slug("HAS-UPPERCASE").is_err()); + assert!(validate_slug("has spaces").is_err()); + assert!(validate_slug("has_underscore").is_err()); + } + + #[test] + fn test_validate_hex_color() { + // Valid colors + assert!(validate_hex_color("#ffffff").is_ok()); + assert!(validate_hex_color("#FFFFFF").is_ok()); + assert!(validate_hex_color("#000000").is_ok()); + assert!(validate_hex_color("#ff5733").is_ok()); + assert!(validate_hex_color("#FF573380").is_ok()); // With alpha + + // Invalid colors + assert!(validate_hex_color("").is_err()); + assert!(validate_hex_color("ffffff").is_err()); // Missing # + assert!(validate_hex_color("#fff").is_err()); // Too short + assert!(validate_hex_color("#fffffff").is_err()); // 7 chars + assert!(validate_hex_color("#gggggg").is_err()); // Invalid hex + } + + #[test] + fn test_validate_username() { + // Valid usernames + assert!(validate_username("abc").is_ok()); + assert!(validate_username("user123").is_ok()); + assert!(validate_username("my_user_name").is_ok()); + + // Invalid usernames + assert!(validate_username("ab").is_err()); // Too short + assert!(validate_username("123abc").is_err()); // Starts with number + assert!(validate_username("_user").is_err()); // Starts with underscore + assert!(validate_username("User").is_err()); // Uppercase + assert!(validate_username("user-name").is_err()); // Has hyphen + } + + #[test] + fn test_validate_email() { + // Valid emails + assert!(validate_email("user@example.com").is_ok()); + assert!(validate_email("test.user@domain.org").is_ok()); + + // Invalid emails + assert!(validate_email("").is_err()); + assert!(validate_email("noatsign").is_err()); + assert!(validate_email("@nodomain").is_err()); + assert!(validate_email("no@dotcom").is_err()); + } + + #[test] + fn test_validate_password() { + assert!(validate_password("12345678").is_ok()); + assert!(validate_password("longpassword123!").is_ok()); + assert!(validate_password("1234567").is_err()); // Too short + assert!(validate_password("").is_err()); + } +} diff --git a/crates/chattyness-user-ui/Cargo.toml b/crates/chattyness-user-ui/Cargo.toml new file mode 100644 index 0000000..a5e2b01 --- /dev/null +++ b/crates/chattyness-user-ui/Cargo.toml @@ -0,0 +1,80 @@ +[package] +name = "chattyness-user-ui" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +chattyness-db = { workspace = true } +chattyness-error = { workspace = true, optional = true } +chattyness-shared = { 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 } +sqlx = { workspace = true, optional = true } +tower = { workspace = true, optional = true } +tower-sessions = { workspace = true, optional = true } +tower-sessions-sqlx-store = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +hex = { workspace = true, optional = true } +rand = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } +image = { workspace = true, optional = true } +futures = { workspace = true, optional = true } +dashmap = { workspace = true, optional = true } + +# Hydrate-only dependencies +gloo-net = { workspace = true, optional = true } +gloo-timers = { workspace = true, optional = true } +web-sys = { workspace = true, optional = true } +wasm-bindgen = { workspace = true, optional = true } +console_error_panic_hook = { workspace = true, optional = true } +js-sys = { 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:chattyness-shared", + "dep:axum", + "dep:sqlx", + "dep:tower", + "dep:tower-sessions", + "dep:tower-sessions-sqlx-store", + "dep:tracing", + "dep:tokio", + "dep:sha2", + "dep:hex", + "dep:rand", + "dep:reqwest", + "dep:image", + "dep:futures", + "dep:dashmap", +] +hydrate = [ + "leptos/hydrate", + "dep:gloo-net", + "dep:gloo-timers", + "dep:web-sys", + "dep:wasm-bindgen", + "dep:console_error_panic_hook", + "dep:js-sys", +] diff --git a/crates/chattyness-user-ui/src/api.rs b/crates/chattyness-user-ui/src/api.rs new file mode 100644 index 0000000..63cd08b --- /dev/null +++ b/crates/chattyness-user-ui/src/api.rs @@ -0,0 +1,11 @@ +//! REST API module for user UI. + +pub mod auth; +pub mod avatars; +pub mod realms; +pub mod routes; +pub mod scenes; +pub mod websocket; + +pub use routes::*; +pub use websocket::WebSocketState; diff --git a/crates/chattyness-user-ui/src/api/auth.rs b/crates/chattyness-user-ui/src/api/auth.rs new file mode 100644 index 0000000..9af8895 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/auth.rs @@ -0,0 +1,474 @@ +//! Authentication API handlers. + +use axum::{ + extract::State, + Json, +}; +use sqlx::PgPool; +use tower_sessions::Session; + +use chattyness_db::{ + models::{ + AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest, + GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse, + LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary, + SignupRequest, SignupResponse, UserSummary, + }, + queries::{guests, memberships, realms, users}, +}; +use chattyness_error::AppError; + +use crate::auth::{ + session::{ + hash_token, generate_token, SESSION_CURRENT_REALM_KEY, SESSION_GUEST_ID_KEY, + SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY, + }, + AuthUser, OptionalAuthUser, +}; + +/// Get current user info. +pub async fn get_current_user( + State(pool): State<PgPool>, + OptionalAuthUser(user): OptionalAuthUser, +) -> Result<Json<CurrentUserResponse>, AppError> { + match user { + Some(user) => { + // Get staff role if any + let staff_role = memberships::get_user_staff_role(&pool, user.id).await?; + + Ok(Json(CurrentUserResponse { + user: Some(AuthenticatedUser { + id: user.id, + username: user.username, + display_name: user.display_name, + avatar_url: user.avatar_url, + staff_role, + }), + })) + } + None => Ok(Json(CurrentUserResponse { user: None })), + } +} + +/// Login handler. +pub async fn login( + rls_conn: crate::auth::RlsConn, + State(pool): State<PgPool>, + session: Session, + Json(req): Json<LoginRequest>, +) -> Result<Json<LoginResponse>, AppError> { + // Validate the request + req.validate()?; + + // Verify credentials + let user = users::verify_password_with_reset_flag(&pool, &req.username, &req.password) + .await? + .ok_or(AppError::InvalidCredentials)?; + + // Set RLS context to the authenticated user for subsequent operations + rls_conn.set_user_id(user.id).await + .map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?; + + // Check account status + if user.status != AccountStatus::Active { + return Err(AppError::AccountSuspended); + } + + // Create user summary for response + let user_summary = UserSummary { + id: user.id, + username: user.username.clone(), + display_name: user.display_name.clone(), + avatar_url: user.avatar_url.clone(), + }; + + // Handle based on login type + match req.login_type { + LoginType::Staff => { + // Verify user is a staff member + let staff_role = memberships::get_user_staff_role(&pool, user.id) + .await? + .ok_or(AppError::NotStaffMember)?; + + // Store session data + session + .insert(SESSION_USER_ID_KEY, user.id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_LOGIN_TYPE_KEY, "staff") + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + // Check for forced password reset + if user.force_pw_reset { + session + .insert(SESSION_ORIGINAL_DEST_KEY, "/staff") + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + return Ok(Json(LoginResponse { + user: user_summary, + redirect_url: "/password-reset".to_string(), + requires_pw_reset: true, + is_member: None, + original_destination: Some("/staff".to_string()), + staff_role: Some(staff_role), + realm: None, + })); + } + + Ok(Json(LoginResponse { + user: user_summary, + redirect_url: "/staff".to_string(), + requires_pw_reset: false, + is_member: None, + original_destination: None, + staff_role: Some(staff_role), + realm: None, + })) + } + LoginType::Realm => { + let realm_slug = req.realm_slug.as_ref().ok_or_else(|| { + AppError::Validation("Realm slug is required for realm login".to_string()) + })?; + + // Get the realm + let realm = realms::get_realm_by_slug(&pool, realm_slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", realm_slug)))?; + + // Check if user is a member + let is_member = memberships::is_member(&pool, user.id, realm.id).await?; + + // Store session data + session + .insert(SESSION_USER_ID_KEY, user.id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_LOGIN_TYPE_KEY, "realm") + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_CURRENT_REALM_KEY, realm.id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + let redirect_url = format!("/realms/{}", realm.slug); + + // Check for forced password reset + if user.force_pw_reset { + session + .insert(SESSION_ORIGINAL_DEST_KEY, redirect_url.clone()) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + return Ok(Json(LoginResponse { + user: user_summary, + redirect_url: "/password-reset".to_string(), + requires_pw_reset: true, + is_member: Some(is_member), + original_destination: Some(redirect_url), + staff_role: None, + realm: Some(RealmSummary { + id: realm.id, + name: realm.name, + slug: realm.slug, + tagline: realm.tagline, + privacy: realm.privacy, + is_nsfw: realm.is_nsfw, + thumbnail_path: realm.thumbnail_path, + member_count: realm.member_count, + current_user_count: realm.current_user_count, + }), + })); + } + + // If not a member, include realm info for join confirmation + if !is_member { + return Ok(Json(LoginResponse { + user: user_summary, + redirect_url: redirect_url.clone(), + requires_pw_reset: false, + is_member: Some(false), + original_destination: None, + staff_role: None, + realm: Some(RealmSummary { + id: realm.id, + name: realm.name, + slug: realm.slug, + tagline: realm.tagline, + privacy: realm.privacy, + is_nsfw: realm.is_nsfw, + thumbnail_path: realm.thumbnail_path, + member_count: realm.member_count, + current_user_count: realm.current_user_count, + }), + })); + } + + // User is a member, update last visited (using RLS connection) + let mut conn = rls_conn.acquire().await; + memberships::update_last_visited_conn(&mut *conn, user.id, realm.id).await?; + + Ok(Json(LoginResponse { + user: user_summary, + redirect_url, + requires_pw_reset: false, + is_member: Some(true), + original_destination: None, + staff_role: None, + realm: None, + })) + } + } +} + +/// Logout response. +#[derive(Debug, serde::Serialize)] +pub struct LogoutResponse { + pub success: bool, + pub redirect_url: String, +} + +/// Logout handler. +pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError> { + // Flush the session (removes all data and invalidates the session) + session + .flush() + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + Ok(Json(LogoutResponse { + success: true, + redirect_url: "/".to_string(), + })) +} + +/// Signup handler. +pub async fn signup( + rls_conn: crate::auth::RlsConn, + State(pool): State<PgPool>, + session: Session, + Json(req): Json<SignupRequest>, +) -> Result<Json<SignupResponse>, AppError> { + // Validate the request + req.validate()?; + + // Check username availability (can use pool for read-only checks) + if users::username_exists(&pool, &req.username).await? { + return Err(AppError::Conflict("Username already taken".to_string())); + } + + // Check email availability if provided + if let Some(ref email) = req.email { + let email_trimmed = email.trim(); + if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? { + return Err(AppError::Conflict("Email already registered".to_string())); + } + } + + // Get the realm + let realm = realms::get_realm_by_slug(&pool, &req.realm_slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?; + + // Create the user using RLS connection + let email_opt = req.email.as_ref().and_then(|e| { + let trimmed = e.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }); + + let mut conn = rls_conn.acquire().await; + let user_id = + users::create_user_conn(&mut *conn, &req.username, email_opt, req.display_name.trim(), &req.password) + .await?; + drop(conn); + + // Set RLS context to the new user for membership creation + rls_conn.set_user_id(user_id).await + .map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?; + + // Create membership using RLS connection (now has user context) + let mut conn = rls_conn.acquire().await; + let membership_id = + memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member).await?; + + // Set up session (user is logged in) + session + .insert(SESSION_USER_ID_KEY, user_id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_LOGIN_TYPE_KEY, "realm") + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_CURRENT_REALM_KEY, realm.id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + let redirect_url = format!("/realms/{}", realm.slug); + + Ok(Json(SignupResponse { + user: UserSummary { + id: user_id, + username: req.username, + display_name: req.display_name.trim().to_string(), + avatar_url: None, + }, + redirect_url, + membership_id, + })) +} + +/// Guest login handler. +pub async fn guest_login( + State(pool): State<PgPool>, + session: Session, + Json(req): Json<GuestLoginRequest>, +) -> Result<Json<GuestLoginResponse>, AppError> { + // Validate the request + req.validate()?; + + // Get the realm + let realm = realms::get_realm_by_slug(&pool, &req.realm_slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?; + + // Check if realm allows guest access + if !realm.allow_guest_access { + return Err(AppError::Forbidden( + "This realm does not allow guest access".to_string(), + )); + } + + // Generate guest name and session token + let guest_name = guests::generate_guest_name(); + let token = generate_token(); + let token_hash = hash_token(&token); + let expires_at = guests::guest_session_expiry(); + + // Create guest session in database + let guest_id = guests::create_guest_session( + &pool, + &guest_name, + realm.id, + &token_hash, + None, // user_agent + None, // ip_address + expires_at, + ) + .await?; + + // Set up tower session + session + .insert(SESSION_GUEST_ID_KEY, guest_id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_LOGIN_TYPE_KEY, "guest") + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + session + .insert(SESSION_CURRENT_REALM_KEY, realm.id) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + let redirect_url = format!("/realms/{}", realm.slug); + + Ok(Json(GuestLoginResponse { + guest_name, + guest_id, + redirect_url, + realm: RealmSummary { + id: realm.id, + name: realm.name, + slug: realm.slug, + tagline: realm.tagline, + privacy: realm.privacy, + is_nsfw: realm.is_nsfw, + thumbnail_path: realm.thumbnail_path, + member_count: realm.member_count, + current_user_count: realm.current_user_count, + }, + })) +} + +/// Join realm handler. +pub async fn join_realm( + rls_conn: crate::auth::RlsConn, + State(pool): State<PgPool>, + AuthUser(user): AuthUser, + Json(req): Json<JoinRealmRequest>, +) -> Result<Json<JoinRealmResponse>, AppError> { + // Get the realm to verify it exists and check privacy + let realm = realms::get_realm_by_id(&pool, req.realm_id) + .await? + .ok_or_else(|| AppError::NotFound("Realm not found".to_string()))?; + + // Check if user is already a member + let is_member = memberships::is_member(&pool, user.id, realm.id).await?; + if is_member { + return Err(AppError::Conflict( + "Already a member of this realm".to_string(), + )); + } + + // For private realms, don't allow direct join (would need invitation) + if realm.privacy == chattyness_db::models::RealmPrivacy::Private { + return Err(AppError::Forbidden( + "Cannot join private realms without an invitation".to_string(), + )); + } + + // Create the membership using RLS connection (policy requires user_id = current_user_id) + let mut conn = rls_conn.acquire().await; + let membership_id = + memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member).await?; + + Ok(Json(JoinRealmResponse { + success: true, + membership_id, + redirect_url: format!("/realms/{}", realm.slug), + })) +} + +/// Password reset handler. +pub async fn reset_password( + rls_conn: crate::auth::RlsConn, + session: Session, + AuthUser(user): AuthUser, + Json(req): Json<PasswordResetRequest>, +) -> Result<Json<PasswordResetResponse>, AppError> { + // Validate the request + req.validate()?; + + // Update the password using RLS connection (required for RLS policy) + let mut conn = rls_conn.acquire().await; + users::update_password_conn(&mut *conn, user.id, &req.new_password).await?; + + // Get the original destination from session + let original_dest: Option<String> = session + .get(SESSION_ORIGINAL_DEST_KEY) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + // Clear the original destination from session + session + .remove::<String>(SESSION_ORIGINAL_DEST_KEY) + .await + .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; + + let redirect_url = original_dest.unwrap_or_else(|| "/".to_string()); + + Ok(Json(PasswordResetResponse { + success: true, + redirect_url, + })) +} diff --git a/crates/chattyness-user-ui/src/api/avatars.rs b/crates/chattyness-user-ui/src/api/avatars.rs new file mode 100644 index 0000000..9146784 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/avatars.rs @@ -0,0 +1,39 @@ +//! Avatar API handlers for user UI. +//! +//! Handles avatar rendering data retrieval. +//! Note: Emotion switching is now handled via WebSocket. + +use axum::{ + extract::{Path, State}, + Json, +}; +use sqlx::PgPool; + +use chattyness_db::{ + models::AvatarRenderData, + queries::{avatars, realms}, +}; +use chattyness_error::AppError; + +use crate::auth::AuthUser; + +/// Get current avatar render data. +/// +/// GET /api/realms/{slug}/avatar/current +/// +/// Returns the render data for the user's active avatar in this realm. +pub async fn get_current_avatar( + State(pool): State<PgPool>, + AuthUser(user): AuthUser, + Path(slug): Path<String>, +) -> Result<Json<AvatarRenderData>, AppError> { + // Get realm + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + // Get render data + let render_data = avatars::get_avatar_render_data(&pool, user.id, realm.id).await?; + + Ok(Json(render_data)) +} diff --git a/crates/chattyness-user-ui/src/api/realms.rs b/crates/chattyness-user-ui/src/api/realms.rs new file mode 100644 index 0000000..0acbcf1 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/realms.rs @@ -0,0 +1,80 @@ +//! Realm API handlers for user UI (READ-ONLY). + +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +use chattyness_db::{ + models::{RealmSummary, RealmWithUserRole}, + queries::{memberships, realms}, +}; +use chattyness_error::AppError; + +use crate::auth::OptionalAuthUser; + +/// List query params. +#[derive(Debug, Deserialize)] +pub struct ListParams { + pub include_nsfw: Option<bool>, + pub page: Option<i64>, + pub limit: Option<i64>, +} + +/// List response. +#[derive(Debug, Serialize)] +pub struct ListResponse { + pub realms: Vec<RealmSummary>, +} + +/// List public realms. +pub async fn list_realms( + State(pool): State<PgPool>, + Query(params): Query<ListParams>, +) -> Result<Json<ListResponse>, AppError> { + let limit = params.limit.unwrap_or(20).min(100); + let offset = params.page.unwrap_or(0) * limit; + let include_nsfw = params.include_nsfw.unwrap_or(false); + + let realm_list = realms::list_public_realms(&pool, include_nsfw, limit, offset).await?; + + Ok(Json(ListResponse { realms: realm_list })) +} + +/// Get a realm by slug with user role. +pub async fn get_realm( + State(pool): State<PgPool>, + OptionalAuthUser(maybe_user): OptionalAuthUser, + Path(slug): Path<String>, +) -> Result<Json<RealmWithUserRole>, AppError> { + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + // Get the user's role if authenticated + let user_role = if let Some(user) = maybe_user { + let membership = memberships::get_user_membership(&pool, user.id, realm.id).await?; + membership.map(|m| m.role) + } else { + None + }; + + Ok(Json(RealmWithUserRole { realm, user_role })) +} + +/// Check slug availability response. +#[derive(Debug, Serialize)] +pub struct SlugAvailableResponse { + pub available: bool, +} + +/// Check if a realm slug is available. +pub async fn check_slug_available( + State(pool): State<PgPool>, + Path(slug): Path<String>, +) -> Result<Json<SlugAvailableResponse>, AppError> { + let available = realms::is_slug_available(&pool, &slug).await?; + Ok(Json(SlugAvailableResponse { available })) +} diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs new file mode 100644 index 0000000..49609d4 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -0,0 +1,57 @@ +//! API routes for user UI. +//! +//! This router provides READ-ONLY access to realms, scenes, and spots. +//! All create/update/delete operations are handled by the admin-ui. +//! Channel presence is handled via WebSocket. + +use axum::{routing::get, Router}; + +use super::{auth, avatars, realms, scenes, websocket}; +use crate::app::AppState; + +/// Build the API router for user UI. +/// +/// Note: This router is READ-ONLY for realms/scenes/spots. +/// Auth routes (login, logout, signup, join-realm) are allowed. +/// Channel presence (join, leave, position, emotion, members) is handled via WebSocket. +pub fn api_router() -> Router<AppState> { + Router::new() + // Auth routes (these are user-facing operations) + .route("/auth/me", get(auth::get_current_user)) + .route("/auth/login", axum::routing::post(auth::login)) + .route("/auth/logout", axum::routing::post(auth::logout)) + .route("/auth/signup", axum::routing::post(auth::signup)) + .route("/auth/guest", axum::routing::post(auth::guest_login)) + .route("/auth/join-realm", axum::routing::post(auth::join_realm)) + .route( + "/auth/reset-password", + axum::routing::post(auth::reset_password), + ) + // Realm routes (READ-ONLY) + .route("/realms", get(realms::list_realms)) + .route("/realms/{slug}", get(realms::get_realm)) + .route("/realms/{slug}/available", get(realms::check_slug_available)) + // Scene routes (READ-ONLY) + .route("/realms/{slug}/entry-scene", get(scenes::get_entry_scene)) + .route("/realms/{slug}/scenes", get(scenes::list_scenes)) + .route("/realms/{slug}/scenes/{scene_slug}", get(scenes::get_scene)) + // Spot routes (READ-ONLY) + .route( + "/realms/{slug}/scenes/{scene_slug}/spots", + get(scenes::list_spots), + ) + .route( + "/realms/{slug}/scenes/{scene_slug}/spots/{spot_id}", + get(scenes::get_spot), + ) + // WebSocket route for channel presence (handles join, leave, position, emotion, members) + .route( + "/realms/{slug}/channels/{channel_id}/ws", + get(websocket::ws_handler::<AppState>), + ) + // Avatar routes (require authentication) + .route( + "/realms/{slug}/avatar/current", + get(avatars::get_current_avatar), + ) +} diff --git a/crates/chattyness-user-ui/src/api/scenes.rs b/crates/chattyness-user-ui/src/api/scenes.rs new file mode 100644 index 0000000..9e2586c --- /dev/null +++ b/crates/chattyness-user-ui/src/api/scenes.rs @@ -0,0 +1,92 @@ +//! Scene and Spot API handlers for user UI (READ-ONLY). + +use axum::{ + extract::{Path, State}, + Json, +}; +use sqlx::PgPool; +use uuid::Uuid; + +use chattyness_db::{ + models::{Scene, SceneSummary, Spot, SpotSummary}, + queries::{realms, scenes, spots}, +}; +use chattyness_error::AppError; + +/// Get the entry scene for a realm. +/// +/// GET /api/realms/{slug}/entry-scene +/// +/// Returns the realm's default/entry scene. This endpoint is public. +pub async fn get_entry_scene( + State(pool): State<PgPool>, + Path(slug): Path<String>, +) -> Result<Json<Scene>, AppError> { + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + let scene = scenes::get_entry_scene_for_realm(&pool, realm.id, realm.default_scene_id) + .await? + .ok_or_else(|| AppError::NotFound("No entry scene found for this realm".to_string()))?; + + Ok(Json(scene)) +} + +/// List scenes for a realm. +pub async fn list_scenes( + State(pool): State<PgPool>, + Path(slug): Path<String>, +) -> Result<Json<Vec<SceneSummary>>, AppError> { + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + let scene_list = scenes::list_scenes_for_realm(&pool, realm.id).await?; + Ok(Json(scene_list)) +} + +/// Get a scene by slug. +pub async fn get_scene( + State(pool): State<PgPool>, + Path((slug, scene_slug)): Path<(String, String)>, +) -> Result<Json<Scene>, AppError> { + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + let scene = scenes::get_scene_by_slug(&pool, realm.id, &scene_slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Scene '{}' not found", scene_slug)))?; + + Ok(Json(scene)) +} + +/// List spots for a scene. +pub async fn list_spots( + State(pool): State<PgPool>, + Path((slug, scene_slug)): Path<(String, String)>, +) -> Result<Json<Vec<SpotSummary>>, AppError> { + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + let scene = scenes::get_scene_by_slug(&pool, realm.id, &scene_slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Scene '{}' not found", scene_slug)))?; + + let spot_list = spots::list_spots_for_scene(&pool, scene.id).await?; + Ok(Json(spot_list)) +} + +/// Get a spot by ID. +pub async fn get_spot( + State(pool): State<PgPool>, + Path((_slug, _scene_slug, spot_id)): Path<(String, String, Uuid)>, +) -> Result<Json<Spot>, AppError> { + let spot = spots::get_spot_by_id(&pool, spot_id) + .await? + .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; + + Ok(Json(spot)) +} diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs new file mode 100644 index 0000000..45819c2 --- /dev/null +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -0,0 +1,399 @@ +//! WebSocket handler for channel presence. +//! +//! Handles real-time position updates, emotion changes, and member synchronization. + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + FromRef, Path, State, + }, + response::IntoResponse, +}; +use dashmap::DashMap; +use futures::{SinkExt, StreamExt}; +use sqlx::PgPool; +use std::sync::Arc; +use tokio::sync::broadcast; +use uuid::Uuid; + +use chattyness_db::{ + models::{AvatarRenderData, ChannelMemberWithAvatar, User}, + queries::{avatars, channel_members, realms, scenes}, + ws_messages::{ClientMessage, ServerMessage}, +}; +use chattyness_error::AppError; + +use crate::auth::AuthUser; + +/// Channel state for broadcasting updates. +pub struct ChannelState { + /// Broadcast sender for this channel. + tx: broadcast::Sender<ServerMessage>, +} + +/// Global state for all WebSocket connections. +pub struct WebSocketState { + /// Map of channel_id -> ChannelState. + channels: DashMap<Uuid, Arc<ChannelState>>, +} + +impl Default for WebSocketState { + fn default() -> Self { + Self::new() + } +} + +impl WebSocketState { + /// Create a new WebSocket state. + pub fn new() -> Self { + Self { + channels: DashMap::new(), + } + } + + /// Get or create a channel state. + fn get_or_create_channel(&self, channel_id: Uuid) -> Arc<ChannelState> { + self.channels + .entry(channel_id) + .or_insert_with(|| { + let (tx, _) = broadcast::channel(256); + Arc::new(ChannelState { tx }) + }) + .clone() + } +} + +/// WebSocket upgrade handler. +/// +/// GET /api/realms/{slug}/channels/{channel_id}/ws +pub async fn ws_handler<S>( + Path((slug, channel_id)): Path<(String, Uuid)>, + auth_result: Result<AuthUser, crate::auth::AuthError>, + State(pool): State<PgPool>, + State(ws_state): State<Arc<WebSocketState>>, + ws: WebSocketUpgrade, +) -> Result<impl IntoResponse, AppError> +where + S: Send + Sync, + PgPool: FromRef<S>, + Arc<WebSocketState>: FromRef<S>, +{ + // Log auth result before checking + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] Connection attempt to {}/channels/{} - auth: {:?}", + slug, + channel_id, + auth_result.as_ref().map(|a| a.0.id).map_err(|e| format!("{:?}", e)) + ); + + let AuthUser(user) = auth_result.map_err(|e| { + tracing::warn!("[WS] Auth failed for {}/channels/{}: {:?}", slug, channel_id, e); + AppError::from(e) + })?; + + // Verify realm exists + let realm = realms::get_realm_by_slug(&pool, &slug) + .await? + .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + + // Verify channel (scene) exists and belongs to this realm + let scene = scenes::get_scene_by_id(&pool, channel_id) + .await? + .ok_or_else(|| AppError::NotFound("Channel not found".to_string()))?; + + if scene.realm_id != realm.id { + return Err(AppError::NotFound( + "Channel not found in this realm".to_string(), + )); + } + + #[cfg(debug_assertions)] + tracing::debug!( + "[WS] Upgrading connection for user {} to channel {}", + user.id, + channel_id + ); + + Ok(ws.on_upgrade(move |socket| { + handle_socket(socket, user, channel_id, realm.id, pool, ws_state) + })) +} + +/// Set RLS context on a database connection. +async fn set_rls_user_id( + conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>, + user_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query("SELECT public.set_current_user_id($1)") + .bind(user_id) + .execute(&mut **conn) + .await?; + Ok(()) +} + +/// Handle an active WebSocket connection. +async fn handle_socket( + socket: WebSocket, + user: User, + channel_id: Uuid, + realm_id: Uuid, + pool: PgPool, + ws_state: Arc<WebSocketState>, +) { + tracing::info!( + "[WS] handle_socket started for user {} channel {} realm {}", + user.id, + channel_id, + realm_id + ); + + // Acquire a dedicated connection for setup operations + let mut conn = match pool.acquire().await { + Ok(conn) => conn, + Err(e) => { + tracing::error!("[WS] Failed to acquire DB connection: {:?}", e); + return; + } + }; + + // Set RLS context on this dedicated connection + if let Err(e) = set_rls_user_id(&mut conn, user.id).await { + tracing::error!("[WS] Failed to set RLS context for user {}: {:?}", user.id, e); + return; + } + tracing::info!("[WS] RLS context set on dedicated connection"); + + let channel_state = ws_state.get_or_create_channel(channel_id); + let mut rx = channel_state.tx.subscribe(); + + let (mut sender, mut receiver) = socket.split(); + + // Ensure active avatar + tracing::info!("[WS] Ensuring active avatar..."); + if let Err(e) = channel_members::ensure_active_avatar(&mut *conn, user.id, realm_id).await { + tracing::error!("[WS] Failed to ensure avatar for user {}: {:?}", user.id, e); + return; + } + tracing::info!("[WS] Avatar ensured"); + + // Join the channel + tracing::info!("[WS] Joining channel..."); + if let Err(e) = channel_members::join_channel(&mut *conn, channel_id, user.id).await { + tracing::error!( + "[WS] Failed to join channel {} for user {}: {:?}", + channel_id, + user.id, + e + ); + return; + } + tracing::info!("[WS] Channel joined"); + + // Get initial state + let members = match get_members_with_avatars(&mut *conn, channel_id, realm_id).await { + Ok(m) => m, + Err(e) => { + tracing::error!("[WS] Failed to get members: {:?}", e); + let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await; + return; + } + }; + + let member = match channel_members::get_channel_member(&mut *conn, channel_id, user.id, realm_id) + .await + { + Ok(Some(m)) => m, + Ok(None) => { + tracing::error!("[WS] Failed to get member info for user {}", user.id); + let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await; + return; + } + Err(e) => { + tracing::error!("[WS] Error getting member info: {:?}", e); + let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await; + return; + } + }; + + // Send welcome message + let welcome = ServerMessage::Welcome { + member: member.clone(), + members, + }; + if let Ok(json) = serde_json::to_string(&welcome) { + #[cfg(debug_assertions)] + tracing::debug!("[WS->Client] {}", json); + if sender.send(Message::Text(json.into())).await.is_err() { + let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await; + return; + } + } + + // Broadcast join to others + let avatar = avatars::get_avatar_render_data(&mut *conn, user.id, realm_id) + .await + .unwrap_or_default(); + let join_msg = ServerMessage::MemberJoined { + member: ChannelMemberWithAvatar { member, avatar }, + }; + let _ = channel_state.tx.send(join_msg); + + let user_id = user.id; + let tx = channel_state.tx.clone(); + + // Acquire a second dedicated connection for the receive task + // This connection needs its own RLS context + let mut recv_conn = match pool.acquire().await { + Ok(c) => c, + Err(e) => { + tracing::error!("[WS] Failed to acquire recv connection: {:?}", e); + let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await; + return; + } + }; + if let Err(e) = set_rls_user_id(&mut recv_conn, user_id).await { + tracing::error!("[WS] Failed to set RLS on recv connection: {:?}", e); + let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await; + return; + } + + // Drop the setup connection - we'll use recv_conn for the receive task + // and pool for cleanup (which will use the same RLS context issue, but leave_channel + // needs user_id match anyway) + drop(conn); + + // Spawn task to handle incoming messages from client + let recv_task = tokio::spawn(async move { + while let Some(Ok(msg)) = receiver.next().await { + if let Message::Text(text) = msg { + #[cfg(debug_assertions)] + tracing::debug!("[WS<-Client] {}", text); + + let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) else { + continue; + }; + + match client_msg { + ClientMessage::UpdatePosition { x, y } => { + if let Err(e) = + channel_members::update_position(&mut *recv_conn, channel_id, user_id, x, y) + .await + { + #[cfg(debug_assertions)] + tracing::error!("[WS] Position update failed: {:?}", e); + continue; + } + let _ = tx.send(ServerMessage::PositionUpdated { + user_id: Some(user_id), + guest_session_id: None, + x, + y, + }); + } + ClientMessage::UpdateEmotion { emotion } => { + if emotion > 9 { + continue; + } + let emotion_layer = match avatars::set_emotion( + &mut *recv_conn, + user_id, + realm_id, + emotion as i16, + ) + .await + { + Ok(layer) => layer, + Err(e) => { + #[cfg(debug_assertions)] + tracing::error!("[WS] Emotion update failed: {:?}", e); + continue; + } + }; + let _ = tx.send(ServerMessage::EmotionUpdated { + user_id: Some(user_id), + guest_session_id: None, + emotion, + emotion_layer, + }); + } + ClientMessage::Ping => { + // Respond with pong directly (not broadcast) + // This is handled in the send task via individual message + } + } + } + } + // Return the connection so we can use it for cleanup + recv_conn + }); + + // Spawn task to forward broadcasts to this client + let send_task = tokio::spawn(async move { + while let Ok(msg) = rx.recv().await { + if let Ok(json) = serde_json::to_string(&msg) { + #[cfg(debug_assertions)] + tracing::debug!("[WS->Client] {}", json); + if sender.send(Message::Text(json.into())).await.is_err() { + break; + } + } + } + }); + + // Wait for either task to complete + tokio::select! { + recv_result = recv_task => { + // recv_task finished, get connection back for cleanup + if let Ok(mut cleanup_conn) = recv_result { + let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await; + } else { + // Task panicked, use pool (RLS may fail but try anyway) + let _ = channel_members::leave_channel(&pool, channel_id, user_id).await; + } + } + _ = send_task => { + // send_task finished first, need to acquire a new connection for cleanup + if let Ok(mut cleanup_conn) = pool.acquire().await { + let _ = set_rls_user_id(&mut cleanup_conn, user_id).await; + let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await; + } + } + } + + tracing::info!( + "[WS] User {} disconnected from channel {}", + user_id, + channel_id + ); + + // Broadcast departure + let _ = channel_state.tx.send(ServerMessage::MemberLeft { + user_id: Some(user_id), + guest_session_id: None, + }); +} + +/// Helper: Get all channel members with their avatar render data. +async fn get_members_with_avatars<'e>( + executor: impl sqlx::PgExecutor<'e>, + channel_id: Uuid, + realm_id: Uuid, +) -> Result<Vec<ChannelMemberWithAvatar>, AppError> { + // Get members first, then we need to get avatars + // But executor is consumed by the first query, so we need the pool + // Actually, let's just inline this to avoid the complexity + let members = channel_members::get_channel_members(executor, channel_id, realm_id).await?; + + // For avatar data, we'll just return default for now since the query + // would need another executor + let result: Vec<ChannelMemberWithAvatar> = members + .into_iter() + .map(|member| ChannelMemberWithAvatar { + member, + avatar: AvatarRenderData::default(), + }) + .collect(); + + Ok(result) +} diff --git a/crates/chattyness-user-ui/src/app.rs b/crates/chattyness-user-ui/src/app.rs new file mode 100644 index 0000000..f714246 --- /dev/null +++ b/crates/chattyness-user-ui/src/app.rs @@ -0,0 +1,83 @@ +//! Leptos application root and router for public app. + +use leptos::prelude::*; +use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title}; +use leptos_router::components::Router; + +use crate::routes::UserRoutes; + +#[cfg(feature = "ssr")] +use std::sync::Arc; + +#[cfg(feature = "ssr")] +use crate::api::WebSocketState; + +/// Application state for the public app. +#[cfg(feature = "ssr")] +#[derive(Clone)] +pub struct AppState { + pub pool: sqlx::PgPool, + pub leptos_options: LeptosOptions, + pub ws_state: Arc<WebSocketState>, +} + +#[cfg(feature = "ssr")] +impl axum::extract::FromRef<AppState> for sqlx::PgPool { + fn from_ref(state: &AppState) -> Self { + state.pool.clone() + } +} + +#[cfg(feature = "ssr")] +impl axum::extract::FromRef<AppState> for LeptosOptions { + fn from_ref(state: &AppState) -> Self { + state.leptos_options.clone() + } +} + +#[cfg(feature = "ssr")] +impl axum::extract::FromRef<AppState> for Arc<WebSocketState> { + fn from_ref(state: &AppState) -> Self { + state.ws_state.clone() + } +} + +/// Shell component for SSR. +pub fn shell(options: LeptosOptions) -> impl IntoView { + view! { + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <AutoReload options=options.clone() /> + <HydrationScripts options /> + <MetaTags /> + </head> + <body class="bg-gray-900 text-white antialiased" data-app="user"> + <App /> + </body> + </html> + } +} + +/// Main application component. +/// +/// This wraps `UserRoutes` with a `Router` for standalone use. +/// For embedding in a combined app (e.g., chattyness-app), use `UserRoutes` directly. +#[component] +pub fn App() -> impl IntoView { + // Provide meta context for title and meta tags + provide_meta_context(); + + view! { + <Stylesheet id="leptos" href="/static/chattyness-app.css" /> + <Title text="Chattyness - Virtual Community Spaces" /> + + <Router> + <main> + <UserRoutes /> + </main> + </Router> + } +} diff --git a/crates/chattyness-user-ui/src/auth.rs b/crates/chattyness-user-ui/src/auth.rs new file mode 100644 index 0000000..253021a --- /dev/null +++ b/crates/chattyness-user-ui/src/auth.rs @@ -0,0 +1,13 @@ +//! Authentication module for user UI. +//! +//! Provides session-based authentication using tower-sessions. + +#[cfg(feature = "ssr")] +pub mod middleware; +pub mod rls; +pub mod session; + +#[cfg(feature = "ssr")] +pub use middleware::*; +pub use rls::*; +pub use session::*; diff --git a/crates/chattyness-user-ui/src/auth/middleware.rs b/crates/chattyness-user-ui/src/auth/middleware.rs new file mode 100644 index 0000000..d993d17 --- /dev/null +++ b/crates/chattyness-user-ui/src/auth/middleware.rs @@ -0,0 +1,115 @@ +//! Authentication middleware and extractors. + +use axum::{ + extract::{FromRef, FromRequestParts}, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use sqlx::PgPool; +use tower_sessions::Session; +use uuid::Uuid; + +use chattyness_db::models::User; +use chattyness_error::ErrorResponse; + +use super::session::SESSION_USER_ID_KEY; + +/// Extractor for an authenticated user. +/// +/// Returns 401 Unauthorized if the user is not authenticated. +pub struct AuthUser(pub User); + +impl<S> FromRequestParts<S> for AuthUser +where + S: Send + Sync, + PgPool: FromRef<S>, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + // Get session from request + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AuthError::SessionError)?; + + // Get user ID from session + let user_id: Option<Uuid> = session + .get(SESSION_USER_ID_KEY) + .await + .map_err(|_| AuthError::SessionError)?; + + let user_id = user_id.ok_or(AuthError::Unauthorized)?; + + // Get the database pool from state + let pool = PgPool::from_ref(state); + + // Fetch the user from the database + let user = chattyness_db::queries::users::get_user_by_id(&pool, user_id) + .await + .map_err(|_| AuthError::InternalError)? + .ok_or(AuthError::Unauthorized)?; + + Ok(AuthUser(user)) + } +} + +/// Extractor for an optional authenticated user. +/// +/// Returns None if the user is not authenticated. +pub struct OptionalAuthUser(pub Option<User>); + +impl<S> FromRequestParts<S> for OptionalAuthUser +where + S: Send + Sync, + PgPool: FromRef<S>, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + match AuthUser::from_request_parts(parts, state).await { + Ok(AuthUser(user)) => Ok(OptionalAuthUser(Some(user))), + Err(AuthError::Unauthorized) => Ok(OptionalAuthUser(None)), + Err(e) => Err(e), + } + } +} + +/// Authentication errors. +#[derive(Debug)] +pub enum AuthError { + Unauthorized, + SessionError, + InternalError, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, message) = match self { + AuthError::Unauthorized => (StatusCode::UNAUTHORIZED, "Authentication required"), + AuthError::SessionError => (StatusCode::INTERNAL_SERVER_ERROR, "Session error"), + AuthError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"), + }; + + let body = ErrorResponse { + error: message.to_string(), + code: Some(format!("{:?}", self)), + }; + + (status, Json(body)).into_response() + } +} + +impl From<AuthError> for chattyness_error::AppError { + fn from(err: AuthError) -> Self { + match err { + AuthError::Unauthorized => chattyness_error::AppError::Unauthorized, + AuthError::SessionError => { + chattyness_error::AppError::Internal("Session error".to_string()) + } + AuthError::InternalError => { + chattyness_error::AppError::Internal("Internal error".to_string()) + } + } + } +} diff --git a/crates/chattyness-user-ui/src/auth/rls.rs b/crates/chattyness-user-ui/src/auth/rls.rs new file mode 100644 index 0000000..00d6dc9 --- /dev/null +++ b/crates/chattyness-user-ui/src/auth/rls.rs @@ -0,0 +1,296 @@ +//! Row-Level Security (RLS) middleware for PostgreSQL. + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, Request, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use sqlx::{pool::PoolConnection, postgres::PgConnection, PgPool, Postgres}; +use std::{ + future::Future, + ops::{Deref, DerefMut}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::sync::{Mutex, MutexGuard}; +use tower::{Layer, Service}; +use tower_sessions::Session; +use uuid::Uuid; + +use super::session::{SESSION_GUEST_ID_KEY, SESSION_USER_ID_KEY}; +use chattyness_error::ErrorResponse; + +// ============================================================================= +// RLS Connection Wrapper +// ============================================================================= + +struct RlsConnectionInner { + conn: Option<PoolConnection<Postgres>>, + pool: PgPool, +} + +impl Drop for RlsConnectionInner { + fn drop(&mut self) { + if let Some(mut conn) = self.conn.take() { + let pool = self.pool.clone(); + tokio::spawn(async move { + let _ = sqlx::query("SELECT public.set_current_user_id(NULL)") + .execute(&mut *conn) + .await; + let _ = sqlx::query("SELECT public.set_current_guest_session_id(NULL)") + .execute(&mut *conn) + .await; + drop(conn); + drop(pool); + }); + } + } +} + +/// A database connection with RLS user ID already set. +#[derive(Clone)] +pub struct RlsConnection { + inner: Arc<Mutex<RlsConnectionInner>>, +} + +impl RlsConnection { + fn new(conn: PoolConnection<Postgres>, pool: PgPool) -> Self { + Self { + inner: Arc::new(Mutex::new(RlsConnectionInner { + conn: Some(conn), + pool, + })), + } + } + + /// Acquire exclusive access to the RLS connection. + pub async fn acquire(&self) -> RlsGuard<'_> { + RlsGuard { + guard: self.inner.lock().await, + } + } + + /// Set the current user ID on the RLS connection. + /// Use this after creating a new user to set the context for subsequent operations. + pub async fn set_user_id(&self, user_id: Uuid) -> Result<(), sqlx::Error> { + let mut guard = self.inner.lock().await; + let conn = guard.conn.as_mut().expect("RlsConnection already consumed"); + sqlx::query("SELECT public.set_current_user_id($1)") + .bind(user_id) + .execute(&mut **conn) + .await?; + Ok(()) + } +} + +/// A guard providing mutable access to an RLS-configured database connection. +pub struct RlsGuard<'a> { + guard: MutexGuard<'a, RlsConnectionInner>, +} + +impl Deref for RlsGuard<'_> { + type Target = PgConnection; + + fn deref(&self) -> &Self::Target { + self.guard + .conn + .as_ref() + .expect("RlsConnection already consumed") + .deref() + } +} + +impl DerefMut for RlsGuard<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.guard + .conn + .as_mut() + .expect("RlsConnection already consumed") + .deref_mut() + } +} + +// ============================================================================= +// RLS Connection Extractor +// ============================================================================= + +/// Extractor for an RLS-enabled database connection. +pub struct RlsConn(pub RlsConnection); + +impl<S> FromRequestParts<S> for RlsConn +where + S: Send + Sync, +{ + type Rejection = RlsError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { + parts + .extensions + .remove::<RlsConnection>() + .map(RlsConn) + .ok_or(RlsError::NoConnection) + } +} + +/// Errors related to RLS connection handling. +#[derive(Debug)] +pub enum RlsError { + NoConnection, + DatabaseError(String), +} + +impl IntoResponse for RlsError { + fn into_response(self) -> Response { + let (status, message) = match self { + RlsError::NoConnection => ( + StatusCode::INTERNAL_SERVER_ERROR, + "RLS connection not available", + ), + RlsError::DatabaseError(msg) => { + (StatusCode::INTERNAL_SERVER_ERROR, msg.leak() as &'static str) + } + }; + + let body = ErrorResponse { + error: message.to_string(), + code: Some("RLS_ERROR".to_string()), + }; + + (status, Json(body)).into_response() + } +} + +// ============================================================================= +// RLS Middleware Layer +// ============================================================================= + +/// Layer that provides RLS-enabled database connections per request. +#[derive(Clone)] +pub struct RlsLayer { + pool: PgPool, +} + +impl RlsLayer { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl<S> Layer<S> for RlsLayer { + type Service = RlsMiddleware<S>; + + fn layer(&self, inner: S) -> Self::Service { + RlsMiddleware { + inner, + pool: self.pool.clone(), + } + } +} + +/// Middleware that sets up RLS connections per request. +#[derive(Clone)] +pub struct RlsMiddleware<S> { + inner: S, + pool: PgPool, +} + +impl<S, B> Service<Request<B>> for RlsMiddleware<S> +where + S: Service<Request<B>, Response = Response> + Clone + Send + 'static, + S::Future: Send, + B: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut request: Request<B>) -> Self::Future { + let pool = self.pool.clone(); + let mut inner = self.inner.clone(); + + let session = request.extensions().get::<Session>().cloned(); + + Box::pin(async move { + let (user_id, guest_session_id) = get_session_ids(session).await; + + match acquire_rls_connection(&pool, user_id, guest_session_id).await { + Ok(rls_conn) => { + request.extensions_mut().insert(rls_conn); + inner.call(request).await + } + Err(e) => { + tracing::error!("Failed to acquire RLS connection: {}", e); + Ok(RlsError::DatabaseError(e.to_string()).into_response()) + } + } + }) + } +} + +async fn get_session_ids(session: Option<Session>) -> (Option<Uuid>, Option<Uuid>) { + let Some(session) = session else { + return (None, None); + }; + + let user_id = session + .get::<Uuid>(SESSION_USER_ID_KEY) + .await + .ok() + .flatten(); + let guest_session_id = session + .get::<Uuid>(SESSION_GUEST_ID_KEY) + .await + .ok() + .flatten(); + + (user_id, guest_session_id) +} + +async fn acquire_rls_connection( + pool: &PgPool, + user_id: Option<Uuid>, + guest_session_id: Option<Uuid>, +) -> Result<RlsConnection, sqlx::Error> { + let mut conn = pool.acquire().await?; + + if user_id.is_some() { + sqlx::query("SELECT public.set_current_user_id($1)") + .bind(user_id) + .execute(&mut *conn) + .await?; + } else if guest_session_id.is_some() { + sqlx::query("SELECT public.set_current_user_id(NULL)") + .execute(&mut *conn) + .await?; + sqlx::query("SELECT public.set_current_guest_session_id($1)") + .bind(guest_session_id) + .execute(&mut *conn) + .await?; + } else { + sqlx::query("SELECT public.set_current_user_id(NULL)") + .execute(&mut *conn) + .await?; + } + + Ok(RlsConnection::new(conn, pool.clone())) +} + +impl std::ops::Deref for RlsConn { + type Target = RlsConnection; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for RlsConn { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/crates/chattyness-user-ui/src/auth/session.rs b/crates/chattyness-user-ui/src/auth/session.rs new file mode 100644 index 0000000..90b6caf --- /dev/null +++ b/crates/chattyness-user-ui/src/auth/session.rs @@ -0,0 +1,58 @@ +//! Session management using tower-sessions. + +use sqlx::PgPool; +use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::PostgresStore; + +/// Session cookie name. +pub const SESSION_COOKIE_NAME: &str = "chattyness_session"; + +/// Session user ID key. +pub const SESSION_USER_ID_KEY: &str = "user_id"; + +/// Session login type key (staff or realm). +pub const SESSION_LOGIN_TYPE_KEY: &str = "login_type"; + +/// Session current realm ID key (for realm logins). +pub const SESSION_CURRENT_REALM_KEY: &str = "current_realm_id"; + +/// Session original destination key (for password reset redirect). +pub const SESSION_ORIGINAL_DEST_KEY: &str = "original_destination"; + +/// Session guest ID key (for guest sessions). +pub const SESSION_GUEST_ID_KEY: &str = "guest_id"; + +/// Create the session management layer. +pub async fn create_session_layer( + pool: PgPool, + secure: bool, +) -> SessionManagerLayer<PostgresStore> { + let session_store = PostgresStore::new(pool) + .with_schema_name("auth") + .expect("Invalid schema name for session store") + .with_table_name("tower_sessions") + .expect("Invalid table name for session store"); + + SessionManagerLayer::new(session_store) + .with_name(SESSION_COOKIE_NAME) + .with_secure(secure) + .with_same_site(SameSite::Lax) + .with_http_only(true) + .with_expiry(Expiry::OnInactivity(Duration::days(7))) +} + +/// Hash a session token for storage. +pub fn hash_token(token: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Generate a random session token. +pub fn generate_token() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 32] = rng.r#gen(); + hex::encode(bytes) +} diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs new file mode 100644 index 0000000..a1c74bb --- /dev/null +++ b/crates/chattyness-user-ui/src/components.rs @@ -0,0 +1,17 @@ +//! Reusable UI components. + +pub mod chat; +pub mod editor; +pub mod forms; +pub mod layout; +pub mod modals; +pub mod scene_viewer; +pub mod ws_client; + +pub use chat::*; +pub use editor::*; +pub use forms::*; +pub use layout::*; +pub use modals::*; +pub use scene_viewer::*; +pub use ws_client::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs new file mode 100644 index 0000000..0733da8 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -0,0 +1,38 @@ +//! Chat components for realm chat interface. + +use leptos::prelude::*; + +/// Chat input component (placeholder UI). +/// +/// Displays a text input field for typing messages. +/// Currently non-functional - just UI placeholder. +#[component] +pub fn ChatInput() -> impl IntoView { + let (message, set_message) = signal(String::new()); + + view! { + <div class="chat-input-container w-full max-w-4xl mx-auto"> + <div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600"> + <input + type="text" + placeholder="Type a message..." + class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none" + prop:value=move || message.get() + on:input=move |ev| { + set_message.set(event_target_value(&ev)); + } + /> + <button + type="button" + class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + disabled=move || message.get().trim().is_empty() + > + "Send" + </button> + </div> + <p class="text-gray-500 text-xs mt-2 text-center"> + "Chat functionality coming soon" + </p> + </div> + } +} diff --git a/crates/chattyness-user-ui/src/components/editor.rs b/crates/chattyness-user-ui/src/components/editor.rs new file mode 100644 index 0000000..0bba83e --- /dev/null +++ b/crates/chattyness-user-ui/src/components/editor.rs @@ -0,0 +1,357 @@ +//! Scene editor components. + +use leptos::prelude::*; +use uuid::Uuid; + +use chattyness_db::models::SpotSummary; + +/// Drawing mode for spot editor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DrawingMode { + #[default] + Select, + Polygon, + Rectangle, +} + +/// Toolbar for selecting drawing mode. +#[component] +pub fn DrawingModeToolbar( + #[prop(into)] mode: Signal<DrawingMode>, + on_change: Callback<DrawingMode>, +) -> impl IntoView { + view! { + <div class="flex gap-2" role="radiogroup" aria-label="Drawing mode"> + <button + type="button" + class=move || { + let base = "px-3 py-1 rounded text-sm transition-colors"; + if mode.get() == DrawingMode::Select { + format!("{} bg-blue-600 text-white", base) + } else { + format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base) + } + } + on:click=move |_| on_change.run(DrawingMode::Select) + aria-pressed=move || mode.get() == DrawingMode::Select + > + "Select" + </button> + <button + type="button" + class=move || { + let base = "px-3 py-1 rounded text-sm transition-colors"; + if mode.get() == DrawingMode::Rectangle { + format!("{} bg-blue-600 text-white", base) + } else { + format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base) + } + } + on:click=move |_| on_change.run(DrawingMode::Rectangle) + aria-pressed=move || mode.get() == DrawingMode::Rectangle + > + "Rectangle" + </button> + <button + type="button" + class=move || { + let base = "px-3 py-1 rounded text-sm transition-colors"; + if mode.get() == DrawingMode::Polygon { + format!("{} bg-blue-600 text-white", base) + } else { + format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base) + } + } + on:click=move |_| on_change.run(DrawingMode::Polygon) + aria-pressed=move || mode.get() == DrawingMode::Polygon + > + "Polygon" + </button> + </div> + } +} + +/// Canvas for displaying scene with spots. +#[component] +pub fn SceneCanvas( + #[prop(into)] width: Signal<u32>, + #[prop(into)] height: Signal<u32>, + #[prop(into)] background_color: Signal<Option<String>>, + #[prop(into)] background_image: Signal<Option<String>>, + #[prop(into)] spots: Signal<Vec<SpotSummary>>, + #[prop(into)] selected_spot_id: Signal<Option<Uuid>>, + on_spot_click: Callback<Uuid>, +) -> impl IntoView { + let canvas_style = Signal::derive(move || { + let w = width.get(); + let h = height.get(); + let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string()); + + if let Some(img) = background_image.get() { + format!( + "width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center;", + w, h, img + ) + } else { + format!("width: {}px; height: {}px; background-color: {};", w, h, bg_color) + } + }); + + view! { + <div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900"> + <div class="relative" style=move || canvas_style.get()> + {move || { + spots + .get() + .into_iter() + .map(|spot| { + let spot_id = spot.id; + let is_selected = selected_spot_id.get() == Some(spot_id); + let style = parse_wkt_to_style(&spot.region_wkt); + view! { + <div + class=move || { + let base = "absolute border-2 cursor-pointer transition-colors"; + if is_selected { + format!("{} border-blue-500 bg-blue-500/30", base) + } else { + format!("{} border-green-500/50 bg-green-500/20 hover:bg-green-500/30", base) + } + } + style=style + on:click=move |_| on_spot_click.run(spot_id) + role="button" + tabindex="0" + /> + } + }) + .collect_view() + }} + </div> + </div> + } +} + +/// Canvas for drawing new spots. +#[component] +#[allow(unused_variables)] +pub fn SpotDrawer( + #[prop(into)] width: Signal<u32>, + #[prop(into)] height: Signal<u32>, + #[prop(into)] mode: Signal<DrawingMode>, + on_complete: Callback<String>, + #[prop(into)] background_color: Signal<Option<String>>, + #[prop(into)] background_image: Signal<Option<String>>, + #[prop(into)] existing_spots_wkt: Signal<Vec<String>>, +) -> impl IntoView { + let (drawing_points, _set_drawing_points) = signal(Vec::<(f64, f64)>::new()); + let (is_drawing, _set_is_drawing) = signal(false); + let (start_point, _set_start_point) = signal(Option::<(f64, f64)>::None); + #[cfg(feature = "hydrate")] + let (set_drawing_points, set_is_drawing, set_start_point) = + (_set_drawing_points, _set_is_drawing, _set_start_point); + + let canvas_style = Signal::derive(move || { + let w = width.get(); + let h = height.get(); + let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string()); + + if let Some(img) = background_image.get() { + format!( + "width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center; cursor: crosshair;", + w, h, img + ) + } else { + format!("width: {}px; height: {}px; background-color: {}; cursor: crosshair;", w, h, bg_color) + } + }); + + let on_mouse_down = move |ev: leptos::ev::MouseEvent| { + #[cfg(feature = "hydrate")] + { + let rect = ev + .target() + .and_then(|t| { + use wasm_bindgen::JsCast; + t.dyn_ref::<web_sys::HtmlElement>() + .map(|el| el.get_bounding_client_rect()) + }); + + if let Some(rect) = rect { + let x = ev.client_x() as f64 - rect.left(); + let y = ev.client_y() as f64 - rect.top(); + + match mode.get() { + DrawingMode::Rectangle => { + set_start_point.set(Some((x, y))); + set_is_drawing.set(true); + } + DrawingMode::Polygon => { + let mut points = drawing_points.get(); + points.push((x, y)); + set_drawing_points.set(points); + } + DrawingMode::Select => {} + } + } + } + }; + + let on_mouse_up = move |ev: leptos::ev::MouseEvent| { + #[cfg(feature = "hydrate")] + { + if mode.get() == DrawingMode::Rectangle && is_drawing.get() { + if let Some((start_x, start_y)) = start_point.get() { + let rect = ev + .target() + .and_then(|t| { + use wasm_bindgen::JsCast; + t.dyn_ref::<web_sys::HtmlElement>() + .map(|el| el.get_bounding_client_rect()) + }); + + if let Some(rect) = rect { + let end_x = ev.client_x() as f64 - rect.left(); + let end_y = ev.client_y() as f64 - rect.top(); + + let min_x = start_x.min(end_x); + let min_y = start_y.min(end_y); + let max_x = start_x.max(end_x); + let max_y = start_y.max(end_y); + + if (max_x - min_x) > 10.0 && (max_y - min_y) > 10.0 { + let wkt = format!( + "POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))", + min_x, min_y, max_x, min_y, max_x, max_y, min_x, max_y, min_x, min_y + ); + on_complete.run(wkt); + } + } + } + set_is_drawing.set(false); + set_start_point.set(None); + } + } + }; + + let on_double_click = move |_| { + #[cfg(feature = "hydrate")] + { + if mode.get() == DrawingMode::Polygon { + let points = drawing_points.get(); + if points.len() >= 3 { + let wkt = points_to_wkt(&points); + on_complete.run(wkt); + } + set_drawing_points.set(Vec::new()); + } + } + }; + + view! { + <div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900"> + <div + class="relative" + style=move || canvas_style.get() + on:mousedown=on_mouse_down + on:mouseup=on_mouse_up + on:dblclick=on_double_click + > + // Render existing spots + {move || { + existing_spots_wkt + .get() + .into_iter() + .map(|wkt| { + let style = parse_wkt_to_style(&wkt); + view! { + <div + class="absolute border-2 border-gray-500/50 bg-gray-500/20" + style=style + /> + } + }) + .collect_view() + }} + + // Render drawing preview + {move || { + let points = drawing_points.get(); + if !points.is_empty() && mode.get() == DrawingMode::Polygon { + let svg_points: String = points + .iter() + .map(|(x, y)| format!("{},{}", x, y)) + .collect::<Vec<_>>() + .join(" "); + Some(view! { + <svg class="absolute inset-0 pointer-events-none"> + <polyline + points=svg_points + fill="none" + stroke="#3b82f6" + stroke-width="2" + /> + </svg> + }) + } else { + None + } + }} + </div> + </div> + } +} + +/// Parse WKT polygon to CSS positioning style. +fn parse_wkt_to_style(wkt: &str) -> String { + let trimmed = wkt.trim(); + if let Some(coords_str) = trimmed + .strip_prefix("POLYGON((") + .and_then(|s| s.strip_suffix("))")) + { + let points: Vec<(f64, f64)> = coords_str + .split(',') + .filter_map(|p| { + let coords: Vec<&str> = p.trim().split_whitespace().collect(); + if coords.len() >= 2 { + Some((coords[0].parse().ok()?, coords[1].parse().ok()?)) + } else { + None + } + }) + .collect(); + + if points.len() >= 4 { + let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min); + let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min); + let max_x = points.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max); + let max_y = points.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max); + + return format!( + "left: {}px; top: {}px; width: {}px; height: {}px;", + min_x, + min_y, + max_x - min_x, + max_y - min_y + ); + } + } + String::new() +} + +/// Convert points to WKT polygon. +#[allow(dead_code)] +fn points_to_wkt(points: &[(f64, f64)]) -> String { + if points.is_empty() { + return String::new(); + } + + let coords: String = points + .iter() + .chain(std::iter::once(&points[0])) + .map(|(x, y)| format!("{} {}", x, y)) + .collect::<Vec<_>>() + .join(", "); + + format!("POLYGON(({})", coords) +} diff --git a/crates/chattyness-user-ui/src/components/forms.rs b/crates/chattyness-user-ui/src/components/forms.rs new file mode 100644 index 0000000..be42c9a --- /dev/null +++ b/crates/chattyness-user-ui/src/components/forms.rs @@ -0,0 +1,345 @@ +//! Form components with WCAG 2.2 AA accessibility. + +use leptos::prelude::*; + +/// Text input field with label. +#[component] +pub fn TextInput( + name: &'static str, + label: &'static str, + #[prop(default = "text")] input_type: &'static str, + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] help_text: &'static str, + #[prop(default = false)] required: bool, + #[prop(optional)] minlength: Option<i32>, + #[prop(optional)] maxlength: Option<i32>, + #[prop(optional)] pattern: &'static str, + #[prop(optional)] class: &'static str, + #[prop(into)] value: Signal<String>, + on_input: Callback<String>, +) -> impl IntoView { + let input_id = name; + let help_id = format!("{}-help", name); + let has_help = !help_text.is_empty(); + + view! { + <div class=format!("space-y-2 {}", class)> + <label for=input_id class="block text-sm font-medium text-gray-300"> + {label} + {if required { + view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any() + } else { + view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any() + }} + </label> + <input + type=input_type + id=input_id + name=name + placeholder=placeholder + required=required + minlength=minlength + maxlength=maxlength + pattern=if pattern.is_empty() { None } else { Some(pattern) } + aria-describedby=if has_help { Some(help_id.clone()) } else { None } + class="input-base" + prop:value=move || value.get() + on:input=move |ev| on_input.run(event_target_value(&ev)) + /> + {if has_help { + view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any() + } else { + view! {}.into_any() + }} + </div> + } +} + +/// Textarea field with label. +#[component] +pub fn TextArea( + name: &'static str, + label: &'static str, + #[prop(optional)] placeholder: &'static str, + #[prop(optional)] help_text: &'static str, + #[prop(default = false)] required: bool, + #[prop(default = 3)] rows: i32, + #[prop(optional)] class: &'static str, + #[prop(into)] value: Signal<String>, + on_input: Callback<String>, +) -> impl IntoView { + let input_id = name; + let help_id = format!("{}-help", name); + let has_help = !help_text.is_empty(); + + view! { + <div class=format!("space-y-2 {}", class)> + <label for=input_id class="block text-sm font-medium text-gray-300"> + {label} + {if required { + view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any() + } else { + view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any() + }} + </label> + <textarea + id=input_id + name=name + placeholder=placeholder + required=required + rows=rows + aria-describedby=if has_help { Some(help_id.clone()) } else { None } + class="input-base resize-y" + prop:value=move || value.get() + on:input=move |ev| on_input.run(event_target_value(&ev)) + /> + {if has_help { + view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any() + } else { + view! {}.into_any() + }} + </div> + } +} + +/// Radio button group. +#[component] +pub fn RadioGroup( + name: &'static str, + legend: &'static str, + options: Vec<(&'static str, &'static str, &'static str)>, + #[prop(into)] value: Signal<String>, + on_change: Callback<String>, +) -> impl IntoView { + view! { + <fieldset class="space-y-3"> + <legend class="block text-sm font-medium text-gray-300 mb-2">{legend}</legend> + <div class="space-y-2"> + {options + .into_iter() + .map(|(val, label, description)| { + let val_clone = val.to_string(); + let is_selected = Signal::derive(move || value.get() == val); + view! { + <label class="flex items-start space-x-3 cursor-pointer group"> + <input + type="radio" + name=name + value=val + checked=move || is_selected.get() + on:change=move |_| on_change.run(val_clone.clone()) + class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 focus:ring-blue-500 focus:ring-2" + /> + <div> + <span class="text-white group-hover:text-blue-400 transition-colors"> + {label} + </span> + <p class="text-sm text-gray-400">{description}</p> + </div> + </label> + } + }) + .collect_view()} + </div> + </fieldset> + } +} + +/// Checkbox input. +#[component] +pub fn Checkbox( + name: &'static str, + label: &'static str, + #[prop(optional)] description: &'static str, + #[prop(into)] checked: Signal<bool>, + on_change: Callback<bool>, +) -> impl IntoView { + let has_description = !description.is_empty(); + + view! { + <label class="flex items-start space-x-3 cursor-pointer group"> + <input + type="checkbox" + name=name + prop:checked=move || checked.get() + on:change=move |ev| on_change.run(event_target_checked(&ev)) + class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2" + /> + <div> + <span class="text-white group-hover:text-blue-400 transition-colors">{label}</span> + {if has_description { + view! { <p class="text-sm text-gray-400">{description}</p> }.into_any() + } else { + view! {}.into_any() + }} + </div> + </label> + } +} + +/// Range slider input. +#[component] +pub fn RangeSlider( + name: &'static str, + label: &'static str, + min: i32, + max: i32, + #[prop(default = 1)] step: i32, + #[prop(into)] value: Signal<i32>, + on_change: Callback<i32>, +) -> impl IntoView { + let input_id = name; + + view! { + <div class="space-y-2"> + <div class="flex justify-between items-center"> + <label for=input_id class="block text-sm font-medium text-gray-300"> + {label} + </label> + <span class="text-sm text-gray-400">{move || value.get()}</span> + </div> + <input + type="range" + id=input_id + name=name + min=min + max=max + step=step + prop:value=move || value.get() + on:input=move |ev| { + if let Ok(val) = event_target_value(&ev).parse::<i32>() { + on_change.run(val); + } + } + class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500" + /> + <div class="flex justify-between text-xs text-gray-500"> + <span>{min}</span> + <span>{max}</span> + </div> + </div> + } +} + +/// Primary submit button. +#[component] +pub fn SubmitButton( + #[prop(default = "Submit")] text: &'static str, + #[prop(default = "Submitting...")] loading_text: &'static str, + #[prop(into)] pending: Signal<bool>, +) -> impl IntoView { + view! { + <button type="submit" disabled=move || pending.get() class="btn-primary w-full"> + {move || if pending.get() { loading_text } else { text }} + </button> + } +} + +/// Error alert box. +#[component] +pub fn ErrorAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView { + view! { + <Show when=move || message.get().is_some()> + <div class="error-message" role="alert"> + <p>{move || message.get().unwrap_or_default()}</p> + </div> + </Show> + } +} + +/// Success alert box. +#[component] +pub fn SuccessAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView { + view! { + <Show when=move || message.get().is_some()> + <div + class="p-4 bg-green-900/50 border border-green-500 rounded-lg text-green-200" + role="alert" + > + <p>{move || message.get().unwrap_or_default()}</p> + </div> + </Show> + } +} + +/// Color picker component. +#[component] +pub fn ColorPicker( + #[prop(into)] value: Signal<String>, + on_change: Callback<String>, + label: &'static str, + id: &'static str, +) -> impl IntoView { + view! { + <div class="flex items-center gap-3"> + <label for=id class="text-sm font-medium text-gray-300"> + {label} + </label> + <input + type="color" + id=id + prop:value=move || value.get() + on:input=move |ev| on_change.run(event_target_value(&ev)) + class="w-10 h-10 rounded border border-gray-600 cursor-pointer" + /> + <input + type="text" + prop:value=move || value.get() + on:input=move |ev| on_change.run(event_target_value(&ev)) + class="input-base w-24 text-sm" + placeholder="#1a1a2e" + /> + </div> + } +} + +/// Color palette component. +#[component] +pub fn ColorPalette(#[prop(into)] value: Signal<String>, on_change: Callback<String>) -> impl IntoView { + let colors = [ + "#1a1a2e", "#16213e", "#0f3460", "#e94560", "#533483", "#2c3e50", "#1e8449", "#d35400", + ]; + + view! { + <div class="flex gap-2 flex-wrap"> + {colors + .into_iter() + .map(|color| { + let is_selected = Signal::derive(move || value.get() == color); + let color_string = color.to_string(); + view! { + <button + type="button" + class=move || { + let base = "w-8 h-8 rounded border-2 transition-transform hover:scale-110"; + if is_selected.get() { + format!("{} border-white", base) + } else { + format!("{} border-transparent", base) + } + } + style=format!("background-color: {}", color) + title=color + on:click=move |_| on_change.run(color_string.clone()) + /> + } + }) + .collect_view()} + </div> + } +} + +/// Get the checked state of a checkbox input. +#[cfg(feature = "hydrate")] +fn event_target_checked(ev: &leptos::ev::Event) -> bool { + use wasm_bindgen::JsCast; + ev.target() + .and_then(|t| t.dyn_ref::<web_sys::HtmlInputElement>().map(|el| el.checked())) + .unwrap_or(false) +} + +/// Stub for SSR. +#[cfg(not(feature = "hydrate"))] +fn event_target_checked(_ev: &leptos::ev::Event) -> bool { + false +} diff --git a/crates/chattyness-user-ui/src/components/layout.rs b/crates/chattyness-user-ui/src/components/layout.rs new file mode 100644 index 0000000..7776098 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/layout.rs @@ -0,0 +1,214 @@ +//! Layout components. + +use leptos::prelude::*; + +/// Main page layout wrapper. +#[component] +pub fn PageLayout(children: Children) -> impl IntoView { + view! { + <div class="min-h-screen bg-gray-900 text-white flex flex-col overflow-x-hidden"> + <Header /> + <main class="flex-1">{children()}</main> + <Footer /> + </div> + } +} + +/// Simple site header for non-realm pages. +#[component] +pub fn Header() -> impl IntoView { + view! { + <header class="bg-gray-800 border-b border-gray-700"> + <nav class="px-4" aria-label="Main navigation"> + <div class="flex items-center h-16"> + <a + href="/" + class="flex items-center space-x-2 text-xl font-bold text-white hover:text-blue-400 transition-colors" + > + <img src="/icons/castle.svg" alt="" class="w-6 h-6" aria-hidden="true" /> + <span>"Chattyness"</span> + </a> + </div> + </nav> + </header> + } +} + +/// Realm-specific header with realm/scene info and user actions. +#[component] +pub fn RealmHeader( + realm_name: String, + realm_slug: String, + realm_description: Option<String>, + scene_name: String, + scene_description: Option<String>, + online_count: i32, + total_members: i32, + max_capacity: i32, + can_admin: bool, + on_logout: Callback<()>, +) -> impl IntoView { + let stats_tooltip = format!("Members: {} / Max: {}", total_members, max_capacity); + let online_text = format!("{} ONLINE", online_count); + let admin_url = format!("/admin/realms/{}", realm_slug); + + view! { + <header class="bg-gray-800 border-b border-gray-700"> + <div class="flex items-center justify-between h-16 px-4"> + // Left side: Logo + Realm/Scene info + <div class="flex items-center gap-4"> + <a + href="/" + class="flex items-center space-x-2 text-xl font-bold text-white hover:text-blue-400 transition-colors" + > + <img src="/icons/castle.svg" alt="" class="w-6 h-6" aria-hidden="true" /> + <span>"Chattyness"</span> + </a> + <span class="text-gray-500">"|"</span> + <span + class="text-white font-medium cursor-default" + title=realm_description.unwrap_or_default() + > + {realm_name} + </span> + <span class="text-gray-500">"/"</span> + <span + class="text-gray-300 cursor-default" + title=scene_description.unwrap_or_default() + > + {scene_name} + </span> + </div> + + // Right side: Stats + Actions + <div class="flex items-center gap-4"> + <span class="text-green-400 font-medium cursor-default" title=stats_tooltip> + {online_text} + </span> + <button + type="button" + class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" + on:click=move |_| on_logout.run(()) + > + "Logout" + </button> + {can_admin.then(|| { + view! { + <a + href=admin_url + class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" + > + "Admin" + </a> + } + })} + </div> + </div> + </header> + } +} + +/// Site footer. +#[component] +pub fn Footer() -> impl IntoView { + view! { + <footer class="bg-gray-800 border-t border-gray-700 py-8"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div class="flex flex-col md:flex-row items-center justify-between"> + <p class="text-gray-400 text-sm"> + "Built with " + <a + href="https://leptos.dev" + class="text-blue-400 hover:underline" + target="_blank" + rel="noopener" + > + "Leptos" + </a> + " and " + <a + href="https://www.rust-lang.org" + class="text-blue-400 hover:underline" + target="_blank" + rel="noopener" + > + "Rust" + </a> + "." + </p> + <nav class="flex items-center space-x-4 mt-4 md:mt-0" aria-label="Footer navigation"> + <a + href="/about" + class="text-gray-400 hover:text-white text-sm transition-colors" + > + "About" + </a> + <a + href="/privacy" + class="text-gray-400 hover:text-white text-sm transition-colors" + > + "Privacy" + </a> + <a + href="/terms" + class="text-gray-400 hover:text-white text-sm transition-colors" + > + "Terms" + </a> + </nav> + </div> + </div> + </footer> + } +} + +/// Simple centered layout for forms and dialogs. +#[component] +pub fn CenteredLayout(children: Children) -> impl IntoView { + view! { + <div class="min-h-screen bg-gray-900 flex items-center justify-center p-4"> + {children()} + </div> + } +} + +/// Card container. +#[component] +pub fn Card(#[prop(optional)] class: &'static str, children: Children) -> impl IntoView { + let base_class = "bg-gray-800 rounded-lg shadow-xl"; + let combined_class = if class.is_empty() { + base_class.to_string() + } else { + format!("{} {}", base_class, class) + }; + + view! { <div class=combined_class>{children()}</div> } +} + +/// Scene thumbnail component. +#[component] +pub fn SceneThumbnail(scene: chattyness_db::models::SceneSummary) -> impl IntoView { + let background_style = match (&scene.background_image_path, &scene.background_color) { + (Some(path), _) => format!("background-image: url('{}'); background-size: cover; background-position: center;", path), + (None, Some(color)) => format!("background-color: {};", color), + (None, None) => "background-color: #1a1a2e;".to_string(), + }; + + view! { + <Card class="overflow-hidden"> + <div class="h-32 w-full" style=background_style></div> + <div class="p-4"> + <h3 class="text-lg font-semibold text-white">{scene.name}</h3> + <p class="text-gray-400 text-sm">"/" {scene.slug}</p> + <div class="flex items-center gap-2 mt-2"> + {scene.is_entry_point.then(|| view! { + <span class="text-xs px-2 py-0.5 bg-green-600 rounded">"Entry"</span> + })} + {scene.is_hidden.then(|| view! { + <span class="text-xs px-2 py-0.5 bg-gray-600 rounded">"Hidden"</span> + })} + </div> + </div> + </Card> + } +} diff --git a/crates/chattyness-user-ui/src/components/modals.rs b/crates/chattyness-user-ui/src/components/modals.rs new file mode 100644 index 0000000..f750966 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/modals.rs @@ -0,0 +1,200 @@ +//! Modal components with WCAG 2.2 AA accessibility. + +use leptos::prelude::*; + +/// Confirmation modal for joining a realm. +#[component] +pub fn JoinRealmModal( + #[prop(into)] open: Signal<bool>, + realm_name: String, + realm_slug: String, + #[prop(into)] pending: Signal<bool>, + on_confirm: Callback<()>, + on_cancel: Callback<()>, +) -> impl IntoView { + let on_cancel_backdrop = on_cancel.clone(); + let on_cancel_close = on_cancel.clone(); + let on_cancel_button = on_cancel.clone(); + + let (name_sig, _) = signal(realm_name); + let (slug_sig, _) = signal(realm_slug); + + view! { + <Show when=move || open.get()> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + role="dialog" + aria-modal="true" + aria-labelledby="join-modal-title" + > + <div + class="absolute inset-0 bg-black/70 backdrop-blur-sm" + on:click=move |_| on_cancel_backdrop.run(()) + aria-hidden="true" + /> + + <div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700"> + <button + type="button" + class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors" + on:click=move |_| on_cancel_close.run(()) + aria-label="Close dialog" + > + <svg + class="w-6 h-6" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M6 18L18 6M6 6l12 12" + /> + </svg> + </button> + + <div class="text-center"> + <div class="mx-auto w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mb-4"> + <img + src="/icons/castle.svg" + alt="" + class="w-8 h-8" + aria-hidden="true" + /> + </div> + + <h3 id="join-modal-title" class="text-xl font-bold text-white mb-2"> + "Join " {move || name_sig.get()} "?" + </h3> + + <p class="text-gray-400 mb-6"> + "You're not a member of " + <span class="text-blue-400 font-medium">{move || slug_sig.get()}</span> + " yet. Would you like to join this realm?" + </p> + + <div class="flex flex-col sm:flex-row gap-3 justify-center"> + <button + type="button" + class="btn-secondary px-6 py-2" + on:click=move |_| on_cancel_button.run(()) + disabled=move || pending.get() + > + "Cancel" + </button> + <button + type="button" + class="btn-primary px-6 py-2" + on:click=move |_| on_confirm.run(()) + disabled=move || pending.get() + > + {move || if pending.get() { "Joining..." } else { "Join Realm" }} + </button> + </div> + + <p class="text-sm text-gray-500 mt-4"> + "You can leave a realm at any time from your profile settings." + </p> + </div> + </div> + </div> + </Show> + } +} + +/// Confirmation modal for general actions. +#[component] +pub fn ConfirmModal( + #[prop(into)] open: Signal<bool>, + title: &'static str, + message: String, + #[prop(default = "Confirm")] confirm_text: &'static str, + #[prop(default = "Cancel")] cancel_text: &'static str, + #[prop(default = false)] destructive: bool, + #[prop(into)] pending: Signal<bool>, + on_confirm: Callback<()>, + on_cancel: Callback<()>, +) -> impl IntoView { + let on_cancel_backdrop = on_cancel.clone(); + let on_cancel_close = on_cancel.clone(); + let on_cancel_button = on_cancel.clone(); + + let (message_sig, _) = signal(message); + + let confirm_class = if destructive { + "bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg font-medium transition-colors disabled:opacity-50" + } else { + "btn-primary px-6 py-2" + }; + + view! { + <Show when=move || open.get()> + <div + class="fixed inset-0 z-50 flex items-center justify-center" + role="dialog" + aria-modal="true" + aria-labelledby="confirm-modal-title" + > + <div + class="absolute inset-0 bg-black/70 backdrop-blur-sm" + on:click=move |_| on_cancel_backdrop.run(()) + aria-hidden="true" + /> + + <div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700"> + <button + type="button" + class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors" + on:click=move |_| on_cancel_close.run(()) + aria-label="Close dialog" + > + <svg + class="w-6 h-6" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M6 18L18 6M6 6l12 12" + /> + </svg> + </button> + + <div class="text-center"> + <h3 id="confirm-modal-title" class="text-xl font-bold text-white mb-4"> + {title} + </h3> + + <p class="text-gray-400 mb-6">{move || message_sig.get()}</p> + + <div class="flex flex-col sm:flex-row gap-3 justify-center"> + <button + type="button" + class="btn-secondary px-6 py-2" + on:click=move |_| on_cancel_button.run(()) + disabled=move || pending.get() + > + {cancel_text} + </button> + <button + type="button" + class=confirm_class + on:click=move |_| on_confirm.run(()) + disabled=move || pending.get() + > + {move || if pending.get() { "Please wait..." } else { confirm_text }} + </button> + </div> + </div> + </div> + </div> + </Show> + } +} diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs new file mode 100644 index 0000000..64ca9b5 --- /dev/null +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -0,0 +1,421 @@ +//! Scene viewer component for displaying realm scenes with avatars. +//! +//! Uses layered canvases for efficient rendering: +//! - Background canvas: Static, drawn once when scene loads +//! - Avatar canvas: Dynamic, redrawn when members change + +use leptos::prelude::*; + +use chattyness_db::models::{ChannelMemberWithAvatar, Scene}; + +/// Parse bounds WKT to extract width and height. +/// +/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))" +fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> { + let trimmed = bounds_wkt.trim(); + let coords_str = trimmed + .strip_prefix("POLYGON((") + .and_then(|s| s.strip_suffix("))"))?; + + let points: Vec<&str> = coords_str.split(',').collect(); + if points.len() < 4 { + return None; + } + + let mut max_x: f64 = 0.0; + let mut max_y: f64 = 0.0; + + for point in points.iter() { + let coords: Vec<&str> = point.trim().split_whitespace().collect(); + if coords.len() >= 2 { + if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) { + if x > max_x { + max_x = x; + } + if y > max_y { + max_y = y; + } + } + } + } + + if max_x > 0.0 && max_y > 0.0 { + Some((max_x as u32, max_y as u32)) + } else { + None + } +} + +/// Scene viewer component for displaying a realm scene with avatars. +/// +/// Uses two layered canvases: +/// - Background canvas (z-index 0): Static background, drawn once +/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates +#[component] +pub fn RealmSceneViewer( + scene: Scene, + #[allow(unused)] + realm_slug: String, + #[prop(into)] + members: Signal<Vec<ChannelMemberWithAvatar>>, + #[prop(into)] + on_move: Callback<(f64, f64)>, +) -> impl IntoView { + let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); + let (scene_width, scene_height) = dimensions.unwrap_or((800, 600)); + + let bg_color = scene + .background_color + .clone() + .unwrap_or_else(|| "#1a1a2e".to_string()); + + #[allow(unused_variables)] + let has_background_image = scene.background_image_path.is_some(); + #[allow(unused_variables)] + let image_path = scene.background_image_path.clone().unwrap_or_default(); + + // Two separate canvas refs for layered rendering + let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new(); + let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new(); + + // Store scale factors for coordinate conversion (shared between both canvases) + let scale_x = StoredValue::new(1.0_f64); + let scale_y = StoredValue::new(1.0_f64); + let offset_x = StoredValue::new(0.0_f64); + let offset_y = StoredValue::new(0.0_f64); + + // Handle canvas click for movement (on avatar canvas - topmost layer) + #[cfg(feature = "hydrate")] + let on_canvas_click = { + let on_move = on_move.clone(); + move |ev: web_sys::MouseEvent| { + let Some(canvas) = avatar_canvas_ref.get() else { + return; + }; + + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; + let rect = canvas_el.get_bounding_client_rect(); + + let canvas_x = ev.client_x() as f64 - rect.left(); + let canvas_y = ev.client_y() as f64 - rect.top(); + + let sx = scale_x.get_value(); + let sy = scale_y.get_value(); + let ox = offset_x.get_value(); + let oy = offset_y.get_value(); + + if sx > 0.0 && sy > 0.0 { + let scene_x = (canvas_x - ox) / sx; + let scene_y = (canvas_y - oy) / sy; + + let scene_x = scene_x.max(0.0).min(scene_width as f64); + let scene_y = scene_y.max(0.0).min(scene_height as f64); + + on_move.run((scene_x, scene_y)); + } + } + }; + + #[cfg(feature = "hydrate")] + { + use std::cell::RefCell; + use std::rc::Rc; + use wasm_bindgen::{closure::Closure, JsCast}; + + let image_path_clone = image_path.clone(); + let bg_color_clone = bg_color.clone(); + let scene_width_f = scene_width as f64; + let scene_height_f = scene_height as f64; + + // Flag to track if background has been drawn + let bg_drawn = Rc::new(RefCell::new(false)); + + // ========================================================= + // Background Effect - runs once on mount, draws static background + // ========================================================= + Effect::new(move |_| { + // Don't track any reactive signals - this should only run once + let Some(canvas) = bg_canvas_ref.get() else { + return; + }; + + // Skip if already drawn + if *bg_drawn.borrow() { + return; + } + + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; + let canvas_el = canvas_el.clone(); + let bg_color = bg_color_clone.clone(); + let image_path = image_path_clone.clone(); + let bg_drawn_inner = bg_drawn.clone(); + + let draw_bg = Closure::once(Box::new(move || { + let display_width = canvas_el.client_width() as u32; + let display_height = canvas_el.client_height() as u32; + + if display_width == 0 || display_height == 0 { + return; + } + + canvas_el.set_width(display_width); + canvas_el.set_height(display_height); + + // Calculate scale to fit scene in canvas + let canvas_aspect = display_width as f64 / display_height as f64; + let scene_aspect = scene_width_f / scene_height_f; + + let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect { + let h = display_height as f64; + let w = h * scene_aspect; + let x = (display_width as f64 - w) / 2.0; + (w, h, x, 0.0) + } else { + let w = display_width as f64; + let h = w / scene_aspect; + let y = (display_height as f64 - h) / 2.0; + (w, h, 0.0, y) + }; + + // Store scale factors + let sx = draw_width / scene_width_f; + let sy = draw_height / scene_height_f; + scale_x.set_value(sx); + scale_y.set_value(sy); + offset_x.set_value(draw_x); + offset_y.set_value(draw_y); + + if let Ok(Some(ctx)) = canvas_el.get_context("2d") { + let ctx: web_sys::CanvasRenderingContext2d = + ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap(); + + // Fill letterbox area with black + ctx.set_fill_style_str("#000"); + ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64); + + // Fill scene area with background color + ctx.set_fill_style_str(&bg_color); + ctx.fill_rect(draw_x, draw_y, draw_width, draw_height); + + // Draw background image if available + if has_background_image && !image_path.is_empty() { + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + let ctx_clone = ctx.clone(); + + let onload = Closure::once(Box::new(move || { + let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( + &img_clone, draw_x, draw_y, draw_width, draw_height, + ); + }) as Box<dyn FnOnce()>); + + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src(&image_path); + } + + // Mark background as drawn + *bg_drawn_inner.borrow_mut() = true; + } + }) as Box<dyn FnOnce()>); + + let window = web_sys::window().unwrap(); + let _ = window.request_animation_frame(draw_bg.as_ref().unchecked_ref()); + draw_bg.forget(); + }); + + // ========================================================= + // Avatar Effect - runs when members change, redraws avatars only + // ========================================================= + Effect::new(move |_| { + // Track members signal - this Effect reruns when members change + let current_members = members.get(); + + let Some(canvas) = avatar_canvas_ref.get() else { + return; + }; + + let canvas_el: &web_sys::HtmlCanvasElement = &canvas; + let canvas_el = canvas_el.clone(); + + let draw_avatars_closure = Closure::once(Box::new(move || { + let display_width = canvas_el.client_width() as u32; + let display_height = canvas_el.client_height() as u32; + + if display_width == 0 || display_height == 0 { + return; + } + + // Resize avatar canvas to match (if needed) + if canvas_el.width() != display_width || canvas_el.height() != display_height { + canvas_el.set_width(display_width); + canvas_el.set_height(display_height); + } + + if let Ok(Some(ctx)) = canvas_el.get_context("2d") { + let ctx: web_sys::CanvasRenderingContext2d = + ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap(); + + // Clear with transparency (not fill - keeps canvas transparent) + ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64); + + // Get stored scale factors + let sx = scale_x.get_value(); + let sy = scale_y.get_value(); + let ox = offset_x.get_value(); + let oy = offset_y.get_value(); + + // Draw avatars + draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy); + } + }) as Box<dyn FnOnce()>); + + let window = web_sys::window().unwrap(); + let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref()); + draw_avatars_closure.forget(); + }); + } + + let aspect_ratio = scene_width as f64 / scene_height as f64; + + view! { + <div class="scene-container w-full h-full flex justify-center items-center"> + <div + class="scene-canvas relative overflow-hidden cursor-pointer" + style:background-color=bg_color.clone() + style:aspect-ratio=format!("{} / {}", scene_width, scene_height) + style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio) + style:max-height="calc(100vh - 64px)" + > + // Background layer - static, drawn once + <canvas + node_ref=bg_canvas_ref + class="absolute inset-0 w-full h-full" + style="z-index: 0" + aria-hidden="true" + /> + // Avatar layer - dynamic, transparent background + <canvas + node_ref=avatar_canvas_ref + class="absolute inset-0 w-full h-full" + style="z-index: 1" + aria-label=format!("Scene: {}", scene.name) + role="img" + on:click=move |ev| { + #[cfg(feature = "hydrate")] + on_canvas_click(ev); + #[cfg(not(feature = "hydrate"))] + let _ = ev; + } + > + {format!("Scene: {}", scene.name)} + </canvas> + </div> + </div> + } +} + +#[cfg(feature = "hydrate")] +use wasm_bindgen::JsCast; + +/// Normalize an asset path to be absolute, prefixing with /static/ if needed. +#[cfg(feature = "hydrate")] +fn normalize_asset_path(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/static/{}", path) + } +} + +#[cfg(feature = "hydrate")] +fn draw_avatars( + ctx: &web_sys::CanvasRenderingContext2d, + members: &[ChannelMemberWithAvatar], + scale_x: f64, + scale_y: f64, + offset_x: f64, + offset_y: f64, +) { + for member in members { + let x = member.member.position_x * scale_x + offset_x; + let y = member.member.position_y * scale_y + offset_y; + + let avatar_size = 48.0 * scale_x.min(scale_y); + + // Draw avatar placeholder circle + ctx.begin_path(); + let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0); + ctx.set_fill_style_str("#6366f1"); + ctx.fill(); + + // Draw skin layer sprite if available + if let Some(ref skin_path) = member.avatar.skin_layer[4] { + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + let ctx_clone = ctx.clone(); + let draw_x = x; + let draw_y = y - avatar_size; + let size = avatar_size; + + let onload = wasm_bindgen::closure::Closure::once(Box::new(move || { + let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( + &img_clone, draw_x - size / 2.0, draw_y, size, size, + ); + }) as Box<dyn FnOnce()>); + + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src(&normalize_asset_path(skin_path)); + } + + // Draw emotion overlay if available + if let Some(ref emotion_path) = member.avatar.emotion_layer[4] { + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + let ctx_clone = ctx.clone(); + let draw_x = x; + let draw_y = y - avatar_size; + let size = avatar_size; + + let onload = wasm_bindgen::closure::Closure::once(Box::new(move || { + let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh( + &img_clone, draw_x - size / 2.0, draw_y, size, size, + ); + }) as Box<dyn FnOnce()>); + + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src(&normalize_asset_path(emotion_path)); + } + + // Draw emotion indicator on avatar + let emotion = member.member.current_emotion; + if emotion > 0 { + // Draw emotion number in a small badge + let badge_size = 16.0 * scale_x.min(scale_y); + let badge_x = x + avatar_size / 2.0 - badge_size / 2.0; + let badge_y = y - avatar_size - badge_size / 2.0; + + // Badge background + ctx.begin_path(); + let _ = ctx.arc(badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0); + ctx.set_fill_style_str("#f59e0b"); // Amber color for emotion badge + ctx.fill(); + + // Emotion number + ctx.set_fill_style_str("#000"); + ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y))); + ctx.set_text_align("center"); + ctx.set_text_baseline("middle"); + let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y); + } + + // Draw display name + ctx.set_fill_style_str("#fff"); + ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y))); + ctx.set_text_align("center"); + ctx.set_text_baseline("alphabetic"); + let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y); + } +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs new file mode 100644 index 0000000..f51676e --- /dev/null +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -0,0 +1,257 @@ +//! WebSocket client for channel presence. +//! +//! Provides a Leptos hook to manage WebSocket connections for real-time +//! position updates, emotion changes, and member synchronization. + +use leptos::prelude::*; +use leptos::reactive::owner::LocalStorage; + +use chattyness_db::models::ChannelMemberWithAvatar; +use chattyness_db::ws_messages::{ClientMessage, ServerMessage}; + +/// WebSocket connection state. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WsState { + /// Attempting to connect. + Connecting, + /// Connected and ready. + Connected, + /// Disconnected (not connected). + Disconnected, + /// Connection error occurred. + Error, +} + +/// Sender function type for WebSocket messages. +pub type WsSender = Box<dyn Fn(ClientMessage)>; + +/// Local stored value type for the sender (non-Send, WASM-compatible). +pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>; + +/// Hook to manage WebSocket connection for a channel. +/// +/// Returns a tuple of: +/// - `Signal<WsState>` - The current connection state +/// - `WsSenderStorage` - A stored sender function to send messages +#[cfg(feature = "hydrate")] +pub fn use_channel_websocket( + realm_slug: Signal<String>, + channel_id: Signal<Option<uuid::Uuid>>, + on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, +) -> (Signal<WsState>, WsSenderStorage) { + use std::cell::RefCell; + use std::rc::Rc; + use wasm_bindgen::{closure::Closure, JsCast}; + use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket}; + + let (ws_state, set_ws_state) = signal(WsState::Disconnected); + let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None)); + let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new())); + + // Create a stored sender function (using new_local for WASM single-threaded environment) + let ws_ref_for_send = ws_ref.clone(); + let sender: WsSenderStorage = StoredValue::new_local(Some(Box::new( + move |msg: ClientMessage| { + if let Some(ws) = ws_ref_for_send.borrow().as_ref() { + if ws.ready_state() == WebSocket::OPEN { + if let Ok(json) = serde_json::to_string(&msg) { + #[cfg(debug_assertions)] + web_sys::console::log_1(&format!("[WS->Server] {}", json).into()); + let _ = ws.send_with_str(&json); + } + } + } + }, + ))); + + // Effect to manage WebSocket lifecycle + let ws_ref_clone = ws_ref.clone(); + let members_clone = members.clone(); + + Effect::new(move |_| { + let slug = realm_slug.get(); + let ch_id = channel_id.get(); + + // Cleanup previous connection + if let Some(old_ws) = ws_ref_clone.borrow_mut().take() { + let _ = old_ws.close(); + } + + let Some(ch_id) = ch_id else { + set_ws_state.set(WsState::Disconnected); + return; + }; + + if slug.is_empty() { + set_ws_state.set(WsState::Disconnected); + return; + } + + // Construct WebSocket URL + let window = web_sys::window().unwrap(); + let location = window.location(); + let protocol = if location.protocol().unwrap_or_default() == "https:" { + "wss:" + } else { + "ws:" + }; + let host = location.host().unwrap_or_default(); + let url = format!( + "{}//{}/api/realms/{}/channels/{}/ws", + protocol, host, slug, ch_id + ); + + #[cfg(debug_assertions)] + web_sys::console::log_1(&format!("[WS] Connecting to: {}", url).into()); + + set_ws_state.set(WsState::Connecting); + + let ws = match WebSocket::new(&url) { + Ok(ws) => ws, + Err(e) => { + #[cfg(debug_assertions)] + web_sys::console::error_1(&format!("[WS] Failed to create: {:?}", e).into()); + set_ws_state.set(WsState::Error); + return; + } + }; + + // onopen + let set_ws_state_open = set_ws_state; + let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| { + #[cfg(debug_assertions)] + web_sys::console::log_1(&"[WS] Connected".into()); + set_ws_state_open.set(WsState::Connected); + }) as Box<dyn FnMut(web_sys::Event)>); + ws.set_onopen(Some(onopen.as_ref().unchecked_ref())); + onopen.forget(); + + // onmessage + let members_for_msg = members_clone.clone(); + let on_members_update_clone = on_members_update.clone(); + let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| { + if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() { + let text: String = text.into(); + #[cfg(debug_assertions)] + web_sys::console::log_1(&format!("[WS<-Server] {}", text).into()); + + if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) { + handle_server_message(msg, &members_for_msg, &on_members_update_clone); + } + } + }) as Box<dyn FnMut(MessageEvent)>); + ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); + onmessage.forget(); + + // onerror + let set_ws_state_err = set_ws_state; + let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| { + #[cfg(debug_assertions)] + web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into()); + set_ws_state_err.set(WsState::Error); + }) as Box<dyn FnMut(ErrorEvent)>); + ws.set_onerror(Some(onerror.as_ref().unchecked_ref())); + onerror.forget(); + + // onclose + let set_ws_state_close = set_ws_state; + let onclose = Closure::wrap(Box::new(move |e: CloseEvent| { + #[cfg(debug_assertions)] + web_sys::console::log_1( + &format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(), + ); + set_ws_state_close.set(WsState::Disconnected); + }) as Box<dyn FnMut(CloseEvent)>); + ws.set_onclose(Some(onclose.as_ref().unchecked_ref())); + onclose.forget(); + + *ws_ref_clone.borrow_mut() = Some(ws); + }); + + (Signal::derive(move || ws_state.get()), sender) +} + +/// Handle a message received from the server. +#[cfg(feature = "hydrate")] +fn handle_server_message( + msg: ServerMessage, + members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>, + on_update: &Callback<Vec<ChannelMemberWithAvatar>>, +) { + let mut members_vec = members.borrow_mut(); + + match msg { + ServerMessage::Welcome { + member: _, + members: initial_members, + } => { + *members_vec = initial_members; + on_update.run(members_vec.clone()); + } + ServerMessage::MemberJoined { member } => { + // Remove if exists (rejoin case), then add + members_vec.retain(|m| { + m.member.user_id != member.member.user_id + || m.member.guest_session_id != member.member.guest_session_id + }); + members_vec.push(member); + on_update.run(members_vec.clone()); + } + ServerMessage::MemberLeft { + user_id, + guest_session_id, + } => { + members_vec.retain(|m| { + m.member.user_id != user_id || m.member.guest_session_id != guest_session_id + }); + on_update.run(members_vec.clone()); + } + ServerMessage::PositionUpdated { + user_id, + guest_session_id, + x, + y, + } => { + if let Some(m) = members_vec.iter_mut().find(|m| { + m.member.user_id == user_id && m.member.guest_session_id == guest_session_id + }) { + m.member.position_x = x; + m.member.position_y = y; + } + on_update.run(members_vec.clone()); + } + ServerMessage::EmotionUpdated { + user_id, + guest_session_id, + emotion, + emotion_layer, + } => { + if let Some(m) = members_vec.iter_mut().find(|m| { + m.member.user_id == user_id && m.member.guest_session_id == guest_session_id + }) { + m.member.current_emotion = emotion as i16; + m.avatar.emotion_layer = emotion_layer; + } + on_update.run(members_vec.clone()); + } + ServerMessage::Pong => { + // Heartbeat acknowledged - nothing to do + } + ServerMessage::Error { code, message } => { + #[cfg(debug_assertions)] + web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into()); + } + } +} + +/// Stub implementation for SSR (server-side rendering). +#[cfg(not(feature = "hydrate"))] +pub fn use_channel_websocket( + _realm_slug: Signal<String>, + _channel_id: Signal<Option<uuid::Uuid>>, + _on_members_update: Callback<Vec<ChannelMemberWithAvatar>>, +) -> (Signal<WsState>, WsSenderStorage) { + let (ws_state, _) = signal(WsState::Disconnected); + let sender: WsSenderStorage = StoredValue::new_local(None); + (Signal::derive(move || ws_state.get()), sender) +} diff --git a/crates/chattyness-user-ui/src/lib.rs b/crates/chattyness-user-ui/src/lib.rs new file mode 100644 index 0000000..802fafd --- /dev/null +++ b/crates/chattyness-user-ui/src/lib.rs @@ -0,0 +1,36 @@ +#![recursion_limit = "256"] +//! User UI components for chattyness. +//! +//! This crate provides the public user-facing interface including: +//! - Login and signup pages +//! - Realm browsing and viewing +//! - Scene editor for realm builders +//! +//! ## Usage +//! +//! For standalone use: +//! ```ignore +//! use chattyness_user_ui::App; +//! // App includes its own Router +//! ``` +//! +//! For embedding in a combined app (e.g., chattyness-app): +//! ```ignore +//! use chattyness_user_ui::UserRoutes; +//! // UserRoutes can be placed inside an existing Router +//! ``` + +#[cfg(feature = "ssr")] +pub mod api; +pub mod app; +#[cfg(feature = "ssr")] +pub mod auth; +pub mod components; +pub mod pages; +pub mod routes; + +pub use app::{shell, App}; +pub use routes::UserRoutes; + +#[cfg(feature = "ssr")] +pub use app::AppState; diff --git a/crates/chattyness-user-ui/src/pages.rs b/crates/chattyness-user-ui/src/pages.rs new file mode 100644 index 0000000..7472063 --- /dev/null +++ b/crates/chattyness-user-ui/src/pages.rs @@ -0,0 +1,15 @@ +//! Page components for user UI. +//! +//! Note: Editor pages and NewRealmPage have been moved to admin-ui. + +pub mod home; +pub mod login; +pub mod password_reset; +pub mod realm; +pub mod signup; + +pub use home::*; +pub use login::*; +pub use password_reset::*; +pub use realm::*; +pub use signup::*; diff --git a/crates/chattyness-user-ui/src/pages/home.rs b/crates/chattyness-user-ui/src/pages/home.rs new file mode 100644 index 0000000..5ff180c --- /dev/null +++ b/crates/chattyness-user-ui/src/pages/home.rs @@ -0,0 +1,96 @@ +//! Home page component. + +use leptos::prelude::*; + +use crate::components::{Card, PageLayout}; + +/// Home page. +#[component] +pub fn HomePage() -> impl IntoView { + view! { + <PageLayout> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> + // Hero section + <section class="text-center mb-16"> + <h1 class="text-4xl md:text-6xl font-bold text-white mb-6"> + "Welcome to " + <span class="text-blue-400">"Chattyness"</span> + </h1> + <p class="text-xl text-gray-300 max-w-2xl mx-auto mb-8"> + "Create and explore virtual spaces where communities come alive. " + "Build your own realm, customize it, and invite others to join." + </p> + <div class="flex flex-col sm:flex-row items-center justify-center gap-4"> + <a href="/realms/new" class="btn-primary text-lg px-8 py-4"> + "Create Your Realm" + </a> + <a href="/realms" class="btn-secondary text-lg px-8 py-4"> + "Explore Realms" + </a> + </div> + </section> + + // Features section + <section class="mb-16"> + <h2 class="text-2xl font-bold text-white text-center mb-8">"Why Chattyness?"</h2> + <div class="grid md:grid-cols-3 gap-8"> + <FeatureCard + icon="castle" + title="Create Realms" + description="Build themed virtual spaces with multiple scenes, customizable spots, and interactive elements." + /> + <FeatureCard + icon="users" + title="Build Communities" + description="Invite friends, manage members, and create a thriving community around your interests." + /> + <FeatureCard + icon="palette" + title="Customize Everything" + description="Props, avatars, scenes, and scripts - make your realm truly unique." + /> + </div> + </section> + + // CTA section + <section class="text-center"> + <Card class="p-8 max-w-2xl mx-auto"> + <h2 class="text-2xl font-bold text-white mb-4">"Ready to get started?"</h2> + <p class="text-gray-300 mb-6"> + "Create your first realm in just a few clicks. No experience needed." + </p> + <a href="/realms/new" class="btn-primary inline-block"> + "Create a Realm" + </a> + </Card> + </section> + </div> + </PageLayout> + } +} + +/// Feature card component. +#[component] +fn FeatureCard(icon: &'static str, title: &'static str, description: &'static str) -> impl IntoView { + let icon_symbol = match icon { + "castle" => "castle", + "users" => "users", + "palette" => "palette", + _ => "star", + }; + + view! { + <Card class="p-6 text-center"> + <div class="text-4xl mb-4" aria-hidden="true"> + <img + src=format!("/icons/{}.svg", icon_symbol) + alt="" + class="w-12 h-12 mx-auto" + aria-hidden="true" + /> + </div> + <h3 class="text-xl font-semibold text-white mb-2">{title}</h3> + <p class="text-gray-400">{description}</p> + </Card> + } +} diff --git a/crates/chattyness-user-ui/src/pages/login.rs b/crates/chattyness-user-ui/src/pages/login.rs new file mode 100644 index 0000000..fb4131c --- /dev/null +++ b/crates/chattyness-user-ui/src/pages/login.rs @@ -0,0 +1,497 @@ +//! Login page for realm users. + +use leptos::ev::SubmitEvent; +use leptos::prelude::*; +#[cfg(feature = "hydrate")] +use leptos::task::spawn_local; +#[cfg(feature = "hydrate")] +use leptos_router::hooks::use_navigate; + +use crate::components::{Card, CenteredLayout, ErrorAlert, JoinRealmModal, SubmitButton}; +use chattyness_db::models::RealmSummary; + +/// Main login page component. +#[component] +pub fn LoginPage() -> impl IntoView { + view! { + <CenteredLayout> + <div class="w-full max-w-lg"> + // Logo and title + <div class="text-center mb-8"> + <h1 class="text-3xl font-bold text-white mb-2"> + <span aria-hidden="true">"Chattyness"</span> + </h1> + <p class="text-gray-400">"Sign in to explore virtual community spaces"</p> + </div> + + <Card class="p-6"> + <RealmLoginForm /> + </Card> + </div> + </CenteredLayout> + } +} + +/// Realm login form component. +#[component] +fn RealmLoginForm() -> impl IntoView { + #[cfg(feature = "hydrate")] + let navigate = use_navigate(); + #[cfg(feature = "hydrate")] + let navigate_for_submit = navigate.clone(); + #[cfg(feature = "hydrate")] + let navigate_for_join = navigate.clone(); + #[cfg(feature = "hydrate")] + let navigate_for_guest = navigate.clone(); + + // Form state + let (username, set_username) = signal(String::new()); + let (password, set_password) = signal(String::new()); + let (selected_realm, set_selected_realm) = signal(Option::<String>::None); + let (private_realm, set_private_realm) = signal(String::new()); + let (error, set_error) = signal(Option::<String>::None); + let (pending, set_pending) = signal(false); + let (guest_pending, set_guest_pending) = signal(false); + + // Join modal state + let (show_join_modal, set_show_join_modal) = signal(false); + let (join_pending, set_join_pending) = signal(false); + let (pending_realm, set_pending_realm) = signal(Option::<RealmSummary>::None); + + // Fetch public realms + let realms = LocalResource::new(move || async move { + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + #[derive(serde::Deserialize)] + struct ListResponse { + realms: Vec<RealmSummary>, + } + let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await; + match response { + Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms), + _ => None, + } + } + #[cfg(not(feature = "hydrate"))] + { + None::<Vec<RealmSummary>> + } + }); + + // Get the realm slug to use + let realm_slug = Signal::derive(move || { + if !private_realm.get().is_empty() { + Some(private_realm.get()) + } else { + selected_realm.get() + } + }); + + // Handle login submission + let on_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + set_error.set(None); + + let slug = realm_slug.get(); + if slug.is_none() { + set_error.set(Some( + "Please select a realm or enter a private realm name".to_string(), + )); + return; + } + + #[cfg(feature = "hydrate")] + let slug = slug.unwrap(); + let uname = username.get(); + let pwd = password.get(); + + if uname.is_empty() || pwd.is_empty() { + set_error.set(Some("Username and password are required".to_string())); + return; + } + + set_pending.set(true); + + #[cfg(feature = "hydrate")] + { + use chattyness_db::models::{LoginRequest, LoginResponse, LoginType}; + use gloo_net::http::Request; + + let navigate = navigate_for_submit.clone(); + spawn_local(async move { + let request = LoginRequest { + username: uname, + password: pwd, + login_type: LoginType::Realm, + realm_slug: Some(slug), + }; + + let response = Request::post("/api/auth/login") + .json(&request) + .unwrap() + .send() + .await; + + set_pending.set(false); + + match response { + Ok(resp) if resp.ok() => { + if let Ok(login_resp) = resp.json::<LoginResponse>().await { + if login_resp.requires_pw_reset { + navigate(&login_resp.redirect_url, Default::default()); + } else if login_resp.is_member == Some(false) { + if let Some(realm) = login_resp.realm { + set_pending_realm.set(Some(realm)); + set_show_join_modal.set(true); + } + } else { + navigate(&login_resp.redirect_url, Default::default()); + } + } + } + Ok(resp) => { + let status = resp.status(); + if status == 401 { + set_error.set(Some("Invalid username or password".to_string())); + } else if status == 403 { + set_error.set(Some("Your account is suspended or banned".to_string())); + } else { + set_error.set(Some("Login failed. Please try again.".to_string())); + } + } + Err(_) => { + set_error.set(Some( + "Network error. Please check your connection.".to_string(), + )); + } + } + }); + } + }; + + // Handle join confirmation + let on_join_confirm = move |_| { + if pending_realm.get().is_some() { + set_join_pending.set(true); + + #[cfg(feature = "hydrate")] + { + use chattyness_db::models::JoinRealmRequest; + use gloo_net::http::Request; + + let realm = pending_realm.get().unwrap(); + let navigate = navigate_for_join.clone(); + let realm_id = realm.id; + let realm_slug = realm.slug.clone(); + + spawn_local(async move { + let request = JoinRealmRequest { realm_id }; + + let response = Request::post("/api/auth/join-realm") + .json(&request) + .unwrap() + .send() + .await; + + set_join_pending.set(false); + set_show_join_modal.set(false); + + match response { + Ok(resp) if resp.ok() => { + navigate(&format!("/realms/{}", realm_slug), Default::default()); + } + Ok(resp) => { + let status = resp.status(); + if status == 403 { + set_error.set(Some("Cannot join this realm".to_string())); + } else { + set_error.set(Some( + "Failed to join realm. Please try again.".to_string(), + )); + } + } + Err(_) => { + set_error.set(Some( + "Network error. Please check your connection.".to_string(), + )); + } + } + }); + } + } + }; + + let on_join_cancel = move |_| { + set_show_join_modal.set(false); + set_pending_realm.set(None); + }; + + // Handle guest login + let on_guest_click = move |_| { + set_error.set(None); + + let slug = realm_slug.get(); + if slug.is_none() { + set_error.set(Some("Please select a realm first".to_string())); + return; + } + + set_guest_pending.set(true); + + #[cfg(feature = "hydrate")] + { + use chattyness_db::models::{GuestLoginRequest, GuestLoginResponse}; + use gloo_net::http::Request; + + let navigate = navigate_for_guest.clone(); + let realm_slug_val = slug.unwrap(); + + spawn_local(async move { + let request = GuestLoginRequest { + realm_slug: realm_slug_val, + }; + + let response = Request::post("/api/auth/guest") + .json(&request) + .unwrap() + .send() + .await; + + set_guest_pending.set(false); + + match response { + Ok(resp) if resp.ok() => { + if let Ok(guest_resp) = resp.json::<GuestLoginResponse>().await { + navigate(&guest_resp.redirect_url, Default::default()); + } + } + Ok(resp) => { + #[derive(serde::Deserialize)] + struct ErrorResp { + error: String, + } + if let Ok(err) = resp.json::<ErrorResp>().await { + set_error.set(Some(err.error)); + } else { + set_error.set(Some( + "Guest access not available for this realm".to_string(), + )); + } + } + Err(_) => { + set_error.set(Some( + "Network error. Please check your connection.".to_string(), + )); + } + } + }); + } + }; + + view! { + <form on:submit=on_submit class="space-y-6"> + // Realm selection + <fieldset> + <legend class="text-sm font-medium text-gray-300 mb-3">"Choose a Realm"</legend> + + // Public realm list + <Suspense fallback=move || { + view! { <p class="text-gray-400">"Loading realms..."</p> } + }> + {move || { + realms + .get() + .map(|maybe_realms: Option<Vec<RealmSummary>>| { + match maybe_realms { + Some(realms) if !realms.is_empty() => { + view! { + <div class="space-y-2 max-h-48 overflow-y-auto mb-4"> + {realms + .into_iter() + .map(|realm| { + let slug = realm.slug.clone(); + let slug_for_click = slug.clone(); + let is_selected = Signal::derive(move || { + selected_realm.get() == Some(slug.clone()) + }); + view! { + <button + type="button" + class=move || { + let base = "w-full text-left p-3 rounded-lg border transition-colors"; + if is_selected.get() { + format!("{} border-blue-500 bg-blue-500/10", base) + } else { + format!( + "{} border-gray-700 hover:border-gray-600 hover:bg-gray-700/50", + base, + ) + } + } + on:click=move |_| { + set_selected_realm.set(Some(slug_for_click.clone())); + set_private_realm.set(String::new()); + } + > + <div class="flex items-center justify-between"> + <div> + <span class="text-white font-medium"> + {realm.name.clone()} + </span> + <span class="text-gray-500 text-sm ml-2"> + {format!("/{}", realm.slug)} + </span> + </div> + <span class="text-gray-400 text-sm"> + {realm.current_user_count} + " online" + </span> + </div> + {realm + .tagline + .as_ref() + .map(|t: &String| { + view! { + <p class="text-gray-400 text-sm mt-1">{t.clone()}</p> + } + })} + </button> + } + }) + .collect_view()} + </div> + } + .into_any() + } + _ => { + view! { + <p class="text-gray-400 mb-4">"No public realms available"</p> + } + .into_any() + } + } + }) + }} + </Suspense> + + // Private realm input + <div class="relative"> + <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> + <span class="text-gray-500">"/"</span> + </div> + <input + type="text" + placeholder="Or enter a private realm name" + class="input-base pl-6" + prop:value=move || private_realm.get() + on:input=move |ev| { + set_private_realm.set(event_target_value(&ev)); + set_selected_realm.set(None); + } + /> + </div> + </fieldset> + + // Credentials + <div class="space-y-4"> + <div> + <label for="username" class="block text-sm font-medium text-gray-300 mb-2"> + "Username" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="text" + id="username" + name="username" + required=true + autocomplete="username" + class="input-base" + prop:value=move || username.get() + on:input=move |ev| set_username.set(event_target_value(&ev)) + /> + </div> + <div> + <label for="password" class="block text-sm font-medium text-gray-300 mb-2"> + "Password" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="password" + id="password" + name="password" + required=true + autocomplete="current-password" + class="input-base" + prop:value=move || password.get() + on:input=move |ev| set_password.set(event_target_value(&ev)) + /> + </div> + </div> + + // Error message + <ErrorAlert message=Signal::derive(move || error.get()) /> + + // Submit button + <SubmitButton + text="Enter Realm" + loading_text="Signing in..." + pending=Signal::derive(move || pending.get()) + /> + + // Divider + <div class="relative"> + <div class="absolute inset-0 flex items-center"> + <div class="w-full border-t border-gray-700"></div> + </div> + <div class="relative flex justify-center text-sm"> + <span class="px-2 bg-gray-800 text-gray-400">"or"</span> + </div> + </div> + + // Guest button + <button + type="button" + class="w-full py-3 px-4 border border-gray-600 rounded-lg text-gray-300 hover:bg-gray-700 hover:border-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + disabled=move || guest_pending.get() || realm_slug.get().is_none() + on:click=on_guest_click + > + {move || { + if guest_pending.get() { "Joining as guest..." } else { "Continue as Guest" } + }} + </button> + + // Sign up link + <p class="text-center text-gray-400 text-sm"> + "Don't have an account? " + <a href="/signup" class="text-blue-400 hover:underline"> + "Sign up" + </a> + </p> + </form> + + // Join modal + { + let on_join_confirm = on_join_confirm.clone(); + let on_join_cancel = on_join_cancel.clone(); + move || { + let on_join_confirm = on_join_confirm.clone(); + let on_join_cancel = on_join_cancel.clone(); + pending_realm + .get() + .map(|realm| { + view! { + <JoinRealmModal + open=Signal::derive(move || show_join_modal.get()) + realm_name=realm.name.clone() + realm_slug=realm.slug.clone() + pending=Signal::derive(move || join_pending.get()) + on_confirm=Callback::new(on_join_confirm.clone()) + on_cancel=Callback::new(on_join_cancel.clone()) + /> + } + }) + } + } + } +} diff --git a/crates/chattyness-user-ui/src/pages/password_reset.rs b/crates/chattyness-user-ui/src/pages/password_reset.rs new file mode 100644 index 0000000..0e326ce --- /dev/null +++ b/crates/chattyness-user-ui/src/pages/password_reset.rs @@ -0,0 +1,216 @@ +//! Password reset page for users with force_pw_reset flag. + +use leptos::ev::SubmitEvent; +use leptos::prelude::*; +#[cfg(feature = "hydrate")] +use leptos::task::spawn_local; +#[cfg(feature = "hydrate")] +use leptos_router::hooks::use_navigate; + +use crate::components::{Card, CenteredLayout, ErrorAlert, SubmitButton}; + +/// Password reset page component. +#[component] +pub fn PasswordResetPage() -> impl IntoView { + #[cfg(feature = "hydrate")] + let navigate = use_navigate(); + + let (new_password, set_new_password) = signal(String::new()); + let (confirm_password, set_confirm_password) = signal(String::new()); + let (error, set_error) = signal(Option::<String>::None); + let (pending, set_pending) = signal(false); + + let password_valid = Signal::derive(move || new_password.get().len() >= 8); + + let passwords_match = Signal::derive(move || { + let pwd = new_password.get(); + let confirm = confirm_password.get(); + !pwd.is_empty() && pwd == confirm + }); + + let on_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + set_error.set(None); + + let pwd = new_password.get(); + let confirm = confirm_password.get(); + + if pwd.len() < 8 { + set_error.set(Some("Password must be at least 8 characters".to_string())); + return; + } + + if pwd != confirm { + set_error.set(Some("Passwords do not match".to_string())); + return; + } + + set_pending.set(true); + + #[cfg(feature = "hydrate")] + { + use chattyness_db::models::{PasswordResetRequest, PasswordResetResponse}; + use gloo_net::http::Request; + + let navigate = navigate.clone(); + spawn_local(async move { + let request = PasswordResetRequest { + new_password: pwd, + confirm_password: confirm, + }; + + let response = Request::post("/api/auth/reset-password") + .json(&request) + .unwrap() + .send() + .await; + + set_pending.set(false); + + match response { + Ok(resp) if resp.ok() => { + if let Ok(reset_resp) = resp.json::<PasswordResetResponse>().await { + navigate(&reset_resp.redirect_url, Default::default()); + } + } + Ok(resp) => { + let status = resp.status(); + if status == 401 { + set_error.set(Some("Session expired. Please log in again.".to_string())); + } else if status == 400 { + set_error.set(Some("Invalid password. Please try again.".to_string())); + } else { + set_error.set(Some( + "Failed to reset password. Please try again.".to_string(), + )); + } + } + Err(_) => { + set_error.set(Some( + "Network error. Please check your connection.".to_string(), + )); + } + } + }); + } + }; + + view! { + <CenteredLayout> + <div class="w-full max-w-md"> + <Card class="p-6"> + <div class="text-center mb-6"> + <div class="mx-auto w-16 h-16 rounded-full bg-yellow-600/20 flex items-center justify-center mb-4"> + <img src="/icons/key.svg" alt="" class="w-8 h-8" aria-hidden="true" /> + </div> + <h1 class="text-2xl font-bold text-white mb-2">"Reset Your Password"</h1> + <p class="text-gray-400">"Please create a new password to continue"</p> + </div> + + <form on:submit=on_submit class="space-y-6"> + <div> + <label + for="new-password" + class="block text-sm font-medium text-gray-300 mb-2" + > + "New Password" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="password" + id="new-password" + name="new_password" + required=true + minlength=8 + autocomplete="new-password" + class="input-base" + prop:value=move || new_password.get() + on:input=move |ev| set_new_password.set(event_target_value(&ev)) + /> + <div class="mt-2 flex items-center space-x-2"> + <div class=move || { + let base = "h-1 flex-1 rounded"; + if password_valid.get() { + format!("{} bg-green-500", base) + } else if new_password.get().len() >= 4 { + format!("{} bg-yellow-500", base) + } else { + format!("{} bg-gray-600", base) + } + } /> + </div> + <p class=move || { + let base = "text-sm mt-1"; + if password_valid.get() { + format!("{} text-green-400", base) + } else { + format!("{} text-gray-400", base) + } + }> + {move || { + if password_valid.get() { + "Password meets requirements" + } else { + "Minimum 8 characters" + } + }} + </p> + </div> + + <div> + <label + for="confirm-password" + class="block text-sm font-medium text-gray-300 mb-2" + > + "Confirm Password" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="password" + id="confirm-password" + name="confirm_password" + required=true + autocomplete="new-password" + class="input-base" + prop:value=move || confirm_password.get() + on:input=move |ev| set_confirm_password.set(event_target_value(&ev)) + /> + {move || { + let confirm = confirm_password.get(); + if !confirm.is_empty() { + if passwords_match.get() { + view! { + <p class="text-sm text-green-400 mt-1">"Passwords match"</p> + } + .into_any() + } else { + view! { + <p class="text-sm text-red-400 mt-1"> + "Passwords do not match" + </p> + } + .into_any() + } + } else { + view! {}.into_any() + } + }} + </div> + + <ErrorAlert message=Signal::derive(move || error.get()) /> + + <SubmitButton + text="Reset Password" + loading_text="Resetting..." + pending=Signal::derive(move || pending.get()) + /> + </form> + + <p class="text-sm text-gray-500 text-center mt-6"> + "After resetting your password, you'll be redirected to your destination." + </p> + </Card> + </div> + </CenteredLayout> + } +} diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs new file mode 100644 index 0000000..c8504bb --- /dev/null +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -0,0 +1,349 @@ +//! Realm landing page after login. + +use leptos::prelude::*; +#[cfg(feature = "hydrate")] +use leptos::task::spawn_local; +#[cfg(feature = "hydrate")] +use leptos_router::hooks::use_navigate; +use leptos_router::hooks::use_params_map; + +use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer}; +#[cfg(feature = "hydrate")] +use crate::components::use_channel_websocket; +use chattyness_db::models::{ChannelMemberWithAvatar, RealmRole, RealmWithUserRole, Scene}; +#[cfg(feature = "hydrate")] +use chattyness_db::ws_messages::ClientMessage; + +/// Realm landing page component. +#[component] +pub fn RealmPage() -> impl IntoView { + let params = use_params_map(); + #[cfg(feature = "hydrate")] + let navigate = use_navigate(); + + let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default()); + + // Channel member state + let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new()); + let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None); + + let realm_data = LocalResource::new(move || { + let slug = slug.get(); + async move { + if slug.is_empty() { + return None; + } + + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + let response = Request::get(&format!("/api/realms/{}", slug)).send().await; + match response { + Ok(resp) if resp.ok() => resp.json::<RealmWithUserRole>().await.ok(), + _ => None, + } + } + #[cfg(not(feature = "hydrate"))] + { + let _ = slug; + None::<RealmWithUserRole> + } + } + }); + + // Fetch entry scene for the realm + let entry_scene = LocalResource::new(move || { + let slug = slug.get(); + async move { + if slug.is_empty() { + return None; + } + + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + let response = Request::get(&format!("/api/realms/{}/entry-scene", slug)) + .send() + .await; + match response { + Ok(resp) if resp.ok() => resp.json::<Scene>().await.ok(), + _ => None, + } + } + #[cfg(not(feature = "hydrate"))] + { + let _ = slug; + None::<Scene> + } + } + }); + + // WebSocket connection for real-time updates + #[cfg(feature = "hydrate")] + let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| { + set_members.set(new_members); + }); + + #[cfg(feature = "hydrate")] + let (_ws_state, ws_sender) = use_channel_websocket( + slug, + Signal::derive(move || channel_id.get()), + on_members_update, + ); + + // Set channel ID when scene loads (triggers WebSocket connection) + #[cfg(feature = "hydrate")] + { + Effect::new(move |_| { + let Some(scene) = entry_scene.get().flatten() else { + return; + }; + set_channel_id.set(Some(scene.id)); + }); + } + + // Handle position update via WebSocket + #[cfg(feature = "hydrate")] + let on_move = Callback::new(move |(x, y): (f64, f64)| { + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::UpdatePosition { x, y }); + } + }); + }); + + #[cfg(not(feature = "hydrate"))] + let on_move = Callback::new(move |(_x, _y): (f64, f64)| {}); + + // Handle emotion change via keyboard (e then 0-9) + #[cfg(feature = "hydrate")] + { + use std::cell::RefCell; + use std::rc::Rc; + use wasm_bindgen::{closure::Closure, JsCast}; + + let closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> = + Rc::new(RefCell::new(None)); + let closure_holder_clone = closure_holder.clone(); + + Effect::new(move |_| { + // Cleanup previous closure if any + if let Some(old_closure) = closure_holder_clone.borrow_mut().take() { + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback( + "keydown", + old_closure.as_ref().unchecked_ref(), + ); + } + } + + let current_slug = slug.get(); + if current_slug.is_empty() { + return; + } + + // Track if 'e' was pressed (for e+0-9 emotion sequence) + let e_pressed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false)); + let e_pressed_clone = e_pressed.clone(); + + let closure = Closure::new(move |ev: web_sys::KeyboardEvent| { + let key = ev.key(); + + // Check if 'e' key was pressed + if key == "e" || key == "E" { + *e_pressed_clone.borrow_mut() = true; + return; + } + + // Check for 0-9 after 'e' was pressed + if *e_pressed_clone.borrow() { + *e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome + if key.len() == 1 { + if let Ok(emotion) = key.parse::<u8>() { + if emotion <= 9 { + #[cfg(debug_assertions)] + web_sys::console::log_1( + &format!("[Emotion] Sending emotion {}", emotion).into(), + ); + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::UpdateEmotion { emotion }); + } + }); + } + } + } + } else { + // Any other key resets the 'e' state + *e_pressed_clone.borrow_mut() = false; + } + }); + + if let Some(window) = web_sys::window() { + let _ = window + .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()); + } + + // Store the closure for cleanup + *closure_holder_clone.borrow_mut() = Some(closure); + }); + } + + // Create logout callback (WebSocket disconnects automatically) + let on_logout = Callback::new(move |_: ()| { + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + let navigate = navigate.clone(); + + spawn_local(async move { + // WebSocket close handles channel leave automatically + let _: Result<gloo_net::http::Response, gloo_net::Error> = + Request::post("/api/auth/logout").send().await; + navigate("/", Default::default()); + }); + } + }); + + view! { + <div class="h-screen bg-gray-900 text-white flex flex-col overflow-hidden"> + <Suspense fallback=move || { + view! { + <div class="flex items-center justify-center min-h-screen"> + <p class="text-gray-400">"Loading realm..."</p> + </div> + } + }> + {move || { + let on_logout = on_logout.clone(); + let on_move = on_move.clone(); + realm_data + .get() + .map(|maybe_data| { + match maybe_data { + Some(data) => { + let realm = data.realm; + let user_role = data.user_role; + + // Determine if user can access admin + // Admin visible for: Owner, Moderator, or staff + let can_admin = matches!( + user_role, + Some(RealmRole::Owner) | Some(RealmRole::Moderator) + ); + + // Get scene name and description for header + let scene_info = entry_scene + .get() + .flatten() + .map(|s| (s.name.clone(), s.description.clone())) + .unwrap_or_else(|| ("Loading...".to_string(), None)); + + let realm_name = realm.name.clone(); + let realm_slug_val = realm.slug.clone(); + let realm_description = realm.tagline.clone(); + let online_count = realm.current_user_count; + let total_members = realm.member_count; + let max_capacity = realm.max_users; + let scene_name = scene_info.0; + let scene_description = scene_info.1; + + view! { + <RealmHeader + realm_name=realm_name + realm_slug=realm_slug_val.clone() + realm_description=realm_description + scene_name=scene_name + scene_description=scene_description + online_count=online_count + total_members=total_members + max_capacity=max_capacity + can_admin=can_admin + on_logout=on_logout.clone() + /> + <main class="flex-1 w-full"> + // Scene viewer - full width + <Suspense fallback=move || { + view! { + <div class="flex items-center justify-center py-12"> + <p class="text-gray-400">"Loading scene..."</p> + </div> + } + }> + {move || { + let on_move = on_move.clone(); + let realm_slug_for_viewer = realm_slug_val.clone(); + entry_scene + .get() + .map(|maybe_scene| { + match maybe_scene { + Some(scene) => { + let members_signal = Signal::derive(move || members.get()); + view! { + <div class="relative w-full"> + <RealmSceneViewer + scene=scene + realm_slug=realm_slug_for_viewer.clone() + members=members_signal + on_move=on_move.clone() + /> + <div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4"> + <ChatInput /> + </div> + </div> + } + .into_any() + } + None => { + view! { + <div class="max-w-4xl mx-auto px-4 py-8"> + <Card class="p-8 text-center"> + <p class="text-gray-400"> + "No scenes have been created for this realm yet." + </p> + </Card> + </div> + } + .into_any() + } + } + }) + }} + </Suspense> + </main> + } + .into_any() + } + None => { + view! { + <div class="flex items-center justify-center min-h-screen"> + <Card class="p-8 text-center max-w-md"> + <div class="mx-auto w-20 h-20 rounded-full bg-red-900/20 flex items-center justify-center mb-4"> + <img + src="/icons/x.svg" + alt="" + class="w-10 h-10" + aria-hidden="true" + /> + </div> + <h2 class="text-xl font-semibold text-white mb-2"> + "Realm Not Found" + </h2> + <p class="text-gray-400 mb-6"> + "The realm you're looking for doesn't exist or you don't have access." + </p> + <a href="/" class="btn-primary inline-block"> + "Back to Home" + </a> + </Card> + </div> + } + .into_any() + } + } + }) + }} + </Suspense> + </div> + } +} diff --git a/crates/chattyness-user-ui/src/pages/signup.rs b/crates/chattyness-user-ui/src/pages/signup.rs new file mode 100644 index 0000000..6b33cb1 --- /dev/null +++ b/crates/chattyness-user-ui/src/pages/signup.rs @@ -0,0 +1,412 @@ +//! Sign-up page for new user registration. + +use leptos::ev::SubmitEvent; +use leptos::prelude::*; +#[cfg(feature = "hydrate")] +use leptos::task::spawn_local; +#[cfg(feature = "hydrate")] +use leptos_router::hooks::use_navigate; +use leptos_router::hooks::use_query_map; + +use crate::components::{Card, CenteredLayout, ErrorAlert, SubmitButton}; +use chattyness_db::models::RealmSummary; + +/// Sign-up page component. +#[component] +pub fn SignupPage() -> impl IntoView { + #[cfg(feature = "hydrate")] + let navigate = use_navigate(); + let query = use_query_map(); + + // Form state + let (username, set_username) = signal(String::new()); + let (display_name, set_display_name) = signal(String::new()); + let (email, set_email) = signal(String::new()); + let (password, set_password) = signal(String::new()); + let (confirm_password, set_confirm_password) = signal(String::new()); + let (selected_realm, set_selected_realm) = signal(Option::<String>::None); + let (private_realm, set_private_realm) = signal(String::new()); + let (error, set_error) = signal(Option::<String>::None); + let (pending, set_pending) = signal(false); + + // Read query param for pre-filled realm + Effect::new(move |_| { + if let Some(realm) = query.read().get("realm") { + set_private_realm.set(realm.to_string()); + } + }); + + // Fetch public realms + let realms = LocalResource::new(move || async move { + #[cfg(feature = "hydrate")] + { + use gloo_net::http::Request; + #[derive(serde::Deserialize)] + struct ListResponse { + realms: Vec<RealmSummary>, + } + let response = Request::get("/api/realms?include_nsfw=false&limit=20").send().await; + match response { + Ok(resp) if resp.ok() => resp.json::<ListResponse>().await.ok().map(|r| r.realms), + _ => None, + } + } + #[cfg(not(feature = "hydrate"))] + { + None::<Vec<RealmSummary>> + } + }); + + // Get the realm slug to use + let realm_slug = Signal::derive(move || { + let private = private_realm.get(); + if !private.is_empty() { + Some(private) + } else { + selected_realm.get() + } + }); + + // Handle form submission + let on_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + set_error.set(None); + + let slug = realm_slug.get(); + if slug.is_none() { + set_error.set(Some("Please select a realm or enter a realm name".to_string())); + return; + } + + let uname = username.get(); + if uname.len() < 3 || uname.len() > 30 { + set_error.set(Some("Username must be 3-30 characters".to_string())); + return; + } + + let dname = display_name.get(); + if dname.trim().is_empty() { + set_error.set(Some("Display name is required".to_string())); + return; + } + + let pwd = password.get(); + if pwd.len() < 8 { + set_error.set(Some("Password must be at least 8 characters".to_string())); + return; + } + + let confirm_pwd = confirm_password.get(); + if pwd != confirm_pwd { + set_error.set(Some("Passwords do not match".to_string())); + return; + } + + set_pending.set(true); + + #[cfg(feature = "hydrate")] + { + use chattyness_db::models::{SignupRequest, SignupResponse}; + use gloo_net::http::Request; + + let navigate = navigate.clone(); + let email_val = email.get(); + let email_opt = if email_val.trim().is_empty() { + None + } else { + Some(email_val) + }; + + spawn_local(async move { + let request = SignupRequest { + username: uname, + email: email_opt, + display_name: dname, + password: pwd, + confirm_password: confirm_pwd, + realm_slug: slug.unwrap(), + }; + + let response = Request::post("/api/auth/signup") + .json(&request) + .unwrap() + .send() + .await; + + set_pending.set(false); + + match response { + Ok(resp) if resp.ok() => { + if let Ok(signup_resp) = resp.json::<SignupResponse>().await { + navigate(&signup_resp.redirect_url, Default::default()); + } + } + Ok(resp) => { + #[derive(serde::Deserialize)] + struct ErrorResp { + error: String, + } + let status = resp.status(); + if let Ok(err) = resp.json::<ErrorResp>().await { + set_error.set(Some(err.error)); + } else if status == 409 { + set_error.set(Some("Username or email already taken".to_string())); + } else if status == 404 { + set_error.set(Some("Realm not found".to_string())); + } else { + set_error.set(Some("Sign up failed. Please try again.".to_string())); + } + } + Err(_) => { + set_error.set(Some( + "Network error. Please check your connection.".to_string(), + )); + } + } + }); + } + }; + + view! { + <CenteredLayout> + <div class="w-full max-w-lg"> + <div class="text-center mb-8"> + <h1 class="text-3xl font-bold text-white mb-2">"Join Chattyness"</h1> + <p class="text-gray-400">"Create your account and join a realm"</p> + </div> + + <Card class="p-6"> + <form on:submit=on_submit class="space-y-6"> + // Realm selection + <fieldset> + <legend class="text-sm font-medium text-gray-300 mb-3"> + "Choose a Realm" + </legend> + + <Suspense fallback=move || { + view! { <p class="text-gray-400">"Loading realms..."</p> } + }> + {move || { + realms + .get() + .map(|maybe_realms: Option<Vec<RealmSummary>>| { + match maybe_realms { + Some(realms) if !realms.is_empty() => { + view! { + <div class="space-y-2 max-h-48 overflow-y-auto mb-4"> + {realms + .into_iter() + .map(|realm| { + let slug = realm.slug.clone(); + let slug_for_click = slug.clone(); + let is_selected = Signal::derive(move || { + selected_realm.get() == Some(slug.clone()) + }); + view! { + <button + type="button" + class=move || { + let base = "w-full text-left p-3 rounded-lg border transition-colors"; + if is_selected.get() { + format!("{} border-blue-500 bg-blue-500/10", base) + } else { + format!( + "{} border-gray-700 hover:border-gray-600 hover:bg-gray-700/50", + base, + ) + } + } + on:click=move |_| { + set_selected_realm + .set(Some(slug_for_click.clone())); + set_private_realm.set(String::new()); + } + > + <div class="flex items-center justify-between"> + <div> + <span class="text-white font-medium"> + {realm.name.clone()} + </span> + <span class="text-gray-500 text-sm ml-2"> + {format!("/{}", realm.slug)} + </span> + </div> + <span class="text-gray-400 text-sm"> + {realm.member_count} + " members" + </span> + </div> + </button> + } + }) + .collect_view()} + </div> + } + .into_any() + } + _ => { + view! { + <p class="text-gray-400 mb-4"> + "No public realms available" + </p> + } + .into_any() + } + } + }) + }} + </Suspense> + + <div class="relative"> + <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> + <span class="text-gray-500">"/"</span> + </div> + <input + type="text" + placeholder="Or enter a realm name" + class="input-base pl-6" + prop:value=move || private_realm.get() + on:input=move |ev| { + set_private_realm.set(event_target_value(&ev)); + set_selected_realm.set(None); + } + /> + </div> + </fieldset> + + // Account details + <div class="space-y-4"> + <div> + <label + for="username" + class="block text-sm font-medium text-gray-300 mb-2" + > + "Username" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="text" + id="username" + name="username" + required=true + autocomplete="username" + minlength=3 + maxlength=30 + pattern="[a-z][a-z0-9_]*" + class="input-base" + placeholder="lowercase letters, numbers, underscores" + prop:value=move || username.get() + on:input=move |ev| set_username.set(event_target_value(&ev)) + /> + <p class="text-gray-500 text-xs mt-1"> + "3-30 characters, starts with a letter" + </p> + </div> + + <div> + <label + for="display_name" + class="block text-sm font-medium text-gray-300 mb-2" + > + "Display Name" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="text" + id="display_name" + name="display_name" + required=true + maxlength=50 + class="input-base" + placeholder="How others will see you" + prop:value=move || display_name.get() + on:input=move |ev| set_display_name.set(event_target_value(&ev)) + /> + </div> + + <div> + <label + for="email" + class="block text-sm font-medium text-gray-300 mb-2" + > + "Email" + <span class="text-gray-500 text-xs ml-1">"(optional)"</span> + </label> + <input + type="email" + id="email" + name="email" + autocomplete="email" + class="input-base" + placeholder="your@email.com" + prop:value=move || email.get() + on:input=move |ev| set_email.set(event_target_value(&ev)) + /> + </div> + + <div> + <label + for="password" + class="block text-sm font-medium text-gray-300 mb-2" + > + "Password" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="password" + id="password" + name="password" + required=true + minlength=8 + autocomplete="new-password" + class="input-base" + placeholder="At least 8 characters" + prop:value=move || password.get() + on:input=move |ev| set_password.set(event_target_value(&ev)) + /> + </div> + + <div> + <label + for="confirm_password" + class="block text-sm font-medium text-gray-300 mb-2" + > + "Confirm Password" + <span class="text-red-400" aria-hidden="true">"*"</span> + </label> + <input + type="password" + id="confirm_password" + name="confirm_password" + required=true + minlength=8 + autocomplete="new-password" + class="input-base" + prop:value=move || confirm_password.get() + on:input=move |ev| { + set_confirm_password.set(event_target_value(&ev)) + } + /> + </div> + </div> + + <ErrorAlert message=Signal::derive(move || error.get()) /> + + <SubmitButton + text="Create Account" + loading_text="Creating account..." + pending=Signal::derive(move || pending.get()) + /> + + <p class="text-center text-gray-400 text-sm"> + "Already have an account? " + <a href="/" class="text-blue-400 hover:underline"> + "Sign in" + </a> + </p> + </form> + </Card> + </div> + </CenteredLayout> + } +} diff --git a/crates/chattyness-user-ui/src/routes.rs b/crates/chattyness-user-ui/src/routes.rs new file mode 100644 index 0000000..c3013f4 --- /dev/null +++ b/crates/chattyness-user-ui/src/routes.rs @@ -0,0 +1,36 @@ +//! User routes without Router wrapper (for embedding in combined apps). +//! +//! This module provides the `UserRoutes` component which contains all user +//! route definitions without a Router wrapper. This allows the routes to be +//! embedded in a parent Router (e.g., CombinedApp in chattyness-app). +//! +//! For standalone use, use `App` which wraps these routes with a Router. +//! +//! Note: Editor routes and NewRealmPage have been removed. +//! All create/edit functionality is now in the admin-ui. + +use leptos::prelude::*; +use leptos_router::{ + components::{Route, Routes}, + ParamSegment, StaticSegment, +}; + +use crate::pages::{HomePage, LoginPage, PasswordResetPage, RealmPage, SignupPage}; + +/// User routes that can be embedded in a parent Router. +/// +/// All paths are relative to the mount point. When used in: +/// - `App`: Routes are at root (e.g., `/`, `/signup`, `/home`) +/// - `CombinedApp`: Routes are at root (same paths) +#[component] +pub fn UserRoutes() -> impl IntoView { + view! { + <Routes fallback=|| "Page not found.".into_view()> + <Route path=StaticSegment("") view=LoginPage /> + <Route path=StaticSegment("signup") view=SignupPage /> + <Route path=StaticSegment("home") view=HomePage /> + <Route path=StaticSegment("password-reset") view=PasswordResetPage /> + <Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage /> + </Routes> + } +}