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! {
+
+
+
+
+
+
+ // ==========================================
+ // User routes (eager loading)
+ // ==========================================
+
+
+
+
+
+
+ // ==========================================
+ // Admin routes (lazy loading)
+ // Server renders fallback, client loads lazy WASM after hydration.
+ // ==========================================
+
+
+
+
+
+
+
+
+
+ // Scene routes (must come before realm detail to match first)
+
+
+
+ // Realm detail (must come after more specific routes)
+
+
+
+
+ }
+}
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> {
+ // 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> {
+ 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: "";
+ inherits: false;
+ initial-value: rgba(0, 0, 0, 0);
+}
+
+@property --tw-gradient-via {
+ syntax: "";
+ inherits: false;
+ initial-value: rgba(0, 0, 0, 0);
+}
+
+@property --tw-gradient-to {
+ syntax: "";
+ 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: "";
+ inherits: false;
+ initial-value: 0%;
+}
+
+@property --tw-gradient-via-position {
+ syntax: "";
+ inherits: false;
+ initial-value: 50%;
+}
+
+@property --tw-gradient-to-position {
+ syntax: "";
+ 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: "";
+ 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: "";
+ 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: "";
+ 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: "";
+ 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> {
+ // 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> {
+ 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: "";
+ inherits: false;
+ initial-value: rgba(0, 0, 0, 0);
+}
+
+@property --tw-gradient-via {
+ syntax: "";
+ inherits: false;
+ initial-value: rgba(0, 0, 0, 0);
+}
+
+@property --tw-gradient-to {
+ syntax: "";
+ 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: "";
+ inherits: false;
+ initial-value: 0%;
+}
+
+@property --tw-gradient-via-position {
+ syntax: "";
+ inherits: false;
+ initial-value: 50%;
+}
+
+@property --tw-gradient-to-position {
+ syntax: "";
+ 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: "";
+ 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: "";
+ 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: "";
+ 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: "";
+ 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;
+ 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;
+ close(): Promise;
+ write(chunk: any): Promise;
+}
+
+export class IntoUnderlyingSource {
+ private constructor();
+ free(): void;
+ [Symbol.dispose](): void;
+ pull(controller: ReadableStreamDefaultController): Promise;
+ 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 }} module_or_path - Passing `InitInput` directly is deprecated.
+*
+* @returns {Promise}
+*/
+export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise;
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}
+ */
+ 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}
+ */
+ abort(reason) {
+ const ptr = this.__destroy_into_raw();
+ const ret = wasm.intounderlyingsink_abort(ptr, reason);
+ return ret;
+ }
+ /**
+ * @returns {Promise}
+ */
+ close() {
+ const ptr = this.__destroy_into_raw();
+ const ret = wasm.intounderlyingsink_close(ptr);
+ return ret;
+ }
+ /**
+ * @param {any} chunk
+ * @returns {Promise}
+ */
+ 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}
+ */
+ 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,
+ session: Session,
+ Json(request): Json,
+) -> Result, (StatusCode, Json)> {
+ // Look up the staff member
+ let staff: Option = 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 = 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, 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,
+}
+
+/// Get auth context endpoint.
+///
+/// Returns the current user's permissions for rendering the sidebar.
+pub async fn get_auth_context(
+ State(pool): State,
+ session: Session,
+) -> Result, (StatusCode, Json)> {
+ // Try to get staff_id from session (server staff)
+ let staff_id: Option = 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 = 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 = 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 = 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,
+) -> Result, AppError> {
+ let config = queries::get_server_config(&pool).await?;
+ Ok(Json(config))
+}
+
+/// Update server config.
+pub async fn update_config(
+ State(pool): State,
+ Json(req): Json,
+) -> Result, 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,
+) -> Result, 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,
+}
+
+impl From 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 {
+ // 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) -> Result>, 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,
+ mut multipart: Multipart,
+) -> Result, AppError> {
+ let mut metadata: Option = None;
+ let mut file_data: Option<(Vec, 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,
+ axum::extract::Path(prop_id): axum::extract::Path,
+) -> Result, 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,
+ axum::extract::Path(prop_id): axum::extract::Path,
+) -> Result, 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,
+}
+
+/// 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,
+ pub limit: Option,
+ pub offset: Option,
+}
+
+/// List realms with optional search.
+pub async fn list_realms(
+ State(pool): State,
+ Query(query): Query,
+) -> Result>, 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,
+ Path(slug): Path,
+) -> Result, 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,
+ Json(req): Json,
+) -> Result, 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,
+ Path(slug): Path,
+ Json(req): Json,
+) -> Result, 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,
+ Path(slug): Path,
+ Json(req): Json,
+) -> Result, 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 {
+ 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 {
+ 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,
+ pub offset: Option,
+}
+
+/// List all scenes for a realm.
+pub async fn list_scenes(
+ State(pool): State,
+ Path(slug): Path,
+) -> Result>, 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,
+ Path(scene_id): Path,
+) -> Result, 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,
+ Path(slug): Path,
+ Json(mut req): Json,
+) -> Result, 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,
+ Path(scene_id): Path,
+ Json(mut req): Json,
+) -> Result, 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,
+ Path(scene_id): Path,
+) -> Result, 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,
+ Path(scene_id): Path,
+) -> Result>, 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,
+ Path(spot_id): Path,
+) -> Result, 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,
+ Path(scene_id): Path,
+ Json(req): Json,
+) -> Result, 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,
+ Path(spot_id): Path,
+ Json(req): Json,
+) -> Result, 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,
+ Path(spot_id): Path,
+) -> Result, 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,
+}
+
+/// List all staff members.
+pub async fn list_staff(
+ State(pool): State,
+) -> Result>, 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,
+ Json(req): Json,
+) -> Result, 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,
+ Path(user_id): Path,
+) -> Result, 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,
+ pub offset: Option,
+}
+
+/// Query parameters for user search.
+#[derive(Debug, Deserialize)]
+pub struct SearchUsersQuery {
+ pub q: String,
+ pub limit: Option,
+}
+
+/// List all users with pagination.
+pub async fn list_users(
+ State(pool): State,
+ Query(query): Query,
+) -> Result>, 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,
+ Json(req): Json,
+) -> Result, 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,
+ Path(user_id): Path,
+) -> Result, 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,
+ Query(query): Query,
+) -> Result>, 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,
+ Path(user_id): Path,
+) -> Result, 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,
+ Path(user_id): Path,
+ Json(req): Json,
+) -> Result, 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,
+ Path(user_id): Path,
+) -> Result>, 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,
+ Path(user_id): Path,
+ Json(req): Json,
+) -> Result, 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,
+ Path((user_id, realm_id)): Path<(Uuid, Uuid)>,
+) -> Result, 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,
+) -> Result>, 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 for LeptosOptions {
+ fn from_ref(state: &AdminAppState) -> Self {
+ state.leptos_options.clone()
+ }
+}
+
+#[cfg(feature = "ssr")]
+impl axum::extract::FromRef 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! {
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
+
+/// 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! {
+
+
+
+
+
+
+ }
+}
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,
+ },
+}
+
+#[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 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 {
+ 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,
+}
+
+/// 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! {
+
+
+
+ {children()}
+
+
+ }
+}
+
+/// Login page layout (no sidebar).
+#[component]
+pub fn LoginLayout(children: Children) -> impl IntoView {
+ view! {
+
+ {children()}
+
+ }
+}
+
+/// 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> {
+ 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::().await.ok(),
+ _ => None,
+ }
+ }
+ #[cfg(not(feature = "hydrate"))]
+ {
+ None::
+ }
+ })
+}
+
+/// 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! {
+
+
+
+ }>
+ {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! {
+
+ {children()}
+
+ }.into_any()
+ }
+ None => {
+ // Fallback: show layout with default props (server staff view)
+ view! {
+
+ {children()}
+
+ }.into_any()
+ }
+ }
+ })
+ }}
+
+ }
+}
+
+/// 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! {
+
+ }
+}
+
+/// 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! {
+
+ {label}
+
+ }
+}
+
+/// 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,
+) -> impl IntoView {
+ let has_subtitle = !subtitle.is_empty();
+
+ view! {
+
+ }
+}
+
+/// 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! {
+
+ {if has_title {
+ view! {
{title} }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+ {children()}
+
+ }
+}
+
+/// Detail grid for key-value display.
+#[component]
+pub fn DetailGrid(children: Children) -> impl IntoView {
+ view! {
+
+ {children()}
+
+ }
+}
+
+/// Detail item within a detail grid.
+#[component]
+pub fn DetailItem(label: &'static str, children: Children) -> impl IntoView {
+ view! {
+
+
{label}
+
{children()}
+
+ }
+}
+
+/// Status badge component.
+#[component]
+pub fn StatusBadge(
+ /// Status text
+ status: String,
+) -> impl IntoView {
+ let class = format!("status-badge status-{}", status.to_lowercase());
+ view! {
+ {status}
+ }
+}
+
+/// Privacy badge component.
+#[component]
+pub fn PrivacyBadge(
+ /// Privacy level
+ privacy: String,
+) -> impl IntoView {
+ let class = format!("privacy-badge privacy-{}", privacy.to_lowercase());
+ view! {
+ {privacy}
+ }
+}
+
+/// NSFW badge component.
+#[component]
+pub fn NsfwBadge() -> impl IntoView {
+ view! {
+ "NSFW"
+ }
+}
+
+/// 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! {
+
+
{message}
+ {if has_action {
+ view! {
+
{action_text}
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+
+ }
+}
+
+/// 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! {
+
+ }
+}
+
+/// 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>) -> impl IntoView {
+ view! {
+
+ {move || {
+ let (msg, is_success) = message.get().unwrap_or_default();
+ let class = if is_success { "alert alert-success" } else { "alert alert-error" };
+ view! {
+
+ }
+ }}
+
+ }
+}
+
+/// Message alert that works with RwSignal.
+#[component]
+pub fn MessageAlertRw(message: RwSignal >) -> impl IntoView {
+ view! {
+
+ {move || {
+ let (msg, is_success) = message.get().unwrap_or_default();
+ let class = if is_success { "alert alert-success" } else { "alert alert-error" };
+ view! {
+
+ }
+ }}
+
+ }
+}
+
+/// Temporary password display component.
+///
+/// Shows the temporary password with a warning to copy it.
+#[component]
+pub fn TempPasswordDisplay(
+ /// The temporary password signal
+ password: ReadSignal >,
+ /// Optional label (default: "Temporary Password:")
+ #[prop(default = "Temporary Password:")]
+ label: &'static str,
+) -> impl IntoView {
+ view! {
+
+
+
{label}
+
{move || password.get().unwrap_or_default()}
+
"Copy this password now - it will not be shown again!"
+
+
+ }
+}
+
+/// 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,
+ /// 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! {
+
+ {message}
+
+
+ {move || if pending.get() { "Deleting..." } else { confirm_text }}
+
+
+ "Cancel"
+
+
+
+ }
+ }
+ >
+
+ {button_text}
+
+
+ }
+}
+
+/// 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,
+ /// 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! {
+
+ {move || if pending.get() { loading_text } else { text }}
+
+ }
+}
+
+/// Loading spinner.
+#[component]
+pub fn LoadingSpinner(#[prop(optional)] message: &'static str) -> impl IntoView {
+ view! {
+
+
+ {if !message.is_empty() {
+ view! {
{message} }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+
+ }
+}
+
+/// Role badge component.
+#[component]
+pub fn RoleBadge(role: String) -> impl IntoView {
+ let class = format!("role-badge role-{}", role.to_lowercase());
+ view! {
+ {role}
+ }
+}
+
+/// 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! {
+
+ }
+}
+
+/// 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,
+) -> impl IntoView {
+ view! {
+
+ }
+}
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::>(move || format!("/api/users?page={}", page()));
+/// ```
+pub fn use_fetch(url_fn: impl Fn() -> String + Send + Sync + 'static) -> LocalResource>
+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::().await.ok(),
+ _ => None,
+ }
+ }
+ #[cfg(not(feature = "hydrate"))]
+ {
+ let _ = url;
+ None::
+ }
+ }
+ })
+}
+
+/// 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::(
+/// move || !user_id().is_empty(),
+/// move || format!("/api/users/{}", user_id())
+/// );
+/// ```
+pub fn use_fetch_if(
+ condition: impl Fn() -> bool + Send + Sync + 'static,
+ url_fn: impl Fn() -> String + Send + Sync + 'static,
+) -> LocalResource>
+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::().await.ok(),
+ _ => None,
+ }
+ }
+ #[cfg(not(feature = "hydrate"))]
+ {
+ let _ = url;
+ None::
+ }
+ }
+ })
+}
+
+/// Pagination state extracted from URL query parameters.
+pub struct PaginationState {
+ /// The current search query (from `?q=...`).
+ pub search_query: Signal,
+ /// The current page number (from `?page=...`, defaults to 1).
+ pub page: Signal,
+ /// Signal for the search input value (for controlled input).
+ pub search_input: RwSignal,
+}
+
+/// 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>;
+
+/// 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(
+ method: &str,
+ url: &str,
+ body: Option<&serde_json::Value>,
+) -> Result
+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::()
+ .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::().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::().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,
+ 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,
+ pub status: String,
+ pub server_role: Option,
+ 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,
+ 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,
+ 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,
+ pub description: Option,
+ pub privacy: String,
+ pub is_nsfw: bool,
+ pub allow_guest_access: bool,
+ pub max_users: i32,
+ pub theme_color: Option,
+ 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,
+}
+
+// =============================================================================
+// 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,
+ pub background_image_path: Option,
+}
+
+/// 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,
+ pub background_image_path: Option,
+ pub background_color: Option,
+ 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,
+ pub welcome_message: Option,
+ 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,
+ 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,
+ pub tags: Vec,
+ pub asset_path: String,
+ pub thumbnail_path: Option,
+ pub default_layer: Option,
+ /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
+ pub default_position: Option,
+ pub is_unique: bool,
+ pub is_transferable: bool,
+ pub is_portable: bool,
+ pub is_active: bool,
+ pub available_from: Option,
+ pub available_until: Option,
+ 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::(|| "/api/admin/config".to_string());
+
+ view! {
+
+
+ "Loading configuration..." }>
+ {move || {
+ config.get().map(|maybe_config| {
+ match maybe_config {
+ Some(cfg) => view! {
+
+ }.into_any(),
+ None => view! {
+
+ "Failed to load configuration. You may not have permission to access this page."
+
+ }.into_any()
+ }
+ })
+ }}
+
+ }
+}
+
+/// Config form component.
+#[component]
+#[allow(unused_variables)]
+fn ConfigForm(
+ config: ServerConfig,
+ message: ReadSignal>,
+ set_message: WriteSignal >,
+ pending: ReadSignal,
+ set_pending: WriteSignal,
+) -> 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:: } else { Some(description.get()) },
+ "welcome_message": if welcome_message.get().is_empty() { None:: } 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! {
+
+
+
+ }
+}
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::(|| "/api/admin/dashboard/stats".to_string());
+
+ view! {
+
+
+
+ "Loading stats..." }>
+ {move || {
+ stats.get().map(|maybe_stats| {
+ match maybe_stats {
+ Some(s) => view! {
+
+
+
+
+
+ }.into_any(),
+ None => view! {
+
+
+
+
+
+ }.into_any()
+ }
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ "Activity feed coming soon..."
+
+
+ }
+}
+
+/// Stat card component.
+#[component]
+fn StatCard(title: &'static str, value: String) -> impl IntoView {
+ view! {
+
+ }
+}
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::::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::().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! {
+
+
+
+
+
+
+
+ "Username"
+ "*"
+
+
+
+
+
+
+ "Password"
+ "*"
+
+
+
+
+
+
+
{move || error.get().unwrap_or_default()}
+
+
+
+
+ {move || if pending.get() { "Signing in..." } else { "Sign In" }}
+
+
+
+
+ }
+}
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::>(|| "/api/admin/props".to_string());
+
+ view! {
+
+
+
+
+
+ "Loading props..." }>
+ {move || {
+ props.get().map(|maybe_props: Option>| {
+ match maybe_props {
+ Some(prop_list) if !prop_list.is_empty() => {
+ if view_mode.get() == ViewMode::Table {
+ view! { }.into_any()
+ } else {
+ view! { }.into_any()
+ }
+ }
+ _ => view! {
+
+ }.into_any()
+ }
+ })
+ }}
+
+
+ }
+}
+
+/// Table view for props.
+#[component]
+fn PropsTable(props: Vec) -> impl IntoView {
+ view! {
+
+
+
+
+ "Preview"
+ "Name"
+ "Slug"
+ "Layer"
+ "Active"
+ "Created"
+
+
+
+ {props.into_iter().map(|prop| {
+ let asset_url = format!("/assets/{}", prop.asset_path);
+ view! {
+
+
+
+
+
+
+ {prop.name}
+
+
+ {prop.slug}
+
+ {prop.default_layer.map(|l| l.to_string()).unwrap_or_else(|| "-".to_string())}
+
+
+ {if prop.is_active {
+ view! { "Active" }.into_any()
+ } else {
+ view! { "Inactive" }.into_any()
+ }}
+
+ {prop.created_at}
+
+ }
+ }).collect_view()}
+
+
+
+ }
+}
+
+/// Grid view for props with 64x64 thumbnails.
+#[component]
+fn PropsGrid(props: Vec) -> impl IntoView {
+ view! {
+
+ {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! {
+
+
+
+ {prop_name_for_label}
+
+
+ }
+ }).collect_view()}
+
+ }
+}
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::(
+ move || !prop_id().is_empty(),
+ move || format!("/api/admin/props/{}", prop_id()),
+ );
+
+ view! {
+
+ "Back to Props"
+
+
+ "Loading prop..." }>
+ {move || {
+ prop.get().map(|maybe_prop| {
+ match maybe_prop {
+ Some(p) => view! {
+
+ }.into_any(),
+ None => view! {
+
+ "Prop not found or you don't have permission to view."
+
+ }.into_any()
+ }
+ })
+ }}
+
+ }
+}
+
+#[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! {
+
+
+
+
+
+
+
+ {prop.id.clone()}
+
+
+ {tags_display}
+
+
+ {prop.default_layer.clone().unwrap_or_else(|| "Not set".to_string())}
+
+
+ {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(),
+ }}
+
+
+ {if prop.is_active {
+ view! { "Active" }.into_any()
+ } else {
+ view! { "Inactive" }.into_any()
+ }}
+
+
+
+
+
+
+
+ {if prop.is_unique { "Yes" } else { "No" }}
+
+
+ {if prop.is_transferable { "Yes" } else { "No" }}
+
+
+ {if prop.is_portable { "Yes" } else { "No" }}
+
+
+
+
+
+
+
+ {prop.available_from.clone().unwrap_or_else(|| "Always".to_string())}
+
+
+ {prop.available_until.clone().unwrap_or_else(|| "No end date".to_string())}
+
+
+ {prop.created_at.clone()}
+
+
+ {prop.updated_at.clone()}
+
+
+
+ }
+}
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::::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::::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::()
+ .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::()
+ .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