add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
90
apps/chattyness-app/Cargo.toml
Normal file
90
apps/chattyness-app/Cargo.toml
Normal file
|
|
@ -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"
|
||||||
1
apps/chattyness-app/public/admin.css
Symbolic link
1
apps/chattyness-app/public/admin.css
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../../target/site-owner/static/chattyness-owner.css
|
||||||
0
apps/chattyness-app/public/favicon.ico
Normal file
0
apps/chattyness-app/public/favicon.ico
Normal file
326
apps/chattyness-app/src/app.rs
Normal file
326
apps/chattyness-app/src/app.rs
Normal file
|
|
@ -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! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="dashboard" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::DashboardPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_login() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::LoginLayout>
|
||||||
|
<chattyness_admin_ui::pages::LoginPage />
|
||||||
|
</chattyness_admin_ui::components::LoginLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_config() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="config" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::ConfigPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_users() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="users" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::UsersPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_user_new() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="users" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::UserNewPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_user_detail() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="users" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::UserDetailPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_staff() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="staff" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::StaffPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_realms() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="realms" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::RealmsPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_realm_new() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="realms" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::RealmNewPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_realm_detail() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="realms" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::RealmDetailPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_scenes() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="scenes" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::ScenesPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_scene_new() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="scenes_new" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::SceneNewPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[lazy]
|
||||||
|
fn lazy_scene_detail() -> AnyView {
|
||||||
|
view! {
|
||||||
|
<Stylesheet href="/static/css/admin.css"/>
|
||||||
|
<chattyness_admin_ui::components::AuthenticatedLayout current_page="scenes" base_path="/admin">
|
||||||
|
<chattyness_admin_ui::pages::SceneDetailPage />
|
||||||
|
</chattyness_admin_ui::components::AuthenticatedLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin loading fallback - shown on both server (SSR) and client until lazy content loads.
|
||||||
|
#[component]
|
||||||
|
fn AdminLoading() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="admin-loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>"Loading admin panel..."</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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! {
|
||||||
|
<Suspense fallback=AdminLoading>
|
||||||
|
{
|
||||||
|
// 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 }) }
|
||||||
|
}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<CombinedAppState> for LeptosOptions {
|
||||||
|
fn from_ref(state: &CombinedAppState) -> Self {
|
||||||
|
state.leptos_options.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<CombinedAppState> 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! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<AutoReload options=options.clone() />
|
||||||
|
<HydrationScripts options />
|
||||||
|
<MetaTags />
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-white antialiased">
|
||||||
|
<CombinedApp />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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! {
|
||||||
|
<Stylesheet id="leptos" href="/static/chattyness-app.css" />
|
||||||
|
<Title text="Chattyness - Virtual Community Spaces" />
|
||||||
|
|
||||||
|
<Router>
|
||||||
|
<main>
|
||||||
|
<Routes fallback=|| "Page not found.".into_view()>
|
||||||
|
// ==========================================
|
||||||
|
// User routes (eager loading)
|
||||||
|
// ==========================================
|
||||||
|
<Route path=StaticSegment("") view=LoginPage />
|
||||||
|
<Route path=StaticSegment("signup") view=SignupPage />
|
||||||
|
<Route path=StaticSegment("home") view=HomePage />
|
||||||
|
<Route path=StaticSegment("password-reset") view=PasswordResetPage />
|
||||||
|
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=RealmPage />
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Admin routes (lazy loading)
|
||||||
|
// Server renders fallback, client loads lazy WASM after hydration.
|
||||||
|
// ==========================================
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("login"))
|
||||||
|
view=admin_login_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=StaticSegment("admin")
|
||||||
|
view=admin_dashboard_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("config"))
|
||||||
|
view=admin_config_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("users"))
|
||||||
|
view=admin_users_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("users"), StaticSegment("new"))
|
||||||
|
view=admin_user_new_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("users"), ParamSegment("user_id"))
|
||||||
|
view=admin_user_detail_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("staff"))
|
||||||
|
view=admin_staff_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("realms"))
|
||||||
|
view=admin_realms_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("realms"), StaticSegment("new"))
|
||||||
|
view=admin_realm_new_view
|
||||||
|
/>
|
||||||
|
// Scene routes (must come before realm detail to match first)
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"))
|
||||||
|
view=admin_scenes_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), StaticSegment("new"))
|
||||||
|
view=admin_scene_new_view
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), ParamSegment("scene_id"))
|
||||||
|
view=admin_scene_detail_view
|
||||||
|
/>
|
||||||
|
// Realm detail (must come after more specific routes)
|
||||||
|
<Route
|
||||||
|
path=(StaticSegment("admin"), StaticSegment("realms"), ParamSegment("slug"))
|
||||||
|
view=admin_realm_detail_view
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/chattyness-app/src/lib.rs
Normal file
20
apps/chattyness-app/src/lib.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
196
apps/chattyness-app/src/main.rs
Normal file
196
apps/chattyness-app/src/main.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
//! App server entry point.
|
||||||
|
//!
|
||||||
|
//! This server runs on port 3000 and serves both user and admin interfaces
|
||||||
|
//! using a unified CombinedApp with lazy-loaded admin routes.
|
||||||
|
//!
|
||||||
|
//! Both interfaces share the same `chattyness_app` database role with RLS.
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
mod server {
|
||||||
|
use axum::Router;
|
||||||
|
use clap::Parser;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use chattyness_app::{combined_shell, CombinedApp, CombinedAppState};
|
||||||
|
use chattyness_user_ui::api::WebSocketState;
|
||||||
|
|
||||||
|
/// CLI arguments.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "chattyness-app")]
|
||||||
|
#[command(about = "Chattyness App Server (User + Admin UI)")]
|
||||||
|
struct Args {
|
||||||
|
/// Host to bind to
|
||||||
|
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
/// Port to bind to
|
||||||
|
#[arg(long, env = "APP_PORT", default_value = "3000")]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Database password for chattyness_app role
|
||||||
|
#[arg(long, env = "DB_CHATTYNESS_APP")]
|
||||||
|
db_password: String,
|
||||||
|
|
||||||
|
/// Use secure cookies
|
||||||
|
#[arg(long, env = "SECURE_COOKIES", default_value = "false")]
|
||||||
|
secure_cookies: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Load environment variables
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "chattyness_app=debug,chattyness_user_ui=debug,tower_http=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
tracing::info!("Starting Chattyness App Server");
|
||||||
|
|
||||||
|
// Create database pool for app access (fixed connection string, RLS-constrained)
|
||||||
|
let database_url = format!(
|
||||||
|
"postgres://chattyness_app:{}@localhost/chattyness",
|
||||||
|
args.db_password
|
||||||
|
);
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(20)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!("Connected to database (app role with RLS)");
|
||||||
|
|
||||||
|
// Configure Leptos
|
||||||
|
let cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
|
||||||
|
let conf = get_configuration(Some(cargo_toml)).unwrap();
|
||||||
|
let leptos_options = conf.leptos_options;
|
||||||
|
let addr = SocketAddr::new(args.host.parse()?, args.port);
|
||||||
|
|
||||||
|
// Create session layer (shared between user and admin interfaces)
|
||||||
|
let session_layer =
|
||||||
|
chattyness_user_ui::auth::session::create_session_layer(pool.clone(), args.secure_cookies)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create combined app state
|
||||||
|
let app_state = CombinedAppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
leptos_options: leptos_options.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate routes for the combined app
|
||||||
|
let routes = generate_route_list(CombinedApp);
|
||||||
|
|
||||||
|
// Get site paths from Leptos config
|
||||||
|
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
|
||||||
|
let site_root = workspace_root.join(&*leptos_options.site_root);
|
||||||
|
// site-pkg-dir is now "pkg" to match wasm_split's hardcoded /pkg/ imports
|
||||||
|
let pkg_dir = site_root.join("pkg");
|
||||||
|
let public_dir = manifest_dir.join("public");
|
||||||
|
// --split mode puts WASM/JS in target/site-app/pkg/ instead of configured location
|
||||||
|
let split_pkg_dir = workspace_root.join("target/site-app/pkg");
|
||||||
|
|
||||||
|
tracing::info!("Serving pkg files from: {}", pkg_dir.display());
|
||||||
|
tracing::info!("Serving split WASM from: {}", split_pkg_dir.display());
|
||||||
|
|
||||||
|
// Shared assets directory for uploaded files (realm images, etc.)
|
||||||
|
let assets_dir = Path::new("/srv/chattyness/assets");
|
||||||
|
|
||||||
|
// Create WebSocket state for real-time channel presence
|
||||||
|
let ws_state = Arc::new(WebSocketState::new());
|
||||||
|
|
||||||
|
// Create state types for each API router
|
||||||
|
let user_api_state = chattyness_user_ui::AppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
leptos_options: leptos_options.clone(),
|
||||||
|
ws_state: ws_state.clone(),
|
||||||
|
};
|
||||||
|
let admin_api_state = chattyness_admin_ui::AdminAppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
leptos_options: leptos_options.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build nested API routers with their own state
|
||||||
|
let user_api_router = chattyness_user_ui::api::api_router()
|
||||||
|
.with_state(user_api_state);
|
||||||
|
let admin_api_router = chattyness_admin_ui::api::admin_api_router()
|
||||||
|
.with_state(admin_api_state);
|
||||||
|
|
||||||
|
// Create RLS layer for row-level security
|
||||||
|
let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone());
|
||||||
|
|
||||||
|
// Build the unified app
|
||||||
|
// Layer order (outer to inner): session -> rls -> router
|
||||||
|
// This ensures session is available when RLS middleware runs
|
||||||
|
let app = Router::new()
|
||||||
|
// API routes (with their own state)
|
||||||
|
.nest("/api", user_api_router)
|
||||||
|
.nest("/api/admin", admin_api_router)
|
||||||
|
// Leptos routes with unified shell
|
||||||
|
.leptos_routes_with_context(
|
||||||
|
&app_state,
|
||||||
|
routes,
|
||||||
|
{
|
||||||
|
let pool = pool.clone();
|
||||||
|
move || {
|
||||||
|
provide_context(pool.clone());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let leptos_options = leptos_options.clone();
|
||||||
|
move || combined_shell(leptos_options.clone())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_state(app_state)
|
||||||
|
// Serve pkg files at /pkg (wasm_split hardcodes /pkg/ imports)
|
||||||
|
// Fallback to split_pkg_dir for --split mode output
|
||||||
|
.nest_service("/pkg", ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir)))
|
||||||
|
// Uploaded assets (realm backgrounds, etc.) - must come before /static
|
||||||
|
.nest_service("/static/realm", ServeDir::new(assets_dir.join("realm")))
|
||||||
|
// Server-level assets (avatar props, etc.)
|
||||||
|
.nest_service("/static/server", ServeDir::new(assets_dir.join("server")))
|
||||||
|
// Also serve at /static for backwards compatibility
|
||||||
|
.nest_service("/static", ServeDir::new(&pkg_dir).fallback(ServeDir::new(&split_pkg_dir)))
|
||||||
|
.nest_service("/favicon.ico", tower_http::services::ServeFile::new(public_dir.join("favicon.ico")))
|
||||||
|
// Serve admin CSS at standardized path (symlinked from owner build)
|
||||||
|
.nest_service("/static/css/admin.css", tower_http::services::ServeFile::new(public_dir.join("admin.css")))
|
||||||
|
// Apply middleware layers (order: session outer, rls inner)
|
||||||
|
.layer(rls_layer)
|
||||||
|
.layer(session_layer);
|
||||||
|
|
||||||
|
tracing::info!("Listening on http://{}", addr);
|
||||||
|
tracing::info!(" User UI: http://{}/", addr);
|
||||||
|
tracing::info!(" Admin UI: http://{}/admin", addr);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app.into_make_service()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
server::main().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
fn main() {
|
||||||
|
// This is for WASM build, which is handled by lib.rs
|
||||||
|
}
|
||||||
78
apps/chattyness-app/style/tailwind.css
Normal file
78
apps/chattyness-app/style/tailwind.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/chattyness-app/style/user.css
Normal file
8
apps/chattyness-app/style/user.css
Normal file
|
|
@ -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 */
|
||||||
2369
apps/chattyness-app/target/site/pkg/chattyness-app.css
Normal file
2369
apps/chattyness-app/target/site/pkg/chattyness-app.css
Normal file
File diff suppressed because it is too large
Load diff
86
apps/chattyness-owner/Cargo.toml
Normal file
86
apps/chattyness-owner/Cargo.toml
Normal file
|
|
@ -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"
|
||||||
0
apps/chattyness-owner/public/favicon.ico
Normal file
0
apps/chattyness-owner/public/favicon.ico
Normal file
9
apps/chattyness-owner/src/lib.rs
Normal file
9
apps/chattyness-owner/src/lib.rs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
151
apps/chattyness-owner/src/main.rs
Normal file
151
apps/chattyness-owner/src/main.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
//! Owner app server entry point.
|
||||||
|
//!
|
||||||
|
//! This server runs on port 3001 and serves the admin UI with the
|
||||||
|
//! `chattyness_owner` database role (no RLS restrictions).
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
mod server {
|
||||||
|
use axum::{response::Redirect, routing::get, Router};
|
||||||
|
use clap::Parser;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use chattyness_admin_ui::{admin_shell, AdminApp, AdminAppState};
|
||||||
|
|
||||||
|
/// CLI arguments.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "chattyness-owner")]
|
||||||
|
#[command(about = "Chattyness Owner Admin Server")]
|
||||||
|
struct Args {
|
||||||
|
/// Host to bind to
|
||||||
|
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
/// Port to bind to
|
||||||
|
#[arg(long, env = "OWNER_PORT", default_value = "3001")]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Database password for chattyness_owner role
|
||||||
|
#[arg(long, env = "DB_CHATTYNESS_OWNER")]
|
||||||
|
db_password: String,
|
||||||
|
|
||||||
|
/// Use secure cookies
|
||||||
|
#[arg(long, env = "SECURE_COOKIES", default_value = "false")]
|
||||||
|
secure_cookies: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Load environment variables
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "chattyness_owner=debug,tower_http=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
tracing::info!("Starting Chattyness Owner Server");
|
||||||
|
|
||||||
|
// Create database pool for owner access (fixed connection string)
|
||||||
|
let database_url = format!(
|
||||||
|
"postgres://chattyness_owner:{}@localhost/chattyness",
|
||||||
|
args.db_password
|
||||||
|
);
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.connect(&database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!("Connected to database (owner role)");
|
||||||
|
|
||||||
|
// Configure Leptos
|
||||||
|
let cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
|
||||||
|
let conf = get_configuration(Some(cargo_toml)).unwrap();
|
||||||
|
let leptos_options = conf.leptos_options;
|
||||||
|
let addr = SocketAddr::new(args.host.parse()?, args.port);
|
||||||
|
|
||||||
|
// Create session layer
|
||||||
|
let session_layer =
|
||||||
|
chattyness_admin_ui::auth::create_admin_session_layer(pool.clone(), args.secure_cookies)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create app state
|
||||||
|
let app_state = AdminAppState {
|
||||||
|
pool: pool.clone(),
|
||||||
|
leptos_options: leptos_options.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate routes
|
||||||
|
let routes = generate_route_list(AdminApp);
|
||||||
|
|
||||||
|
// Get site paths from Leptos config
|
||||||
|
// site_root is relative to workspace root, make it absolute
|
||||||
|
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
|
||||||
|
let site_root = workspace_root.join(&*leptos_options.site_root);
|
||||||
|
let static_dir = site_root.join("static");
|
||||||
|
let favicon_path = manifest_dir.join("public/favicon.ico");
|
||||||
|
|
||||||
|
tracing::info!("Serving static files from: {}", site_root.display());
|
||||||
|
|
||||||
|
// Admin CSS path
|
||||||
|
let admin_css_path = static_dir.join("chattyness-owner.css");
|
||||||
|
|
||||||
|
// Shared assets directory for uploaded files (realm images, etc.)
|
||||||
|
let assets_dir = Path::new("/srv/chattyness/assets");
|
||||||
|
|
||||||
|
// Build the app
|
||||||
|
let app = Router::new()
|
||||||
|
// Redirect root to admin
|
||||||
|
.route("/", get(|| async { Redirect::permanent("/admin") }))
|
||||||
|
// Nest API routes under /api/admin (matches frontend expectations when UI is at /admin)
|
||||||
|
.nest("/api/admin", chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone()))
|
||||||
|
// Uploaded assets (realm backgrounds, props, etc.) - must come before /static
|
||||||
|
.nest_service("/assets/server", ServeDir::new(assets_dir.join("server")))
|
||||||
|
.nest_service("/static/realm", ServeDir::new(assets_dir.join("realm")))
|
||||||
|
// Static files (build output: JS, CSS, WASM)
|
||||||
|
.nest_service("/static", ServeDir::new(&static_dir))
|
||||||
|
.nest_service("/favicon.ico", tower_http::services::ServeFile::new(&favicon_path))
|
||||||
|
// Serve admin CSS at standardized path
|
||||||
|
.nest_service("/static/css/admin.css", tower_http::services::ServeFile::new(&admin_css_path))
|
||||||
|
// Leptos routes
|
||||||
|
.leptos_routes(&app_state, routes, {
|
||||||
|
let leptos_options = leptos_options.clone();
|
||||||
|
move || admin_shell(leptos_options.clone())
|
||||||
|
})
|
||||||
|
// Apply session middleware
|
||||||
|
.layer(session_layer)
|
||||||
|
.with_state(app_state);
|
||||||
|
|
||||||
|
tracing::info!("Listening on http://{}", addr);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app.into_make_service()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
server::main().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
fn main() {
|
||||||
|
// This is for WASM build, which is handled by lib.rs
|
||||||
|
}
|
||||||
463
apps/chattyness-owner/style/admin.css
Normal file
463
apps/chattyness-owner/style/admin.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
879
apps/chattyness-owner/style/shared.css
Normal file
879
apps/chattyness-owner/style/shared.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
83
apps/chattyness-owner/style/tailwind.css
Normal file
83
apps/chattyness-owner/style/tailwind.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3543
apps/chattyness-owner/target/site/pkg/chattyness-owner.css
Normal file
3543
apps/chattyness-owner/target/site/pkg/chattyness-owner.css
Normal file
File diff suppressed because it is too large
Load diff
96
apps/chattyness-owner/target/site/pkg/chattyness-owner.d.ts
vendored
Normal file
96
apps/chattyness-owner/target/site/pkg/chattyness-owner.d.ts
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* The `ReadableStreamType` enum.
|
||||||
|
*
|
||||||
|
* *This API requires the following crate features to be activated: `ReadableStreamType`*
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ReadableStreamType = "bytes";
|
||||||
|
|
||||||
|
export class IntoUnderlyingByteSource {
|
||||||
|
private constructor();
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
pull(controller: ReadableByteStreamController): Promise<any>;
|
||||||
|
start(controller: ReadableByteStreamController): void;
|
||||||
|
cancel(): void;
|
||||||
|
readonly autoAllocateChunkSize: number;
|
||||||
|
readonly type: ReadableStreamType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IntoUnderlyingSink {
|
||||||
|
private constructor();
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
abort(reason: any): Promise<any>;
|
||||||
|
close(): Promise<any>;
|
||||||
|
write(chunk: any): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IntoUnderlyingSource {
|
||||||
|
private constructor();
|
||||||
|
free(): void;
|
||||||
|
[Symbol.dispose](): void;
|
||||||
|
pull(controller: ReadableStreamDefaultController): Promise<any>;
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrate(): void;
|
||||||
|
|
||||||
|
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
export interface InitOutput {
|
||||||
|
readonly memory: WebAssembly.Memory;
|
||||||
|
readonly hydrate: () => void;
|
||||||
|
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||||
|
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||||
|
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||||
|
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||||
|
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||||
|
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||||
|
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||||
|
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||||
|
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||||
|
readonly intounderlyingsink_close: (a: number) => any;
|
||||||
|
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||||
|
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||||
|
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___wasm_bindgen_3ecf883c72d93b1f___JsValue_____: (a: number, b: number, c: any) => void;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__wasm_bindgen_3ecf883c72d93b1f___JsValue____Output_______: (a: number, b: number) => void;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke______: (a: number, b: number) => void;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut_____Output_______: (a: number, b: number) => void;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___web_sys_ad13626d47bc89a9___features__gen_Event__Event_____: (a: number, b: number, c: any) => void;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___closure__destroy___dyn_core_2ca9b9bcaa049ca7___ops__function__FnMut__web_sys_ad13626d47bc89a9___features__gen_Event__Event____Output_______: (a: number, b: number) => void;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___bool_: (a: number, b: number) => number;
|
||||||
|
readonly wasm_bindgen_3ecf883c72d93b1f___convert__closures_____invoke___js_sys_21257ab1a865f8ae___Function__js_sys_21257ab1a865f8ae___Function_____: (a: number, b: number, c: any, d: any) => void;
|
||||||
|
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||||
|
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
|
readonly __wbindgen_exn_store: (a: number) => void;
|
||||||
|
readonly __externref_table_alloc: () => number;
|
||||||
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||||
|
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
|
readonly __wbindgen_start: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates the given `module`, which can either be bytes or
|
||||||
|
* a precompiled `WebAssembly.Module`.
|
||||||
|
*
|
||||||
|
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {InitOutput}
|
||||||
|
*/
|
||||||
|
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||||
|
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||||
|
*
|
||||||
|
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {Promise<InitOutput>}
|
||||||
|
*/
|
||||||
|
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||||
1261
apps/chattyness-owner/target/site/pkg/chattyness-owner.js
Normal file
1261
apps/chattyness-owner/target/site/pkg/chattyness-owner.js
Normal file
File diff suppressed because it is too large
Load diff
BIN
apps/chattyness-owner/target/site/pkg/chattyness-owner.wasm
Normal file
BIN
apps/chattyness-owner/target/site/pkg/chattyness-owner.wasm
Normal file
Binary file not shown.
32
apps/chattyness-owner/target/site/pkg/chattyness-owner_bg.wasm.d.ts
vendored
Normal file
32
apps/chattyness-owner/target/site/pkg/chattyness-owner_bg.wasm.d.ts
vendored
Normal file
|
|
@ -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;
|
||||||
72
crates/chattyness-admin-ui/Cargo.toml
Normal file
72
crates/chattyness-admin-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
[package]
|
||||||
|
name = "chattyness-admin-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chattyness-db = { workspace = true }
|
||||||
|
chattyness-error = { workspace = true, optional = true }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
tracing = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# Leptos
|
||||||
|
leptos = { workspace = true }
|
||||||
|
leptos_meta = { workspace = true }
|
||||||
|
leptos_router = { workspace = true }
|
||||||
|
|
||||||
|
# SSR-only dependencies
|
||||||
|
axum = { workspace = true, optional = true }
|
||||||
|
axum-extra = { workspace = true, optional = true }
|
||||||
|
sqlx = { workspace = true, optional = true }
|
||||||
|
tower-sessions = { workspace = true, optional = true }
|
||||||
|
tower-sessions-sqlx-store = { workspace = true, optional = true }
|
||||||
|
argon2 = { workspace = true, optional = true }
|
||||||
|
image = { workspace = true, optional = true }
|
||||||
|
reqwest = { workspace = true, optional = true }
|
||||||
|
sha2 = { workspace = true, optional = true }
|
||||||
|
hex = { workspace = true, optional = true }
|
||||||
|
tokio = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# Hydrate-only dependencies
|
||||||
|
gloo-net = { workspace = true, optional = true }
|
||||||
|
web-sys = { workspace = true, optional = true }
|
||||||
|
wasm-bindgen = { workspace = true, optional = true }
|
||||||
|
console_error_panic_hook = { workspace = true, optional = true }
|
||||||
|
urlencoding = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = [
|
||||||
|
"leptos/ssr",
|
||||||
|
"leptos_meta/ssr",
|
||||||
|
"leptos_router/ssr",
|
||||||
|
"chattyness-db/ssr",
|
||||||
|
"chattyness-error/ssr",
|
||||||
|
"dep:chattyness-error",
|
||||||
|
"dep:axum",
|
||||||
|
"dep:axum-extra",
|
||||||
|
"dep:sqlx",
|
||||||
|
"dep:tracing",
|
||||||
|
"dep:tower-sessions",
|
||||||
|
"dep:tower-sessions-sqlx-store",
|
||||||
|
"dep:argon2",
|
||||||
|
"dep:image",
|
||||||
|
"dep:reqwest",
|
||||||
|
"dep:sha2",
|
||||||
|
"dep:hex",
|
||||||
|
"dep:tokio",
|
||||||
|
]
|
||||||
|
hydrate = [
|
||||||
|
"leptos/hydrate",
|
||||||
|
"dep:gloo-net",
|
||||||
|
"dep:web-sys",
|
||||||
|
"dep:wasm-bindgen",
|
||||||
|
"dep:console_error_panic_hook",
|
||||||
|
"dep:urlencoding",
|
||||||
|
]
|
||||||
25
crates/chattyness-admin-ui/src/api.rs
Normal file
25
crates/chattyness-admin-ui/src/api.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
//! Admin API module.
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod auth;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod config;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod dashboard;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod props;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod realms;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod routes;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod scenes;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod spots;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod staff;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod users;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use routes::admin_api_router;
|
||||||
296
crates/chattyness-admin-ui/src/api/auth.rs
Normal file
296
crates/chattyness-admin-ui/src/api/auth.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
//! Admin authentication API handlers.
|
||||||
|
|
||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use crate::auth::ADMIN_SESSION_STAFF_ID_KEY;
|
||||||
|
|
||||||
|
/// Login request body.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login response body.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error response body.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staff member row for login lookup.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct StaffLoginRow {
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login handler for server staff.
|
||||||
|
///
|
||||||
|
/// Authenticates staff member and creates a session.
|
||||||
|
pub async fn login(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
session: Session,
|
||||||
|
Json(request): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
// Look up the staff member
|
||||||
|
let staff: Option<StaffLoginRow> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id as user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name
|
||||||
|
FROM auth.users u
|
||||||
|
JOIN server.staff s ON s.user_id = u.id
|
||||||
|
WHERE u.username = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&request.username)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database error during login: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Internal server error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let staff = match staff {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Invalid username or password".to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify password using Argon2
|
||||||
|
let password_hash: Option<String> = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT password_hash
|
||||||
|
FROM auth.users
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(staff.user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database error during password check: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Internal server error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let password_hash = match password_hash {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Invalid username or password".to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify password with Argon2
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
|
let parsed_hash = PasswordHash::new(&password_hash).map_err(|e| {
|
||||||
|
tracing::error!("Password hash parse error: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Internal server error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(request.password.as_bytes(), &parsed_hash)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Invalid username or password".to_string(),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is suspended or banned
|
||||||
|
let status: String = sqlx::query_scalar(r#"SELECT status::text FROM auth.users WHERE id = $1"#)
|
||||||
|
.bind(staff.user_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database error during status check: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Internal server error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if status != "active" {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Account is {}", status),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
session
|
||||||
|
.insert(ADMIN_SESSION_STAFF_ID_KEY, staff.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Session error: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Session error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
success: true,
|
||||||
|
username: staff.username,
|
||||||
|
display_name: staff.display_name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout handler.
|
||||||
|
///
|
||||||
|
/// Clears the session.
|
||||||
|
pub async fn logout(session: Session) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
session.flush().await.map_err(|e| {
|
||||||
|
tracing::error!("Session flush error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "success": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Auth Context Types (shared between SSR and hydrate)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Realm info for auth context.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ManagedRealm {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth context response for the frontend.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct AuthContextResponse {
|
||||||
|
pub is_server_staff: bool,
|
||||||
|
pub managed_realms: Vec<ManagedRealm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get auth context endpoint.
|
||||||
|
///
|
||||||
|
/// Returns the current user's permissions for rendering the sidebar.
|
||||||
|
pub async fn get_auth_context(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
session: Session,
|
||||||
|
) -> Result<Json<AuthContextResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
// Try to get staff_id from session (server staff)
|
||||||
|
let staff_id: Option<uuid::Uuid> = session
|
||||||
|
.get(ADMIN_SESSION_STAFF_ID_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if let Some(staff_id) = staff_id {
|
||||||
|
// Check if this is actually a staff member
|
||||||
|
let is_staff: Option<bool> = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM server.staff WHERE user_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(staff_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if is_staff == Some(true) {
|
||||||
|
return Ok(Json(AuthContextResponse {
|
||||||
|
is_server_staff: true,
|
||||||
|
managed_realms: vec![],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get user_id from session (realm admin)
|
||||||
|
let user_id: Option<uuid::Uuid> = session
|
||||||
|
.get(crate::auth::SESSION_USER_ID_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
// Get realms where this user has admin privileges (owner, moderator, builder)
|
||||||
|
let realms: Vec<ManagedRealm> = sqlx::query_as::<_, (String, String)>(
|
||||||
|
r#"
|
||||||
|
SELECT r.slug, r.name
|
||||||
|
FROM realm.realms r
|
||||||
|
JOIN realm.memberships m ON m.realm_id = r.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
AND m.role IN ('owner', 'moderator', 'builder')
|
||||||
|
ORDER BY r.name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Database error fetching managed realms: {}", e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Internal server error".to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(slug, name)| ManagedRealm { slug, name })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return Ok(Json(AuthContextResponse {
|
||||||
|
is_server_staff: false,
|
||||||
|
managed_realms: realms,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid session
|
||||||
|
Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: "Not authenticated".to_string(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
27
crates/chattyness-admin-ui/src/api/config.rs
Normal file
27
crates/chattyness-admin-ui/src/api/config.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
//! Server config API handlers.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{ServerConfig, UpdateServerConfigRequest},
|
||||||
|
queries::owner as queries,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Get server config.
|
||||||
|
pub async fn get_config(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<ServerConfig>, AppError> {
|
||||||
|
let config = queries::get_server_config(&pool).await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update server config.
|
||||||
|
pub async fn update_config(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(req): Json<UpdateServerConfigRequest>,
|
||||||
|
) -> Result<Json<ServerConfig>, AppError> {
|
||||||
|
req.validate()?;
|
||||||
|
let config = queries::update_server_config(&pool, &req).await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
53
crates/chattyness-admin-ui/src/api/dashboard.rs
Normal file
53
crates/chattyness-admin-ui/src/api/dashboard.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
//! Dashboard API handlers.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Dashboard stats response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DashboardStats {
|
||||||
|
pub total_users: i64,
|
||||||
|
pub active_users: i64,
|
||||||
|
pub total_realms: i64,
|
||||||
|
pub online_users: i64,
|
||||||
|
pub staff_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get dashboard stats.
|
||||||
|
pub async fn get_stats(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<DashboardStats>, AppError> {
|
||||||
|
// Total users
|
||||||
|
let total_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM auth.users")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Active users
|
||||||
|
let active_users: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM auth.users WHERE status = 'active'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Total realms
|
||||||
|
let total_realms: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM realm.realms")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Staff count
|
||||||
|
let staff_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server.staff")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Online users would require presence tracking - hardcoded to 0 for now
|
||||||
|
let online_users = 0;
|
||||||
|
|
||||||
|
Ok(Json(DashboardStats {
|
||||||
|
total_users,
|
||||||
|
active_users,
|
||||||
|
total_realms,
|
||||||
|
online_users,
|
||||||
|
staff_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
220
crates/chattyness-admin-ui/src/api/props.rs
Normal file
220
crates/chattyness-admin-ui/src/api/props.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
//! Props management API handlers for admin UI.
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use axum_extra::extract::Multipart;
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
|
||||||
|
queries::props,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Response for prop creation.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreatePropResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub asset_path: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ServerProp> for CreatePropResponse {
|
||||||
|
fn from(prop: ServerProp) -> Self {
|
||||||
|
Self {
|
||||||
|
id: prop.id,
|
||||||
|
name: prop.name,
|
||||||
|
slug: prop.slug,
|
||||||
|
asset_path: prop.asset_path,
|
||||||
|
created_at: prop.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// File Handling
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Validate and get file extension from filename.
|
||||||
|
fn validate_file_extension(filename: &str) -> Result<&'static str, AppError> {
|
||||||
|
let ext = filename
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.map(|e| e.to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
match ext.as_str() {
|
||||||
|
"svg" => Ok("svg"),
|
||||||
|
"png" => Ok("png"),
|
||||||
|
_ => Err(AppError::Validation(
|
||||||
|
"File must be SVG or PNG".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store uploaded file and return the asset path.
|
||||||
|
async fn store_prop_file(bytes: &[u8], extension: &str) -> Result<String, AppError> {
|
||||||
|
// Compute SHA256 hash of the file content
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bytes);
|
||||||
|
let hash = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
// Create directory structure: /srv/chattyness/assets/server/
|
||||||
|
let dir_path = PathBuf::from("/srv/chattyness/assets/server");
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(&dir_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?;
|
||||||
|
|
||||||
|
// Write the file with SHA256 hash as filename
|
||||||
|
let filename = format!("{}.{}", hash, extension);
|
||||||
|
let file_path = dir_path.join(&filename);
|
||||||
|
|
||||||
|
tokio::fs::write(&file_path, bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to write file: {}", e)))?;
|
||||||
|
|
||||||
|
// Return the relative path for database storage
|
||||||
|
Ok(format!("server/{}", filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// List all server props.
|
||||||
|
pub async fn list_props(State(pool): State<PgPool>) -> Result<Json<Vec<ServerPropSummary>>, AppError> {
|
||||||
|
let prop_list = props::list_server_props(&pool).await?;
|
||||||
|
Ok(Json(prop_list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new server prop via multipart upload.
|
||||||
|
///
|
||||||
|
/// Expects multipart form with:
|
||||||
|
/// - `metadata`: JSON object with prop details (CreateServerPropRequest)
|
||||||
|
/// - `file`: Binary SVG or PNG file
|
||||||
|
pub async fn create_prop(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<CreatePropResponse>, AppError> {
|
||||||
|
let mut metadata: Option<CreateServerPropRequest> = None;
|
||||||
|
let mut file_data: Option<(Vec<u8>, String)> = None; // (bytes, extension)
|
||||||
|
|
||||||
|
// Parse multipart fields
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Validation(format!("Failed to read multipart field: {}", e)))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
|
||||||
|
match name.as_str() {
|
||||||
|
"metadata" => {
|
||||||
|
let text = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Validation(format!("Failed to read metadata: {}", e)))?;
|
||||||
|
|
||||||
|
metadata = Some(serde_json::from_str(&text).map_err(|e| {
|
||||||
|
AppError::Validation(format!("Invalid metadata JSON: {}", e))
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
"file" => {
|
||||||
|
let filename = field
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown.png".to_string());
|
||||||
|
|
||||||
|
let extension = validate_file_extension(&filename)?;
|
||||||
|
|
||||||
|
let bytes = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Validation(format!("Failed to read file: {}", e)))?;
|
||||||
|
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(AppError::Validation("File is empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
file_data = Some((bytes.to_vec(), extension.to_string()));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Ignore unknown fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate we have both required fields
|
||||||
|
let metadata = metadata.ok_or_else(|| {
|
||||||
|
AppError::Validation("Missing 'metadata' field in multipart form".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (file_bytes, extension) = file_data.ok_or_else(|| {
|
||||||
|
AppError::Validation("Missing 'file' field in multipart form".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Validate the request
|
||||||
|
metadata.validate()?;
|
||||||
|
|
||||||
|
// Check slug availability
|
||||||
|
let slug = metadata.slug_or_generate();
|
||||||
|
let available = props::is_prop_slug_available(&pool, &slug).await?;
|
||||||
|
if !available {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Prop slug '{}' is already taken",
|
||||||
|
slug
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the file
|
||||||
|
let asset_path = store_prop_file(&file_bytes, &extension).await?;
|
||||||
|
|
||||||
|
// Create the prop in database
|
||||||
|
let prop = props::create_server_prop(&pool, &metadata, &asset_path, None).await?;
|
||||||
|
|
||||||
|
tracing::info!("Created server prop: {} ({})", prop.name, prop.id);
|
||||||
|
|
||||||
|
Ok(Json(CreatePropResponse::from(prop)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a server prop by ID.
|
||||||
|
pub async fn get_prop(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
axum::extract::Path(prop_id): axum::extract::Path<Uuid>,
|
||||||
|
) -> Result<Json<ServerProp>, AppError> {
|
||||||
|
let prop = props::get_server_prop_by_id(&pool, prop_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
|
||||||
|
Ok(Json(prop))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a server prop.
|
||||||
|
pub async fn delete_prop(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
axum::extract::Path(prop_id): axum::extract::Path<Uuid>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
// Get the prop first to get the asset path
|
||||||
|
let prop = props::get_server_prop_by_id(&pool, prop_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
props::delete_server_prop(&pool, prop_id).await?;
|
||||||
|
|
||||||
|
// Try to delete the file (don't fail if file doesn't exist)
|
||||||
|
let file_path = PathBuf::from("/srv/chattyness/assets").join(&prop.asset_path);
|
||||||
|
tokio::fs::remove_file(&file_path).await.ok();
|
||||||
|
|
||||||
|
tracing::info!("Deleted server prop: {} ({})", prop.name, prop_id);
|
||||||
|
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
133
crates/chattyness-admin-ui/src/api/realms.rs
Normal file
133
crates/chattyness-admin-ui/src/api/realms.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
//! Realm management API handlers.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
|
||||||
|
queries::owner as queries,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Create realm response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateRealmResponse {
|
||||||
|
pub realm_id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub owner_temporary_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer ownership request.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TransferOwnershipRequest {
|
||||||
|
pub new_owner_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List query params.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListRealmsQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List realms with optional search.
|
||||||
|
pub async fn list_realms(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(query): Query<ListRealmsQuery>,
|
||||||
|
) -> Result<Json<Vec<RealmListItem>>, AppError> {
|
||||||
|
let limit = query.limit.unwrap_or(25).min(100);
|
||||||
|
let offset = query.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let realms = if let Some(ref q) = query.q {
|
||||||
|
queries::search_realms(&pool, q, limit).await?
|
||||||
|
} else {
|
||||||
|
queries::list_realms_with_owner(&pool, limit, offset).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(realms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a realm by slug.
|
||||||
|
pub async fn get_realm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<RealmDetail>, AppError> {
|
||||||
|
let realm = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||||
|
Ok(Json(realm))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new realm.
|
||||||
|
pub async fn create_realm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(req): Json<OwnerCreateRealmRequest>,
|
||||||
|
) -> Result<Json<CreateRealmResponse>, AppError> {
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// If owner_id is provided, create realm with existing user
|
||||||
|
if let Some(owner_id) = req.owner_id {
|
||||||
|
let realm_id = queries::create_realm(
|
||||||
|
&pool,
|
||||||
|
owner_id,
|
||||||
|
&req.name,
|
||||||
|
&req.slug,
|
||||||
|
req.description.as_deref(),
|
||||||
|
req.tagline.as_deref(),
|
||||||
|
req.privacy,
|
||||||
|
req.is_nsfw,
|
||||||
|
req.max_users,
|
||||||
|
req.allow_guest_access,
|
||||||
|
req.theme_color.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(CreateRealmResponse {
|
||||||
|
realm_id,
|
||||||
|
slug: req.slug,
|
||||||
|
owner_id,
|
||||||
|
owner_temporary_password: None,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// Create realm with new user as owner
|
||||||
|
let (realm_id, user_id, temporary_password) =
|
||||||
|
queries::create_realm_with_new_owner(&pool, &req).await?;
|
||||||
|
|
||||||
|
Ok(Json(CreateRealmResponse {
|
||||||
|
realm_id,
|
||||||
|
slug: req.slug,
|
||||||
|
owner_id: user_id,
|
||||||
|
owner_temporary_password: Some(temporary_password),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a realm.
|
||||||
|
pub async fn update_realm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(req): Json<UpdateRealmRequest>,
|
||||||
|
) -> Result<Json<RealmDetail>, AppError> {
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// First get the realm to find its ID
|
||||||
|
let existing = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||||
|
let realm = queries::update_realm(&pool, existing.id, &req).await?;
|
||||||
|
Ok(Json(realm))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer realm ownership.
|
||||||
|
pub async fn transfer_ownership(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(req): Json<TransferOwnershipRequest>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
// First get the realm to find its ID
|
||||||
|
let existing = queries::get_realm_by_slug(&pool, &slug).await?;
|
||||||
|
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
96
crates/chattyness-admin-ui/src/api/routes.rs
Normal file
96
crates/chattyness-admin-ui/src/api/routes.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! Admin API routes.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
|
||||||
|
use crate::app::AdminAppState;
|
||||||
|
|
||||||
|
/// Create the admin API router.
|
||||||
|
///
|
||||||
|
/// Note: HTML pages are handled by Leptos - this router only contains API endpoints.
|
||||||
|
pub fn admin_api_router() -> Router<AdminAppState> {
|
||||||
|
Router::new()
|
||||||
|
// API - Health
|
||||||
|
.route("/health", get(health_check))
|
||||||
|
// API - Dashboard
|
||||||
|
.route("/dashboard/stats", get(dashboard::get_stats))
|
||||||
|
// API - Auth
|
||||||
|
.route("/auth/login", post(auth::login))
|
||||||
|
.route("/auth/logout", post(auth::logout))
|
||||||
|
.route("/auth/context", get(auth::get_auth_context))
|
||||||
|
// API - Config
|
||||||
|
.route(
|
||||||
|
"/config",
|
||||||
|
get(config::get_config).put(config::update_config),
|
||||||
|
)
|
||||||
|
// API - Staff
|
||||||
|
.route("/staff", get(staff::list_staff).post(staff::create_staff))
|
||||||
|
.route("/staff/{user_id}", delete(staff::delete_staff))
|
||||||
|
// API - Users
|
||||||
|
.route("/users", get(users::list_users).post(users::create_user))
|
||||||
|
.route("/users/search", get(users::search_users))
|
||||||
|
.route("/users/{user_id}", get(users::get_user))
|
||||||
|
.route("/users/{user_id}/status", put(users::update_status))
|
||||||
|
.route(
|
||||||
|
"/users/{user_id}/reset-password",
|
||||||
|
post(users::reset_password),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{user_id}/realms",
|
||||||
|
get(users::get_user_realms).post(users::add_to_realm),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{user_id}/realms/{realm_id}",
|
||||||
|
delete(users::remove_from_realm),
|
||||||
|
)
|
||||||
|
// API - Realms
|
||||||
|
.route(
|
||||||
|
"/realms",
|
||||||
|
get(realms::list_realms).post(realms::create_realm),
|
||||||
|
)
|
||||||
|
.route("/realms/simple", get(users::list_realms))
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}",
|
||||||
|
get(realms::get_realm).put(realms::update_realm),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/transfer",
|
||||||
|
post(realms::transfer_ownership),
|
||||||
|
)
|
||||||
|
// API - Scenes
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/scenes",
|
||||||
|
get(scenes::list_scenes).post(scenes::create_scene),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/scenes/{scene_id}",
|
||||||
|
get(scenes::get_scene)
|
||||||
|
.put(scenes::update_scene)
|
||||||
|
.delete(scenes::delete_scene),
|
||||||
|
)
|
||||||
|
// API - Spots
|
||||||
|
.route(
|
||||||
|
"/scenes/{scene_id}/spots",
|
||||||
|
get(spots::list_spots).post(spots::create_spot),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/spots/{spot_id}",
|
||||||
|
get(spots::get_spot)
|
||||||
|
.put(spots::update_spot)
|
||||||
|
.delete(spots::delete_spot),
|
||||||
|
)
|
||||||
|
// API - Props (server-wide)
|
||||||
|
.route("/props", get(props::list_props).post(props::create_prop))
|
||||||
|
.route(
|
||||||
|
"/props/{prop_id}",
|
||||||
|
get(props::get_prop).delete(props::delete_prop),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check endpoint.
|
||||||
|
async fn health_check() -> &'static str {
|
||||||
|
"Admin API OK"
|
||||||
|
}
|
||||||
317
crates/chattyness-admin-ui/src/api/scenes.rs
Normal file
317
crates/chattyness-admin-ui/src/api/scenes.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
//! Scene management API handlers for admin UI.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
|
||||||
|
queries::{realms, scenes},
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Image Processing Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Result of downloading and storing a background image.
|
||||||
|
struct ImageDownloadResult {
|
||||||
|
/// The local path to the stored image (relative to static root, for URL).
|
||||||
|
local_path: String,
|
||||||
|
/// Image dimensions if requested.
|
||||||
|
dimensions: Option<(u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download an image from a URL and store it locally.
|
||||||
|
///
|
||||||
|
/// Returns the local path and optionally the dimensions.
|
||||||
|
/// Path format: /static/realm/{realm_id}/scene/{scene_id}/{sha256}.{ext}
|
||||||
|
async fn download_and_store_image(
|
||||||
|
url: &str,
|
||||||
|
realm_id: Uuid,
|
||||||
|
scene_id: Uuid,
|
||||||
|
extract_dimensions: bool,
|
||||||
|
) -> Result<ImageDownloadResult, AppError> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"Image URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the image
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.header(
|
||||||
|
reqwest::header::USER_AGENT,
|
||||||
|
"Chattyness/1.0 (Background image downloader)",
|
||||||
|
)
|
||||||
|
.header(reqwest::header::ACCEPT, "image/*")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to fetch image: {}", e)))?;
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Determine extension from content type
|
||||||
|
let ext = match content_type {
|
||||||
|
t if t.starts_with("image/jpeg") => "jpg",
|
||||||
|
t if t.starts_with("image/png") => "png",
|
||||||
|
t if t.starts_with("image/gif") => "gif",
|
||||||
|
t if t.starts_with("image/webp") => "webp",
|
||||||
|
_ => {
|
||||||
|
// Try to infer from URL
|
||||||
|
if url.contains(".jpg") || url.contains(".jpeg") {
|
||||||
|
"jpg"
|
||||||
|
} else if url.contains(".png") {
|
||||||
|
"png"
|
||||||
|
} else if url.contains(".gif") {
|
||||||
|
"gif"
|
||||||
|
} else if url.contains(".webp") {
|
||||||
|
"webp"
|
||||||
|
} else {
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"Unsupported image type: {}",
|
||||||
|
content_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the image bytes
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to read image data: {}", e)))?;
|
||||||
|
|
||||||
|
// Compute SHA256 hash of the image content
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&bytes);
|
||||||
|
let hash = hex::encode(hasher.finalize());
|
||||||
|
|
||||||
|
// Extract dimensions if requested
|
||||||
|
let dimensions = if extract_dimensions {
|
||||||
|
let reader = image::ImageReader::new(std::io::Cursor::new(&bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to detect image format: {}", e)))?;
|
||||||
|
|
||||||
|
let dims = reader
|
||||||
|
.into_dimensions()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to read image dimensions: {}", e)))?;
|
||||||
|
|
||||||
|
Some(dims)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create directory structure: /srv/chattyness/assets/realm/{realm_id}/scene/{scene_id}/
|
||||||
|
let dir_path = PathBuf::from("/srv/chattyness/assets")
|
||||||
|
.join("realm")
|
||||||
|
.join(realm_id.to_string())
|
||||||
|
.join("scene")
|
||||||
|
.join(scene_id.to_string());
|
||||||
|
|
||||||
|
tokio::fs::create_dir_all(&dir_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to create directory: {}", e)))?;
|
||||||
|
|
||||||
|
// Write the file with SHA256 hash as filename
|
||||||
|
let filename = format!("{}.{}", hash, ext);
|
||||||
|
let file_path = dir_path.join(&filename);
|
||||||
|
|
||||||
|
tokio::fs::write(&file_path, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to write image file: {}", e)))?;
|
||||||
|
|
||||||
|
// Return the URL path (relative to server root)
|
||||||
|
let local_path = format!(
|
||||||
|
"/static/realm/{}/scene/{}/{}",
|
||||||
|
realm_id, scene_id, filename
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ImageDownloadResult {
|
||||||
|
local_path,
|
||||||
|
dimensions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all image files for a scene.
|
||||||
|
async fn delete_scene_images(realm_id: Uuid, scene_id: Uuid) -> Result<(), AppError> {
|
||||||
|
let dir_path = PathBuf::from("/srv/chattyness/assets")
|
||||||
|
.join("realm")
|
||||||
|
.join(realm_id.to_string())
|
||||||
|
.join("scene")
|
||||||
|
.join(scene_id.to_string());
|
||||||
|
|
||||||
|
// Try to remove all files in the directory
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&dir_path).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() {
|
||||||
|
tokio::fs::remove_file(&path).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Query parameters for scene list.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListScenesQuery {
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all scenes for a realm.
|
||||||
|
pub async fn list_scenes(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<SceneSummary>>, AppError> {
|
||||||
|
// Get the realm
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let scene_list = scenes::list_scenes_for_realm(&pool, realm.id).await?;
|
||||||
|
Ok(Json(scene_list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a scene by ID.
|
||||||
|
pub async fn get_scene(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(scene_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Scene>, AppError> {
|
||||||
|
let scene = scenes::get_scene_by_id(&pool, scene_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||||
|
Ok(Json(scene))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create scene response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateSceneResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new scene in a realm.
|
||||||
|
pub async fn create_scene(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(mut req): Json<CreateSceneRequest>,
|
||||||
|
) -> Result<Json<CreateSceneResponse>, AppError> {
|
||||||
|
// Get the realm
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
// Check if slug is available
|
||||||
|
let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?;
|
||||||
|
if !available {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Scene slug '{}' is already taken in this realm",
|
||||||
|
req.slug
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a temporary scene ID for image storage path
|
||||||
|
let scene_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// Handle background image URL - download and store locally
|
||||||
|
if let Some(ref url) = req.background_image_url {
|
||||||
|
if !url.is_empty() {
|
||||||
|
let result = download_and_store_image(
|
||||||
|
url,
|
||||||
|
realm.id,
|
||||||
|
scene_id,
|
||||||
|
req.infer_dimensions_from_image,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
req.background_image_path = Some(result.local_path);
|
||||||
|
|
||||||
|
if let Some((width, height)) = result.dimensions {
|
||||||
|
req.bounds_wkt = Some(format!(
|
||||||
|
"POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))",
|
||||||
|
width, width, height, height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?;
|
||||||
|
Ok(Json(CreateSceneResponse {
|
||||||
|
id: scene.id,
|
||||||
|
slug: scene.slug,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a scene.
|
||||||
|
pub async fn update_scene(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(scene_id): Path<Uuid>,
|
||||||
|
Json(mut req): Json<UpdateSceneRequest>,
|
||||||
|
) -> Result<Json<Scene>, AppError> {
|
||||||
|
// Get the existing scene to get realm_id
|
||||||
|
let existing_scene = scenes::get_scene_by_id(&pool, scene_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||||
|
|
||||||
|
// Handle clear background image
|
||||||
|
if req.clear_background_image {
|
||||||
|
delete_scene_images(existing_scene.realm_id, scene_id).await?;
|
||||||
|
req.background_image_path = Some(String::new());
|
||||||
|
}
|
||||||
|
// Handle new background image URL - download and store locally
|
||||||
|
else if let Some(ref url) = req.background_image_url {
|
||||||
|
if !url.is_empty() {
|
||||||
|
delete_scene_images(existing_scene.realm_id, scene_id).await?;
|
||||||
|
|
||||||
|
let result = download_and_store_image(
|
||||||
|
url,
|
||||||
|
existing_scene.realm_id,
|
||||||
|
scene_id,
|
||||||
|
req.infer_dimensions_from_image,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
req.background_image_path = Some(result.local_path);
|
||||||
|
|
||||||
|
if let Some((width, height)) = result.dimensions {
|
||||||
|
req.bounds_wkt = Some(format!(
|
||||||
|
"POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))",
|
||||||
|
width, width, height, height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = scenes::update_scene(&pool, scene_id, &req).await?;
|
||||||
|
Ok(Json(scene))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a scene.
|
||||||
|
pub async fn delete_scene(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(scene_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
scenes::delete_scene(&pool, scene_id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
97
crates/chattyness-admin-ui/src/api/spots.rs
Normal file
97
crates/chattyness-admin-ui/src/api/spots.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
//! Spot management API handlers for admin UI.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest},
|
||||||
|
queries::spots,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// List all spots for a scene.
|
||||||
|
pub async fn list_spots(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(scene_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<SpotSummary>>, AppError> {
|
||||||
|
let spot_list = spots::list_spots_for_scene(&pool, scene_id).await?;
|
||||||
|
Ok(Json(spot_list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a spot by ID.
|
||||||
|
pub async fn get_spot(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(spot_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Spot>, AppError> {
|
||||||
|
let spot = spots::get_spot_by_id(&pool, spot_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||||
|
Ok(Json(spot))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create spot response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateSpotResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new spot in a scene.
|
||||||
|
pub async fn create_spot(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(scene_id): Path<Uuid>,
|
||||||
|
Json(req): Json<CreateSpotRequest>,
|
||||||
|
) -> Result<Json<CreateSpotResponse>, AppError> {
|
||||||
|
// Check if slug is available (if provided)
|
||||||
|
if let Some(ref slug) = req.slug {
|
||||||
|
let available = spots::is_spot_slug_available(&pool, scene_id, slug).await?;
|
||||||
|
if !available {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Spot slug '{}' is already taken in this scene",
|
||||||
|
slug
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let spot = spots::create_spot(&pool, scene_id, &req).await?;
|
||||||
|
Ok(Json(CreateSpotResponse { id: spot.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a spot.
|
||||||
|
pub async fn update_spot(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(spot_id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateSpotRequest>,
|
||||||
|
) -> Result<Json<Spot>, AppError> {
|
||||||
|
// If updating slug, check availability
|
||||||
|
if let Some(ref new_slug) = req.slug {
|
||||||
|
let existing = spots::get_spot_by_id(&pool, spot_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||||
|
|
||||||
|
if Some(new_slug.clone()) != existing.slug {
|
||||||
|
let available = spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
|
||||||
|
if !available {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"Spot slug '{}' is already taken in this scene",
|
||||||
|
new_slug
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let spot = spots::update_spot(&pool, spot_id, &req).await?;
|
||||||
|
Ok(Json(spot))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a spot.
|
||||||
|
pub async fn delete_spot(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(spot_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
spots::delete_spot(&pool, spot_id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
71
crates/chattyness-admin-ui/src/api/staff.rs
Normal file
71
crates/chattyness-admin-ui/src/api/staff.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
//! Staff management API handlers.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{CreateStaffRequest, StaffMember},
|
||||||
|
queries::owner as queries,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Create staff response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateStaffResponse {
|
||||||
|
pub staff: StaffMember,
|
||||||
|
pub temporary_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all staff members.
|
||||||
|
pub async fn list_staff(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<StaffMember>>, AppError> {
|
||||||
|
let staff = queries::get_all_staff(&pool).await?;
|
||||||
|
Ok(Json(staff))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new staff member.
|
||||||
|
pub async fn create_staff(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(req): Json<CreateStaffRequest>,
|
||||||
|
) -> Result<Json<CreateStaffResponse>, AppError> {
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// If user_id is provided, promote existing user
|
||||||
|
if let Some(user_id) = req.user_id {
|
||||||
|
let staff = queries::create_staff(&pool, user_id, req.role, None).await?;
|
||||||
|
Ok(Json(CreateStaffResponse {
|
||||||
|
staff,
|
||||||
|
temporary_password: None,
|
||||||
|
}))
|
||||||
|
} else if let Some(ref new_user) = req.new_user {
|
||||||
|
// Create new user and promote to staff
|
||||||
|
let (user_id, temporary_password) = queries::create_user(
|
||||||
|
&pool,
|
||||||
|
new_user,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let staff = queries::create_staff(&pool, user_id, req.role, None).await?;
|
||||||
|
Ok(Json(CreateStaffResponse {
|
||||||
|
staff,
|
||||||
|
temporary_password: Some(temporary_password),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Err(AppError::Validation(
|
||||||
|
"Must provide either user_id or new_user".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a staff member.
|
||||||
|
pub async fn delete_staff(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
queries::delete_staff(&pool, user_id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
161
crates/chattyness-admin-ui/src/api/users.rs
Normal file
161
crates/chattyness-admin-ui/src/api/users.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//! User management API handlers.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{
|
||||||
|
AccountStatus, CreateUserRequest, RealmRole, RealmSummary, UserDetail, UserListItem,
|
||||||
|
UserRealmMembership,
|
||||||
|
},
|
||||||
|
queries::owner as queries,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Response for user creation.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateUserResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub temporary_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for password reset.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PasswordResetResponse {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub temporary_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for user list.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListUsersQuery {
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for user search.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SearchUsersQuery {
|
||||||
|
pub q: String,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all users with pagination.
|
||||||
|
pub async fn list_users(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(query): Query<ListUsersQuery>,
|
||||||
|
) -> Result<Json<Vec<UserListItem>>, AppError> {
|
||||||
|
let limit = query.limit.unwrap_or(25).min(100);
|
||||||
|
let offset = query.offset.unwrap_or(0);
|
||||||
|
let users = queries::list_users(&pool, limit, offset).await?;
|
||||||
|
Ok(Json(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user (optionally with staff role).
|
||||||
|
pub async fn create_user(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(req): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<CreateUserResponse>, AppError> {
|
||||||
|
req.validate()?;
|
||||||
|
let (user_id, temporary_password) = queries::create_user_with_staff(&pool, &req).await?;
|
||||||
|
Ok(Json(CreateUserResponse {
|
||||||
|
id: user_id,
|
||||||
|
username: req.username,
|
||||||
|
temporary_password,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset user's password to a random token.
|
||||||
|
pub async fn reset_password(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<PasswordResetResponse>, AppError> {
|
||||||
|
let temporary_password = queries::reset_user_password(&pool, user_id).await?;
|
||||||
|
Ok(Json(PasswordResetResponse {
|
||||||
|
user_id,
|
||||||
|
temporary_password,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search users by username, email, or display name.
|
||||||
|
pub async fn search_users(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(query): Query<SearchUsersQuery>,
|
||||||
|
) -> Result<Json<Vec<UserListItem>>, AppError> {
|
||||||
|
let limit = query.limit.unwrap_or(10).min(50);
|
||||||
|
let users = queries::search_users(&pool, &query.q, limit).await?;
|
||||||
|
Ok(Json(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user detail by ID.
|
||||||
|
pub async fn get_user(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<UserDetail>, AppError> {
|
||||||
|
let user = queries::get_user_detail(&pool, user_id).await?;
|
||||||
|
Ok(Json(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update status request.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateStatusRequest {
|
||||||
|
pub status: AccountStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update user's account status.
|
||||||
|
pub async fn update_status(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateStatusRequest>,
|
||||||
|
) -> Result<Json<UserDetail>, AppError> {
|
||||||
|
let user = queries::update_user_status(&pool, user_id, req.status).await?;
|
||||||
|
Ok(Json(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user's realm memberships.
|
||||||
|
pub async fn get_user_realms(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<UserRealmMembership>>, AppError> {
|
||||||
|
let memberships = queries::get_user_realms(&pool, user_id).await?;
|
||||||
|
Ok(Json(memberships))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add to realm request.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddToRealmRequestBody {
|
||||||
|
pub realm_id: Uuid,
|
||||||
|
pub role: RealmRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add user to a realm.
|
||||||
|
pub async fn add_to_realm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
Json(req): Json<AddToRealmRequestBody>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
queries::add_user_to_realm(&pool, user_id, req.realm_id, req.role).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove user from a realm.
|
||||||
|
pub async fn remove_from_realm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path((user_id, realm_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<()>, AppError> {
|
||||||
|
queries::remove_user_from_realm(&pool, user_id, realm_id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all realms (for dropdown).
|
||||||
|
pub async fn list_realms(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<RealmSummary>>, AppError> {
|
||||||
|
let realms = queries::list_all_realms(&pool).await?;
|
||||||
|
Ok(Json(realms))
|
||||||
|
}
|
||||||
78
crates/chattyness-admin-ui/src/app.rs
Normal file
78
crates/chattyness-admin-ui/src/app.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
//! Admin Leptos application root and router.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
||||||
|
use leptos_router::components::Router;
|
||||||
|
|
||||||
|
use crate::routes::AdminRoutes;
|
||||||
|
|
||||||
|
/// Application state for the admin app.
|
||||||
|
///
|
||||||
|
/// Note: We intentionally don't derive `FromRef` because both pools are
|
||||||
|
/// the same type (`PgPool`), which would cause a conflicting implementation.
|
||||||
|
/// Instead, handlers should use Extension extractors for the pools.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminAppState {
|
||||||
|
/// The primary database pool for this admin instance.
|
||||||
|
/// For Owner App: chattyness_owner pool (no RLS)
|
||||||
|
/// For Admin App: chattyness_app pool (RLS enforced)
|
||||||
|
pub pool: sqlx::PgPool,
|
||||||
|
pub leptos_options: LeptosOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<AdminAppState> for LeptosOptions {
|
||||||
|
fn from_ref(state: &AdminAppState) -> Self {
|
||||||
|
state.leptos_options.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<AdminAppState> for sqlx::PgPool {
|
||||||
|
fn from_ref(state: &AdminAppState) -> Self {
|
||||||
|
state.pool.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell component for SSR.
|
||||||
|
///
|
||||||
|
/// The `data-app="admin"` attribute tells the WASM hydration script to mount
|
||||||
|
/// AdminApp.
|
||||||
|
pub fn admin_shell(options: LeptosOptions) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<AutoReload options=options.clone() />
|
||||||
|
<HydrationScripts options />
|
||||||
|
<MetaTags />
|
||||||
|
</head>
|
||||||
|
<body class="admin-app" data-app="admin">
|
||||||
|
<AdminApp />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main admin application component.
|
||||||
|
///
|
||||||
|
/// This wraps `AdminRoutes` with a `Router` for standalone use (e.g., chattyness-owner).
|
||||||
|
/// Routes are nested under `/admin` to match the link paths used in page components.
|
||||||
|
/// For embedding in a combined app (e.g., chattyness-app), use `AdminRoutes` directly.
|
||||||
|
#[component]
|
||||||
|
pub fn AdminApp() -> impl IntoView {
|
||||||
|
// Provide meta context for title and meta tags
|
||||||
|
provide_meta_context();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Stylesheet id="admin-styles" href="/static/css/admin.css" />
|
||||||
|
<Title text="Chattyness Admin Panel" />
|
||||||
|
|
||||||
|
<Router base="/admin">
|
||||||
|
<AdminRoutes />
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
195
crates/chattyness-admin-ui/src/auth.rs
Normal file
195
crates/chattyness-admin-ui/src/auth.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
//! Admin authentication module.
|
||||||
|
//!
|
||||||
|
//! Dual-mode authentication for the admin interface:
|
||||||
|
//! - Server staff: Uses chattyness_owner pool (bypasses RLS, full access)
|
||||||
|
//! - Realm admins: Uses chattyness_app pool (RLS enforces permissions)
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use sqlx::PgPool;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer};
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use tower_sessions_sqlx_store::PostgresStore;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use chattyness_db::models::{RealmRole, ServerRole, StaffMember, User};
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Session Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Admin session cookie name.
|
||||||
|
pub const ADMIN_SESSION_COOKIE_NAME: &str = "chattyness_admin_session";
|
||||||
|
|
||||||
|
/// Staff ID key in admin session.
|
||||||
|
pub const ADMIN_SESSION_STAFF_ID_KEY: &str = "staff_id";
|
||||||
|
|
||||||
|
/// User ID key in session (for realm admins coming from app).
|
||||||
|
pub const SESSION_USER_ID_KEY: &str = "user_id";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin Authentication
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Realm admin role information.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RealmAdminRole {
|
||||||
|
pub realm_id: Uuid,
|
||||||
|
pub realm_slug: String,
|
||||||
|
pub realm_name: String,
|
||||||
|
pub role: RealmRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticated admin - either server staff or realm admin.
|
||||||
|
///
|
||||||
|
/// This enum determines which database pool to use:
|
||||||
|
/// - ServerStaff: Uses owner_pool (bypasses RLS)
|
||||||
|
/// - RealmAdmin: Uses app_pool (RLS enforces permissions)
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AdminAuth {
|
||||||
|
/// Server staff member - full access via owner pool.
|
||||||
|
ServerStaff { staff: StaffMember },
|
||||||
|
/// Realm admin (owner, moderator, or builder) - scoped access via app pool with RLS.
|
||||||
|
RealmAdmin {
|
||||||
|
user: User,
|
||||||
|
/// Realms where this user has admin privileges.
|
||||||
|
realm_roles: Vec<RealmAdminRole>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl AdminAuth {
|
||||||
|
/// Get the display name for the authenticated admin.
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AdminAuth::ServerStaff { staff } => &staff.display_name,
|
||||||
|
AdminAuth::RealmAdmin { user, .. } => &user.display_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the username for the authenticated admin.
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AdminAuth::ServerStaff { staff } => &staff.username,
|
||||||
|
AdminAuth::RealmAdmin { user, .. } => &user.username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a server staff member.
|
||||||
|
pub fn is_server_staff(&self) -> bool {
|
||||||
|
matches!(self, AdminAuth::ServerStaff { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this admin can access server-wide settings.
|
||||||
|
pub fn can_access_server_config(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AdminAuth::ServerStaff { staff } => {
|
||||||
|
matches!(staff.role, ServerRole::Owner | ServerRole::Admin)
|
||||||
|
}
|
||||||
|
AdminAuth::RealmAdmin { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this admin can manage server staff.
|
||||||
|
pub fn can_manage_staff(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AdminAuth::ServerStaff { staff } => matches!(staff.role, ServerRole::Owner),
|
||||||
|
AdminAuth::RealmAdmin { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this admin can view all users.
|
||||||
|
pub fn can_view_all_users(&self) -> bool {
|
||||||
|
matches!(self, AdminAuth::ServerStaff { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the realms this admin can manage (empty for server staff who see all).
|
||||||
|
pub fn managed_realms(&self) -> &[RealmAdminRole] {
|
||||||
|
match self {
|
||||||
|
AdminAuth::ServerStaff { .. } => &[],
|
||||||
|
AdminAuth::RealmAdmin { realm_roles, .. } => realm_roles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin Auth Error
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Admin authentication errors.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AdminAuthError {
|
||||||
|
Unauthorized,
|
||||||
|
SessionError,
|
||||||
|
InternalError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl IntoResponse for AdminAuthError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
AdminAuthError::Unauthorized => {
|
||||||
|
// Redirect to login page instead of returning 401
|
||||||
|
Redirect::to("/admin/login").into_response()
|
||||||
|
}
|
||||||
|
AdminAuthError::SessionError | AdminAuthError::InternalError => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl From<AdminAuthError> for AppError {
|
||||||
|
fn from(err: AdminAuthError) -> Self {
|
||||||
|
match err {
|
||||||
|
AdminAuthError::Unauthorized => AppError::Unauthorized,
|
||||||
|
AdminAuthError::SessionError => AppError::Internal("Session error".to_string()),
|
||||||
|
AdminAuthError::InternalError => AppError::Internal("Internal error".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Session Layer
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Create the session management layer for admin interface.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn create_admin_session_layer(
|
||||||
|
pool: PgPool,
|
||||||
|
secure: bool,
|
||||||
|
) -> SessionManagerLayer<PostgresStore> {
|
||||||
|
let session_store = PostgresStore::new(pool)
|
||||||
|
.with_schema_name("auth")
|
||||||
|
.expect("Invalid schema name for session store")
|
||||||
|
.with_table_name("tower_sessions")
|
||||||
|
.expect("Invalid table name for session store");
|
||||||
|
|
||||||
|
// Create session table if it doesn't exist
|
||||||
|
if let Err(e) = session_store.migrate().await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Admin session table migration failed (may already exist): {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionManagerLayer::new(session_store)
|
||||||
|
.with_name(ADMIN_SESSION_COOKIE_NAME)
|
||||||
|
.with_secure(secure)
|
||||||
|
.with_same_site(SameSite::Lax)
|
||||||
|
.with_http_only(true)
|
||||||
|
.with_expiry(Expiry::OnInactivity(Duration::hours(4)))
|
||||||
|
}
|
||||||
729
crates/chattyness-admin-ui/src/components.rs
Normal file
729
crates/chattyness-admin-ui/src/components.rs
Normal file
|
|
@ -0,0 +1,729 @@
|
||||||
|
//! Admin-specific Leptos components.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Auth Context Types (for sidebar rendering)
|
||||||
|
// These are duplicated from api/auth.rs because api is SSR-only
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Realm info for auth context.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ManagedRealm {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth context response for the frontend.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct AuthContextResponse {
|
||||||
|
pub is_server_staff: bool,
|
||||||
|
pub managed_realms: Vec<ManagedRealm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin layout with sidebar navigation.
|
||||||
|
///
|
||||||
|
/// Note: CSS must be loaded by the parent app:
|
||||||
|
/// - chattyness-owner: Loads `/static/chattyness-owner.css` in AdminApp
|
||||||
|
/// - chattyness-app: Loads `/admin.css` in lazy wrapper functions
|
||||||
|
#[component]
|
||||||
|
pub fn AdminLayout(
|
||||||
|
/// Current page identifier for nav highlighting
|
||||||
|
current_page: &'static str,
|
||||||
|
/// Base path for navigation links (e.g., "/admin")
|
||||||
|
#[prop(default = "/admin")]
|
||||||
|
base_path: &'static str,
|
||||||
|
/// Whether the user is server staff (shows all server-level options)
|
||||||
|
#[prop(default = false)]
|
||||||
|
is_server_staff: bool,
|
||||||
|
/// Realms this user can manage (slug, name pairs)
|
||||||
|
#[prop(default = vec![])]
|
||||||
|
managed_realms: Vec<(String, String)>,
|
||||||
|
/// Page content
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="admin-layout">
|
||||||
|
<Sidebar
|
||||||
|
current_page=current_page
|
||||||
|
base_path=base_path
|
||||||
|
is_server_staff=is_server_staff
|
||||||
|
managed_realms=managed_realms
|
||||||
|
/>
|
||||||
|
<main class="admin-content">
|
||||||
|
{children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login page layout (no sidebar).
|
||||||
|
#[component]
|
||||||
|
pub fn LoginLayout(children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="login-layout">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch auth context from API (for client-side use).
|
||||||
|
///
|
||||||
|
/// The API path is determined dynamically based on the current URL:
|
||||||
|
/// - If at `/admin/...`, uses `/api/admin/auth/context`
|
||||||
|
/// - If at root, uses `/api/auth/context`
|
||||||
|
pub fn use_auth_context() -> LocalResource<Option<AuthContextResponse>> {
|
||||||
|
LocalResource::new(move || async move {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
// Determine API base path from current URL
|
||||||
|
let api_path = web_sys::window()
|
||||||
|
.and_then(|w| w.location().pathname().ok())
|
||||||
|
.map(|path| {
|
||||||
|
if path.starts_with("/admin") {
|
||||||
|
"/api/admin/auth/context".to_string()
|
||||||
|
} else {
|
||||||
|
"/api/auth/context".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "/api/auth/context".to_string());
|
||||||
|
|
||||||
|
let resp = Request::get(&api_path).send().await;
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.ok() => r.json::<AuthContextResponse>().await.ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
None::<AuthContextResponse>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticated admin layout that fetches auth context.
|
||||||
|
///
|
||||||
|
/// This wrapper fetches the current user's auth context and passes it to
|
||||||
|
/// AdminLayout for proper sidebar rendering.
|
||||||
|
#[component]
|
||||||
|
pub fn AuthenticatedLayout(
|
||||||
|
current_page: &'static str,
|
||||||
|
#[prop(default = "/admin")]
|
||||||
|
base_path: &'static str,
|
||||||
|
children: ChildrenFn,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let auth_context = use_auth_context();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=move || view! {
|
||||||
|
<AdminLayout current_page=current_page base_path=base_path>
|
||||||
|
<div class="loading-container">
|
||||||
|
<p>"Loading..."</p>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
}>
|
||||||
|
{move || {
|
||||||
|
let children = children.clone();
|
||||||
|
auth_context.get().map(move |maybe_ctx| {
|
||||||
|
let children = children.clone();
|
||||||
|
match maybe_ctx {
|
||||||
|
Some(ctx) => {
|
||||||
|
let managed_realms: Vec<(String, String)> = ctx.managed_realms
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.slug, r.name))
|
||||||
|
.collect();
|
||||||
|
view! {
|
||||||
|
<AdminLayout
|
||||||
|
current_page=current_page
|
||||||
|
base_path=base_path
|
||||||
|
is_server_staff=ctx.is_server_staff
|
||||||
|
managed_realms=managed_realms
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</AdminLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Fallback: show layout with default props (server staff view)
|
||||||
|
view! {
|
||||||
|
<AdminLayout current_page=current_page base_path=base_path is_server_staff=true>
|
||||||
|
{children()}
|
||||||
|
</AdminLayout>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sidebar navigation component.
|
||||||
|
#[component]
|
||||||
|
fn Sidebar(
|
||||||
|
current_page: &'static str,
|
||||||
|
base_path: &'static str,
|
||||||
|
#[prop(default = false)]
|
||||||
|
is_server_staff: bool,
|
||||||
|
#[prop(default = vec![])]
|
||||||
|
managed_realms: Vec<(String, String)>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Build hrefs with base path
|
||||||
|
let dashboard_href = base_path.to_string();
|
||||||
|
let config_href = format!("{}/config", base_path);
|
||||||
|
let users_href = format!("{}/users", base_path);
|
||||||
|
let users_new_href = format!("{}/users/new", base_path);
|
||||||
|
let staff_href = format!("{}/staff", base_path);
|
||||||
|
let realms_href = format!("{}/realms", base_path);
|
||||||
|
let realms_new_href = format!("{}/realms/new", base_path);
|
||||||
|
let props_href = format!("{}/props", base_path);
|
||||||
|
let props_new_href = format!("{}/props/new", base_path);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<a href="/admin" class="sidebar-brand">"Chattyness"</a>
|
||||||
|
<span class="sidebar-badge">"Admin"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav-list">
|
||||||
|
// Server staff: show all server-level options
|
||||||
|
{if is_server_staff {
|
||||||
|
view! {
|
||||||
|
<NavItem
|
||||||
|
href=dashboard_href.clone()
|
||||||
|
label="Dashboard"
|
||||||
|
active=current_page == "dashboard"
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=config_href.clone()
|
||||||
|
label="Server Config"
|
||||||
|
active=current_page == "config"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<span class="nav-section-title">"User Management"</span>
|
||||||
|
<ul class="nav-sublist">
|
||||||
|
<NavItem
|
||||||
|
href=users_href.clone()
|
||||||
|
label="All Users"
|
||||||
|
active=current_page == "users"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=users_new_href.clone()
|
||||||
|
label="Create User"
|
||||||
|
active=current_page == "users_new"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=staff_href.clone()
|
||||||
|
label="Staff"
|
||||||
|
active=current_page == "staff"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<span class="nav-section-title">"Realm Management"</span>
|
||||||
|
<ul class="nav-sublist">
|
||||||
|
<NavItem
|
||||||
|
href=realms_href.clone()
|
||||||
|
label="All Realms"
|
||||||
|
active=current_page == "realms"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=realms_new_href.clone()
|
||||||
|
label="Create Realm"
|
||||||
|
active=current_page == "realms_new"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-section">
|
||||||
|
<span class="nav-section-title">"Props"</span>
|
||||||
|
<ul class="nav-sublist">
|
||||||
|
<NavItem
|
||||||
|
href=props_href.clone()
|
||||||
|
label="All Props"
|
||||||
|
active=current_page == "props"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=props_new_href.clone()
|
||||||
|
label="Create Prop"
|
||||||
|
active=current_page == "props_new"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
// Realm admin: show realm-specific options only
|
||||||
|
view! {
|
||||||
|
{managed_realms.into_iter().map(|(slug, name)| {
|
||||||
|
let scenes_href = format!("{}/realms/{}/scenes", base_path, slug);
|
||||||
|
let scenes_new_href = format!("{}/realms/{}/scenes/new", base_path, slug);
|
||||||
|
let realm_settings_href = format!("{}/realms/{}", base_path, slug);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li class="nav-section">
|
||||||
|
<span class="nav-section-title">{name}</span>
|
||||||
|
<ul class="nav-sublist">
|
||||||
|
<NavItem
|
||||||
|
href=scenes_href
|
||||||
|
label="Scenes"
|
||||||
|
active=current_page == "scenes"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=scenes_new_href
|
||||||
|
label="Create Scene"
|
||||||
|
active=current_page == "scenes_new"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href=realm_settings_href
|
||||||
|
label="Realm Settings"
|
||||||
|
active=current_page == "realms"
|
||||||
|
sub=true
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button type="button" class="sidebar-logout" id="logout-btn">
|
||||||
|
"Logout"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation item component.
|
||||||
|
///
|
||||||
|
/// Supports both static and dynamic hrefs via `#[prop(into)]`.
|
||||||
|
#[component]
|
||||||
|
fn NavItem(
|
||||||
|
#[prop(into)] href: String,
|
||||||
|
label: &'static str,
|
||||||
|
#[prop(default = false)] active: bool,
|
||||||
|
/// Whether this is a sub-item (indented)
|
||||||
|
#[prop(default = false)] sub: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let link_class = match (active, sub) {
|
||||||
|
(true, false) => "block w-full px-6 py-2 bg-violet-600 text-white transition-all duration-150",
|
||||||
|
(false, false) => "block w-full px-6 py-2 text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150",
|
||||||
|
(true, true) => "block w-full pl-10 pr-6 py-2 text-sm bg-violet-600 text-white transition-all duration-150",
|
||||||
|
(false, true) => "block w-full pl-10 pr-6 py-2 text-sm text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li class="my-0.5">
|
||||||
|
<a href=href class=link_class>{label}</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page header component.
|
||||||
|
#[component]
|
||||||
|
pub fn PageHeader(
|
||||||
|
/// Page title
|
||||||
|
title: &'static str,
|
||||||
|
/// Optional subtitle (accepts String or &str)
|
||||||
|
#[prop(optional, into)]
|
||||||
|
subtitle: String,
|
||||||
|
/// Optional action buttons
|
||||||
|
#[prop(optional)]
|
||||||
|
children: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let has_subtitle = !subtitle.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-header-text">
|
||||||
|
<h1 class="page-title">{title}</h1>
|
||||||
|
{if has_subtitle {
|
||||||
|
view! { <p class="page-subtitle">{subtitle}</p> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{if let Some(children) = children {
|
||||||
|
view! {
|
||||||
|
<div class="page-header-actions">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card component.
|
||||||
|
#[component]
|
||||||
|
pub fn Card(
|
||||||
|
#[prop(optional)] title: &'static str,
|
||||||
|
#[prop(optional)] class: &'static str,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let has_title = !title.is_empty();
|
||||||
|
let card_class = if class.is_empty() {
|
||||||
|
"card".to_string()
|
||||||
|
} else {
|
||||||
|
format!("card {}", class)
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=card_class>
|
||||||
|
{if has_title {
|
||||||
|
view! { <h2 class="card-title">{title}</h2> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detail grid for key-value display.
|
||||||
|
#[component]
|
||||||
|
pub fn DetailGrid(children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="detail-grid">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detail item within a detail grid.
|
||||||
|
#[component]
|
||||||
|
pub fn DetailItem(label: &'static str, children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">{label}</div>
|
||||||
|
<div class="detail-value">{children()}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status badge component.
|
||||||
|
#[component]
|
||||||
|
pub fn StatusBadge(
|
||||||
|
/// Status text
|
||||||
|
status: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class = format!("status-badge status-{}", status.to_lowercase());
|
||||||
|
view! {
|
||||||
|
<span class=class>{status}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Privacy badge component.
|
||||||
|
#[component]
|
||||||
|
pub fn PrivacyBadge(
|
||||||
|
/// Privacy level
|
||||||
|
privacy: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class = format!("privacy-badge privacy-{}", privacy.to_lowercase());
|
||||||
|
view! {
|
||||||
|
<span class=class>{privacy}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NSFW badge component.
|
||||||
|
#[component]
|
||||||
|
pub fn NsfwBadge() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<span class="nsfw-badge">"NSFW"</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty state placeholder.
|
||||||
|
#[component]
|
||||||
|
pub fn EmptyState(
|
||||||
|
message: &'static str,
|
||||||
|
#[prop(optional)] action_href: &'static str,
|
||||||
|
#[prop(optional)] action_text: &'static str,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let has_action = !action_href.is_empty() && !action_text.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>{message}</p>
|
||||||
|
{if has_action {
|
||||||
|
view! {
|
||||||
|
<a href=action_href class="btn btn-primary">{action_text}</a>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alert message component.
|
||||||
|
#[component]
|
||||||
|
pub fn Alert(
|
||||||
|
/// Alert variant: success, error, warning, info
|
||||||
|
variant: &'static str,
|
||||||
|
/// Alert message
|
||||||
|
message: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class = format!("alert alert-{}", variant);
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message alert that shows/hides based on signal state.
|
||||||
|
///
|
||||||
|
/// This component reduces the boilerplate for showing form feedback messages.
|
||||||
|
/// The message signal contains `Option<(String, bool)>` where bool is `is_success`.
|
||||||
|
#[component]
|
||||||
|
pub fn MessageAlert(message: ReadSignal<Option<(String, bool)>>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Show when=move || message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message alert that works with RwSignal.
|
||||||
|
#[component]
|
||||||
|
pub fn MessageAlertRw(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Show when=move || message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temporary password display component.
|
||||||
|
///
|
||||||
|
/// Shows the temporary password with a warning to copy it.
|
||||||
|
#[component]
|
||||||
|
pub fn TempPasswordDisplay(
|
||||||
|
/// The temporary password signal
|
||||||
|
password: ReadSignal<Option<String>>,
|
||||||
|
/// Optional label (default: "Temporary Password:")
|
||||||
|
#[prop(default = "Temporary Password:")]
|
||||||
|
label: &'static str,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Show when=move || password.get().is_some()>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p><strong>{label}</strong></p>
|
||||||
|
<code class="temp-password">{move || password.get().unwrap_or_default()}</code>
|
||||||
|
<p class="text-muted">"Copy this password now - it will not be shown again!"</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete confirmation component with danger zone styling.
|
||||||
|
///
|
||||||
|
/// Shows a button that reveals a confirmation dialog when clicked.
|
||||||
|
#[component]
|
||||||
|
pub fn DeleteConfirmation(
|
||||||
|
/// Warning message to show
|
||||||
|
message: &'static str,
|
||||||
|
/// Button text (default: "Delete")
|
||||||
|
#[prop(default = "Delete")]
|
||||||
|
button_text: &'static str,
|
||||||
|
/// Confirm button text (default: "Yes, Delete")
|
||||||
|
#[prop(default = "Yes, Delete")]
|
||||||
|
confirm_text: &'static str,
|
||||||
|
/// Pending state signal
|
||||||
|
pending: ReadSignal<bool>,
|
||||||
|
/// Callback when delete is confirmed
|
||||||
|
on_confirm: impl Fn() + Clone + Send + Sync + 'static,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (show_confirm, set_show_confirm) = signal(false);
|
||||||
|
let on_confirm_clone = on_confirm.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show
|
||||||
|
when=move || !show_confirm.get()
|
||||||
|
fallback=move || {
|
||||||
|
let on_confirm = on_confirm_clone.clone();
|
||||||
|
view! {
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<p>{message}</p>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
on:click=move |_| on_confirm()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Deleting..." } else { confirm_text }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click=move |_| set_show_confirm.set(false)
|
||||||
|
>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
on:click=move |_| set_show_confirm.set(true)
|
||||||
|
>
|
||||||
|
{button_text}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit button with loading state.
|
||||||
|
#[component]
|
||||||
|
pub fn SubmitButton(
|
||||||
|
/// Button text when not pending
|
||||||
|
text: &'static str,
|
||||||
|
/// Button text when pending (default adds "...")
|
||||||
|
#[prop(optional)]
|
||||||
|
pending_text: Option<&'static str>,
|
||||||
|
/// Whether the button is in pending state
|
||||||
|
pending: ReadSignal<bool>,
|
||||||
|
/// Additional CSS classes
|
||||||
|
#[prop(default = "btn btn-primary")]
|
||||||
|
class: &'static str,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let loading_text = pending_text.unwrap_or_else(|| {
|
||||||
|
// Can't do string manipulation at compile time, so use a simple approach
|
||||||
|
text
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class=class
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { loading_text } else { text }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loading spinner.
|
||||||
|
#[component]
|
||||||
|
pub fn LoadingSpinner(#[prop(optional)] message: &'static str) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
{if !message.is_empty() {
|
||||||
|
view! { <span class="loading-message">{message}</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Role badge component.
|
||||||
|
#[component]
|
||||||
|
pub fn RoleBadge(role: String) -> impl IntoView {
|
||||||
|
let class = format!("role-badge role-{}", role.to_lowercase());
|
||||||
|
view! {
|
||||||
|
<span class=class>{role}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination component.
|
||||||
|
#[component]
|
||||||
|
pub fn Pagination(current_page: i64, base_url: String, query: String) -> impl IntoView {
|
||||||
|
let prev_page = current_page - 1;
|
||||||
|
let next_page = current_page + 1;
|
||||||
|
|
||||||
|
let prev_url = if query.is_empty() {
|
||||||
|
format!("{}?page={}", base_url, prev_page)
|
||||||
|
} else {
|
||||||
|
format!("{}?q={}&page={}", base_url, query, prev_page)
|
||||||
|
};
|
||||||
|
|
||||||
|
let next_url = if query.is_empty() {
|
||||||
|
format!("{}?page={}", base_url, next_page)
|
||||||
|
} else {
|
||||||
|
format!("{}?q={}&page={}", base_url, query, next_page)
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="pagination">
|
||||||
|
{if current_page > 1 {
|
||||||
|
view! {
|
||||||
|
<a href=prev_url class="btn btn-secondary">"Previous"</a>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<span class="btn btn-secondary btn-disabled">"Previous"</span>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
<span class="pagination-info">"Page " {current_page}</span>
|
||||||
|
<a href=next_url class="btn btn-secondary">"Next"</a>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search form component for list pages.
|
||||||
|
#[component]
|
||||||
|
pub fn SearchForm(
|
||||||
|
/// Form action URL (e.g., "/admin/users")
|
||||||
|
action: &'static str,
|
||||||
|
/// Placeholder text
|
||||||
|
placeholder: &'static str,
|
||||||
|
/// Current search value signal
|
||||||
|
search_input: RwSignal<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<form method="get" action=action class="search-form">
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
placeholder=placeholder
|
||||||
|
class="form-input search-input"
|
||||||
|
prop:value=move || search_input.get()
|
||||||
|
on:input=move |ev| search_input.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">"Search"</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
226
crates/chattyness-admin-ui/src/hooks.rs
Normal file
226
crates/chattyness-admin-ui/src/hooks.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
//! Reusable hooks for the admin UI.
|
||||||
|
//!
|
||||||
|
//! These hooks provide common patterns for data fetching, pagination,
|
||||||
|
//! and form submissions to reduce duplication across pages.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
|
/// A hook for fetching data from an API endpoint.
|
||||||
|
///
|
||||||
|
/// Handles the `#[cfg(feature = "hydrate")]` boilerplate and provides
|
||||||
|
/// a consistent pattern for client-side data fetching.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```rust
|
||||||
|
/// let users = use_fetch::<Vec<User>>(move || format!("/api/users?page={}", page()));
|
||||||
|
/// ```
|
||||||
|
pub fn use_fetch<T>(url_fn: impl Fn() -> String + Send + Sync + 'static) -> LocalResource<Option<T>>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned + Send + 'static,
|
||||||
|
{
|
||||||
|
LocalResource::new(move || {
|
||||||
|
let url = url_fn();
|
||||||
|
async move {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
match Request::get(&url).send().await {
|
||||||
|
Ok(r) if r.ok() => r.json::<T>().await.ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
let _ = url;
|
||||||
|
None::<T>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hook for fetching data with a condition.
|
||||||
|
///
|
||||||
|
/// Similar to `use_fetch` but allows skipping the fetch if a condition is false.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```rust
|
||||||
|
/// let user = use_fetch_if::<UserDetail>(
|
||||||
|
/// move || !user_id().is_empty(),
|
||||||
|
/// move || format!("/api/users/{}", user_id())
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
pub fn use_fetch_if<T>(
|
||||||
|
condition: impl Fn() -> bool + Send + Sync + 'static,
|
||||||
|
url_fn: impl Fn() -> String + Send + Sync + 'static,
|
||||||
|
) -> LocalResource<Option<T>>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned + Send + 'static,
|
||||||
|
{
|
||||||
|
LocalResource::new(move || {
|
||||||
|
let should_fetch = condition();
|
||||||
|
let url = url_fn();
|
||||||
|
async move {
|
||||||
|
if !should_fetch {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
match Request::get(&url).send().await {
|
||||||
|
Ok(r) if r.ok() => r.json::<T>().await.ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
let _ = url;
|
||||||
|
None::<T>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination state extracted from URL query parameters.
|
||||||
|
pub struct PaginationState {
|
||||||
|
/// The current search query (from `?q=...`).
|
||||||
|
pub search_query: Signal<String>,
|
||||||
|
/// The current page number (from `?page=...`, defaults to 1).
|
||||||
|
pub page: Signal<i64>,
|
||||||
|
/// Signal for the search input value (for controlled input).
|
||||||
|
pub search_input: RwSignal<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hook for extracting pagination state from URL query parameters.
|
||||||
|
///
|
||||||
|
/// Returns search query, page number, and a controlled search input signal.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```rust
|
||||||
|
/// let pagination = use_pagination();
|
||||||
|
/// let url = format!("/api/users?q={}&page={}", pagination.search_query.get(), pagination.page.get());
|
||||||
|
/// ```
|
||||||
|
pub fn use_pagination() -> PaginationState {
|
||||||
|
let query = use_query_map();
|
||||||
|
|
||||||
|
let search_query = Signal::derive(move || query.get().get("q").unwrap_or_default());
|
||||||
|
|
||||||
|
let page = Signal::derive(move || {
|
||||||
|
query
|
||||||
|
.get()
|
||||||
|
.get("page")
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(1i64)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use get_untracked for initial value to avoid reactive tracking warning
|
||||||
|
let initial_search = query.get_untracked().get("q").unwrap_or_default();
|
||||||
|
let search_input = RwSignal::new(initial_search);
|
||||||
|
|
||||||
|
PaginationState {
|
||||||
|
search_query,
|
||||||
|
page,
|
||||||
|
search_input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message state for form feedback (message text, is_success).
|
||||||
|
pub type MessageSignal = RwSignal<Option<(String, bool)>>;
|
||||||
|
|
||||||
|
/// Creates a message signal for form feedback.
|
||||||
|
pub fn use_message() -> MessageSignal {
|
||||||
|
RwSignal::new(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A helper for making POST/PUT/DELETE requests with JSON body.
|
||||||
|
///
|
||||||
|
/// Returns the response or error message.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub async fn api_request<T>(
|
||||||
|
method: &str,
|
||||||
|
url: &str,
|
||||||
|
body: Option<&serde_json::Value>,
|
||||||
|
) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let request = match method {
|
||||||
|
"POST" => Request::post(url),
|
||||||
|
"PUT" => Request::put(url),
|
||||||
|
"DELETE" => Request::delete(url),
|
||||||
|
_ => Request::get(url),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = if let Some(body) = body {
|
||||||
|
request
|
||||||
|
.json(body)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
request.send().await
|
||||||
|
}
|
||||||
|
.map_err(|_| "Network error".to_string())?;
|
||||||
|
|
||||||
|
if response.ok() {
|
||||||
|
response
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Failed to parse response".to_string())
|
||||||
|
} else {
|
||||||
|
// Try to parse error response
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = response.json::<ErrorResp>().await {
|
||||||
|
Err(err.error)
|
||||||
|
} else {
|
||||||
|
Err("Request failed".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A helper for making POST/PUT/DELETE requests that return success/failure.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub async fn api_request_simple(
|
||||||
|
method: &str,
|
||||||
|
url: &str,
|
||||||
|
body: Option<&serde_json::Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let request = match method {
|
||||||
|
"POST" => Request::post(url),
|
||||||
|
"PUT" => Request::put(url),
|
||||||
|
"DELETE" => Request::delete(url),
|
||||||
|
_ => Request::get(url),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = if let Some(body) = body {
|
||||||
|
request
|
||||||
|
.json(body)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
request.send().await
|
||||||
|
}
|
||||||
|
.map_err(|_| "Network error".to_string())?;
|
||||||
|
|
||||||
|
if response.ok() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = response.json::<ErrorResp>().await {
|
||||||
|
Err(err.error)
|
||||||
|
} else {
|
||||||
|
Err("Request failed".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
crates/chattyness-admin-ui/src/lib.rs
Normal file
51
crates/chattyness-admin-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
//! Admin UI crate for Chattyness.
|
||||||
|
//!
|
||||||
|
//! This crate provides the Leptos-based admin interface that can be used by:
|
||||||
|
//! - The Owner App (:3001) with `chattyness_owner` DB role (no RLS)
|
||||||
|
//! - The Admin App (:3000/admin) with `chattyness_app` DB role (RLS enforced)
|
||||||
|
//!
|
||||||
|
//! The UI components are the same; only the database connection differs.
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! For standalone use (e.g., chattyness-owner):
|
||||||
|
//! ```ignore
|
||||||
|
//! use chattyness_admin_ui::AdminApp;
|
||||||
|
//! // AdminApp includes its own Router
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! For embedding in a combined app (e.g., chattyness-app):
|
||||||
|
//! ```ignore
|
||||||
|
//! use chattyness_admin_ui::AdminRoutes;
|
||||||
|
//! // AdminRoutes can be placed inside an existing Router
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod app;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod components;
|
||||||
|
pub mod hooks;
|
||||||
|
pub mod models;
|
||||||
|
pub mod pages;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
pub use app::{admin_shell, AdminApp};
|
||||||
|
pub use routes::AdminRoutes;
|
||||||
|
|
||||||
|
// Re-export commonly used items for convenience
|
||||||
|
pub use components::{
|
||||||
|
Alert, Card, DeleteConfirmation, DetailGrid, DetailItem, EmptyState, LoadingSpinner,
|
||||||
|
MessageAlert, MessageAlertRw, NsfwBadge, PageHeader, Pagination, PrivacyBadge, RoleBadge,
|
||||||
|
SearchForm, StatusBadge, SubmitButton, TempPasswordDisplay,
|
||||||
|
};
|
||||||
|
pub use hooks::{use_fetch, use_fetch_if, use_message, use_pagination, PaginationState};
|
||||||
|
pub use models::*;
|
||||||
|
pub use utils::{build_bounds_wkt, build_paginated_url, get_api_base, parse_bounds_wkt};
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub use utils::{confirm, navigate_to, reload_page};
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use app::AdminAppState;
|
||||||
258
crates/chattyness-admin-ui/src/models.rs
Normal file
258
crates/chattyness-admin-ui/src/models.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
//! Shared model types for the admin UI.
|
||||||
|
//!
|
||||||
|
//! These are client-side DTOs (Data Transfer Objects) for API responses.
|
||||||
|
//! They are separate from the database models in `chattyness_db`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// User Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// User summary for list display.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User detail from API.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserDetail {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub server_role: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for user creation.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct CreateUserResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub temporary_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for password reset.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PasswordResetResponse {
|
||||||
|
pub user_id: String,
|
||||||
|
pub temporary_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Staff Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Staff member for list display.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct StaffMemberSummary {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub role: String,
|
||||||
|
pub appointed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Realm Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Realm summary for list display.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct RealmSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub tagline: Option<String>,
|
||||||
|
pub privacy: String,
|
||||||
|
pub is_nsfw: bool,
|
||||||
|
pub owner_id: String,
|
||||||
|
pub owner_username: String,
|
||||||
|
pub member_count: i64,
|
||||||
|
pub current_user_count: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Realm detail from API.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct RealmDetail {
|
||||||
|
pub id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub tagline: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub privacy: String,
|
||||||
|
pub is_nsfw: bool,
|
||||||
|
pub allow_guest_access: bool,
|
||||||
|
pub max_users: i32,
|
||||||
|
pub theme_color: Option<String>,
|
||||||
|
pub owner_id: String,
|
||||||
|
pub owner_username: String,
|
||||||
|
pub owner_display_name: String,
|
||||||
|
pub member_count: i64,
|
||||||
|
pub current_user_count: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for realm creation.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct CreateRealmResponse {
|
||||||
|
pub realm_id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub owner_id: String,
|
||||||
|
pub owner_temporary_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Scene Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Scene summary for list display.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SceneSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub is_entry_point: bool,
|
||||||
|
pub is_hidden: bool,
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
pub background_image_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scene detail from API.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SceneDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub realm_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub background_image_path: Option<String>,
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
pub bounds_wkt: String,
|
||||||
|
pub dimension_mode: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub is_entry_point: bool,
|
||||||
|
pub is_hidden: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for image dimensions.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ImageDimensionsResponse {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Dashboard Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Dashboard stats from server.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DashboardStats {
|
||||||
|
pub total_users: i64,
|
||||||
|
pub active_users: i64,
|
||||||
|
pub total_realms: i64,
|
||||||
|
pub online_users: i64,
|
||||||
|
pub staff_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Server Config Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Server configuration from API.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub welcome_message: Option<String>,
|
||||||
|
pub max_users_per_channel: i32,
|
||||||
|
pub message_rate_limit: i32,
|
||||||
|
pub message_rate_window_seconds: i32,
|
||||||
|
pub allow_guest_access: bool,
|
||||||
|
pub allow_user_uploads: bool,
|
||||||
|
pub require_email_verification: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Common Response Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Generic error response from API.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic success response.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SuccessResponse {
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Prop Models
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Prop summary for list display.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PropSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub asset_path: String,
|
||||||
|
pub default_layer: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prop detail from API.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct PropDetail {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub asset_path: String,
|
||||||
|
pub thumbnail_path: Option<String>,
|
||||||
|
pub default_layer: Option<String>,
|
||||||
|
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
|
||||||
|
pub default_position: Option<i16>,
|
||||||
|
pub is_unique: bool,
|
||||||
|
pub is_transferable: bool,
|
||||||
|
pub is_portable: bool,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub available_from: Option<String>,
|
||||||
|
pub available_until: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for prop creation.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct CreatePropResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub asset_path: String,
|
||||||
|
}
|
||||||
35
crates/chattyness-admin-ui/src/pages.rs
Normal file
35
crates/chattyness-admin-ui/src/pages.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
//! Admin interface Leptos page components.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod dashboard;
|
||||||
|
mod login;
|
||||||
|
mod props;
|
||||||
|
mod props_detail;
|
||||||
|
mod props_new;
|
||||||
|
mod realm_detail;
|
||||||
|
mod realm_new;
|
||||||
|
mod realms;
|
||||||
|
mod scene_detail;
|
||||||
|
mod scene_new;
|
||||||
|
mod scenes;
|
||||||
|
mod staff;
|
||||||
|
mod user_detail;
|
||||||
|
mod user_new;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
pub use config::ConfigPage;
|
||||||
|
pub use dashboard::DashboardPage;
|
||||||
|
pub use login::LoginPage;
|
||||||
|
pub use props::PropsPage;
|
||||||
|
pub use props_detail::PropsDetailPage;
|
||||||
|
pub use props_new::PropsNewPage;
|
||||||
|
pub use realm_detail::RealmDetailPage;
|
||||||
|
pub use realm_new::RealmNewPage;
|
||||||
|
pub use realms::RealmsPage;
|
||||||
|
pub use scene_detail::SceneDetailPage;
|
||||||
|
pub use scene_new::SceneNewPage;
|
||||||
|
pub use scenes::ScenesPage;
|
||||||
|
pub use staff::StaffPage;
|
||||||
|
pub use user_detail::UserDetailPage;
|
||||||
|
pub use user_new::UserNewPage;
|
||||||
|
pub use users::UsersPage;
|
||||||
252
crates/chattyness-admin-ui/src/pages/config.rs
Normal file
252
crates/chattyness-admin-ui/src/pages/config.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
//! Server config page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{Card, MessageAlert, PageHeader};
|
||||||
|
use crate::hooks::use_fetch;
|
||||||
|
use crate::models::ServerConfig;
|
||||||
|
|
||||||
|
/// Config page component.
|
||||||
|
#[component]
|
||||||
|
pub fn ConfigPage() -> impl IntoView {
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
|
||||||
|
let config = use_fetch::<ServerConfig>(|| "/api/admin/config".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader
|
||||||
|
title="Server Configuration"
|
||||||
|
subtitle="Manage global server settings"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading configuration..."</p> }>
|
||||||
|
{move || {
|
||||||
|
config.get().map(|maybe_config| {
|
||||||
|
match maybe_config {
|
||||||
|
Some(cfg) => view! {
|
||||||
|
<ConfigForm config=cfg message=message set_message=set_message pending=pending set_pending=set_pending />
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<Card>
|
||||||
|
<p class="text-error">"Failed to load configuration. You may not have permission to access this page."</p>
|
||||||
|
</Card>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config form component.
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn ConfigForm(
|
||||||
|
config: ServerConfig,
|
||||||
|
message: ReadSignal<Option<(String, bool)>>,
|
||||||
|
set_message: WriteSignal<Option<(String, bool)>>,
|
||||||
|
pending: ReadSignal<bool>,
|
||||||
|
set_pending: WriteSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (name, set_name) = signal(config.name.clone());
|
||||||
|
let (description, set_description) = signal(config.description.clone().unwrap_or_default());
|
||||||
|
let (welcome_message, set_welcome_message) =
|
||||||
|
signal(config.welcome_message.clone().unwrap_or_default());
|
||||||
|
let (max_users_per_channel, set_max_users_per_channel) = signal(config.max_users_per_channel);
|
||||||
|
let (message_rate_limit, set_message_rate_limit) = signal(config.message_rate_limit);
|
||||||
|
let (message_rate_window_seconds, set_message_rate_window_seconds) =
|
||||||
|
signal(config.message_rate_window_seconds);
|
||||||
|
let (allow_guest_access, set_allow_guest_access) = signal(config.allow_guest_access);
|
||||||
|
let (allow_user_uploads, set_allow_user_uploads) = signal(config.allow_user_uploads);
|
||||||
|
let (require_email_verification, set_require_email_verification) =
|
||||||
|
signal(config.require_email_verification);
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"name": name.get(),
|
||||||
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
|
"welcome_message": if welcome_message.get().is_empty() { None::<String> } else { Some(welcome_message.get()) },
|
||||||
|
"max_users_per_channel": max_users_per_channel.get(),
|
||||||
|
"message_rate_limit": message_rate_limit.get(),
|
||||||
|
"message_rate_window_seconds": message_rate_window_seconds.get(),
|
||||||
|
"allow_guest_access": allow_guest_access.get(),
|
||||||
|
"allow_user_uploads": allow_user_uploads.get(),
|
||||||
|
"require_email_verification": require_email_verification.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::put("/api/admin/config")
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
set_message.set(Some((
|
||||||
|
"Configuration saved successfully!".to_string(),
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
set_message.set(Some(("Failed to save configuration".to_string(), false)));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Card>
|
||||||
|
<form on:submit=on_submit class="config-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">"Server Name"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || name.get()
|
||||||
|
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">"Server Description"</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="welcome_message" class="form-label">"Welcome Message"</label>
|
||||||
|
<textarea
|
||||||
|
id="welcome_message"
|
||||||
|
class="form-textarea"
|
||||||
|
prop:value=move || welcome_message.get()
|
||||||
|
on:input=move |ev| set_welcome_message.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="max_users_per_channel" class="form-label">"Max Users per Channel"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="max_users_per_channel"
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || max_users_per_channel.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_max_users_per_channel.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="message_rate_limit" class="form-label">"Message Rate Limit"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="message_rate_limit"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || message_rate_limit.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_message_rate_limit.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="message_rate_window_seconds" class="form-label">"Rate Window (seconds)"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="message_rate_window_seconds"
|
||||||
|
min="1"
|
||||||
|
max="300"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || message_rate_window_seconds.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_message_rate_window_seconds.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || allow_guest_access.get()
|
||||||
|
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Allow Guest Access"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || allow_user_uploads.get()
|
||||||
|
on:change=move |ev| set_allow_user_uploads.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Allow User Uploads"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || require_email_verification.get()
|
||||||
|
on:change=move |ev| set_require_email_verification.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Require Email Verification"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageAlert message=message />
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Saving..." } else { "Save Configuration" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
71
crates/chattyness-admin-ui/src/pages/dashboard.rs
Normal file
71
crates/chattyness-admin-ui/src/pages/dashboard.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
//! Dashboard page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{Card, PageHeader};
|
||||||
|
use crate::hooks::use_fetch;
|
||||||
|
use crate::models::DashboardStats;
|
||||||
|
|
||||||
|
/// Dashboard page component.
|
||||||
|
#[component]
|
||||||
|
pub fn DashboardPage() -> impl IntoView {
|
||||||
|
let stats = use_fetch::<DashboardStats>(|| "/api/admin/dashboard/stats".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader
|
||||||
|
title="Dashboard"
|
||||||
|
subtitle="Server overview and quick stats"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading stats..."</p> }>
|
||||||
|
{move || {
|
||||||
|
stats.get().map(|maybe_stats| {
|
||||||
|
match maybe_stats {
|
||||||
|
Some(s) => view! {
|
||||||
|
<StatCard title="Total Users" value=s.total_users.to_string() />
|
||||||
|
<StatCard title="Active Users" value=s.active_users.to_string() />
|
||||||
|
<StatCard title="Total Realms" value=s.total_realms.to_string() />
|
||||||
|
<StatCard title="Online Now" value=s.online_users.to_string() />
|
||||||
|
<StatCard title="Staff Members" value=s.staff_count.to_string() />
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<StatCard title="Total Users" value="-".to_string() />
|
||||||
|
<StatCard title="Active Users" value="-".to_string() />
|
||||||
|
<StatCard title="Total Realms" value="-".to_string() />
|
||||||
|
<StatCard title="Online Now" value="-".to_string() />
|
||||||
|
<StatCard title="Staff Members" value="-".to_string() />
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-sections">
|
||||||
|
<Card title="Quick Actions">
|
||||||
|
<div class="quick-actions">
|
||||||
|
<a href="/admin/users/new" class="btn btn-primary">"Create User"</a>
|
||||||
|
<a href="/admin/realms/new" class="btn btn-primary">"Create Realm"</a>
|
||||||
|
<a href="/admin/staff" class="btn btn-secondary">"Manage Staff"</a>
|
||||||
|
<a href="/admin/config" class="btn btn-secondary">"Server Config"</a>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Recent Activity">
|
||||||
|
<p class="text-muted">"Activity feed coming soon..."</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stat card component.
|
||||||
|
#[component]
|
||||||
|
fn StatCard(title: &'static str, value: String) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{value}</div>
|
||||||
|
<div class="stat-title">{title}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
137
crates/chattyness-admin-ui/src/pages/login.rs
Normal file
137
crates/chattyness-admin-ui/src/pages/login.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
//! Login page component.
|
||||||
|
|
||||||
|
use leptos::ev::SubmitEvent;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::Card;
|
||||||
|
|
||||||
|
/// Login page component.
|
||||||
|
#[component]
|
||||||
|
pub fn LoginPage() -> impl IntoView {
|
||||||
|
let (username, set_username) = signal(String::new());
|
||||||
|
let (password, set_password) = signal(String::new());
|
||||||
|
let (error, set_error) = signal(Option::<String>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
|
||||||
|
let on_submit = move |ev: SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_error.set(None);
|
||||||
|
|
||||||
|
let uname = username.get();
|
||||||
|
let pwd = password.get();
|
||||||
|
|
||||||
|
if uname.is_empty() || pwd.is_empty() {
|
||||||
|
set_error.set(Some("Username and password are required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_pending.set(true);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::post("/api/admin/auth/login")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"username": uname,
|
||||||
|
"password": pwd
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
// Redirect to dashboard
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().set_href("/admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||||
|
set_error.set(Some(err.error));
|
||||||
|
} else {
|
||||||
|
set_error.set(Some("Invalid username or password".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_error.set(Some("Network error. Please try again.".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>"Chattyness"</h1>
|
||||||
|
<span class="login-badge">"Admin Panel"</span>
|
||||||
|
<p>"Administration interface"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card class="login-card">
|
||||||
|
<form on:submit=on_submit class="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username" class="form-label">
|
||||||
|
"Username"
|
||||||
|
<span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required=true
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || username.get()
|
||||||
|
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password" class="form-label">
|
||||||
|
"Password"
|
||||||
|
<span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required=true
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || password.get()
|
||||||
|
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || error.get().is_some()>
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
<p>{move || error.get().unwrap_or_default()}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-full"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Signing in..." } else { "Sign In" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
165
crates/chattyness-admin-ui/src/pages/props.rs
Normal file
165
crates/chattyness-admin-ui/src/pages/props.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! Props list page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{Card, EmptyState, PageHeader};
|
||||||
|
use crate::hooks::use_fetch;
|
||||||
|
use crate::models::PropSummary;
|
||||||
|
|
||||||
|
/// View mode for props listing.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ViewMode {
|
||||||
|
Table,
|
||||||
|
Grid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Props page component with table and grid views.
|
||||||
|
#[component]
|
||||||
|
pub fn PropsPage() -> impl IntoView {
|
||||||
|
let (view_mode, set_view_mode) = signal(ViewMode::Table);
|
||||||
|
|
||||||
|
let props = use_fetch::<Vec<PropSummary>>(|| "/api/admin/props".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="All Props" subtitle="Manage server props and avatar items">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || if view_mode.get() == ViewMode::Table {
|
||||||
|
"btn btn-primary"
|
||||||
|
} else {
|
||||||
|
"btn btn-secondary"
|
||||||
|
}
|
||||||
|
on:click=move |_| set_view_mode.set(ViewMode::Table)
|
||||||
|
>
|
||||||
|
"Table"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || if view_mode.get() == ViewMode::Grid {
|
||||||
|
"btn btn-primary"
|
||||||
|
} else {
|
||||||
|
"btn btn-secondary"
|
||||||
|
}
|
||||||
|
on:click=move |_| set_view_mode.set(ViewMode::Grid)
|
||||||
|
>
|
||||||
|
"Grid"
|
||||||
|
</button>
|
||||||
|
<a href="/admin/props/new" class="btn btn-primary">"Create Prop"</a>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading props..."</p> }>
|
||||||
|
{move || {
|
||||||
|
props.get().map(|maybe_props: Option<Vec<PropSummary>>| {
|
||||||
|
match maybe_props {
|
||||||
|
Some(prop_list) if !prop_list.is_empty() => {
|
||||||
|
if view_mode.get() == ViewMode::Table {
|
||||||
|
view! { <PropsTable props=prop_list.clone() /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <PropsGrid props=prop_list.clone() /> }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => view! {
|
||||||
|
<EmptyState
|
||||||
|
message="No props found."
|
||||||
|
action_href="/admin/props/new"
|
||||||
|
action_text="Create Prop"
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Table view for props.
|
||||||
|
#[component]
|
||||||
|
fn PropsTable(props: Vec<PropSummary>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Preview"</th>
|
||||||
|
<th>"Name"</th>
|
||||||
|
<th>"Slug"</th>
|
||||||
|
<th>"Layer"</th>
|
||||||
|
<th>"Active"</th>
|
||||||
|
<th>"Created"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.into_iter().map(|prop| {
|
||||||
|
let asset_url = format!("/assets/{}", prop.asset_path);
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
src=asset_url
|
||||||
|
alt=prop.name.clone()
|
||||||
|
class="prop-thumbnail"
|
||||||
|
style="width: 32px; height: 32px; object-fit: contain;"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/props/{}", prop.id) class="table-link">
|
||||||
|
{prop.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><code>{prop.slug}</code></td>
|
||||||
|
<td>
|
||||||
|
{prop.default_layer.map(|l| l.to_string()).unwrap_or_else(|| "-".to_string())}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{if prop.is_active {
|
||||||
|
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>{prop.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grid view for props with 64x64 thumbnails.
|
||||||
|
#[component]
|
||||||
|
fn PropsGrid(props: Vec<PropSummary>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="props-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 16px; padding: 16px;">
|
||||||
|
{props.into_iter().map(|prop| {
|
||||||
|
let asset_url = format!("/assets/{}", prop.asset_path);
|
||||||
|
let prop_url = format!("/admin/props/{}", prop.id);
|
||||||
|
let prop_name_for_title = prop.name.clone();
|
||||||
|
let prop_name_for_alt = prop.name.clone();
|
||||||
|
let prop_name_for_label = prop.name;
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
href=prop_url
|
||||||
|
class="props-grid-item"
|
||||||
|
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 8px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s;"
|
||||||
|
title=prop_name_for_title
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src=asset_url
|
||||||
|
alt=prop_name_for_alt
|
||||||
|
style="width: 64px; height: 64px; object-fit: contain;"
|
||||||
|
/>
|
||||||
|
<span style="font-size: 0.75rem; margin-top: 4px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
|
||||||
|
{prop_name_for_label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
139
crates/chattyness-admin-ui/src/pages/props_detail.rs
Normal file
139
crates/chattyness-admin-ui/src/pages/props_detail.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
//! Prop detail page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
|
||||||
|
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
|
||||||
|
use crate::hooks::use_fetch_if;
|
||||||
|
use crate::models::PropDetail;
|
||||||
|
|
||||||
|
/// Prop detail page component.
|
||||||
|
#[component]
|
||||||
|
pub fn PropsDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let prop_id = move || params.get().get("prop_id").unwrap_or_default();
|
||||||
|
let initial_prop_id = params.get_untracked().get("prop_id").unwrap_or_default();
|
||||||
|
|
||||||
|
let prop = use_fetch_if::<PropDetail>(
|
||||||
|
move || !prop_id().is_empty(),
|
||||||
|
move || format!("/api/admin/props/{}", prop_id()),
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Prop Details" subtitle=initial_prop_id>
|
||||||
|
<a href="/admin/props" class="btn btn-secondary">"Back to Props"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading prop..."</p> }>
|
||||||
|
{move || {
|
||||||
|
prop.get().map(|maybe_prop| {
|
||||||
|
match maybe_prop {
|
||||||
|
Some(p) => view! {
|
||||||
|
<PropDetailView prop=p />
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<Card>
|
||||||
|
<p class="text-error">"Prop not found or you don't have permission to view."</p>
|
||||||
|
</Card>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn PropDetailView(prop: PropDetail) -> impl IntoView {
|
||||||
|
let asset_url = format!("/assets/{}", prop.asset_path);
|
||||||
|
let tags_display = if prop.tags.is_empty() {
|
||||||
|
"None".to_string()
|
||||||
|
} else {
|
||||||
|
prop.tags.join(", ")
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Card>
|
||||||
|
<div class="prop-header" style="display: flex; gap: 24px; align-items: flex-start;">
|
||||||
|
<div class="prop-preview" style="flex-shrink: 0;">
|
||||||
|
<img
|
||||||
|
src=asset_url
|
||||||
|
alt=prop.name.clone()
|
||||||
|
style="width: 128px; height: 128px; object-fit: contain; border: 1px solid var(--color-border, #334155); border-radius: 8px; background: var(--color-bg-tertiary, #0f172a);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="prop-info" style="flex: 1;">
|
||||||
|
<h2 style="margin: 0 0 8px 0;">{prop.name.clone()}</h2>
|
||||||
|
<p class="text-muted" style="margin: 0;"><code>{prop.slug.clone()}</code></p>
|
||||||
|
{prop.description.clone().map(|desc| view! {
|
||||||
|
<p style="margin-top: 12px; color: var(--color-text-secondary, #94a3b8);">{desc}</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Details">
|
||||||
|
<DetailGrid>
|
||||||
|
<DetailItem label="Prop ID">
|
||||||
|
<code>{prop.id.clone()}</code>
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Tags">
|
||||||
|
{tags_display}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Default Layer">
|
||||||
|
{prop.default_layer.clone().unwrap_or_else(|| "Not set".to_string())}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Default Position">
|
||||||
|
{match prop.default_position {
|
||||||
|
Some(pos) => {
|
||||||
|
let labels = ["Top-Left", "Top-Center", "Top-Right",
|
||||||
|
"Middle-Left", "Center", "Middle-Right",
|
||||||
|
"Bottom-Left", "Bottom-Center", "Bottom-Right"];
|
||||||
|
labels.get(pos as usize).map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("{}", pos))
|
||||||
|
},
|
||||||
|
None => "Not set".to_string(),
|
||||||
|
}}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Status">
|
||||||
|
{if prop.is_active {
|
||||||
|
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Properties">
|
||||||
|
<DetailGrid>
|
||||||
|
<DetailItem label="Unique">
|
||||||
|
{if prop.is_unique { "Yes" } else { "No" }}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Transferable">
|
||||||
|
{if prop.is_transferable { "Yes" } else { "No" }}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Portable">
|
||||||
|
{if prop.is_portable { "Yes" } else { "No" }}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Availability">
|
||||||
|
<DetailGrid>
|
||||||
|
<DetailItem label="Available From">
|
||||||
|
{prop.available_from.clone().unwrap_or_else(|| "Always".to_string())}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Available Until">
|
||||||
|
{prop.available_until.clone().unwrap_or_else(|| "No end date".to_string())}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Created">
|
||||||
|
{prop.created_at.clone()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Updated">
|
||||||
|
{prop.updated_at.clone()}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailGrid>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
332
crates/chattyness-admin-ui/src/pages/props_new.rs
Normal file
332
crates/chattyness-admin-ui/src/pages/props_new.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
//! Create new prop page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{Card, PageHeader};
|
||||||
|
|
||||||
|
/// Prop new page component with file upload.
|
||||||
|
#[component]
|
||||||
|
pub fn PropsNewPage() -> impl IntoView {
|
||||||
|
// Form state
|
||||||
|
let (name, set_name) = signal(String::new());
|
||||||
|
let (slug, set_slug) = signal(String::new());
|
||||||
|
let (description, set_description) = signal(String::new());
|
||||||
|
let (tags, set_tags) = signal(String::new());
|
||||||
|
let (default_layer, set_default_layer) = signal("clothes".to_string());
|
||||||
|
let (default_position, set_default_position) = signal(4i16); // Center position
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
let (created_id, _set_created_id) = signal(Option::<String>::None);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let set_created_id = _set_created_id;
|
||||||
|
let (slug_auto, set_slug_auto) = signal(true);
|
||||||
|
let (file_name, _set_file_name) = signal(Option::<String>::None);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let set_file_name = _set_file_name;
|
||||||
|
|
||||||
|
let update_name = move |ev: leptos::ev::Event| {
|
||||||
|
let new_name = event_target_value(&ev);
|
||||||
|
set_name.set(new_name.clone());
|
||||||
|
if slug_auto.get() {
|
||||||
|
let new_slug = new_name
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string();
|
||||||
|
set_slug.set(new_slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_file_change = move |ev: leptos::ev::Event| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
let target = ev.target().unwrap();
|
||||||
|
let input: web_sys::HtmlInputElement = target.dyn_into().unwrap();
|
||||||
|
if let Some(files) = input.files() {
|
||||||
|
if files.length() > 0 {
|
||||||
|
if let Some(file) = files.get(0) {
|
||||||
|
set_file_name.set(Some(file.name()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
let _ = ev;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
// Get the form element
|
||||||
|
let target = ev.target().unwrap();
|
||||||
|
let form: web_sys::HtmlFormElement = target.dyn_into().unwrap();
|
||||||
|
|
||||||
|
// Get the file input
|
||||||
|
let file_input = form
|
||||||
|
.query_selector("input[type='file']")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into::<web_sys::HtmlInputElement>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let files = file_input.files();
|
||||||
|
if files.is_none() || files.as_ref().unwrap().length() == 0 {
|
||||||
|
set_message.set(Some(("Please select a file".to_string(), false)));
|
||||||
|
set_pending.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = files.unwrap().get(0).unwrap();
|
||||||
|
|
||||||
|
// Build tags array from comma-separated string
|
||||||
|
let tags_vec: Vec<String> = tags
|
||||||
|
.get()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Create metadata JSON
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"name": name.get(),
|
||||||
|
"slug": if slug.get().is_empty() { None::<String> } else { Some(slug.get()) },
|
||||||
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
|
"tags": tags_vec,
|
||||||
|
"default_layer": default_layer.get(),
|
||||||
|
"default_position": default_position.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create FormData
|
||||||
|
let form_data = web_sys::FormData::new().unwrap();
|
||||||
|
form_data
|
||||||
|
.append_with_str("metadata", &metadata.to_string())
|
||||||
|
.unwrap();
|
||||||
|
form_data.append_with_blob("file", &file).unwrap();
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let response = Request::post("/api/admin/props")
|
||||||
|
.body(form_data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CreateResponse {
|
||||||
|
id: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
name: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||||
|
set_created_id.set(Some(result.id));
|
||||||
|
set_message.set(Some(("Prop created successfully!".to_string(), true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||||
|
set_message.set(Some((err.error, false)));
|
||||||
|
} else {
|
||||||
|
set_message.set(Some(("Failed to create prop".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Create New Prop" subtitle="Upload a new server prop image">
|
||||||
|
<a href="/admin/props" class="btn btn-secondary">"Back to Props"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<h3 class="section-title">"Prop Details"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">
|
||||||
|
"Name" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Smile Expression"
|
||||||
|
prop:value=move || name.get()
|
||||||
|
on:input=update_name
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="slug" class="form-label">"Slug (URL)"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="slug"
|
||||||
|
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="smile-expression"
|
||||||
|
prop:value=move || slug.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
set_slug_auto.set(false);
|
||||||
|
set_slug.set(event_target_value(&ev));
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">"Description"</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
placeholder="A happy smile expression for avatars"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tags" class="form-label">"Tags"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tags"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="expression, face, happy"
|
||||||
|
prop:value=move || tags.get()
|
||||||
|
on:input=move |ev| set_tags.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Comma-separated list of tags"</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Image File"</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="file" class="form-label">
|
||||||
|
"Image File" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file"
|
||||||
|
required=true
|
||||||
|
accept=".svg,.png,image/svg+xml,image/png"
|
||||||
|
class="form-input"
|
||||||
|
on:change=on_file_change
|
||||||
|
/>
|
||||||
|
<small class="form-help">"SVG or PNG image file (64x64 recommended)"</small>
|
||||||
|
<Show when=move || file_name.get().is_some()>
|
||||||
|
<p class="text-muted">"Selected: " {move || file_name.get().unwrap_or_default()}</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Default Positioning"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="default_layer" class="form-label">"Layer"</label>
|
||||||
|
<select
|
||||||
|
id="default_layer"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| set_default_layer.set(event_target_value(&ev))
|
||||||
|
>
|
||||||
|
<option value="skin" selected=move || default_layer.get() == "skin">"Skin (behind)"</option>
|
||||||
|
<option value="clothes" selected=move || default_layer.get() == "clothes">"Clothes (with)"</option>
|
||||||
|
<option value="accessories" selected=move || default_layer.get() == "accessories">"Accessories (front)"</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-help">"Z-depth layer for prop placement"</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="default_position" class="form-label">"Position"</label>
|
||||||
|
<select
|
||||||
|
id="default_position"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_default_position.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="0" selected=move || default_position.get() == 0>"Top-Left (0)"</option>
|
||||||
|
<option value="1" selected=move || default_position.get() == 1>"Top-Center (1)"</option>
|
||||||
|
<option value="2" selected=move || default_position.get() == 2>"Top-Right (2)"</option>
|
||||||
|
<option value="3" selected=move || default_position.get() == 3>"Middle-Left (3)"</option>
|
||||||
|
<option value="4" selected=move || default_position.get() == 4>"Center (4)"</option>
|
||||||
|
<option value="5" selected=move || default_position.get() == 5>"Middle-Right (5)"</option>
|
||||||
|
<option value="6" selected=move || default_position.get() == 6>"Bottom-Left (6)"</option>
|
||||||
|
<option value="7" selected=move || default_position.get() == 7>"Bottom-Center (7)"</option>
|
||||||
|
<option value="8" selected=move || default_position.get() == 8>"Bottom-Right (8)"</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-help">"Grid position (3x3 grid)"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when=move || created_id.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let id = created_id.get().unwrap_or_default();
|
||||||
|
view! {
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p>
|
||||||
|
<a href=format!("/admin/props/{}", id)>
|
||||||
|
"View prop"
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Uploading..." } else { "Create Prop" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
295
crates/chattyness-admin-ui/src/pages/realm_detail.rs
Normal file
295
crates/chattyness-admin-ui/src/pages/realm_detail.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
//! Realm detail/edit page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{
|
||||||
|
Card, DetailGrid, DetailItem, MessageAlert, NsfwBadge, PageHeader, PrivacyBadge,
|
||||||
|
};
|
||||||
|
use crate::hooks::use_fetch_if;
|
||||||
|
use crate::models::RealmDetail;
|
||||||
|
use crate::utils::get_api_base;
|
||||||
|
|
||||||
|
/// Realm detail page component.
|
||||||
|
#[component]
|
||||||
|
pub fn RealmDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let slug = move || params.get().get("slug").unwrap_or_default();
|
||||||
|
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||||
|
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
|
||||||
|
let realm = use_fetch_if::<RealmDetail>(
|
||||||
|
move || !slug().is_empty(),
|
||||||
|
move || format!("{}/realms/{}", get_api_base(), slug()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let slug_for_scenes = initial_slug.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Realm Details" subtitle=format!("/{}", initial_slug)>
|
||||||
|
<a href=format!("/admin/realms/{}/scenes", slug_for_scenes) class="btn btn-primary">"Manage Scenes"</a>
|
||||||
|
<a href="/admin/realms" class="btn btn-secondary">"Back to Realms"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading realm..."</p> }>
|
||||||
|
{move || {
|
||||||
|
realm.get().map(|maybe_realm| {
|
||||||
|
match maybe_realm {
|
||||||
|
Some(r) => view! {
|
||||||
|
<RealmDetailView realm=r message=message set_message=set_message />
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<Card>
|
||||||
|
<p class="text-error">"Realm not found or you don't have permission to view."</p>
|
||||||
|
</Card>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn RealmDetailView(
|
||||||
|
realm: RealmDetail,
|
||||||
|
message: ReadSignal<Option<(String, bool)>>,
|
||||||
|
set_message: WriteSignal<Option<(String, bool)>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let slug = realm.slug.clone();
|
||||||
|
let slug_display = realm.slug.clone();
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let (name, set_name) = signal(realm.name.clone());
|
||||||
|
let (tagline, set_tagline) = signal(realm.tagline.clone().unwrap_or_default());
|
||||||
|
let (description, set_description) = signal(realm.description.clone().unwrap_or_default());
|
||||||
|
let (privacy, set_privacy) = signal(realm.privacy.clone());
|
||||||
|
let (max_users, set_max_users) = signal(realm.max_users);
|
||||||
|
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
|
||||||
|
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
|
||||||
|
let (theme_color, set_theme_color) =
|
||||||
|
signal(realm.theme_color.clone().unwrap_or_else(|| "#7c3aed".to_string()));
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let api_base = get_api_base();
|
||||||
|
let slug = slug.clone();
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"name": name.get(),
|
||||||
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
|
"tagline": if tagline.get().is_empty() { None::<String> } else { Some(tagline.get()) },
|
||||||
|
"privacy": privacy.get(),
|
||||||
|
"is_nsfw": is_nsfw.get(),
|
||||||
|
"max_users": max_users.get(),
|
||||||
|
"allow_guest_access": allow_guest_access.get(),
|
||||||
|
"theme_color": theme_color.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::put(&format!("{}/realms/{}", api_base, slug))
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
set_message.set(Some(("Realm updated successfully!".to_string(), true)));
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
set_message.set(Some(("Failed to update realm".to_string(), false)));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Card>
|
||||||
|
<div class="realm-header">
|
||||||
|
<div class="realm-info">
|
||||||
|
<h2>{realm.name.clone()}</h2>
|
||||||
|
<p class="text-muted">{realm.tagline.clone().unwrap_or_default()}</p>
|
||||||
|
</div>
|
||||||
|
<div class="realm-badges">
|
||||||
|
<PrivacyBadge privacy=realm.privacy.clone() />
|
||||||
|
{if realm.is_nsfw {
|
||||||
|
view! { <NsfwBadge /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailGrid>
|
||||||
|
<DetailItem label="Owner">
|
||||||
|
<a href=format!("/admin/users/{}", realm.owner_id) class="table-link">
|
||||||
|
{realm.owner_display_name.clone()} " (@" {realm.owner_username.clone()} ")"
|
||||||
|
</a>
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Members">
|
||||||
|
{realm.member_count.to_string()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Current Users">
|
||||||
|
{realm.current_user_count.to_string()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Max Users">
|
||||||
|
{realm.max_users.to_string()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Created">
|
||||||
|
{realm.created_at.clone()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Updated">
|
||||||
|
{realm.updated_at.clone()}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Edit Realm Settings">
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">"Realm Name"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || name.get()
|
||||||
|
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">"Slug (URL)"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value=slug_display
|
||||||
|
class="form-input"
|
||||||
|
disabled=true
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Slug cannot be changed"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tagline" class="form-label">"Tagline"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tagline"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="A short description"
|
||||||
|
prop:value=move || tagline.get()
|
||||||
|
on:input=move |ev| set_tagline.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">"Description"</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="privacy" class="form-label">"Privacy"</label>
|
||||||
|
<select
|
||||||
|
id="privacy"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| set_privacy.set(event_target_value(&ev))
|
||||||
|
>
|
||||||
|
<option value="public" selected=move || privacy.get() == "public">"Public"</option>
|
||||||
|
<option value="unlisted" selected=move || privacy.get() == "unlisted">"Unlisted"</option>
|
||||||
|
<option value="private" selected=move || privacy.get() == "private">"Private"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="max_users" class="form-label">"Max Users"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="max_users"
|
||||||
|
min=1
|
||||||
|
max=10000
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || max_users.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_max_users.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || is_nsfw.get()
|
||||||
|
on:change=move |ev| set_is_nsfw.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"NSFW Content"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || allow_guest_access.get()
|
||||||
|
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Allow Guest Access"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="theme_color" class="form-label">"Theme Color"</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="theme_color"
|
||||||
|
class="form-color"
|
||||||
|
prop:value=move || theme_color.get()
|
||||||
|
on:input=move |ev| set_theme_color.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageAlert message=message />
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
388
crates/chattyness-admin-ui/src/pages/realm_new.rs
Normal file
388
crates/chattyness-admin-ui/src/pages/realm_new.rs
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
//! Create new realm page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{Card, PageHeader};
|
||||||
|
|
||||||
|
/// Realm new page component.
|
||||||
|
#[component]
|
||||||
|
pub fn RealmNewPage() -> impl IntoView {
|
||||||
|
// Form state
|
||||||
|
let (name, set_name) = signal(String::new());
|
||||||
|
let (slug, set_slug) = signal(String::new());
|
||||||
|
let (tagline, set_tagline) = signal(String::new());
|
||||||
|
let (description, set_description) = signal(String::new());
|
||||||
|
let (privacy, set_privacy) = signal("public".to_string());
|
||||||
|
let (max_users, set_max_users) = signal(100i32);
|
||||||
|
let (is_nsfw, set_is_nsfw) = signal(false);
|
||||||
|
let (allow_guest_access, set_allow_guest_access) = signal(false);
|
||||||
|
let (theme_color, set_theme_color) = signal("#7c3aed".to_string());
|
||||||
|
|
||||||
|
// Owner selection
|
||||||
|
let (owner_mode, set_owner_mode) = signal("existing".to_string());
|
||||||
|
let (owner_id, set_owner_id) = signal(String::new());
|
||||||
|
let (new_username, set_new_username) = signal(String::new());
|
||||||
|
let (new_email, set_new_email) = signal(String::new());
|
||||||
|
let (new_display_name, set_new_display_name) = signal(String::new());
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
let (created_slug, _set_created_slug) = signal(Option::<String>::None);
|
||||||
|
let (temp_password, _set_temp_password) = signal(Option::<String>::None);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let (set_created_slug, set_temp_password) = (_set_created_slug, _set_temp_password);
|
||||||
|
let (slug_auto, set_slug_auto) = signal(true);
|
||||||
|
|
||||||
|
let update_name = move |ev: leptos::ev::Event| {
|
||||||
|
let new_name = event_target_value(&ev);
|
||||||
|
set_name.set(new_name.clone());
|
||||||
|
if slug_auto.get() {
|
||||||
|
let new_slug = new_name
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string();
|
||||||
|
set_slug.set(new_slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let mut data = serde_json::json!({
|
||||||
|
"name": name.get(),
|
||||||
|
"slug": slug.get(),
|
||||||
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
|
"tagline": if tagline.get().is_empty() { None::<String> } else { Some(tagline.get()) },
|
||||||
|
"privacy": privacy.get(),
|
||||||
|
"is_nsfw": is_nsfw.get(),
|
||||||
|
"max_users": max_users.get(),
|
||||||
|
"allow_guest_access": allow_guest_access.get(),
|
||||||
|
"theme_color": theme_color.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
if owner_mode.get() == "existing" {
|
||||||
|
if owner_id.get().is_empty() {
|
||||||
|
set_message.set(Some(("Please enter an owner User ID".to_string(), false)));
|
||||||
|
set_pending.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data["owner_id"] = serde_json::json!(owner_id.get());
|
||||||
|
} else {
|
||||||
|
if new_username.get().is_empty() || new_email.get().is_empty() || new_display_name.get().is_empty() {
|
||||||
|
set_message.set(Some(("Please fill in all new owner fields".to_string(), false)));
|
||||||
|
set_pending.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data["new_owner"] = serde_json::json!({
|
||||||
|
"username": new_username.get(),
|
||||||
|
"email": new_email.get(),
|
||||||
|
"display_name": new_display_name.get()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::post("/api/admin/realms")
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CreateResponse {
|
||||||
|
slug: String,
|
||||||
|
owner_temporary_password: Option<String>,
|
||||||
|
}
|
||||||
|
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||||
|
set_created_slug.set(Some(result.slug));
|
||||||
|
set_temp_password.set(result.owner_temporary_password);
|
||||||
|
set_message.set(Some(("Realm created successfully!".to_string(), true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||||
|
set_message.set(Some((err.error, false)));
|
||||||
|
} else {
|
||||||
|
set_message.set(Some(("Failed to create realm".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Create New Realm" subtitle="Create a new realm space">
|
||||||
|
<a href="/admin/realms" class="btn btn-secondary">"Back to Realms"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<h3 class="section-title">"Realm Details"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">
|
||||||
|
"Realm Name" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
placeholder="My Awesome Realm"
|
||||||
|
prop:value=move || name.get()
|
||||||
|
on:input=update_name
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="slug" class="form-label">
|
||||||
|
"Slug (URL)" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="slug"
|
||||||
|
required=true
|
||||||
|
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="my-realm"
|
||||||
|
prop:value=move || slug.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
set_slug_auto.set(false);
|
||||||
|
set_slug.set(event_target_value(&ev));
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Lowercase letters, numbers, hyphens only"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tagline" class="form-label">"Tagline"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tagline"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="A short description"
|
||||||
|
prop:value=move || tagline.get()
|
||||||
|
on:input=move |ev| set_tagline.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">"Description"</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
placeholder="Detailed description of the realm"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="privacy" class="form-label">"Privacy"</label>
|
||||||
|
<select
|
||||||
|
id="privacy"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| set_privacy.set(event_target_value(&ev))
|
||||||
|
>
|
||||||
|
<option value="public" selected=move || privacy.get() == "public">"Public"</option>
|
||||||
|
<option value="unlisted" selected=move || privacy.get() == "unlisted">"Unlisted"</option>
|
||||||
|
<option value="private" selected=move || privacy.get() == "private">"Private"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="max_users" class="form-label">"Max Users"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="max_users"
|
||||||
|
min=1
|
||||||
|
max=10000
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || max_users.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_max_users.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || is_nsfw.get()
|
||||||
|
on:change=move |ev| set_is_nsfw.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"NSFW Content"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || allow_guest_access.get()
|
||||||
|
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Allow Guest Access"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="theme_color" class="form-label">"Theme Color"</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="theme_color"
|
||||||
|
class="form-color"
|
||||||
|
prop:value=move || theme_color.get()
|
||||||
|
on:input=move |ev| set_theme_color.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Realm Owner"</h3>
|
||||||
|
|
||||||
|
<div class="tab-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || if owner_mode.get() == "existing" { "btn btn-primary" } else { "btn btn-secondary" }
|
||||||
|
on:click=move |_| set_owner_mode.set("existing".to_string())
|
||||||
|
>
|
||||||
|
"Existing User"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || if owner_mode.get() == "new" { "btn btn-primary" } else { "btn btn-secondary" }
|
||||||
|
on:click=move |_| set_owner_mode.set("new".to_string())
|
||||||
|
>
|
||||||
|
"Create New User"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || owner_mode.get() == "existing">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="owner_id" class="form-label">"Owner User ID"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="owner_id"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="UUID of existing user"
|
||||||
|
prop:value=move || owner_id.get()
|
||||||
|
on:input=move |ev| set_owner_id.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when=move || owner_mode.get() == "new">
|
||||||
|
<p class="text-muted">"A random temporary password will be generated for the new owner."</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_username" class="form-label">"Username"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="new_username"
|
||||||
|
minlength=3
|
||||||
|
maxlength=32
|
||||||
|
class="form-input"
|
||||||
|
placeholder="username"
|
||||||
|
prop:value=move || new_username.get()
|
||||||
|
on:input=move |ev| set_new_username.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_email" class="form-label">"Email"</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="new_email"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
prop:value=move || new_email.get()
|
||||||
|
on:input=move |ev| set_new_email.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_display_name" class="form-label">"Display Name"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="new_display_name"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Display Name"
|
||||||
|
prop:value=move || new_display_name.get()
|
||||||
|
on:input=move |ev| set_new_display_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when=move || message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when=move || created_slug.get().is_some()>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p>
|
||||||
|
<a href=format!("/admin/realms/{}", created_slug.get().unwrap_or_default())>
|
||||||
|
"View realm"
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when=move || temp_password.get().is_some()>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<p><strong>"New Owner Temporary Password:"</strong></p>
|
||||||
|
<code class="temp-password">{move || temp_password.get().unwrap_or_default()}</code>
|
||||||
|
<p class="text-muted">"Copy this password now - it will not be shown again!"</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Creating..." } else { "Create Realm" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
111
crates/chattyness-admin-ui/src/pages/realms.rs
Normal file
111
crates/chattyness-admin-ui/src/pages/realms.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//! Realms list page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{
|
||||||
|
Card, EmptyState, NsfwBadge, PageHeader, Pagination, PrivacyBadge, SearchForm,
|
||||||
|
};
|
||||||
|
use crate::hooks::{use_fetch, use_pagination};
|
||||||
|
use crate::models::RealmSummary;
|
||||||
|
use crate::utils::build_paginated_url;
|
||||||
|
|
||||||
|
/// Realms page component.
|
||||||
|
#[component]
|
||||||
|
pub fn RealmsPage() -> impl IntoView {
|
||||||
|
let pagination = use_pagination();
|
||||||
|
|
||||||
|
let realms = use_fetch::<Vec<RealmSummary>>(move || {
|
||||||
|
build_paginated_url(
|
||||||
|
"/api/admin/realms",
|
||||||
|
pagination.page.get(),
|
||||||
|
&pagination.search_query.get(),
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="All Realms" subtitle="Manage realm spaces">
|
||||||
|
<a href="/admin/realms/new" class="btn btn-primary">"Create Realm"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<SearchForm
|
||||||
|
action="/admin/realms"
|
||||||
|
placeholder="Search by name or slug..."
|
||||||
|
search_input=pagination.search_input
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading realms..."</p> }>
|
||||||
|
{move || {
|
||||||
|
realms.get().map(|maybe_realms: Option<Vec<RealmSummary>>| {
|
||||||
|
match maybe_realms {
|
||||||
|
Some(realm_list) if !realm_list.is_empty() => {
|
||||||
|
view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Name"</th>
|
||||||
|
<th>"Tagline"</th>
|
||||||
|
<th>"Privacy"</th>
|
||||||
|
<th>"NSFW"</th>
|
||||||
|
<th>"Owner"</th>
|
||||||
|
<th>"Members"</th>
|
||||||
|
<th>"Online"</th>
|
||||||
|
<th>"Created"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{realm_list.into_iter().map(|realm| {
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/realms/{}", realm.slug) class="table-link">
|
||||||
|
{realm.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{realm.tagline.unwrap_or_default()}</td>
|
||||||
|
<td><PrivacyBadge privacy=realm.privacy /></td>
|
||||||
|
<td>
|
||||||
|
{if realm.is_nsfw {
|
||||||
|
view! { <NsfwBadge /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span>"-"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/users/{}", realm.owner_id) class="table-link">
|
||||||
|
{realm.owner_username}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{realm.member_count}</td>
|
||||||
|
<td>{realm.current_user_count}</td>
|
||||||
|
<td>{realm.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
current_page=pagination.page.get()
|
||||||
|
base_url="/admin/realms".to_string()
|
||||||
|
query=pagination.search_query.get()
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
_ => view! {
|
||||||
|
<EmptyState
|
||||||
|
message="No realms found."
|
||||||
|
action_href="/admin/realms/new"
|
||||||
|
action_text="Create Realm"
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
783
crates/chattyness-admin-ui/src/pages/scene_detail.rs
Normal file
783
crates/chattyness-admin-ui/src/pages/scene_detail.rs
Normal file
|
|
@ -0,0 +1,783 @@
|
||||||
|
//! Scene detail/edit page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use crate::utils::fetch_image_dimensions_client;
|
||||||
|
|
||||||
|
/// Scene detail from API.
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct SceneDetail {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub realm_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub background_image_path: Option<String>,
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
pub bounds_wkt: String,
|
||||||
|
pub dimension_mode: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub is_entry_point: bool,
|
||||||
|
pub is_hidden: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse width and height from WKT bounds string.
|
||||||
|
/// Example: "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))" -> (800, 600)
|
||||||
|
/// Handles both "0 0, 800 0" (with space) and "0 0,800 0" (without space) formats.
|
||||||
|
fn parse_bounds_wkt(wkt: &str) -> (i32, i32) {
|
||||||
|
// Extract coordinates from POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x5 y5))
|
||||||
|
// The second point has (width, 0) and third point has (width, height)
|
||||||
|
if let Some(start) = wkt.find("((") {
|
||||||
|
if let Some(end) = wkt.find("))") {
|
||||||
|
let coords_str = &wkt[start + 2..end];
|
||||||
|
let points: Vec<&str> = coords_str.split(',').map(|s| s.trim()).collect();
|
||||||
|
if points.len() >= 3 {
|
||||||
|
// Second point: "width 0"
|
||||||
|
let second: Vec<&str> = points[1].split_whitespace().collect();
|
||||||
|
// Third point: "width height"
|
||||||
|
let third: Vec<&str> = points[2].split_whitespace().collect();
|
||||||
|
if !second.is_empty() && third.len() >= 2 {
|
||||||
|
let width = second[0].parse().unwrap_or(800);
|
||||||
|
let height = third[1].parse().unwrap_or(600);
|
||||||
|
return (width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(800, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scene detail page component.
|
||||||
|
#[component]
|
||||||
|
pub fn SceneDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||||
|
let scene_id = move || params.get().get("scene_id").unwrap_or_default();
|
||||||
|
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||||
|
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
|
||||||
|
let scene = LocalResource::new(move || {
|
||||||
|
let id = scene_id();
|
||||||
|
async move {
|
||||||
|
if id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
let resp = Request::get(&format!("/api/admin/scenes/{}", id)).send().await;
|
||||||
|
match resp {
|
||||||
|
Ok(r) if r.ok() => r.json::<SceneDetail>().await.ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
{
|
||||||
|
let _ = id;
|
||||||
|
None::<SceneDetail>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let slug_for_back = initial_realm_slug.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Scene Details" subtitle="View and edit scene">
|
||||||
|
<a href=format!("/admin/realms/{}/scenes", slug_for_back) class="btn btn-secondary">"Back to Scenes"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading scene..."</p> }>
|
||||||
|
{move || {
|
||||||
|
let realm_slug_val = realm_slug();
|
||||||
|
scene.get().map(|maybe_scene| {
|
||||||
|
match maybe_scene {
|
||||||
|
Some(s) => view! {
|
||||||
|
<SceneDetailView scene=s realm_slug=realm_slug_val message=message set_message=set_message />
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<Card>
|
||||||
|
<p class="text-error">"Scene not found or you don't have permission to view."</p>
|
||||||
|
</Card>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn SceneDetailView(
|
||||||
|
scene: SceneDetail,
|
||||||
|
realm_slug: String,
|
||||||
|
message: ReadSignal<Option<(String, bool)>>,
|
||||||
|
set_message: WriteSignal<Option<(String, bool)>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let scene_id = scene.id.to_string();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let scene_id_for_delete = scene.id.to_string();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let realm_slug_for_delete = realm_slug.clone();
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
let (delete_pending, set_delete_pending) = signal(false);
|
||||||
|
let (show_delete_confirm, set_show_delete_confirm) = signal(false);
|
||||||
|
let (show_image_modal, set_show_image_modal) = signal(false);
|
||||||
|
|
||||||
|
// Parse dimensions from bounds_wkt
|
||||||
|
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
|
||||||
|
|
||||||
|
// Clone scene data for view (to avoid move issues)
|
||||||
|
let scene_name_display = scene.name.clone();
|
||||||
|
let scene_slug_display = scene.slug.clone();
|
||||||
|
let scene_slug_disabled = scene.slug.clone();
|
||||||
|
let scene_description_display = scene.description.clone();
|
||||||
|
let scene_background_image_path = scene.background_image_path.clone();
|
||||||
|
let scene_background_image_path_for_modal = scene.background_image_path.clone();
|
||||||
|
let scene_background_image_path_for_check = scene.background_image_path.clone();
|
||||||
|
let scene_background_image_path_for_dimensions = scene.background_image_path.clone();
|
||||||
|
let scene_background_color_display = scene.background_color.clone();
|
||||||
|
let scene_created_at = scene.created_at.clone();
|
||||||
|
let scene_updated_at = scene.updated_at.clone();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let (name, set_name) = signal(scene.name.clone());
|
||||||
|
let (description, set_description) = signal(scene.description.clone().unwrap_or_default());
|
||||||
|
let (background_color, set_background_color) = signal(
|
||||||
|
scene.background_color.clone().unwrap_or_else(|| "#1a1a2e".to_string()),
|
||||||
|
);
|
||||||
|
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||||
|
let (clear_background_image, set_clear_background_image) = signal(false);
|
||||||
|
let (infer_dimensions, set_infer_dimensions) = signal(false);
|
||||||
|
let (width, set_width) = signal(initial_width);
|
||||||
|
let (height, set_height) = signal(initial_height);
|
||||||
|
let (dimension_mode, set_dimension_mode) = signal(scene.dimension_mode.clone());
|
||||||
|
let (sort_order, set_sort_order) = signal(scene.sort_order);
|
||||||
|
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
|
||||||
|
let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
|
||||||
|
|
||||||
|
// UI state for dimension fetching
|
||||||
|
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||||
|
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
|
||||||
|
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||||
|
let url = background_image_url.get();
|
||||||
|
if url.is_empty() {
|
||||||
|
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_fetching_dimensions.set(true);
|
||||||
|
set_dimension_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
fetch_image_dimensions_client(
|
||||||
|
url,
|
||||||
|
move |w, h| {
|
||||||
|
set_width.set(w as i32);
|
||||||
|
set_height.set(h as i32);
|
||||||
|
set_dimension_message.set(Some((
|
||||||
|
format!("Dimensions: {}x{}", w, h),
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
set_dimension_message.set(Some((err, false)));
|
||||||
|
},
|
||||||
|
set_fetching_dimensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let id = scene_id.clone();
|
||||||
|
// Build bounds WKT from width/height
|
||||||
|
let w = width.get();
|
||||||
|
let h = height.get();
|
||||||
|
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
||||||
|
|
||||||
|
let mut data = serde_json::json!({
|
||||||
|
"name": name.get(),
|
||||||
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
|
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
||||||
|
"bounds_wkt": bounds_wkt,
|
||||||
|
"dimension_mode": dimension_mode.get(),
|
||||||
|
"sort_order": sort_order.get(),
|
||||||
|
"is_entry_point": is_entry_point.get(),
|
||||||
|
"is_hidden": is_hidden.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only include background_image_url if provided
|
||||||
|
let bg_url = background_image_url.get();
|
||||||
|
if !bg_url.is_empty() {
|
||||||
|
data["background_image_url"] = serde_json::json!(bg_url);
|
||||||
|
// Include infer dimensions flag when uploading new image
|
||||||
|
if infer_dimensions.get() {
|
||||||
|
data["infer_dimensions_from_image"] = serde_json::json!(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include clear flag if set
|
||||||
|
if clear_background_image.get() {
|
||||||
|
data["clear_background_image"] = serde_json::json!(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::put(&format!("/api/admin/scenes/{}", id))
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
set_message.set(Some(("Scene updated successfully!".to_string(), true)));
|
||||||
|
// Clear the background image URL field after success
|
||||||
|
set_background_image_url.set(String::new());
|
||||||
|
set_clear_background_image.set(false);
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||||
|
set_message.set(Some((err.error, false)));
|
||||||
|
} else {
|
||||||
|
set_message.set(Some(("Failed to update scene".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Card>
|
||||||
|
<div class="realm-header">
|
||||||
|
<div class="realm-info">
|
||||||
|
<h2>{scene_name_display}</h2>
|
||||||
|
<p class="text-muted">"/" {scene_slug_display}</p>
|
||||||
|
</div>
|
||||||
|
<div class="realm-badges">
|
||||||
|
{if scene.is_entry_point {
|
||||||
|
view! { <span class="badge badge-success">"Entry Point"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
{if scene.is_hidden {
|
||||||
|
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailGrid>
|
||||||
|
<DetailItem label="Scene ID">
|
||||||
|
<code>{scene.id.to_string()}</code>
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Realm ID">
|
||||||
|
<code>{scene.realm_id.to_string()}</code>
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Dimensions">
|
||||||
|
{format!("{}x{}", initial_width, initial_height)}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Sort Order">
|
||||||
|
{scene.sort_order.to_string()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Background">
|
||||||
|
{if let Some(ref path) = scene_background_image_path {
|
||||||
|
let path_clone = path.clone();
|
||||||
|
view! {
|
||||||
|
<div style="display:inline-flex;align-items:center;gap:0.75rem">
|
||||||
|
<img
|
||||||
|
src=path_clone.clone()
|
||||||
|
alt="Background thumbnail"
|
||||||
|
style="max-width:100px;max-height:75px;border:1px solid #555;border-radius:4px;cursor:pointer"
|
||||||
|
title="Click to view full size"
|
||||||
|
on:click=move |_| set_show_image_modal.set(true)
|
||||||
|
/>
|
||||||
|
<span class="text-muted" style="font-size:0.85em">{path_clone}</span>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else if let Some(ref color) = scene_background_color_display {
|
||||||
|
view! {
|
||||||
|
<span style=format!("display:inline-flex;align-items:center;gap:0.5rem")>
|
||||||
|
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
|
||||||
|
{color.clone()}
|
||||||
|
</span>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span class="text-muted">"None"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Created">
|
||||||
|
{scene_created_at}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Updated">
|
||||||
|
{scene_updated_at}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailGrid>
|
||||||
|
|
||||||
|
{if let Some(ref desc) = scene_description_display {
|
||||||
|
view! {
|
||||||
|
<div class="realm-description">
|
||||||
|
<h4>"Description"</h4>
|
||||||
|
<p>{desc.clone()}</p>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Edit Scene">
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<h3 class="section-title">"Scene Details"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">"Scene Name"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || name.get()
|
||||||
|
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">"Slug (URL)"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value=scene_slug_disabled
|
||||||
|
class="form-input"
|
||||||
|
disabled=true
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Slug cannot be changed"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">"Description"</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Background"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="background_color" class="form-label">"Background Color"</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="background_color"
|
||||||
|
class="form-color"
|
||||||
|
prop:value=move || background_color.get()
|
||||||
|
on:input=move |ev| set_background_color.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 2">
|
||||||
|
<label for="background_image_url" class="form-label">"New Background Image URL"</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="background_image_url"
|
||||||
|
class="form-input"
|
||||||
|
style="flex: 1"
|
||||||
|
placeholder="https://example.com/image.png"
|
||||||
|
prop:value=move || background_image_url.get()
|
||||||
|
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled=move || fetching_dimensions.get()
|
||||||
|
on:click=fetch_dimensions
|
||||||
|
>
|
||||||
|
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-help">"Leave empty to keep current image. Click 'Get Size' to auto-fill dimensions."</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Image preview (for new URL)
|
||||||
|
<Show when=move || !background_image_url.get().is_empty()>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">"New Image Preview"</label>
|
||||||
|
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
|
||||||
|
<img
|
||||||
|
src=move || background_image_url.get()
|
||||||
|
alt="New background preview"
|
||||||
|
style="max-width: 100%; height: auto; display: block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Dimension fetch message
|
||||||
|
<Show when=move || dimension_message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert" style="margin-bottom: 1rem">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Infer dimensions checkbox (only shown when new URL is provided)
|
||||||
|
<Show when=move || !background_image_url.get().is_empty()>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || infer_dimensions.get()
|
||||||
|
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Infer dimensions from image"
|
||||||
|
</label>
|
||||||
|
<small class="form-help">"If enabled, server will extract dimensions from the image when saving"</small>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{if scene_background_image_path_for_check.is_some() {
|
||||||
|
view! {
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || clear_background_image.get()
|
||||||
|
on:change=move |ev| set_clear_background_image.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Remove current background image"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
<h3 class="section-title">"Dimensions"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="width" class="form-label">"Width"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="width"
|
||||||
|
min=100
|
||||||
|
max=10000
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || width.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_width.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="height" class="form-label">"Height"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="height"
|
||||||
|
min=100
|
||||||
|
max=10000
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || height.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_height.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
||||||
|
<select
|
||||||
|
id="dimension_mode"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
||||||
|
>
|
||||||
|
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
||||||
|
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Button to set dimensions from existing background image
|
||||||
|
{if let Some(ref path) = scene_background_image_path_for_dimensions {
|
||||||
|
let path_for_closure = path.clone();
|
||||||
|
view! {
|
||||||
|
<div class="form-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled=move || fetching_dimensions.get()
|
||||||
|
on:click=move |_| {
|
||||||
|
set_fetching_dimensions.set(true);
|
||||||
|
set_dimension_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let path = path_for_closure.clone();
|
||||||
|
fetch_image_dimensions_client(
|
||||||
|
path,
|
||||||
|
move |w, h| {
|
||||||
|
set_width.set(w as i32);
|
||||||
|
set_height.set(h as i32);
|
||||||
|
set_dimension_message.set(Some((
|
||||||
|
format!("Set from image: {}x{}", w, h),
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
set_dimension_message.set(Some((err, false)));
|
||||||
|
},
|
||||||
|
set_fetching_dimensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{move || if fetching_dimensions.get() { "Fetching..." } else { "Set from background image" }}
|
||||||
|
</button>
|
||||||
|
<small class="form-help">"Set dimensions to match the current background image"</small>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
<h3 class="section-title">"Options"</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sort_order" class="form-label">"Sort Order"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="sort_order"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || sort_order.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_sort_order.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Lower numbers appear first in scene lists"</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || is_entry_point.get()
|
||||||
|
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Entry Point"
|
||||||
|
</label>
|
||||||
|
<small class="form-help">"Users spawn here when entering the realm"</small>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || is_hidden.get()
|
||||||
|
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Hidden"
|
||||||
|
</label>
|
||||||
|
<small class="form-help">"Scene won't appear in public listings"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Danger Zone">
|
||||||
|
<p class="text-muted">"Deleting a scene is permanent and cannot be undone. All spots within this scene will also be deleted."</p>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when=move || !show_delete_confirm.get()
|
||||||
|
fallback={
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let id = scene_id_for_delete.clone();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let slug = realm_slug_for_delete.clone();
|
||||||
|
move || {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let id = id.clone();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let slug = slug.clone();
|
||||||
|
view! {
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<p>"Are you sure you want to delete this scene? This action cannot be undone."</p>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled=move || delete_pending.get()
|
||||||
|
on:click={
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let id = id.clone();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let slug = slug.clone();
|
||||||
|
move |_| {
|
||||||
|
set_delete_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let id = id.clone();
|
||||||
|
let slug = slug.clone();
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::delete(&format!("/api/admin/scenes/{}", id))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_delete_pending.set(false);
|
||||||
|
set_show_delete_confirm.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().set_href(&format!("/admin/realms/{}/scenes", slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
set_message.set(Some(("Failed to delete scene".to_string(), false)));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{move || if delete_pending.get() { "Deleting..." } else { "Yes, Delete Scene" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click=move |_| set_show_delete_confirm.set(false)
|
||||||
|
>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
on:click=move |_| set_show_delete_confirm.set(true)
|
||||||
|
>
|
||||||
|
"Delete Scene"
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// Image preview modal
|
||||||
|
<Show when=move || show_image_modal.get()>
|
||||||
|
{
|
||||||
|
let path = scene_background_image_path_for_modal.clone();
|
||||||
|
view! {
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
on:click=move |_| set_show_image_modal.set(false)
|
||||||
|
></div>
|
||||||
|
<div class="modal-content" style="max-width:90vw;max-height:90vh;padding:0;background:transparent">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-close"
|
||||||
|
style="position:absolute;top:-30px;right:0;background:#333;color:#fff;border:none;padding:0.5rem;border-radius:4px;cursor:pointer"
|
||||||
|
on:click=move |_| set_show_image_modal.set(false)
|
||||||
|
>
|
||||||
|
"x"
|
||||||
|
</button>
|
||||||
|
{if let Some(ref img_path) = path {
|
||||||
|
view! {
|
||||||
|
<img
|
||||||
|
src=img_path.clone()
|
||||||
|
alt="Background image"
|
||||||
|
style="max-width:90vw;max-height:85vh;object-fit:contain;border-radius:4px"
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span>"No image"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
429
crates/chattyness-admin-ui/src/pages/scene_new.rs
Normal file
429
crates/chattyness-admin-ui/src/pages/scene_new.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
//! Create new scene page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{Card, PageHeader};
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use crate::utils::fetch_image_dimensions_client;
|
||||||
|
|
||||||
|
/// Scene new page component.
|
||||||
|
#[component]
|
||||||
|
pub fn SceneNewPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let (name, set_name) = signal(String::new());
|
||||||
|
let (slug, set_slug) = signal(String::new());
|
||||||
|
let (description, set_description) = signal(String::new());
|
||||||
|
let (background_color, set_background_color) = signal("#1a1a2e".to_string());
|
||||||
|
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||||
|
let (infer_dimensions, set_infer_dimensions) = signal(false);
|
||||||
|
let (width, set_width) = signal(800i32);
|
||||||
|
let (height, set_height) = signal(600i32);
|
||||||
|
let (dimension_mode, set_dimension_mode) = signal("fixed".to_string());
|
||||||
|
let (sort_order, set_sort_order) = signal(0i32);
|
||||||
|
let (is_entry_point, set_is_entry_point) = signal(false);
|
||||||
|
let (is_hidden, set_is_hidden) = signal(false);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
let (created_id, _set_created_id) = signal(Option::<String>::None);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let set_created_id = _set_created_id;
|
||||||
|
let (slug_auto, set_slug_auto) = signal(true);
|
||||||
|
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||||
|
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
|
||||||
|
let update_name = move |ev: leptos::ev::Event| {
|
||||||
|
let new_name = event_target_value(&ev);
|
||||||
|
set_name.set(new_name.clone());
|
||||||
|
if slug_auto.get() {
|
||||||
|
let new_slug = new_name
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string();
|
||||||
|
set_slug.set(new_slug);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||||
|
let url = background_image_url.get();
|
||||||
|
if url.is_empty() {
|
||||||
|
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_fetching_dimensions.set(true);
|
||||||
|
set_dimension_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
fetch_image_dimensions_client(
|
||||||
|
url,
|
||||||
|
move |w, h| {
|
||||||
|
set_width.set(w as i32);
|
||||||
|
set_height.set(h as i32);
|
||||||
|
set_dimension_message.set(Some((
|
||||||
|
format!("Dimensions: {}x{}", w, h),
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
set_dimension_message.set(Some((err, false)));
|
||||||
|
},
|
||||||
|
set_fetching_dimensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let realm_slug_val = realm_slug();
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
// Build bounds WKT from width/height
|
||||||
|
let w = width.get();
|
||||||
|
let h = height.get();
|
||||||
|
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
||||||
|
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"name": name.get(),
|
||||||
|
"slug": slug.get(),
|
||||||
|
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||||
|
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
||||||
|
"background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) },
|
||||||
|
"infer_dimensions_from_image": infer_dimensions.get(),
|
||||||
|
"bounds_wkt": bounds_wkt,
|
||||||
|
"dimension_mode": dimension_mode.get(),
|
||||||
|
"sort_order": sort_order.get(),
|
||||||
|
"is_entry_point": is_entry_point.get(),
|
||||||
|
"is_hidden": is_hidden.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let url = format!("/api/admin/realms/{}/scenes", realm_slug_val);
|
||||||
|
let response = Request::post(&url)
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct CreateResponse {
|
||||||
|
id: String,
|
||||||
|
slug: String,
|
||||||
|
}
|
||||||
|
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||||
|
set_created_id.set(Some(result.id));
|
||||||
|
set_message.set(Some(("Scene created successfully!".to_string(), true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||||
|
set_message.set(Some((err.error, false)));
|
||||||
|
} else {
|
||||||
|
set_message.set(Some(("Failed to create scene".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let slug_for_header = realm_slug();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Create New Scene" subtitle="Create a new scene in this realm">
|
||||||
|
<a href=format!("/admin/realms/{}/scenes", slug_for_header) class="btn btn-secondary">"Back to Scenes"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<h3 class="section-title">"Scene Details"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name" class="form-label">
|
||||||
|
"Scene Name" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Main Lobby"
|
||||||
|
prop:value=move || name.get()
|
||||||
|
on:input=update_name
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="slug" class="form-label">
|
||||||
|
"Slug (URL)" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="slug"
|
||||||
|
required=true
|
||||||
|
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="main-lobby"
|
||||||
|
prop:value=move || slug.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
set_slug_auto.set(false);
|
||||||
|
set_slug.set(event_target_value(&ev));
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Lowercase letters, numbers, hyphens only"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description" class="form-label">"Description"</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
class="form-textarea"
|
||||||
|
placeholder="Description of this scene"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Background"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="background_color" class="form-label">"Background Color"</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="background_color"
|
||||||
|
class="form-color"
|
||||||
|
prop:value=move || background_color.get()
|
||||||
|
on:input=move |ev| set_background_color.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex: 2">
|
||||||
|
<label for="background_image_url" class="form-label">"Background Image URL"</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="background_image_url"
|
||||||
|
class="form-input"
|
||||||
|
style="flex: 1"
|
||||||
|
placeholder="https://example.com/image.png"
|
||||||
|
prop:value=move || background_image_url.get()
|
||||||
|
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled=move || fetching_dimensions.get()
|
||||||
|
on:click=fetch_dimensions
|
||||||
|
>
|
||||||
|
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-help">"Enter a public image URL and click 'Get Size' to auto-fill dimensions"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Image preview
|
||||||
|
<Show when=move || !background_image_url.get().is_empty()>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">"Image Preview"</label>
|
||||||
|
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
|
||||||
|
<img
|
||||||
|
src=move || background_image_url.get()
|
||||||
|
alt="Background preview"
|
||||||
|
style="max-width: 100%; height: auto; display: block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Dimension fetch message
|
||||||
|
<Show when=move || dimension_message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert" style="margin-bottom: 1rem">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || infer_dimensions.get()
|
||||||
|
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Infer dimensions from image"
|
||||||
|
</label>
|
||||||
|
<small class="form-help">"If enabled, server will extract dimensions from the image when creating the scene"</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Dimensions"</h3>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="width" class="form-label">"Width"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="width"
|
||||||
|
min=100
|
||||||
|
max=10000
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || width.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_width.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="height" class="form-label">"Height"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="height"
|
||||||
|
min=100
|
||||||
|
max=10000
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || height.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_height.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
||||||
|
<select
|
||||||
|
id="dimension_mode"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
||||||
|
>
|
||||||
|
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
||||||
|
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">"Options"</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sort_order" class="form-label">"Sort Order"</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="sort_order"
|
||||||
|
class="form-input"
|
||||||
|
prop:value=move || sort_order.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Ok(v) = event_target_value(&ev).parse() {
|
||||||
|
set_sort_order.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Lower numbers appear first in scene lists"</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || is_entry_point.get()
|
||||||
|
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Entry Point"
|
||||||
|
</label>
|
||||||
|
<small class="form-help">"Users spawn here when entering the realm"</small>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
prop:checked=move || is_hidden.get()
|
||||||
|
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
"Hidden"
|
||||||
|
</label>
|
||||||
|
<small class="form-help">"Scene won't appear in public listings"</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || message.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let (msg, is_success) = message.get().unwrap_or_default();
|
||||||
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||||
|
view! {
|
||||||
|
<div class=class role="alert">
|
||||||
|
<p>{msg}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when=move || created_id.get().is_some()>
|
||||||
|
{move || {
|
||||||
|
let id = created_id.get().unwrap_or_default();
|
||||||
|
let slug = realm_slug();
|
||||||
|
view! {
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<p>
|
||||||
|
<a href=format!("/admin/realms/{}/scenes/{}", slug, id)>
|
||||||
|
"View scene"
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Creating..." } else { "Create Scene" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
116
crates/chattyness-admin-ui/src/pages/scenes.rs
Normal file
116
crates/chattyness-admin-ui/src/pages/scenes.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
//! Scenes list page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
|
||||||
|
use crate::components::{Card, EmptyState, PageHeader};
|
||||||
|
use crate::hooks::use_fetch_if;
|
||||||
|
use crate::models::SceneSummary;
|
||||||
|
|
||||||
|
/// Scenes list page component.
|
||||||
|
#[component]
|
||||||
|
pub fn ScenesPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||||
|
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||||
|
|
||||||
|
let scenes = use_fetch_if::<Vec<SceneSummary>>(
|
||||||
|
move || !realm_slug().is_empty(),
|
||||||
|
move || format!("/api/admin/realms/{}/scenes", realm_slug()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let slug_for_create = initial_slug.clone();
|
||||||
|
let slug_for_back = initial_slug.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Scenes" subtitle="Manage scenes for realm">
|
||||||
|
<a href=format!("/admin/realms/{}/scenes/new", slug_for_create) class="btn btn-primary">"Create Scene"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href=format!("/admin/realms/{}", slug_for_back) class="btn btn-secondary">"Back to Realm"</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading scenes..."</p> }>
|
||||||
|
{move || {
|
||||||
|
let slug = realm_slug();
|
||||||
|
scenes.get().map(|maybe_scenes: Option<Vec<SceneSummary>>| {
|
||||||
|
match maybe_scenes {
|
||||||
|
Some(scene_list) if !scene_list.is_empty() => {
|
||||||
|
view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Name"</th>
|
||||||
|
<th>"Slug"</th>
|
||||||
|
<th>"Order"</th>
|
||||||
|
<th>"Entry Point"</th>
|
||||||
|
<th>"Hidden"</th>
|
||||||
|
<th>"Background"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{scene_list.into_iter().map(|scene| {
|
||||||
|
let scene_id = scene.id.to_string();
|
||||||
|
let slug_clone = slug.clone();
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/realms/{}/scenes/{}", slug_clone, scene_id) class="table-link">
|
||||||
|
{scene.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{scene.slug}</td>
|
||||||
|
<td>{scene.sort_order}</td>
|
||||||
|
<td>
|
||||||
|
{if scene.is_entry_point {
|
||||||
|
view! { <span class="badge badge-success">"Yes"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span class="text-muted">"-"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{if scene.is_hidden {
|
||||||
|
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span class="text-muted">"-"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{if let Some(color) = scene.background_color {
|
||||||
|
view! {
|
||||||
|
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
|
||||||
|
}.into_any()
|
||||||
|
} else if scene.background_image_path.is_some() {
|
||||||
|
view! { <span class="text-muted">"Image"</span> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span class="text-muted">"-"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let slug_for_empty = slug.clone();
|
||||||
|
view! {
|
||||||
|
<EmptyState
|
||||||
|
message="No scenes found for this realm."
|
||||||
|
action_href=format!("/admin/realms/{}/scenes/new", slug_for_empty).leak()
|
||||||
|
action_text="Create Scene"
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
263
crates/chattyness-admin-ui/src/pages/staff.rs
Normal file
263
crates/chattyness-admin-ui/src/pages/staff.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
//! Staff management page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{Card, EmptyState, MessageAlertRw, PageHeader, RoleBadge};
|
||||||
|
use crate::hooks::use_fetch;
|
||||||
|
use crate::models::StaffMemberSummary;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use crate::utils::reload_page;
|
||||||
|
|
||||||
|
/// Staff page component.
|
||||||
|
#[component]
|
||||||
|
pub fn StaffPage() -> impl IntoView {
|
||||||
|
let message = RwSignal::new(Option::<(String, bool)>::None);
|
||||||
|
|
||||||
|
let staff = use_fetch::<Vec<StaffMemberSummary>>(|| "/api/admin/staff".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Server Staff" subtitle="Manage server administrators">
|
||||||
|
<AddStaffButton message=message />
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<MessageAlertRw message=message />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading staff..."</p> }>
|
||||||
|
{move || {
|
||||||
|
staff.get().map(|maybe_staff| {
|
||||||
|
match maybe_staff {
|
||||||
|
Some(staff_list) if !staff_list.is_empty() => {
|
||||||
|
view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Username"</th>
|
||||||
|
<th>"Display Name"</th>
|
||||||
|
<th>"Email"</th>
|
||||||
|
<th>"Role"</th>
|
||||||
|
<th>"Appointed"</th>
|
||||||
|
<th>"Actions"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{staff_list.into_iter().map(|member| {
|
||||||
|
let user_id = member.user_id.clone();
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/users/{}", member.user_id) class="table-link">
|
||||||
|
{member.username}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{member.display_name}</td>
|
||||||
|
<td>{member.email.unwrap_or_else(|| "-".to_string())}</td>
|
||||||
|
<td><RoleBadge role=member.role /></td>
|
||||||
|
<td>{member.appointed_at}</td>
|
||||||
|
<td>
|
||||||
|
<RemoveStaffButton
|
||||||
|
user_id=user_id
|
||||||
|
message=message
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
_ => view! {
|
||||||
|
<EmptyState message="No staff members found." />
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn AddStaffButton(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
|
||||||
|
let (show_modal, set_show_modal) = signal(false);
|
||||||
|
let (user_id, set_user_id) = signal(String::new());
|
||||||
|
let (role, set_role) = signal("moderator".to_string());
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"user_id": user_id.get(),
|
||||||
|
"role": role.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::post("/api/admin/staff")
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
message.set(Some(("Staff member added!".to_string(), true)));
|
||||||
|
set_show_modal.set(false);
|
||||||
|
set_user_id.set(String::new());
|
||||||
|
reload_page();
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
message.set(Some(("Failed to add staff member".to_string(), false)));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click=move |_| set_show_modal.set(true)
|
||||||
|
>
|
||||||
|
"Add Staff Member"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when=move || show_modal.get()>
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="modal-backdrop" on:click=move |_| set_show_modal.set(false)></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-close"
|
||||||
|
on:click=move |_| set_show_modal.set(false)
|
||||||
|
>
|
||||||
|
"x"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 class="modal-title">"Add Staff Member"</h3>
|
||||||
|
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="staff_user_id" class="form-label">"User ID"</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="staff_user_id"
|
||||||
|
required=true
|
||||||
|
class="form-input"
|
||||||
|
placeholder="UUID of user to make staff"
|
||||||
|
prop:value=move || user_id.get()
|
||||||
|
on:input=move |ev| set_user_id.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="staff_role" class="form-label">"Role"</label>
|
||||||
|
<select
|
||||||
|
id="staff_role"
|
||||||
|
class="form-select"
|
||||||
|
on:change=move |ev| set_role.set(event_target_value(&ev))
|
||||||
|
>
|
||||||
|
<option value="moderator" selected=move || role.get() == "moderator">"Moderator"</option>
|
||||||
|
<option value="admin" selected=move || role.get() == "admin">"Admin"</option>
|
||||||
|
<option value="owner" selected=move || role.get() == "owner">"Owner"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click=move |_| set_show_modal.set(false)
|
||||||
|
>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Adding..." } else { "Add Staff" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn RemoveStaffButton(
|
||||||
|
user_id: String,
|
||||||
|
message: RwSignal<Option<(String, bool)>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let user_id_for_click = user_id.clone();
|
||||||
|
|
||||||
|
let on_click = move |_| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use crate::utils::confirm;
|
||||||
|
|
||||||
|
if !confirm("Remove this staff member?") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_pending.set(true);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let user_id = user_id_for_click.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::delete(&format!("/api/admin/staff/{}", user_id))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
message.set(Some(("Staff member removed!".to_string(), true)));
|
||||||
|
reload_page();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
message.set(Some(("Failed to remove staff member".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
on:click=on_click
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "..." } else { "Remove" }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
228
crates/chattyness-admin-ui/src/pages/user_detail.rs
Normal file
228
crates/chattyness-admin-ui/src/pages/user_detail.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
//! User detail page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{Card, DetailGrid, DetailItem, MessageAlert, PageHeader, StatusBadge, TempPasswordDisplay};
|
||||||
|
use crate::hooks::use_fetch_if;
|
||||||
|
use crate::models::UserDetail;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use crate::utils::reload_page;
|
||||||
|
|
||||||
|
/// User detail page component.
|
||||||
|
#[component]
|
||||||
|
pub fn UserDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let user_id = move || params.get().get("user_id").unwrap_or_default();
|
||||||
|
let initial_user_id = params.get_untracked().get("user_id").unwrap_or_default();
|
||||||
|
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
|
||||||
|
let user = use_fetch_if::<UserDetail>(
|
||||||
|
move || !user_id().is_empty(),
|
||||||
|
move || format!("/api/admin/users/{}", user_id()),
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="User Details" subtitle=initial_user_id>
|
||||||
|
<a href="/admin/users" class="btn btn-secondary">"Back to Users"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading user..."</p> }>
|
||||||
|
{move || {
|
||||||
|
user.get().map(|maybe_user| {
|
||||||
|
match maybe_user {
|
||||||
|
Some(u) => view! {
|
||||||
|
<UserDetailView user=u message=message set_message=set_message />
|
||||||
|
}.into_any(),
|
||||||
|
None => view! {
|
||||||
|
<Card>
|
||||||
|
<p class="text-error">"User not found or you don't have permission to view."</p>
|
||||||
|
</Card>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn UserDetailView(
|
||||||
|
user: UserDetail,
|
||||||
|
message: ReadSignal<Option<(String, bool)>>,
|
||||||
|
set_message: WriteSignal<Option<(String, bool)>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let user_id = user.id.clone();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let user_id_for_status = user_id.clone();
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let user_id_for_reset = user_id.clone();
|
||||||
|
let user_status = user.status.clone();
|
||||||
|
let user_status_for_badge = user_status.clone();
|
||||||
|
|
||||||
|
let (pending_status, set_pending_status) = signal(false);
|
||||||
|
let (pending_reset, set_pending_reset) = signal(false);
|
||||||
|
let (new_password, set_new_password) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
let update_status = {
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
move |new_status: &'static str| {
|
||||||
|
set_pending_status.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let user_id = user_id_for_status.clone();
|
||||||
|
let status = new_status.to_string();
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::put(&format!("/api/admin/users/{}/status", user_id))
|
||||||
|
.json(&serde_json::json!({ "status": status }))
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending_status.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
set_message.set(Some(("Status updated!".to_string(), true)));
|
||||||
|
reload_page();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
set_message.set(Some(("Failed to update status".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let reset_password = move |_| {
|
||||||
|
set_pending_reset.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
set_new_password.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let user_id = user_id_for_reset.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::post(&format!("/api/admin/users/{}/reset-password", user_id))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending_reset.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ResetResponse {
|
||||||
|
temporary_password: String,
|
||||||
|
}
|
||||||
|
if let Ok(result) = resp.json::<ResetResponse>().await {
|
||||||
|
set_new_password.set(Some(result.temporary_password));
|
||||||
|
set_message.set(Some(("Password reset successfully!".to_string(), true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
set_message.set(Some(("Failed to reset password".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Card>
|
||||||
|
<div class="user-header">
|
||||||
|
<div class="user-info">
|
||||||
|
<h2>{user.display_name.clone()}</h2>
|
||||||
|
<p class="text-muted">"@" {user.username.clone()}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status=user_status_for_badge />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailGrid>
|
||||||
|
<DetailItem label="User ID">
|
||||||
|
<code>{user.id.clone()}</code>
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Email">
|
||||||
|
{user.email.clone().unwrap_or_else(|| "Not set".to_string())}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Server Role">
|
||||||
|
{user.server_role.clone().unwrap_or_else(|| "None".to_string())}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Created">
|
||||||
|
{user.created_at.clone()}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="Updated">
|
||||||
|
{user.updated_at.clone()}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Account Actions">
|
||||||
|
<MessageAlert message=message />
|
||||||
|
<TempPasswordDisplay password=new_password label="New Temporary Password:" />
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled=move || pending_reset.get()
|
||||||
|
on:click=reset_password
|
||||||
|
>
|
||||||
|
{move || if pending_reset.get() { "Resetting..." } else { "Reset Password" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{if user_status != "suspended" {
|
||||||
|
let update_status = update_status.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
disabled=move || pending_status.get()
|
||||||
|
on:click=move |_| update_status("suspended")
|
||||||
|
>
|
||||||
|
"Suspend User"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
let update_status = update_status.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending_status.get()
|
||||||
|
on:click=move |_| update_status("active")
|
||||||
|
>
|
||||||
|
"Activate User"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
{if user_status != "banned" {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled=move || pending_status.get()
|
||||||
|
on:click=move |_| update_status("banned")
|
||||||
|
>
|
||||||
|
"Ban User"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
149
crates/chattyness-admin-ui/src/pages/user_new.rs
Normal file
149
crates/chattyness-admin-ui/src/pages/user_new.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
//! Create new user page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::components::{Card, MessageAlert, PageHeader, TempPasswordDisplay};
|
||||||
|
|
||||||
|
/// User new page component.
|
||||||
|
#[component]
|
||||||
|
pub fn UserNewPage() -> impl IntoView {
|
||||||
|
let (username, set_username) = signal(String::new());
|
||||||
|
let (email, set_email) = signal(String::new());
|
||||||
|
let (display_name, set_display_name) = signal(String::new());
|
||||||
|
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
let (temp_password, _set_temp_password) = signal(Option::<String>::None);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let set_temp_password = _set_temp_password;
|
||||||
|
|
||||||
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
set_pending.set(true);
|
||||||
|
set_message.set(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"username": username.get(),
|
||||||
|
"email": if email.get().is_empty() { None::<String> } else { Some(email.get()) },
|
||||||
|
"display_name": display_name.get()
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = Request::post("/api/admin/users")
|
||||||
|
.json(&data)
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
set_pending.set(false);
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(resp) if resp.ok() => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CreateResponse {
|
||||||
|
temporary_password: String,
|
||||||
|
}
|
||||||
|
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||||
|
set_temp_password.set(Some(result.temporary_password));
|
||||||
|
set_message.set(Some(("User created successfully!".to_string(), true)));
|
||||||
|
set_username.set(String::new());
|
||||||
|
set_email.set(String::new());
|
||||||
|
set_display_name.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ErrorResp {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||||
|
set_message.set(Some((err.error, false)));
|
||||||
|
} else {
|
||||||
|
set_message.set(Some(("Failed to create user".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_message.set(Some(("Network error".to_string(), false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="Create New User" subtitle="Add a new user account">
|
||||||
|
<a href="/admin/users" class="btn btn-secondary">"Back to Users"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username" class="form-label">
|
||||||
|
"Username" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
required=true
|
||||||
|
minlength=3
|
||||||
|
maxlength=32
|
||||||
|
pattern="[a-zA-Z][a-zA-Z0-9_]*"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="username"
|
||||||
|
prop:value=move || username.get()
|
||||||
|
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<small class="form-help">"Letters, numbers, and underscores only"</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email" class="form-label">"Email"</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
prop:value=move || email.get()
|
||||||
|
on:input=move |ev| set_email.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_name" class="form-label">
|
||||||
|
"Display Name" <span class="required">"*"</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display_name"
|
||||||
|
required=true
|
||||||
|
minlength=1
|
||||||
|
maxlength=64
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Display Name"
|
||||||
|
prop:value=move || display_name.get()
|
||||||
|
on:input=move |ev| set_display_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageAlert message=message />
|
||||||
|
<TempPasswordDisplay password=temp_password />
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled=move || pending.get()
|
||||||
|
>
|
||||||
|
{move || if pending.get() { "Creating..." } else { "Create User" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
94
crates/chattyness-admin-ui/src/pages/users.rs
Normal file
94
crates/chattyness-admin-ui/src/pages/users.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
//! Users list page component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::{Card, EmptyState, PageHeader, Pagination, SearchForm, StatusBadge};
|
||||||
|
use crate::hooks::{use_fetch, use_pagination};
|
||||||
|
use crate::models::UserSummary;
|
||||||
|
use crate::utils::build_paginated_url;
|
||||||
|
|
||||||
|
/// Users page component.
|
||||||
|
#[component]
|
||||||
|
pub fn UsersPage() -> impl IntoView {
|
||||||
|
let pagination = use_pagination();
|
||||||
|
|
||||||
|
// Fetch users using the new hook
|
||||||
|
let users = use_fetch::<Vec<UserSummary>>(move || {
|
||||||
|
build_paginated_url(
|
||||||
|
"/api/admin/users",
|
||||||
|
pagination.page.get(),
|
||||||
|
&pagination.search_query.get(),
|
||||||
|
25,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageHeader title="All Users" subtitle="Manage user accounts">
|
||||||
|
<a href="/admin/users/new" class="btn btn-primary">"Create User"</a>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<SearchForm
|
||||||
|
action="/admin/users"
|
||||||
|
placeholder="Search by username or email..."
|
||||||
|
search_input=pagination.search_input
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading users..."</p> }>
|
||||||
|
{move || {
|
||||||
|
users.get().map(|maybe_users: Option<Vec<UserSummary>>| {
|
||||||
|
match maybe_users {
|
||||||
|
Some(user_list) if !user_list.is_empty() => {
|
||||||
|
view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Username"</th>
|
||||||
|
<th>"Display Name"</th>
|
||||||
|
<th>"Email"</th>
|
||||||
|
<th>"Status"</th>
|
||||||
|
<th>"Created"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{user_list.into_iter().map(|user| {
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/users/{}", user.id) class="table-link">
|
||||||
|
{user.username}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{user.display_name}</td>
|
||||||
|
<td>{user.email.unwrap_or_else(|| "-".to_string())}</td>
|
||||||
|
<td><StatusBadge status=user.status /></td>
|
||||||
|
<td>{user.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
current_page=pagination.page.get()
|
||||||
|
base_url="/admin/users".to_string()
|
||||||
|
query=pagination.search_query.get()
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
_ => view! {
|
||||||
|
<EmptyState
|
||||||
|
message="No users found."
|
||||||
|
action_href="/admin/users/new"
|
||||||
|
action_text="Create User"
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
}
|
||||||
131
crates/chattyness-admin-ui/src/routes.rs
Normal file
131
crates/chattyness-admin-ui/src/routes.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Admin routes without Router wrapper (for embedding in combined apps).
|
||||||
|
//!
|
||||||
|
//! This module provides the `AdminRoutes` component which contains all admin
|
||||||
|
//! route definitions without a Router wrapper. This allows the routes to be
|
||||||
|
//! embedded in a parent Router (e.g., CombinedApp in chattyness-app).
|
||||||
|
//!
|
||||||
|
//! For standalone use (e.g., chattyness-owner), use `AdminApp` which wraps
|
||||||
|
//! these routes with a Router.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::{
|
||||||
|
components::{Route, Routes},
|
||||||
|
ParamSegment, StaticSegment,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::components::{AuthenticatedLayout, LoginLayout};
|
||||||
|
use crate::pages::{
|
||||||
|
ConfigPage, DashboardPage, LoginPage, PropsDetailPage, PropsNewPage, PropsPage,
|
||||||
|
RealmDetailPage, RealmNewPage, RealmsPage, SceneDetailPage, SceneNewPage, ScenesPage,
|
||||||
|
StaffPage, UserDetailPage, UserNewPage, UsersPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Admin routes that can be embedded in a parent Router.
|
||||||
|
///
|
||||||
|
/// All paths are relative to the Router's base path. When used in:
|
||||||
|
/// - `AdminApp`: The Router is configured with base="/admin"
|
||||||
|
/// - `CombinedApp`: The Router should be configured with base="/admin"
|
||||||
|
#[component]
|
||||||
|
pub fn AdminRoutes() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Routes fallback=|| "Page not found.".into_view()>
|
||||||
|
// Login page (no layout)
|
||||||
|
<Route path=StaticSegment("login") view=|| view! {
|
||||||
|
<LoginLayout>
|
||||||
|
<LoginPage />
|
||||||
|
</LoginLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
<Route path=StaticSegment("") view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="dashboard">
|
||||||
|
<DashboardPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Config
|
||||||
|
<Route path=StaticSegment("config") view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="config">
|
||||||
|
<ConfigPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Users
|
||||||
|
<Route path=StaticSegment("users") view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="users">
|
||||||
|
<UsersPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("users"), StaticSegment("new")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="users_new">
|
||||||
|
<UserNewPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("users"), ParamSegment("user_id")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="users">
|
||||||
|
<UserDetailPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Staff
|
||||||
|
<Route path=StaticSegment("staff") view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="staff">
|
||||||
|
<StaffPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Props
|
||||||
|
<Route path=StaticSegment("props") view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="props">
|
||||||
|
<PropsPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("props"), StaticSegment("new")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="props_new">
|
||||||
|
<PropsNewPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("props"), ParamSegment("prop_id")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="props">
|
||||||
|
<PropsDetailPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Realms
|
||||||
|
<Route path=StaticSegment("realms") view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="realms">
|
||||||
|
<RealmsPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("realms"), StaticSegment("new")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="realms_new">
|
||||||
|
<RealmNewPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Scenes (nested under realms)
|
||||||
|
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="scenes">
|
||||||
|
<ScenesPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), StaticSegment("new")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="scenes_new">
|
||||||
|
<SceneNewPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
<Route path=(StaticSegment("realms"), ParamSegment("slug"), StaticSegment("scenes"), ParamSegment("scene_id")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="scenes">
|
||||||
|
<SceneDetailPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Realm detail (must come after more specific realm routes)
|
||||||
|
<Route path=(StaticSegment("realms"), ParamSegment("slug")) view=|| view! {
|
||||||
|
<AuthenticatedLayout current_page="realms">
|
||||||
|
<RealmDetailPage />
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
}
|
||||||
|
}
|
||||||
204
crates/chattyness-admin-ui/src/utils.rs
Normal file
204
crates/chattyness-admin-ui/src/utils.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
//! Utility functions for the admin UI.
|
||||||
|
|
||||||
|
/// Gets the API base path based on the current URL.
|
||||||
|
///
|
||||||
|
/// Returns `/api/admin` if the current path starts with `/admin`,
|
||||||
|
/// otherwise returns `/api`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```rust
|
||||||
|
/// let api_base = get_api_base();
|
||||||
|
/// let url = format!("{}/realms/{}", api_base, slug);
|
||||||
|
/// ```
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn get_api_base() -> String {
|
||||||
|
web_sys::window()
|
||||||
|
.and_then(|w| w.location().pathname().ok())
|
||||||
|
.map(|path| {
|
||||||
|
if path.starts_with("/admin") {
|
||||||
|
"/api/admin".to_string()
|
||||||
|
} else {
|
||||||
|
"/api".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "/api".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the API base path (SSR fallback - always returns /api).
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn get_api_base() -> String {
|
||||||
|
"/api".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reloads the current page.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn reload_page() {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reloads the current page (SSR no-op).
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn reload_page() {}
|
||||||
|
|
||||||
|
/// Navigates to a new URL.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn navigate_to(url: &str) {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().set_href(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to a new URL (SSR no-op).
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn navigate_to(_url: &str) {}
|
||||||
|
|
||||||
|
/// Shows a browser confirm dialog and returns the result.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn confirm(message: &str) -> bool {
|
||||||
|
web_sys::window()
|
||||||
|
.and_then(|w| w.confirm_with_message(message).ok())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a browser confirm dialog (SSR fallback - always returns false).
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
pub fn confirm(_message: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a paginated URL with optional search query.
|
||||||
|
pub fn build_paginated_url(base: &str, page: i64, query: &str, limit: i64) -> String {
|
||||||
|
if query.is_empty() {
|
||||||
|
format!("{}?page={}&limit={}", base, page, limit)
|
||||||
|
} else {
|
||||||
|
format!("{}?q={}&page={}&limit={}", base, query, page, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse width and height from WKT bounds string.
|
||||||
|
///
|
||||||
|
/// Example: "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))" -> (800, 600)
|
||||||
|
/// Handles both "0 0, 800 0" (with space) and "0 0,800 0" (without space) formats.
|
||||||
|
pub fn parse_bounds_wkt(wkt: &str) -> (i32, i32) {
|
||||||
|
// Extract coordinates from POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x5 y5))
|
||||||
|
// The second point has (width, 0) and third point has (width, height)
|
||||||
|
if let Some(start) = wkt.find("((") {
|
||||||
|
if let Some(end) = wkt.find("))") {
|
||||||
|
let coords_str = &wkt[start + 2..end];
|
||||||
|
let points: Vec<&str> = coords_str.split(',').map(|s| s.trim()).collect();
|
||||||
|
if points.len() >= 3 {
|
||||||
|
// Second point: "width 0"
|
||||||
|
let second: Vec<&str> = points[1].split_whitespace().collect();
|
||||||
|
// Third point: "width height"
|
||||||
|
let third: Vec<&str> = points[2].split_whitespace().collect();
|
||||||
|
if !second.is_empty() && third.len() >= 2 {
|
||||||
|
let width = second[0].parse().unwrap_or(800);
|
||||||
|
let height = third[1].parse().unwrap_or(600);
|
||||||
|
return (width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(800, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a WKT polygon string from width and height.
|
||||||
|
pub fn build_bounds_wkt(width: i32, height: i32) -> String {
|
||||||
|
format!(
|
||||||
|
"POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))",
|
||||||
|
width, width, height, height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch image dimensions client-side using JavaScript Image API.
|
||||||
|
///
|
||||||
|
/// This works regardless of CORS since we're only reading dimensions, not pixel data.
|
||||||
|
/// The key is NOT setting the `crossorigin` attribute on the image element.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `url` - The image URL to fetch dimensions from
|
||||||
|
/// * `on_success` - Callback receiving (width, height) on success
|
||||||
|
/// * `on_error` - Callback receiving error message on failure
|
||||||
|
/// * `set_loading` - Signal to set loading state
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub fn fetch_image_dimensions_client<F, E>(
|
||||||
|
url: String,
|
||||||
|
on_success: F,
|
||||||
|
on_error: E,
|
||||||
|
set_loading: leptos::prelude::WriteSignal<bool>,
|
||||||
|
)
|
||||||
|
where
|
||||||
|
F: Fn(u32, u32) + 'static,
|
||||||
|
E: Fn(String) + Clone + 'static,
|
||||||
|
{
|
||||||
|
use leptos::prelude::Set;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
let on_error_for_onerror = on_error.clone();
|
||||||
|
|
||||||
|
let window = match web_sys::window() {
|
||||||
|
Some(w) => w,
|
||||||
|
None => {
|
||||||
|
on_error("No window object available".to_string());
|
||||||
|
set_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let document = match window.document() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
on_error("No document object available".to_string());
|
||||||
|
set_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let img: web_sys::HtmlImageElement = match document
|
||||||
|
.create_element("img")
|
||||||
|
.ok()
|
||||||
|
.and_then(|el| el.dyn_into().ok())
|
||||||
|
{
|
||||||
|
Some(img) => img,
|
||||||
|
None => {
|
||||||
|
on_error("Failed to create image element".to_string());
|
||||||
|
set_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: We intentionally do NOT set crossorigin attribute.
|
||||||
|
// Without it, we can load images from any URL and read their dimensions.
|
||||||
|
// The crossorigin attribute would cause CORS errors for external images.
|
||||||
|
|
||||||
|
let img_clone = img.clone();
|
||||||
|
|
||||||
|
let onload = Closure::wrap(Box::new(move || {
|
||||||
|
let width = img_clone.natural_width();
|
||||||
|
let height = img_clone.natural_height();
|
||||||
|
set_loading.set(false);
|
||||||
|
if width > 0 && height > 0 {
|
||||||
|
on_success(width, height);
|
||||||
|
} else {
|
||||||
|
on_error("Could not determine image dimensions".to_string());
|
||||||
|
}
|
||||||
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
|
let onerror = Closure::wrap(Box::new(move || {
|
||||||
|
set_loading.set(false);
|
||||||
|
on_error_for_onerror("Failed to load image".to_string());
|
||||||
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
|
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||||
|
img.set_onerror(Some(onerror.as_ref().unchecked_ref()));
|
||||||
|
|
||||||
|
// Trigger the load
|
||||||
|
img.set_src(&url);
|
||||||
|
|
||||||
|
// Prevent closures from being dropped
|
||||||
|
onload.forget();
|
||||||
|
onerror.forget();
|
||||||
|
}
|
||||||
20
crates/chattyness-db/Cargo.toml
Normal file
20
crates/chattyness-db/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "chattyness-db"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chattyness-error = { workspace = true, optional = true }
|
||||||
|
chattyness-shared = { workspace = true, optional = true }
|
||||||
|
serde.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
|
||||||
|
# SSR-only dependencies
|
||||||
|
sqlx = { workspace = true, optional = true }
|
||||||
|
argon2 = { workspace = true, optional = true }
|
||||||
|
rand = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = ["sqlx", "argon2", "rand", "chattyness-error/ssr", "dep:chattyness-error", "dep:chattyness-shared"]
|
||||||
16
crates/chattyness-db/src/lib.rs
Normal file
16
crates/chattyness-db/src/lib.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//! Database module for chattyness.
|
||||||
|
//!
|
||||||
|
//! Provides SQLx-based database access with runtime queries.
|
||||||
|
|
||||||
|
pub mod models;
|
||||||
|
pub mod ws_messages;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod pool;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod queries;
|
||||||
|
|
||||||
|
pub use models::*;
|
||||||
|
pub use ws_messages::*;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use pool::*;
|
||||||
1697
crates/chattyness-db/src/models.rs
Normal file
1697
crates/chattyness-db/src/models.rs
Normal file
File diff suppressed because it is too large
Load diff
84
crates/chattyness-db/src/pool.rs
Normal file
84
crates/chattyness-db/src/pool.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//! Database connection pool and RLS context management.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Create a new database connection pool for the owner interface.
|
||||||
|
///
|
||||||
|
/// Uses the `chattyness_owner` role which has full database access.
|
||||||
|
pub async fn create_owner_pool(database_url: &str) -> Result<PgPool, AppError> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.acquire_timeout(Duration::from_secs(5))
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new database connection pool for the public app.
|
||||||
|
///
|
||||||
|
/// Uses the `chattyness_app` role which has Row-Level Security (RLS) policies.
|
||||||
|
pub async fn create_app_pool(database_url: &str) -> Result<PgPool, AppError> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.acquire_timeout(Duration::from_secs(5))
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current user context for Row-Level Security.
|
||||||
|
///
|
||||||
|
/// This should be called at the start of each request to enable RLS policies
|
||||||
|
/// that depend on the current user ID.
|
||||||
|
pub async fn set_user_context(pool: &PgPool, user_id: Option<Uuid>) -> Result<(), AppError> {
|
||||||
|
if let Some(id) = user_id {
|
||||||
|
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
// Clear the user context for anonymous requests
|
||||||
|
sqlx::query("SELECT public.clear_current_user_id()")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current user context.
|
||||||
|
///
|
||||||
|
/// Called at the end of a request to ensure the connection is clean
|
||||||
|
/// before returning to the pool.
|
||||||
|
pub async fn clear_user_context(pool: &PgPool) -> Result<(), AppError> {
|
||||||
|
sqlx::query("SELECT public.clear_current_user_id()")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current guest session context for Row-Level Security.
|
||||||
|
///
|
||||||
|
/// This should be called for guest users to enable RLS policies
|
||||||
|
/// that depend on the current guest session ID.
|
||||||
|
pub async fn set_guest_context(pool: &PgPool, guest_session_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query("SELECT public.set_current_guest_session_id($1)")
|
||||||
|
.bind(guest_session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current guest session context.
|
||||||
|
pub async fn clear_guest_context(pool: &PgPool) -> Result<(), AppError> {
|
||||||
|
sqlx::query("SELECT public.set_current_guest_session_id(NULL)")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
12
crates/chattyness-db/src/queries.rs
Normal file
12
crates/chattyness-db/src/queries.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//! Database query modules.
|
||||||
|
|
||||||
|
pub mod avatars;
|
||||||
|
pub mod channel_members;
|
||||||
|
pub mod guests;
|
||||||
|
pub mod memberships;
|
||||||
|
pub mod owner;
|
||||||
|
pub mod props;
|
||||||
|
pub mod realms;
|
||||||
|
pub mod scenes;
|
||||||
|
pub mod spots;
|
||||||
|
pub mod users;
|
||||||
201
crates/chattyness-db/src/queries/avatars.rs
Normal file
201
crates/chattyness-db/src/queries/avatars.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
//! Avatar-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgExecutor;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{ActiveAvatar, AvatarRenderData};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Get the active avatar for a user in a realm.
|
||||||
|
pub async fn get_active_avatar<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Option<ActiveAvatar>, AppError> {
|
||||||
|
let avatar = sqlx::query_as::<_, ActiveAvatar>(
|
||||||
|
r#"
|
||||||
|
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
|
||||||
|
FROM props.active_avatars
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(avatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current emotion for a user in a realm.
|
||||||
|
/// Returns the full emotion layer (9 asset paths) for the new emotion.
|
||||||
|
pub async fn set_emotion<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
emotion: i16,
|
||||||
|
) -> Result<[Option<String>; 9], AppError> {
|
||||||
|
if emotion < 0 || emotion > 9 {
|
||||||
|
return Err(AppError::Validation("Emotion must be 0-9".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map emotion index to column prefix
|
||||||
|
let emotion_prefix = match emotion {
|
||||||
|
0 => "e_neutral",
|
||||||
|
1 => "e_happy",
|
||||||
|
2 => "e_sad",
|
||||||
|
3 => "e_angry",
|
||||||
|
4 => "e_surprised",
|
||||||
|
5 => "e_thinking",
|
||||||
|
6 => "e_laughing",
|
||||||
|
7 => "e_crying",
|
||||||
|
8 => "e_love",
|
||||||
|
9 => "e_confused",
|
||||||
|
_ => return Err(AppError::Validation("Emotion must be 0-9".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build dynamic query for the specific emotion's 9 positions
|
||||||
|
let query = format!(
|
||||||
|
r#"
|
||||||
|
WITH updated AS (
|
||||||
|
UPDATE props.active_avatars
|
||||||
|
SET current_emotion = $3, updated_at = now()
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
RETURNING avatar_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_0) as p0,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_1) as p1,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_2) as p2,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_3) as p3,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_4) as p4,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_5) as p5,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_6) as p6,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_7) as p7,
|
||||||
|
(SELECT prop_asset_path FROM props.inventory WHERE id = a.{prefix}_8) as p8
|
||||||
|
FROM updated u
|
||||||
|
JOIN props.avatars a ON a.id = u.avatar_id
|
||||||
|
"#,
|
||||||
|
prefix = emotion_prefix
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(emotion)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Some(row) => Ok([
|
||||||
|
row.p0, row.p1, row.p2, row.p3, row.p4, row.p5, row.p6, row.p7, row.p8,
|
||||||
|
]),
|
||||||
|
None => Err(AppError::NotFound(
|
||||||
|
"No active avatar for this user in this realm".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row type for emotion layer query.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct EmotionLayerRow {
|
||||||
|
p0: Option<String>,
|
||||||
|
p1: Option<String>,
|
||||||
|
p2: Option<String>,
|
||||||
|
p3: Option<String>,
|
||||||
|
p4: Option<String>,
|
||||||
|
p5: Option<String>,
|
||||||
|
p6: Option<String>,
|
||||||
|
p7: Option<String>,
|
||||||
|
p8: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get render data for a user's avatar in a realm.
|
||||||
|
///
|
||||||
|
/// Returns the asset paths for all equipped props in the avatar's current state.
|
||||||
|
/// This is a simplified version that only returns the center position (position 4)
|
||||||
|
/// props for skin, clothes, accessories, and current emotion layers.
|
||||||
|
pub async fn get_avatar_render_data<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<AvatarRenderData, AppError> {
|
||||||
|
// Simplified query: just get position 4 (center) props for each layer
|
||||||
|
// This covers the common case of simple face avatars
|
||||||
|
let render_data = sqlx::query_as::<_, SimplifiedAvatarRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
a.id as avatar_id,
|
||||||
|
aa.current_emotion,
|
||||||
|
-- Skin layer center
|
||||||
|
skin.prop_asset_path as skin_center,
|
||||||
|
-- Clothes layer center
|
||||||
|
clothes.prop_asset_path as clothes_center,
|
||||||
|
-- Accessories layer center
|
||||||
|
acc.prop_asset_path as accessories_center,
|
||||||
|
-- Current emotion layer center (based on current_emotion)
|
||||||
|
CASE aa.current_emotion
|
||||||
|
WHEN 0 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4)
|
||||||
|
WHEN 1 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4)
|
||||||
|
WHEN 2 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4)
|
||||||
|
WHEN 3 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4)
|
||||||
|
WHEN 4 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4)
|
||||||
|
WHEN 5 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4)
|
||||||
|
WHEN 6 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4)
|
||||||
|
WHEN 7 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4)
|
||||||
|
WHEN 8 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4)
|
||||||
|
WHEN 9 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4)
|
||||||
|
END as emotion_center
|
||||||
|
FROM props.active_avatars aa
|
||||||
|
JOIN props.avatars a ON aa.avatar_id = a.id
|
||||||
|
LEFT JOIN props.inventory skin ON a.l_skin_4 = skin.id
|
||||||
|
LEFT JOIN props.inventory clothes ON a.l_clothes_4 = clothes.id
|
||||||
|
LEFT JOIN props.inventory acc ON a.l_accessories_4 = acc.id
|
||||||
|
WHERE aa.user_id = $1 AND aa.realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match render_data {
|
||||||
|
Some(row) => Ok(row.into()),
|
||||||
|
None => Ok(AvatarRenderData::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simplified avatar row for center-only rendering.
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct SimplifiedAvatarRow {
|
||||||
|
avatar_id: Uuid,
|
||||||
|
current_emotion: i16,
|
||||||
|
skin_center: Option<String>,
|
||||||
|
clothes_center: Option<String>,
|
||||||
|
accessories_center: Option<String>,
|
||||||
|
emotion_center: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SimplifiedAvatarRow> for AvatarRenderData {
|
||||||
|
fn from(row: SimplifiedAvatarRow) -> Self {
|
||||||
|
// For now, only populate position 4 (center)
|
||||||
|
let mut skin_layer: [Option<String>; 9] = Default::default();
|
||||||
|
let mut clothes_layer: [Option<String>; 9] = Default::default();
|
||||||
|
let mut accessories_layer: [Option<String>; 9] = Default::default();
|
||||||
|
let mut emotion_layer: [Option<String>; 9] = Default::default();
|
||||||
|
|
||||||
|
skin_layer[4] = row.skin_center;
|
||||||
|
clothes_layer[4] = row.clothes_center;
|
||||||
|
accessories_layer[4] = row.accessories_center;
|
||||||
|
emotion_layer[4] = row.emotion_center;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
avatar_id: row.avatar_id,
|
||||||
|
current_emotion: row.current_emotion,
|
||||||
|
skin_layer,
|
||||||
|
clothes_layer,
|
||||||
|
accessories_layer,
|
||||||
|
emotion_layer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
crates/chattyness-db/src/queries/channel_members.rs
Normal file
233
crates/chattyness-db/src/queries/channel_members.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
//! Channel member queries for user presence in channels.
|
||||||
|
|
||||||
|
use sqlx::PgExecutor;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{ChannelMember, ChannelMemberInfo};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Join a channel as an authenticated user.
|
||||||
|
///
|
||||||
|
/// Creates a channel_members entry with default position (400, 300).
|
||||||
|
pub async fn join_channel<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<ChannelMember, AppError> {
|
||||||
|
let member = sqlx::query_as::<_, ChannelMember>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.channel_members (channel_id, user_id, position)
|
||||||
|
VALUES ($1, $2, ST_SetSRID(ST_MakePoint(400, 300), 0))
|
||||||
|
ON CONFLICT (channel_id, user_id) DO UPDATE
|
||||||
|
SET joined_at = now()
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
channel_id,
|
||||||
|
user_id,
|
||||||
|
guest_session_id,
|
||||||
|
ST_X(position) as position_x,
|
||||||
|
ST_Y(position) as position_y,
|
||||||
|
facing_direction,
|
||||||
|
is_moving,
|
||||||
|
is_afk,
|
||||||
|
joined_at,
|
||||||
|
last_moved_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure an active avatar exists for a user in a realm.
|
||||||
|
/// Uses the user's default avatar (slot 0) if none exists.
|
||||||
|
pub async fn ensure_active_avatar<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO props.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
||||||
|
SELECT $1, $2, id, 0
|
||||||
|
FROM props.avatars
|
||||||
|
WHERE user_id = $1 AND slot_number = 0
|
||||||
|
ON CONFLICT (user_id, realm_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave a channel.
|
||||||
|
pub async fn leave_channel<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"DELETE FROM realm.channel_members WHERE channel_id = $1 AND user_id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a user's position in a channel.
|
||||||
|
pub async fn update_position<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.channel_members
|
||||||
|
SET position = ST_SetSRID(ST_MakePoint($3, $4), 0),
|
||||||
|
last_moved_at = now(),
|
||||||
|
is_moving = true
|
||||||
|
WHERE channel_id = $1 AND user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(x)
|
||||||
|
.bind(y)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound("Channel member not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all members in a channel with their display info and current emotion.
|
||||||
|
pub async fn get_channel_members<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Vec<ChannelMemberInfo>, AppError> {
|
||||||
|
let members = sqlx::query_as::<_, ChannelMemberInfo>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
cm.id,
|
||||||
|
cm.channel_id,
|
||||||
|
cm.user_id,
|
||||||
|
cm.guest_session_id,
|
||||||
|
COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name,
|
||||||
|
ST_X(cm.position) as position_x,
|
||||||
|
ST_Y(cm.position) as position_y,
|
||||||
|
cm.facing_direction,
|
||||||
|
cm.is_moving,
|
||||||
|
cm.is_afk,
|
||||||
|
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||||
|
cm.joined_at
|
||||||
|
FROM realm.channel_members cm
|
||||||
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||||
|
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
|
||||||
|
LEFT JOIN props.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2
|
||||||
|
WHERE cm.channel_id = $1
|
||||||
|
ORDER BY cm.joined_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(members)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific channel member by user ID.
|
||||||
|
pub async fn get_channel_member<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Option<ChannelMemberInfo>, AppError> {
|
||||||
|
let member = sqlx::query_as::<_, ChannelMemberInfo>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
cm.id,
|
||||||
|
cm.channel_id,
|
||||||
|
cm.user_id,
|
||||||
|
cm.guest_session_id,
|
||||||
|
COALESCE(u.display_name, 'Anonymous') as display_name,
|
||||||
|
ST_X(cm.position) as position_x,
|
||||||
|
ST_Y(cm.position) as position_y,
|
||||||
|
cm.facing_direction,
|
||||||
|
cm.is_moving,
|
||||||
|
cm.is_afk,
|
||||||
|
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||||
|
cm.joined_at
|
||||||
|
FROM realm.channel_members cm
|
||||||
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||||
|
LEFT JOIN props.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3
|
||||||
|
WHERE cm.channel_id = $1 AND cm.user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's moving state to false (called after movement animation completes).
|
||||||
|
pub async fn set_stopped<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.channel_members
|
||||||
|
SET is_moving = false
|
||||||
|
WHERE channel_id = $1 AND user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's AFK state.
|
||||||
|
pub async fn set_afk<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
is_afk: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.channel_members
|
||||||
|
SET is_afk = $3
|
||||||
|
WHERE channel_id = $1 AND user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(is_afk)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
95
crates/chattyness-db/src/queries/guests.rs
Normal file
95
crates/chattyness-db/src/queries/guests.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//! Guest session database queries.
|
||||||
|
|
||||||
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Guest session record.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct GuestSession {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub guest_name: String,
|
||||||
|
pub current_realm_id: Option<Uuid>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new guest session.
|
||||||
|
///
|
||||||
|
/// Returns the guest session ID.
|
||||||
|
pub async fn create_guest_session(
|
||||||
|
pool: &PgPool,
|
||||||
|
guest_name: &str,
|
||||||
|
realm_id: Uuid,
|
||||||
|
token_hash: &str,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
let (session_id,): (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.guest_sessions (guest_name, token_hash, user_agent, ip_address, current_realm_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4::inet, $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(guest_name)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(ip_address)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a guest session by ID.
|
||||||
|
pub async fn get_guest_session(pool: &PgPool, session_id: Uuid) -> Result<Option<GuestSession>, AppError> {
|
||||||
|
let session = sqlx::query_as::<_, GuestSession>(
|
||||||
|
r#"
|
||||||
|
SELECT id, guest_name, current_realm_id, expires_at, created_at
|
||||||
|
FROM auth.guest_sessions
|
||||||
|
WHERE id = $1 AND expires_at > now()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(session_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update last activity if session exists
|
||||||
|
if session.is_some() {
|
||||||
|
sqlx::query("UPDATE auth.guest_sessions SET last_activity_at = now() WHERE id = $1")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a guest session.
|
||||||
|
pub async fn delete_guest_session(pool: &PgPool, session_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM auth.guest_sessions WHERE id = $1")
|
||||||
|
.bind(session_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random guest name like "Guest_12345".
|
||||||
|
pub fn generate_guest_name() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let number: u32 = rng.gen_range(10000..100000);
|
||||||
|
format!("Guest_{}", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate guest session expiry (24 hours from now).
|
||||||
|
pub fn guest_session_expiry() -> DateTime<Utc> {
|
||||||
|
Utc::now() + TimeDelta::hours(24)
|
||||||
|
}
|
||||||
201
crates/chattyness-db/src/queries/memberships.rs
Normal file
201
crates/chattyness-db/src/queries/memberships.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
//! Membership-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{Membership, MembershipWithRealm, RealmRole, ServerRole};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Get a user's membership in a specific realm.
|
||||||
|
pub async fn get_user_membership(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Option<Membership>, AppError> {
|
||||||
|
let membership = sqlx::query_as::<_, Membership>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
realm_id,
|
||||||
|
user_id,
|
||||||
|
role,
|
||||||
|
nickname,
|
||||||
|
created_at AS joined_at,
|
||||||
|
last_visited_at
|
||||||
|
FROM realm.memberships
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(membership)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new membership (join a realm).
|
||||||
|
pub async fn create_membership(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
role: RealmRole,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
let (membership_id,): (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update member count on the realm
|
||||||
|
sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1")
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(membership_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new membership using a connection (for RLS support).
|
||||||
|
pub async fn create_membership_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
role: RealmRole,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
let (membership_id,): (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update member count on the realm
|
||||||
|
sqlx::query("UPDATE realm.realms SET member_count = member_count + 1 WHERE id = $1")
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(membership_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all realm memberships for a user.
|
||||||
|
pub async fn get_user_memberships(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Vec<MembershipWithRealm>, AppError> {
|
||||||
|
let memberships = sqlx::query_as::<_, MembershipWithRealm>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
m.id AS membership_id,
|
||||||
|
m.realm_id,
|
||||||
|
r.name AS realm_name,
|
||||||
|
r.slug AS realm_slug,
|
||||||
|
r.privacy AS realm_privacy,
|
||||||
|
m.role,
|
||||||
|
m.nickname,
|
||||||
|
m.last_visited_at
|
||||||
|
FROM realm.memberships m
|
||||||
|
JOIN realm.realms r ON m.realm_id = r.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
ORDER BY m.last_visited_at DESC NULLS LAST
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(memberships)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user's server staff role (if any).
|
||||||
|
pub async fn get_user_staff_role(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Option<ServerRole>, AppError> {
|
||||||
|
let result: Option<(ServerRole,)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT role
|
||||||
|
FROM server.staff
|
||||||
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.map(|(role,)| role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update last visited timestamp for a membership.
|
||||||
|
pub async fn update_last_visited(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.memberships
|
||||||
|
SET last_visited_at = now()
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update last visited timestamp using a connection (for RLS support).
|
||||||
|
pub async fn update_last_visited_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.memberships
|
||||||
|
SET last_visited_at = now()
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a user is a member of a realm.
|
||||||
|
pub async fn is_member(pool: &PgPool, user_id: Uuid, realm_id: Uuid) -> Result<bool, AppError> {
|
||||||
|
let exists: (bool,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM realm.memberships
|
||||||
|
WHERE user_id = $1 AND realm_id = $2
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(exists.0)
|
||||||
|
}
|
||||||
25
crates/chattyness-db/src/queries/owner.rs
Normal file
25
crates/chattyness-db/src/queries/owner.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
//! Owner-related database queries.
|
||||||
|
//!
|
||||||
|
//! These queries are used by the owner interface and require the chattyness_owner role.
|
||||||
|
//!
|
||||||
|
//! This module is organized into submodules by domain:
|
||||||
|
//! - `config`: Server configuration queries
|
||||||
|
//! - `staff`: Staff management queries
|
||||||
|
//! - `users`: User management queries
|
||||||
|
//! - `realms`: Realm management queries
|
||||||
|
//! - `helpers`: Shared helper functions (password hashing, token generation)
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod helpers;
|
||||||
|
mod realms;
|
||||||
|
mod staff;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
// Re-export all public functions for backwards compatibility
|
||||||
|
pub use config::*;
|
||||||
|
pub use realms::*;
|
||||||
|
pub use staff::*;
|
||||||
|
pub use users::*;
|
||||||
|
|
||||||
|
// Re-export helpers for use by other modules if needed
|
||||||
|
pub use helpers::{generate_random_token, hash_password};
|
||||||
93
crates/chattyness-db/src/queries/owner/config.rs
Normal file
93
crates/chattyness-db/src/queries/owner/config.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
//! Server configuration database queries.
|
||||||
|
//!
|
||||||
|
//! These queries are used by the owner interface and require the chattyness_owner role.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{ServerConfig, UpdateServerConfigRequest};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// The fixed UUID for the singleton server config row.
|
||||||
|
pub fn server_config_id() -> Uuid {
|
||||||
|
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the server configuration.
|
||||||
|
pub async fn get_server_config(pool: &PgPool) -> Result<ServerConfig, AppError> {
|
||||||
|
let config = sqlx::query_as::<_, ServerConfig>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
welcome_message,
|
||||||
|
max_users_per_channel,
|
||||||
|
message_rate_limit,
|
||||||
|
message_rate_window_seconds,
|
||||||
|
allow_guest_access,
|
||||||
|
allow_user_uploads,
|
||||||
|
require_email_verification,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM server.config
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(server_config_id())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the server configuration.
|
||||||
|
pub async fn update_server_config(
|
||||||
|
pool: &PgPool,
|
||||||
|
req: &UpdateServerConfigRequest,
|
||||||
|
) -> Result<ServerConfig, AppError> {
|
||||||
|
let config = sqlx::query_as::<_, ServerConfig>(
|
||||||
|
r#"
|
||||||
|
UPDATE server.config
|
||||||
|
SET
|
||||||
|
name = $1,
|
||||||
|
description = $2,
|
||||||
|
welcome_message = $3,
|
||||||
|
max_users_per_channel = $4,
|
||||||
|
message_rate_limit = $5,
|
||||||
|
message_rate_window_seconds = $6,
|
||||||
|
allow_guest_access = $7,
|
||||||
|
allow_user_uploads = $8,
|
||||||
|
require_email_verification = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $10
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
welcome_message,
|
||||||
|
max_users_per_channel,
|
||||||
|
message_rate_limit,
|
||||||
|
message_rate_window_seconds,
|
||||||
|
allow_guest_access,
|
||||||
|
allow_user_uploads,
|
||||||
|
require_email_verification,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.welcome_message)
|
||||||
|
.bind(req.max_users_per_channel)
|
||||||
|
.bind(req.message_rate_limit)
|
||||||
|
.bind(req.message_rate_window_seconds)
|
||||||
|
.bind(req.allow_guest_access)
|
||||||
|
.bind(req.allow_user_uploads)
|
||||||
|
.bind(req.require_email_verification)
|
||||||
|
.bind(server_config_id())
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
32
crates/chattyness-db/src/queries/owner/helpers.rs
Normal file
32
crates/chattyness-db/src/queries/owner/helpers.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
//! Shared helper functions for owner queries.
|
||||||
|
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Hash a password using argon2.
|
||||||
|
pub fn hash_password(password: &str) -> Result<String, AppError> {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random 50-character alphanumeric token for temporary passwords.
|
||||||
|
pub fn generate_random_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
(0..50)
|
||||||
|
.map(|_| {
|
||||||
|
let idx = rng.gen_range(0..CHARSET.len());
|
||||||
|
CHARSET[idx] as char
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
385
crates/chattyness-db/src/queries/owner/realms.rs
Normal file
385
crates/chattyness-db/src/queries/owner/realms.rs
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
//! Realm management database queries.
|
||||||
|
//!
|
||||||
|
//! These queries are used by the owner interface and require the chattyness_owner role.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
OwnerCreateRealmRequest, RealmDetail, RealmListItem, RealmPrivacy, UpdateRealmRequest,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
use super::helpers::{generate_random_token, hash_password};
|
||||||
|
|
||||||
|
/// List all realms with owner info (for admin interface).
|
||||||
|
pub async fn list_realms_with_owner(
|
||||||
|
pool: &PgPool,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<RealmListItem>, AppError> {
|
||||||
|
let realms = sqlx::query_as::<_, RealmListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
r.tagline,
|
||||||
|
r.privacy,
|
||||||
|
r.is_nsfw,
|
||||||
|
r.owner_id,
|
||||||
|
u.username as owner_username,
|
||||||
|
r.member_count,
|
||||||
|
r.current_user_count,
|
||||||
|
r.created_at
|
||||||
|
FROM realm.realms r
|
||||||
|
JOIN auth.users u ON r.owner_id = u.id
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search realms by name or slug.
|
||||||
|
pub async fn search_realms(
|
||||||
|
pool: &PgPool,
|
||||||
|
query: &str,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<RealmListItem>, AppError> {
|
||||||
|
let pattern = format!("%{}%", query);
|
||||||
|
let realms = sqlx::query_as::<_, RealmListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
r.tagline,
|
||||||
|
r.privacy,
|
||||||
|
r.is_nsfw,
|
||||||
|
r.owner_id,
|
||||||
|
u.username as owner_username,
|
||||||
|
r.member_count,
|
||||||
|
r.current_user_count,
|
||||||
|
r.created_at
|
||||||
|
FROM realm.realms r
|
||||||
|
JOIN auth.users u ON r.owner_id = u.id
|
||||||
|
WHERE
|
||||||
|
r.name ILIKE $1
|
||||||
|
OR r.slug ILIKE $1
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN r.slug ILIKE $1 THEN 1
|
||||||
|
WHEN r.name ILIKE $1 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
r.name
|
||||||
|
LIMIT $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&pattern)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new realm with an existing user as owner.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn create_realm(
|
||||||
|
pool: &PgPool,
|
||||||
|
owner_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
slug: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
tagline: Option<&str>,
|
||||||
|
privacy: RealmPrivacy,
|
||||||
|
is_nsfw: bool,
|
||||||
|
max_users: i32,
|
||||||
|
allow_guest_access: bool,
|
||||||
|
theme_color: Option<&str>,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
// Start a transaction
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Create the realm
|
||||||
|
let realm_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.realms (
|
||||||
|
name, slug, description, tagline, owner_id,
|
||||||
|
privacy, is_nsfw, max_users, allow_guest_access, theme_color
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(slug)
|
||||||
|
.bind(description)
|
||||||
|
.bind(tagline)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(privacy)
|
||||||
|
.bind(is_nsfw)
|
||||||
|
.bind(max_users)
|
||||||
|
.bind(allow_guest_access)
|
||||||
|
.bind(theme_color)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Add owner as realm member with owner role
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(owner_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(realm_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a realm with a new user as owner (atomically).
|
||||||
|
/// Returns (realm_id, user_id, plaintext_token) - the token should be shown to the server owner.
|
||||||
|
pub async fn create_realm_with_new_owner(
|
||||||
|
pool: &PgPool,
|
||||||
|
req: &OwnerCreateRealmRequest,
|
||||||
|
) -> Result<(Uuid, Uuid, String), AppError> {
|
||||||
|
let new_owner = req
|
||||||
|
.new_owner
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::Validation("new_owner is required".to_string()))?;
|
||||||
|
|
||||||
|
// Generate a random token as the temporary password
|
||||||
|
let token = generate_random_token();
|
||||||
|
let password_hash = hash_password(&token)?;
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Create the user with force_pw_reset = true
|
||||||
|
let user_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&new_owner.username)
|
||||||
|
.bind(&new_owner.email)
|
||||||
|
.bind(&new_owner.display_name)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create the realm
|
||||||
|
let realm_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.realms (
|
||||||
|
name, slug, description, tagline, owner_id,
|
||||||
|
privacy, is_nsfw, max_users, allow_guest_access, theme_color
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.slug)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.tagline)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(req.privacy)
|
||||||
|
.bind(req.is_nsfw)
|
||||||
|
.bind(req.max_users)
|
||||||
|
.bind(req.allow_guest_access)
|
||||||
|
.bind(&req.theme_color)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Add owner as realm member with owner role
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((realm_id, user_id, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a realm by slug with full details for editing.
|
||||||
|
pub async fn get_realm_by_slug(pool: &PgPool, slug: &str) -> Result<RealmDetail, AppError> {
|
||||||
|
let realm = sqlx::query_as::<_, RealmDetail>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
r.description,
|
||||||
|
r.tagline,
|
||||||
|
r.owner_id,
|
||||||
|
u.username as owner_username,
|
||||||
|
u.display_name as owner_display_name,
|
||||||
|
r.privacy,
|
||||||
|
r.is_nsfw,
|
||||||
|
r.min_reputation_tier,
|
||||||
|
r.theme_color,
|
||||||
|
r.banner_image_path,
|
||||||
|
r.thumbnail_path,
|
||||||
|
r.max_users,
|
||||||
|
r.allow_guest_access,
|
||||||
|
r.member_count,
|
||||||
|
r.current_user_count,
|
||||||
|
r.created_at,
|
||||||
|
r.updated_at
|
||||||
|
FROM realm.realms r
|
||||||
|
JOIN auth.users u ON r.owner_id = u.id
|
||||||
|
WHERE r.slug = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm with slug '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
Ok(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a realm's settings.
|
||||||
|
pub async fn update_realm(
|
||||||
|
pool: &PgPool,
|
||||||
|
realm_id: Uuid,
|
||||||
|
req: &UpdateRealmRequest,
|
||||||
|
) -> Result<RealmDetail, AppError> {
|
||||||
|
// Update the realm
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.realms
|
||||||
|
SET
|
||||||
|
name = $1,
|
||||||
|
description = $2,
|
||||||
|
tagline = $3,
|
||||||
|
privacy = $4,
|
||||||
|
is_nsfw = $5,
|
||||||
|
max_users = $6,
|
||||||
|
allow_guest_access = $7,
|
||||||
|
theme_color = $8,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $9
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.tagline)
|
||||||
|
.bind(req.privacy)
|
||||||
|
.bind(req.is_nsfw)
|
||||||
|
.bind(req.max_users)
|
||||||
|
.bind(req.allow_guest_access)
|
||||||
|
.bind(&req.theme_color)
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Fetch and return the updated realm
|
||||||
|
let realm = sqlx::query_as::<_, RealmDetail>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
r.description,
|
||||||
|
r.tagline,
|
||||||
|
r.owner_id,
|
||||||
|
u.username as owner_username,
|
||||||
|
u.display_name as owner_display_name,
|
||||||
|
r.privacy,
|
||||||
|
r.is_nsfw,
|
||||||
|
r.min_reputation_tier,
|
||||||
|
r.theme_color,
|
||||||
|
r.banner_image_path,
|
||||||
|
r.thumbnail_path,
|
||||||
|
r.max_users,
|
||||||
|
r.allow_guest_access,
|
||||||
|
r.member_count,
|
||||||
|
r.current_user_count,
|
||||||
|
r.created_at,
|
||||||
|
r.updated_at
|
||||||
|
FROM realm.realms r
|
||||||
|
JOIN auth.users u ON r.owner_id = u.id
|
||||||
|
WHERE r.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer realm ownership to a different user.
|
||||||
|
pub async fn transfer_realm_ownership(
|
||||||
|
pool: &PgPool,
|
||||||
|
realm_id: Uuid,
|
||||||
|
new_owner_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Get current owner_id
|
||||||
|
let current_owner_id =
|
||||||
|
sqlx::query_scalar::<_, Uuid>(r#"SELECT owner_id FROM realm.realms WHERE id = $1"#)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update realm owner
|
||||||
|
sqlx::query(r#"UPDATE realm.realms SET owner_id = $1, updated_at = now() WHERE id = $2"#)
|
||||||
|
.bind(new_owner_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update old owner's membership role to member (or remove?)
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE realm.memberships
|
||||||
|
SET role = 'moderator'
|
||||||
|
WHERE realm_id = $1 AND user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(current_owner_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Ensure new owner has membership with owner role
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
ON CONFLICT (realm_id, user_id) DO UPDATE SET role = 'owner'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(new_owner_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
111
crates/chattyness-db/src/queries/owner/staff.rs
Normal file
111
crates/chattyness-db/src/queries/owner/staff.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//! Staff management database queries.
|
||||||
|
//!
|
||||||
|
//! These queries are used by the owner interface and require the chattyness_owner role.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{ServerRole, StaffMember};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Get all staff members.
|
||||||
|
pub async fn get_all_staff(pool: &PgPool) -> Result<Vec<StaffMember>, AppError> {
|
||||||
|
let staff = sqlx::query_as::<_, StaffMember>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
s.user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
s.role,
|
||||||
|
s.appointed_by,
|
||||||
|
s.appointed_at
|
||||||
|
FROM server.staff s
|
||||||
|
JOIN auth.users u ON s.user_id = u.id
|
||||||
|
ORDER BY
|
||||||
|
CASE s.role
|
||||||
|
WHEN 'owner' THEN 1
|
||||||
|
WHEN 'admin' THEN 2
|
||||||
|
WHEN 'moderator' THEN 3
|
||||||
|
END,
|
||||||
|
s.appointed_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(staff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get staff members by role.
|
||||||
|
pub async fn get_staff_by_role(
|
||||||
|
pool: &PgPool,
|
||||||
|
role: ServerRole,
|
||||||
|
) -> Result<Vec<StaffMember>, AppError> {
|
||||||
|
let staff = sqlx::query_as::<_, StaffMember>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
s.user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
s.role,
|
||||||
|
s.appointed_by,
|
||||||
|
s.appointed_at
|
||||||
|
FROM server.staff s
|
||||||
|
JOIN auth.users u ON s.user_id = u.id
|
||||||
|
WHERE s.role = $1
|
||||||
|
ORDER BY s.appointed_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(role)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(staff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a staff member (promote existing user).
|
||||||
|
pub async fn create_staff(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
role: ServerRole,
|
||||||
|
appointed_by: Option<Uuid>,
|
||||||
|
) -> Result<StaffMember, AppError> {
|
||||||
|
let staff = sqlx::query_as::<_, StaffMember>(
|
||||||
|
r#"
|
||||||
|
WITH inserted AS (
|
||||||
|
INSERT INTO server.staff (user_id, role, appointed_by)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING user_id, role, appointed_by, appointed_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
i.user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
i.role,
|
||||||
|
i.appointed_by,
|
||||||
|
i.appointed_at
|
||||||
|
FROM inserted i
|
||||||
|
JOIN auth.users u ON i.user_id = u.id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role)
|
||||||
|
.bind(appointed_by)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(staff)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a staff member.
|
||||||
|
pub async fn delete_staff(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM server.staff WHERE user_id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
346
crates/chattyness-db/src/queries/owner/users.rs
Normal file
346
crates/chattyness-db/src/queries/owner/users.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
//! User management database queries.
|
||||||
|
//!
|
||||||
|
//! These queries are used by the owner interface and require the chattyness_owner role.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AccountStatus, CreateUserRequest, NewUserData, RealmRole, RealmSummary, UserDetail,
|
||||||
|
UserListItem, UserRealmMembership,
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
use super::helpers::{generate_random_token, hash_password};
|
||||||
|
|
||||||
|
/// List all users with pagination.
|
||||||
|
pub async fn list_users(
|
||||||
|
pool: &PgPool,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<UserListItem>, AppError> {
|
||||||
|
let users = sqlx::query_as::<_, UserListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
u.status,
|
||||||
|
u.reputation_tier,
|
||||||
|
s.role as staff_role,
|
||||||
|
u.created_at,
|
||||||
|
u.last_seen_at
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN server.staff s ON u.id = s.user_id
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search users by username, email, or display_name.
|
||||||
|
pub async fn search_users(
|
||||||
|
pool: &PgPool,
|
||||||
|
query: &str,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<UserListItem>, AppError> {
|
||||||
|
let pattern = format!("%{}%", query);
|
||||||
|
let users = sqlx::query_as::<_, UserListItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
u.status,
|
||||||
|
u.reputation_tier,
|
||||||
|
s.role as staff_role,
|
||||||
|
u.created_at,
|
||||||
|
u.last_seen_at
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN server.staff s ON u.id = s.user_id
|
||||||
|
WHERE
|
||||||
|
u.username ILIKE $1
|
||||||
|
OR u.email ILIKE $1
|
||||||
|
OR u.display_name ILIKE $1
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN u.username ILIKE $1 THEN 1
|
||||||
|
WHEN u.display_name ILIKE $1 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
u.username
|
||||||
|
LIMIT $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&pattern)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get full user detail by ID.
|
||||||
|
pub async fn get_user_detail(pool: &PgPool, user_id: Uuid) -> Result<UserDetail, AppError> {
|
||||||
|
let user = sqlx::query_as::<_, UserDetail>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.display_name,
|
||||||
|
u.bio,
|
||||||
|
u.avatar_url,
|
||||||
|
u.reputation_tier,
|
||||||
|
u.status,
|
||||||
|
u.email_verified,
|
||||||
|
s.role as staff_role,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at,
|
||||||
|
u.last_seen_at
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN server.staff s ON u.id = s.user_id
|
||||||
|
WHERE u.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a user's account status.
|
||||||
|
pub async fn update_user_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
status: AccountStatus,
|
||||||
|
) -> Result<UserDetail, AppError> {
|
||||||
|
// First update the status
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET status = $1, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(status)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Then return the updated user detail
|
||||||
|
get_user_detail(pool, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user account with a random temporary password.
|
||||||
|
/// Returns (user_id, plaintext_token) - the token should be shown to the server owner.
|
||||||
|
pub async fn create_user(pool: &PgPool, data: &NewUserData) -> Result<(Uuid, String), AppError> {
|
||||||
|
// Generate a random token as the temporary password
|
||||||
|
let token = generate_random_token();
|
||||||
|
let password_hash = hash_password(&token)?;
|
||||||
|
|
||||||
|
let user_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&data.username)
|
||||||
|
.bind(&data.email)
|
||||||
|
.bind(&data.display_name)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((user_id, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset a user's password to a random token.
|
||||||
|
/// Returns the plaintext token (to show to the server owner).
|
||||||
|
pub async fn reset_user_password(pool: &PgPool, user_id: Uuid) -> Result<String, AppError> {
|
||||||
|
let token = generate_random_token();
|
||||||
|
let password_hash = hash_password(&token)?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET password_hash = $1, force_pw_reset = true, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all realm memberships for a user.
|
||||||
|
pub async fn get_user_realms(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<Vec<UserRealmMembership>, AppError> {
|
||||||
|
let memberships = sqlx::query_as::<_, UserRealmMembership>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
m.realm_id,
|
||||||
|
r.name as realm_name,
|
||||||
|
r.slug as realm_slug,
|
||||||
|
m.role,
|
||||||
|
m.nickname,
|
||||||
|
m.created_at as joined_at,
|
||||||
|
m.last_visited_at
|
||||||
|
FROM realm.memberships m
|
||||||
|
JOIN realm.realms r ON m.realm_id = r.id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
ORDER BY m.last_visited_at DESC NULLS LAST, m.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(memberships)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a user to a realm.
|
||||||
|
pub async fn add_user_to_realm(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
role: RealmRole,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.memberships (realm_id, user_id, role)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (realm_id, user_id) DO UPDATE SET role = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a user from a realm.
|
||||||
|
pub async fn remove_user_from_realm(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM realm.memberships
|
||||||
|
WHERE realm_id = $1 AND user_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all realms (for dropdown selection).
|
||||||
|
pub async fn list_all_realms(pool: &PgPool) -> Result<Vec<RealmSummary>, AppError> {
|
||||||
|
let realms = sqlx::query_as::<_, RealmSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
thumbnail_path,
|
||||||
|
member_count,
|
||||||
|
current_user_count
|
||||||
|
FROM realm.realms
|
||||||
|
ORDER BY name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user with optional staff role (atomically).
|
||||||
|
/// Returns (user_id, plaintext_token) - the token should be shown to the server owner.
|
||||||
|
pub async fn create_user_with_staff(
|
||||||
|
pool: &PgPool,
|
||||||
|
req: &CreateUserRequest,
|
||||||
|
) -> Result<(Uuid, String), AppError> {
|
||||||
|
// Generate a random token as the temporary password
|
||||||
|
let token = generate_random_token();
|
||||||
|
let password_hash = hash_password(&token)?;
|
||||||
|
|
||||||
|
// Start a transaction if we need to also create staff record
|
||||||
|
if let Some(staff_role) = req.staff_role {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Create the user with force_pw_reset = true
|
||||||
|
let user_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.username)
|
||||||
|
.bind(&req.email)
|
||||||
|
.bind(&req.display_name)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create the staff record
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO server.staff (user_id, role)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(staff_role)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok((user_id, token))
|
||||||
|
} else {
|
||||||
|
// Just create the user with force_pw_reset = true
|
||||||
|
let user_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (username, email, display_name, password_hash, force_pw_reset)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.username)
|
||||||
|
.bind(&req.email)
|
||||||
|
.bind(&req.display_name)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((user_id, token))
|
||||||
|
}
|
||||||
|
}
|
||||||
180
crates/chattyness-db/src/queries/props.rs
Normal file
180
crates/chattyness-db/src/queries/props.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
//! Props-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgExecutor;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{CreateServerPropRequest, ServerProp, ServerPropSummary};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// List all server props.
|
||||||
|
pub async fn list_server_props<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
) -> Result<Vec<ServerPropSummary>, AppError> {
|
||||||
|
let props = sqlx::query_as::<_, ServerPropSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
asset_path,
|
||||||
|
default_layer,
|
||||||
|
is_active,
|
||||||
|
created_at
|
||||||
|
FROM server.props
|
||||||
|
ORDER BY name ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a server prop by ID.
|
||||||
|
pub async fn get_server_prop_by_id<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
prop_id: Uuid,
|
||||||
|
) -> Result<Option<ServerProp>, AppError> {
|
||||||
|
let prop = sqlx::query_as::<_, ServerProp>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
asset_path,
|
||||||
|
thumbnail_path,
|
||||||
|
default_layer,
|
||||||
|
default_emotion,
|
||||||
|
default_position,
|
||||||
|
is_unique,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_active,
|
||||||
|
available_from,
|
||||||
|
available_until,
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM server.props
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(prop_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a prop slug is available.
|
||||||
|
pub async fn is_prop_slug_available<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let exists: (bool,) =
|
||||||
|
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM server.props WHERE slug = $1)"#)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(!exists.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new server prop.
|
||||||
|
pub async fn create_server_prop<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
req: &CreateServerPropRequest,
|
||||||
|
asset_path: &str,
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
) -> Result<ServerProp, AppError> {
|
||||||
|
let slug = req.slug_or_generate();
|
||||||
|
|
||||||
|
// Positioning: either content layer OR emotion layer OR neither (all NULL)
|
||||||
|
// Database constraint enforces mutual exclusivity
|
||||||
|
let (default_layer, default_emotion, default_position) =
|
||||||
|
if req.default_layer.is_some() {
|
||||||
|
// Content layer prop
|
||||||
|
(
|
||||||
|
req.default_layer.map(|l| l.to_string()),
|
||||||
|
None,
|
||||||
|
Some(req.default_position.unwrap_or(4)), // Default to center position
|
||||||
|
)
|
||||||
|
} else if req.default_emotion.is_some() {
|
||||||
|
// Emotion layer prop
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
req.default_emotion.map(|e| e.to_string()),
|
||||||
|
Some(req.default_position.unwrap_or(4)), // Default to center position
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Non-avatar prop
|
||||||
|
(None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let prop = sqlx::query_as::<_, ServerProp>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO server.props (
|
||||||
|
name, slug, description, tags, asset_path,
|
||||||
|
default_layer, default_emotion, default_position,
|
||||||
|
created_by
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6::props.avatar_layer, $7::props.emotion_state, $8,
|
||||||
|
$9
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
asset_path,
|
||||||
|
thumbnail_path,
|
||||||
|
default_layer,
|
||||||
|
default_emotion,
|
||||||
|
default_position,
|
||||||
|
is_unique,
|
||||||
|
is_transferable,
|
||||||
|
is_portable,
|
||||||
|
is_active,
|
||||||
|
available_from,
|
||||||
|
available_until,
|
||||||
|
created_by,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.tags)
|
||||||
|
.bind(asset_path)
|
||||||
|
.bind(&default_layer)
|
||||||
|
.bind(&default_emotion)
|
||||||
|
.bind(default_position)
|
||||||
|
.bind(created_by)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a server prop.
|
||||||
|
pub async fn delete_server_prop<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
prop_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let result = sqlx::query(r#"DELETE FROM server.props WHERE id = $1"#)
|
||||||
|
.bind(prop_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound("Prop not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
228
crates/chattyness-db/src/queries/realms.rs
Normal file
228
crates/chattyness-db/src/queries/realms.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
//! Realm-related database queries.
|
||||||
|
|
||||||
|
use sqlx::{PgExecutor, PgPool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{CreateRealmRequest, Realm, RealmSummary};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Create a new realm.
|
||||||
|
pub async fn create_realm(
|
||||||
|
pool: &PgPool,
|
||||||
|
owner_id: Uuid,
|
||||||
|
req: &CreateRealmRequest,
|
||||||
|
) -> Result<Realm, AppError> {
|
||||||
|
let privacy_str = req.privacy.as_str();
|
||||||
|
let realm = sqlx::query_as::<_, Realm>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.realms (
|
||||||
|
name, slug, description, tagline, owner_id,
|
||||||
|
privacy, is_nsfw, max_users, allow_guest_access, theme_color
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
tagline,
|
||||||
|
owner_id,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
min_reputation_tier,
|
||||||
|
theme_color,
|
||||||
|
banner_image_path,
|
||||||
|
thumbnail_path,
|
||||||
|
max_users,
|
||||||
|
allow_guest_access,
|
||||||
|
default_scene_id,
|
||||||
|
member_count,
|
||||||
|
current_user_count,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.slug)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.tagline)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(privacy_str)
|
||||||
|
.bind(req.is_nsfw)
|
||||||
|
.bind(req.max_users)
|
||||||
|
.bind(req.allow_guest_access)
|
||||||
|
.bind(&req.theme_color)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a realm slug is available.
|
||||||
|
pub async fn is_slug_available(pool: &PgPool, slug: &str) -> Result<bool, AppError> {
|
||||||
|
let exists: (bool,) = sqlx::query_as(
|
||||||
|
r#"SELECT EXISTS(SELECT 1 FROM realm.realms WHERE slug = $1)"#,
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(!exists.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a realm by its slug.
|
||||||
|
pub async fn get_realm_by_slug<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<Realm>, AppError> {
|
||||||
|
let realm = sqlx::query_as::<_, Realm>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
tagline,
|
||||||
|
owner_id,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
min_reputation_tier,
|
||||||
|
theme_color,
|
||||||
|
banner_image_path,
|
||||||
|
thumbnail_path,
|
||||||
|
max_users,
|
||||||
|
allow_guest_access,
|
||||||
|
default_scene_id,
|
||||||
|
member_count,
|
||||||
|
current_user_count,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.realms
|
||||||
|
WHERE slug = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a realm by its ID.
|
||||||
|
pub async fn get_realm_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Realm>, AppError> {
|
||||||
|
let realm = sqlx::query_as::<_, Realm>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
tagline,
|
||||||
|
owner_id,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
min_reputation_tier,
|
||||||
|
theme_color,
|
||||||
|
banner_image_path,
|
||||||
|
thumbnail_path,
|
||||||
|
max_users,
|
||||||
|
allow_guest_access,
|
||||||
|
default_scene_id,
|
||||||
|
member_count,
|
||||||
|
current_user_count,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.realms
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List public realms.
|
||||||
|
pub async fn list_public_realms(
|
||||||
|
pool: &PgPool,
|
||||||
|
include_nsfw: bool,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<RealmSummary>, AppError> {
|
||||||
|
let realms = if include_nsfw {
|
||||||
|
sqlx::query_as::<_, RealmSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
thumbnail_path,
|
||||||
|
member_count,
|
||||||
|
current_user_count
|
||||||
|
FROM realm.realms
|
||||||
|
WHERE privacy = 'public'
|
||||||
|
ORDER BY current_user_count DESC, member_count DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, RealmSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
thumbnail_path,
|
||||||
|
member_count,
|
||||||
|
current_user_count
|
||||||
|
FROM realm.realms
|
||||||
|
WHERE privacy = 'public' AND is_nsfw = false
|
||||||
|
ORDER BY current_user_count DESC, member_count DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(realms)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get realms owned by a user.
|
||||||
|
pub async fn get_user_realms(pool: &PgPool, user_id: Uuid) -> Result<Vec<RealmSummary>, AppError> {
|
||||||
|
let realms = sqlx::query_as::<_, RealmSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
privacy,
|
||||||
|
is_nsfw,
|
||||||
|
thumbnail_path,
|
||||||
|
member_count,
|
||||||
|
current_user_count
|
||||||
|
FROM realm.realms
|
||||||
|
WHERE owner_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(realms)
|
||||||
|
}
|
||||||
442
crates/chattyness-db/src/queries/scenes.rs
Normal file
442
crates/chattyness-db/src/queries/scenes.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
//! Scene-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgExecutor;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// List all scenes for a realm.
|
||||||
|
pub async fn list_scenes_for_realm<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Vec<SceneSummary>, AppError> {
|
||||||
|
let scenes = sqlx::query_as::<_, SceneSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
sort_order,
|
||||||
|
is_entry_point,
|
||||||
|
is_hidden,
|
||||||
|
background_color,
|
||||||
|
background_image_path
|
||||||
|
FROM realm.scenes
|
||||||
|
WHERE realm_id = $1
|
||||||
|
ORDER BY sort_order ASC, name ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(scenes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a scene by its ID.
|
||||||
|
pub async fn get_scene_by_id<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
) -> Result<Option<Scene>, AppError> {
|
||||||
|
let scene = sqlx::query_as::<_, Scene>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
realm_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
background_image_path,
|
||||||
|
background_color,
|
||||||
|
ST_AsText(bounds) as bounds_wkt,
|
||||||
|
dimension_mode,
|
||||||
|
ambient_audio_id,
|
||||||
|
ambient_volume,
|
||||||
|
sort_order,
|
||||||
|
is_entry_point,
|
||||||
|
is_hidden,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.scenes
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(scene_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a scene by realm ID and slug.
|
||||||
|
pub async fn get_scene_by_slug<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
realm_id: Uuid,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<Scene>, AppError> {
|
||||||
|
let scene = sqlx::query_as::<_, Scene>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
realm_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
background_image_path,
|
||||||
|
background_color,
|
||||||
|
ST_AsText(bounds) as bounds_wkt,
|
||||||
|
dimension_mode,
|
||||||
|
ambient_audio_id,
|
||||||
|
ambient_volume,
|
||||||
|
sort_order,
|
||||||
|
is_entry_point,
|
||||||
|
is_hidden,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.scenes
|
||||||
|
WHERE realm_id = $1 AND slug = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a scene slug is available within a realm.
|
||||||
|
pub async fn is_scene_slug_available<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
realm_id: Uuid,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let exists: (bool,) =
|
||||||
|
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.scenes WHERE realm_id = $1 AND slug = $2)"#)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(!exists.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new scene.
|
||||||
|
pub async fn create_scene<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
realm_id: Uuid,
|
||||||
|
req: &CreateSceneRequest,
|
||||||
|
) -> Result<Scene, AppError> {
|
||||||
|
let bounds_wkt = req
|
||||||
|
.bounds_wkt
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))");
|
||||||
|
let dimension_mode = req.dimension_mode.unwrap_or_default().to_string();
|
||||||
|
let sort_order = req.sort_order.unwrap_or(0);
|
||||||
|
let is_entry_point = req.is_entry_point.unwrap_or(false);
|
||||||
|
let is_hidden = req.is_hidden.unwrap_or(false);
|
||||||
|
|
||||||
|
let scene = sqlx::query_as::<_, Scene>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.scenes (
|
||||||
|
realm_id, name, slug, description,
|
||||||
|
background_image_path, background_color,
|
||||||
|
bounds, dimension_mode,
|
||||||
|
sort_order, is_entry_point, is_hidden
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4,
|
||||||
|
$5, $6,
|
||||||
|
ST_GeomFromText($7, 0), $8::realm.dimension_mode,
|
||||||
|
$9, $10, $11
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
realm_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
background_image_path,
|
||||||
|
background_color,
|
||||||
|
ST_AsText(bounds) as bounds_wkt,
|
||||||
|
dimension_mode,
|
||||||
|
ambient_audio_id,
|
||||||
|
ambient_volume,
|
||||||
|
sort_order,
|
||||||
|
is_entry_point,
|
||||||
|
is_hidden,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.slug)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.background_image_path)
|
||||||
|
.bind(&req.background_color)
|
||||||
|
.bind(bounds_wkt)
|
||||||
|
.bind(&dimension_mode)
|
||||||
|
.bind(sort_order)
|
||||||
|
.bind(is_entry_point)
|
||||||
|
.bind(is_hidden)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new scene with a specific ID.
|
||||||
|
///
|
||||||
|
/// This is used when we need to know the scene ID before creating it
|
||||||
|
/// (e.g., for storing background images in the correct path).
|
||||||
|
pub async fn create_scene_with_id<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
req: &CreateSceneRequest,
|
||||||
|
) -> Result<Scene, AppError> {
|
||||||
|
let bounds_wkt = req
|
||||||
|
.bounds_wkt
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))");
|
||||||
|
let dimension_mode = req.dimension_mode.unwrap_or_default().to_string();
|
||||||
|
let sort_order = req.sort_order.unwrap_or(0);
|
||||||
|
let is_entry_point = req.is_entry_point.unwrap_or(false);
|
||||||
|
let is_hidden = req.is_hidden.unwrap_or(false);
|
||||||
|
|
||||||
|
let scene = sqlx::query_as::<_, Scene>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.scenes (
|
||||||
|
id, realm_id, name, slug, description,
|
||||||
|
background_image_path, background_color,
|
||||||
|
bounds, dimension_mode,
|
||||||
|
sort_order, is_entry_point, is_hidden
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3, $4, $5,
|
||||||
|
$6, $7,
|
||||||
|
ST_GeomFromText($8, 0), $9::realm.dimension_mode,
|
||||||
|
$10, $11, $12
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
realm_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
background_image_path,
|
||||||
|
background_color,
|
||||||
|
ST_AsText(bounds) as bounds_wkt,
|
||||||
|
dimension_mode,
|
||||||
|
ambient_audio_id,
|
||||||
|
ambient_volume,
|
||||||
|
sort_order,
|
||||||
|
is_entry_point,
|
||||||
|
is_hidden,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(scene_id)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.slug)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(&req.background_image_path)
|
||||||
|
.bind(&req.background_color)
|
||||||
|
.bind(bounds_wkt)
|
||||||
|
.bind(&dimension_mode)
|
||||||
|
.bind(sort_order)
|
||||||
|
.bind(is_entry_point)
|
||||||
|
.bind(is_hidden)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a scene.
|
||||||
|
pub async fn update_scene<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
req: &UpdateSceneRequest,
|
||||||
|
) -> Result<Scene, AppError> {
|
||||||
|
// Build dynamic update query
|
||||||
|
let mut set_clauses = Vec::new();
|
||||||
|
let mut param_idx = 2; // $1 is scene_id
|
||||||
|
|
||||||
|
if req.name.is_some() {
|
||||||
|
set_clauses.push(format!("name = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.description.is_some() {
|
||||||
|
set_clauses.push(format!("description = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.background_image_path.is_some() {
|
||||||
|
set_clauses.push(format!("background_image_path = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.background_color.is_some() {
|
||||||
|
set_clauses.push(format!("background_color = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.bounds_wkt.is_some() {
|
||||||
|
set_clauses.push(format!("bounds = ST_GeomFromText(${}, 0)", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.dimension_mode.is_some() {
|
||||||
|
set_clauses.push(format!("dimension_mode = ${}::realm.dimension_mode", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.sort_order.is_some() {
|
||||||
|
set_clauses.push(format!("sort_order = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.is_entry_point.is_some() {
|
||||||
|
set_clauses.push(format!("is_entry_point = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.is_hidden.is_some() {
|
||||||
|
set_clauses.push(format!("is_hidden = ${}", param_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no updates, just return the current scene
|
||||||
|
let query = if set_clauses.is_empty() {
|
||||||
|
r#"SELECT id, realm_id, name, slug, description, background_image_path,
|
||||||
|
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
|
||||||
|
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
|
||||||
|
is_hidden, created_at, updated_at
|
||||||
|
FROM realm.scenes WHERE id = $1"#.to_string()
|
||||||
|
} else {
|
||||||
|
set_clauses.push("updated_at = now()".to_string());
|
||||||
|
format!(
|
||||||
|
r#"UPDATE realm.scenes SET {}
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, realm_id, name, slug, description, background_image_path,
|
||||||
|
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
|
||||||
|
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
|
||||||
|
is_hidden, created_at, updated_at"#,
|
||||||
|
set_clauses.join(", ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut query_builder = sqlx::query_as::<_, Scene>(&query).bind(scene_id);
|
||||||
|
|
||||||
|
if let Some(ref name) = req.name {
|
||||||
|
query_builder = query_builder.bind(name);
|
||||||
|
}
|
||||||
|
if let Some(ref description) = req.description {
|
||||||
|
query_builder = query_builder.bind(description);
|
||||||
|
}
|
||||||
|
if let Some(ref background_image_path) = req.background_image_path {
|
||||||
|
query_builder = query_builder.bind(background_image_path);
|
||||||
|
}
|
||||||
|
if let Some(ref background_color) = req.background_color {
|
||||||
|
query_builder = query_builder.bind(background_color);
|
||||||
|
}
|
||||||
|
if let Some(ref bounds_wkt) = req.bounds_wkt {
|
||||||
|
query_builder = query_builder.bind(bounds_wkt);
|
||||||
|
}
|
||||||
|
if let Some(ref dimension_mode) = req.dimension_mode {
|
||||||
|
query_builder = query_builder.bind(dimension_mode.to_string());
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = req.sort_order {
|
||||||
|
query_builder = query_builder.bind(sort_order);
|
||||||
|
}
|
||||||
|
if let Some(is_entry_point) = req.is_entry_point {
|
||||||
|
query_builder = query_builder.bind(is_entry_point);
|
||||||
|
}
|
||||||
|
if let Some(is_hidden) = req.is_hidden {
|
||||||
|
query_builder = query_builder.bind(is_hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = query_builder
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a scene.
|
||||||
|
pub async fn delete_scene<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let result = sqlx::query(r#"DELETE FROM realm.scenes WHERE id = $1"#)
|
||||||
|
.bind(scene_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound("Scene not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next sort order for a new scene in a realm.
|
||||||
|
pub async fn get_next_sort_order<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<i32, AppError> {
|
||||||
|
let result: (Option<i32>,) = sqlx::query_as(
|
||||||
|
r#"SELECT MAX(sort_order) FROM realm.scenes WHERE realm_id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.0.unwrap_or(0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the entry scene for a realm.
|
||||||
|
///
|
||||||
|
/// Returns the scene in this priority order:
|
||||||
|
/// 1. The scene specified by `default_scene_id` on the realm (if provided and exists)
|
||||||
|
/// 2. The first scene marked as `is_entry_point`
|
||||||
|
/// 3. The first scene by sort_order
|
||||||
|
pub async fn get_entry_scene_for_realm<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
realm_id: Uuid,
|
||||||
|
default_scene_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<Scene>, AppError> {
|
||||||
|
// Use a single query that handles the priority in SQL
|
||||||
|
let scene = sqlx::query_as::<_, Scene>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
realm_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
background_image_path,
|
||||||
|
background_color,
|
||||||
|
ST_AsText(bounds) as bounds_wkt,
|
||||||
|
dimension_mode,
|
||||||
|
ambient_audio_id,
|
||||||
|
ambient_volume,
|
||||||
|
sort_order,
|
||||||
|
is_entry_point,
|
||||||
|
is_hidden,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.scenes
|
||||||
|
WHERE realm_id = $1 AND is_hidden = false
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN id = $2 THEN 0 ELSE 1 END,
|
||||||
|
is_entry_point DESC,
|
||||||
|
sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(realm_id)
|
||||||
|
.bind(default_scene_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(scene)
|
||||||
|
}
|
||||||
324
crates/chattyness-db/src/queries/spots.rs
Normal file
324
crates/chattyness-db/src/queries/spots.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
//! Spot-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgExecutor;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// List all spots for a scene.
|
||||||
|
pub async fn list_spots_for_scene<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
) -> Result<Vec<SpotSummary>, AppError> {
|
||||||
|
let spots = sqlx::query_as::<_, SpotSummary>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
spot_type,
|
||||||
|
ST_AsText(region) as region_wkt,
|
||||||
|
sort_order,
|
||||||
|
is_visible,
|
||||||
|
is_active
|
||||||
|
FROM realm.spots
|
||||||
|
WHERE scene_id = $1
|
||||||
|
ORDER BY sort_order ASC, name ASC NULLS LAST
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(scene_id)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(spots)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a spot by its ID.
|
||||||
|
pub async fn get_spot_by_id<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
spot_id: Uuid,
|
||||||
|
) -> Result<Option<Spot>, AppError> {
|
||||||
|
let spot = sqlx::query_as::<_, Spot>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scene_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
ST_AsText(region) as region_wkt,
|
||||||
|
spot_type,
|
||||||
|
destination_scene_id,
|
||||||
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
|
current_state,
|
||||||
|
sort_order,
|
||||||
|
is_visible,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.spots
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(spot_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(spot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a spot by scene ID and slug.
|
||||||
|
pub async fn get_spot_by_slug<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<Spot>, AppError> {
|
||||||
|
let spot = sqlx::query_as::<_, Spot>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scene_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
ST_AsText(region) as region_wkt,
|
||||||
|
spot_type,
|
||||||
|
destination_scene_id,
|
||||||
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
|
current_state,
|
||||||
|
sort_order,
|
||||||
|
is_visible,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM realm.spots
|
||||||
|
WHERE scene_id = $1 AND slug = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(scene_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(spot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a spot slug is available within a scene.
|
||||||
|
pub async fn is_spot_slug_available<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let exists: (bool,) =
|
||||||
|
sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM realm.spots WHERE scene_id = $1 AND slug = $2)"#)
|
||||||
|
.bind(scene_id)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(!exists.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new spot.
|
||||||
|
pub async fn create_spot<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
req: &CreateSpotRequest,
|
||||||
|
) -> Result<Spot, AppError> {
|
||||||
|
let spot_type = req.spot_type.unwrap_or_default().to_string();
|
||||||
|
let sort_order = req.sort_order.unwrap_or(0);
|
||||||
|
let is_visible = req.is_visible.unwrap_or(true);
|
||||||
|
let is_active = req.is_active.unwrap_or(true);
|
||||||
|
|
||||||
|
let spot = sqlx::query_as::<_, Spot>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO realm.spots (
|
||||||
|
scene_id, name, slug,
|
||||||
|
region, spot_type,
|
||||||
|
destination_scene_id, destination_position,
|
||||||
|
sort_order, is_visible, is_active
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
ST_GeomFromText($4, 0), $5::realm.spot_type,
|
||||||
|
$6, CASE WHEN $7 IS NOT NULL THEN ST_GeomFromText($7, 0) ELSE NULL END,
|
||||||
|
$8, $9, $10
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
scene_id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
ST_AsText(region) as region_wkt,
|
||||||
|
spot_type,
|
||||||
|
destination_scene_id,
|
||||||
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
|
current_state,
|
||||||
|
sort_order,
|
||||||
|
is_visible,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(scene_id)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.slug)
|
||||||
|
.bind(&req.region_wkt)
|
||||||
|
.bind(&spot_type)
|
||||||
|
.bind(req.destination_scene_id)
|
||||||
|
.bind(&req.destination_position_wkt)
|
||||||
|
.bind(sort_order)
|
||||||
|
.bind(is_visible)
|
||||||
|
.bind(is_active)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(spot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a spot.
|
||||||
|
pub async fn update_spot<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
spot_id: Uuid,
|
||||||
|
req: &UpdateSpotRequest,
|
||||||
|
) -> Result<Spot, AppError> {
|
||||||
|
// Build dynamic update query
|
||||||
|
let mut set_clauses = Vec::new();
|
||||||
|
let mut param_idx = 2; // $1 is spot_id
|
||||||
|
|
||||||
|
if req.name.is_some() {
|
||||||
|
set_clauses.push(format!("name = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.slug.is_some() {
|
||||||
|
set_clauses.push(format!("slug = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.region_wkt.is_some() {
|
||||||
|
set_clauses.push(format!("region = ST_GeomFromText(${}, 0)", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.spot_type.is_some() {
|
||||||
|
set_clauses.push(format!("spot_type = ${}::realm.spot_type", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.destination_scene_id.is_some() {
|
||||||
|
set_clauses.push(format!("destination_scene_id = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.destination_position_wkt.is_some() {
|
||||||
|
set_clauses.push(format!(
|
||||||
|
"destination_position = CASE WHEN ${} IS NOT NULL THEN ST_GeomFromText(${}, 0) ELSE NULL END",
|
||||||
|
param_idx, param_idx
|
||||||
|
));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.current_state.is_some() {
|
||||||
|
set_clauses.push(format!("current_state = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.sort_order.is_some() {
|
||||||
|
set_clauses.push(format!("sort_order = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.is_visible.is_some() {
|
||||||
|
set_clauses.push(format!("is_visible = ${}", param_idx));
|
||||||
|
param_idx += 1;
|
||||||
|
}
|
||||||
|
if req.is_active.is_some() {
|
||||||
|
set_clauses.push(format!("is_active = ${}", param_idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no updates, just return the current spot via SELECT
|
||||||
|
let query = if set_clauses.is_empty() {
|
||||||
|
r#"SELECT id, scene_id, name, slug, ST_AsText(region) as region_wkt,
|
||||||
|
spot_type, destination_scene_id,
|
||||||
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
|
current_state, sort_order, is_visible, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM realm.spots WHERE id = $1"#.to_string()
|
||||||
|
} else {
|
||||||
|
set_clauses.push("updated_at = now()".to_string());
|
||||||
|
format!(
|
||||||
|
r#"UPDATE realm.spots SET {}
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, scene_id, name, slug, ST_AsText(region) as region_wkt,
|
||||||
|
spot_type, destination_scene_id,
|
||||||
|
ST_AsText(destination_position) as destination_position_wkt,
|
||||||
|
current_state, sort_order, is_visible, is_active,
|
||||||
|
created_at, updated_at"#,
|
||||||
|
set_clauses.join(", ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut query_builder = sqlx::query_as::<_, Spot>(&query).bind(spot_id);
|
||||||
|
|
||||||
|
if let Some(ref name) = req.name {
|
||||||
|
query_builder = query_builder.bind(name);
|
||||||
|
}
|
||||||
|
if let Some(ref slug) = req.slug {
|
||||||
|
query_builder = query_builder.bind(slug);
|
||||||
|
}
|
||||||
|
if let Some(ref region_wkt) = req.region_wkt {
|
||||||
|
query_builder = query_builder.bind(region_wkt);
|
||||||
|
}
|
||||||
|
if let Some(ref spot_type) = req.spot_type {
|
||||||
|
query_builder = query_builder.bind(spot_type.to_string());
|
||||||
|
}
|
||||||
|
if let Some(destination_scene_id) = req.destination_scene_id {
|
||||||
|
query_builder = query_builder.bind(destination_scene_id);
|
||||||
|
}
|
||||||
|
if let Some(ref destination_position_wkt) = req.destination_position_wkt {
|
||||||
|
query_builder = query_builder.bind(destination_position_wkt);
|
||||||
|
}
|
||||||
|
if let Some(current_state) = req.current_state {
|
||||||
|
query_builder = query_builder.bind(current_state);
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = req.sort_order {
|
||||||
|
query_builder = query_builder.bind(sort_order);
|
||||||
|
}
|
||||||
|
if let Some(is_visible) = req.is_visible {
|
||||||
|
query_builder = query_builder.bind(is_visible);
|
||||||
|
}
|
||||||
|
if let Some(is_active) = req.is_active {
|
||||||
|
query_builder = query_builder.bind(is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
let spot = query_builder
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(spot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a spot.
|
||||||
|
pub async fn delete_spot<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
spot_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let result = sqlx::query(r#"DELETE FROM realm.spots WHERE id = $1"#)
|
||||||
|
.bind(spot_id)
|
||||||
|
.execute(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound("Spot not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next sort order for a new spot in a scene.
|
||||||
|
pub async fn get_next_sort_order<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
scene_id: Uuid,
|
||||||
|
) -> Result<i32, AppError> {
|
||||||
|
let result: (Option<i32>,) =
|
||||||
|
sqlx::query_as(r#"SELECT MAX(sort_order) FROM realm.spots WHERE scene_id = $1"#)
|
||||||
|
.bind(scene_id)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.0.unwrap_or(0) + 1)
|
||||||
|
}
|
||||||
493
crates/chattyness-db/src/queries/users.rs
Normal file
493
crates/chattyness-db/src/queries/users.rs
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
//! User-related database queries.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{StaffMember, User, UserWithAuth};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Get a user by their ID.
|
||||||
|
pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, AppError> {
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
avatar_url,
|
||||||
|
reputation_tier,
|
||||||
|
status,
|
||||||
|
email_verified,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM auth.users
|
||||||
|
WHERE id = $1 AND status = 'active'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user by their username.
|
||||||
|
pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Option<User>, AppError> {
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
avatar_url,
|
||||||
|
reputation_tier,
|
||||||
|
status,
|
||||||
|
email_verified,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM auth.users
|
||||||
|
WHERE username = $1 AND status = 'active'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user by their email.
|
||||||
|
pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, AppError> {
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
avatar_url,
|
||||||
|
reputation_tier,
|
||||||
|
status,
|
||||||
|
email_verified,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM auth.users
|
||||||
|
WHERE lower(email) = lower($1) AND status = 'active'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row type for password verification query.
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct PasswordRow {
|
||||||
|
id: Uuid,
|
||||||
|
password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a user's password and return the user if valid.
|
||||||
|
pub async fn verify_password(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Option<User>, AppError> {
|
||||||
|
// First get the password hash
|
||||||
|
let row = sqlx::query_as::<_, PasswordRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, password_hash
|
||||||
|
FROM auth.users
|
||||||
|
WHERE username = $1 AND status = 'active' AND auth_provider = 'local'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref password_hash) = row.password_hash else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify the password using argon2
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
|
let parsed_hash = PasswordHash::new(password_hash)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Invalid password hash: {}", e)))?;
|
||||||
|
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password is valid, fetch the full user
|
||||||
|
get_user_by_id(pool, row.id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user session.
|
||||||
|
pub async fn create_session(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
token_hash: &str,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
ip_address: Option<std::net::IpAddr>,
|
||||||
|
expires_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
let ip_str = ip_address.map(|ip| ip.to_string());
|
||||||
|
|
||||||
|
let session_id: (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.sessions (user_id, token_hash, user_agent, ip_address, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4::inet, $5)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(ip_str)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(session_id.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user by their session token hash.
|
||||||
|
pub async fn get_user_by_session(
|
||||||
|
pool: &PgPool,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<User>, AppError> {
|
||||||
|
let user = sqlx::query_as::<_, User>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.display_name,
|
||||||
|
u.bio,
|
||||||
|
u.avatar_url,
|
||||||
|
u.reputation_tier,
|
||||||
|
u.status,
|
||||||
|
u.email_verified,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at
|
||||||
|
FROM auth.users u
|
||||||
|
JOIN auth.sessions s ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = $1
|
||||||
|
AND s.expires_at > now()
|
||||||
|
AND u.status = 'active'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update last activity
|
||||||
|
if user.is_some() {
|
||||||
|
sqlx::query("UPDATE auth.sessions SET last_activity_at = now() WHERE token_hash = $1")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a session by token hash.
|
||||||
|
pub async fn delete_session(pool: &PgPool, token_hash: &str) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM auth.sessions WHERE token_hash = $1")
|
||||||
|
.bind(token_hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a user's last seen timestamp.
|
||||||
|
pub async fn update_last_seen(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query("UPDATE auth.users SET last_seen_at = now() WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user with auth fields for login verification.
|
||||||
|
pub async fn get_user_with_auth(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<UserWithAuth>, AppError> {
|
||||||
|
let user = sqlx::query_as::<_, UserWithAuth>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
status,
|
||||||
|
force_pw_reset,
|
||||||
|
password_hash
|
||||||
|
FROM auth.users
|
||||||
|
WHERE username = $1 AND auth_provider = 'local'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify password and return user with auth info.
|
||||||
|
pub async fn verify_password_with_reset_flag(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Option<UserWithAuth>, AppError> {
|
||||||
|
let user = get_user_with_auth(pool, username).await?;
|
||||||
|
|
||||||
|
let Some(user) = user else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ref password_hash) = user.password_hash else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify the password using argon2
|
||||||
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||||
|
let parsed_hash = PasswordHash::new(password_hash)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Invalid password hash: {}", e)))?;
|
||||||
|
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed_hash)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a user's password.
|
||||||
|
pub async fn update_password(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, SaltString},
|
||||||
|
Argon2, PasswordHasher,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(new_password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET password_hash = $1, force_pw_reset = false, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a user's password using a connection (for RLS support).
|
||||||
|
pub async fn update_password_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, SaltString},
|
||||||
|
Argon2, PasswordHasher,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(new_password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET password_hash = $1, force_pw_reset = false, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the force_pw_reset flag for a user.
|
||||||
|
pub async fn clear_force_pw_reset(pool: &PgPool, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.users
|
||||||
|
SET force_pw_reset = false, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a username already exists.
|
||||||
|
pub async fn username_exists(pool: &PgPool, username: &str) -> Result<bool, AppError> {
|
||||||
|
let (exists,): (bool,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(SELECT 1 FROM auth.users WHERE username = $1)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an email already exists.
|
||||||
|
pub async fn email_exists(pool: &PgPool, email: &str) -> Result<bool, AppError> {
|
||||||
|
let (exists,): (bool,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(SELECT 1 FROM auth.users WHERE lower(email) = lower($1))
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(email)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user with hashed password.
|
||||||
|
pub async fn create_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
display_name: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, SaltString},
|
||||||
|
Argon2, PasswordHasher,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (user_id,): (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'local', 'active')
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(email)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(display_name)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new user using a connection (for RLS support).
|
||||||
|
pub async fn create_user_conn(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
username: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
display_name: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::{rand_core::OsRng, SaltString},
|
||||||
|
Argon2, PasswordHasher,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let password_hash = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to hash password: {}", e)))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (user_id,): (Uuid,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'local', 'active')
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(email)
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(display_name)
|
||||||
|
.fetch_one(conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a staff member by their user ID.
|
||||||
|
///
|
||||||
|
/// Returns the staff member with their user info joined.
|
||||||
|
pub async fn get_staff_member(pool: &PgPool, user_id: Uuid) -> Result<Option<StaffMember>, AppError> {
|
||||||
|
let staff = sqlx::query_as::<_, StaffMember>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
s.user_id,
|
||||||
|
u.username,
|
||||||
|
u.display_name,
|
||||||
|
u.email,
|
||||||
|
s.role,
|
||||||
|
s.appointed_by,
|
||||||
|
s.appointed_at
|
||||||
|
FROM server.staff s
|
||||||
|
JOIN auth.users u ON s.user_id = u.id
|
||||||
|
WHERE s.user_id = $1 AND u.status = 'active'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(staff)
|
||||||
|
}
|
||||||
92
crates/chattyness-db/src/ws_messages.rs
Normal file
92
crates/chattyness-db/src/ws_messages.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
//! WebSocket message protocol for channel presence.
|
||||||
|
//!
|
||||||
|
//! Shared message types used by both server and WASM client.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar};
|
||||||
|
|
||||||
|
/// Client-to-server WebSocket messages.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
/// Update position in the channel.
|
||||||
|
UpdatePosition {
|
||||||
|
/// X coordinate in scene space.
|
||||||
|
x: f64,
|
||||||
|
/// Y coordinate in scene space.
|
||||||
|
y: f64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Update emotion (0-9).
|
||||||
|
UpdateEmotion {
|
||||||
|
/// Emotion slot (0-9, keyboard: e0-e9).
|
||||||
|
emotion: u8,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Ping to keep connection alive.
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-to-client WebSocket messages.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
/// Welcome message with initial state after connection.
|
||||||
|
Welcome {
|
||||||
|
/// This user's member info.
|
||||||
|
member: ChannelMemberInfo,
|
||||||
|
/// All current members with avatars.
|
||||||
|
members: Vec<ChannelMemberWithAvatar>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A member joined the channel.
|
||||||
|
MemberJoined {
|
||||||
|
/// The member that joined.
|
||||||
|
member: ChannelMemberWithAvatar,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A member left the channel.
|
||||||
|
MemberLeft {
|
||||||
|
/// User ID (if authenticated user).
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A member updated their position.
|
||||||
|
PositionUpdated {
|
||||||
|
/// User ID (if authenticated user).
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
|
/// New X coordinate.
|
||||||
|
x: f64,
|
||||||
|
/// New Y coordinate.
|
||||||
|
y: f64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A member changed their emotion.
|
||||||
|
EmotionUpdated {
|
||||||
|
/// User ID (if authenticated user).
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
/// Guest session ID (if guest).
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
|
/// New emotion slot (0-9).
|
||||||
|
emotion: u8,
|
||||||
|
/// Asset paths for all 9 positions of the new emotion layer.
|
||||||
|
emotion_layer: [Option<String>; 9],
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Pong response to client ping.
|
||||||
|
Pong,
|
||||||
|
|
||||||
|
/// Error message.
|
||||||
|
Error {
|
||||||
|
/// Error code.
|
||||||
|
code: String,
|
||||||
|
/// Error message.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
17
crates/chattyness-error/Cargo.toml
Normal file
17
crates/chattyness-error/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "chattyness-error"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
|
# SSR-only dependencies
|
||||||
|
sqlx = { workspace = true, optional = true }
|
||||||
|
axum = { workspace = true, optional = true }
|
||||||
|
http.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = ["sqlx", "axum"]
|
||||||
106
crates/chattyness-error/src/lib.rs
Normal file
106
crates/chattyness-error/src/lib.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Application error types for chattyness.
|
||||||
|
///
|
||||||
|
/// All errors derive From for automatic conversion where applicable.
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(String),
|
||||||
|
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("Authentication required")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[error("Forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
|
||||||
|
#[error("Invalid credentials")]
|
||||||
|
InvalidCredentials,
|
||||||
|
|
||||||
|
#[error("Account suspended or banned")]
|
||||||
|
AccountSuspended,
|
||||||
|
|
||||||
|
#[error("Not a staff member")]
|
||||||
|
NotStaffMember,
|
||||||
|
|
||||||
|
#[error("Password reset required")]
|
||||||
|
PasswordResetRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API error response for JSON serialization.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for ErrorResponse {
|
||||||
|
fn from(err: AppError) -> Self {
|
||||||
|
let code = match &err {
|
||||||
|
AppError::Database(_) => Some("DATABASE_ERROR".to_string()),
|
||||||
|
AppError::Validation(_) => Some("VALIDATION_ERROR".to_string()),
|
||||||
|
AppError::Unauthorized => Some("UNAUTHORIZED".to_string()),
|
||||||
|
AppError::Forbidden(_) => Some("FORBIDDEN".to_string()),
|
||||||
|
AppError::NotFound(_) => Some("NOT_FOUND".to_string()),
|
||||||
|
AppError::Conflict(_) => Some("CONFLICT".to_string()),
|
||||||
|
AppError::Internal(_) => Some("INTERNAL_ERROR".to_string()),
|
||||||
|
AppError::InvalidCredentials => Some("INVALID_CREDENTIALS".to_string()),
|
||||||
|
AppError::AccountSuspended => Some("ACCOUNT_SUSPENDED".to_string()),
|
||||||
|
AppError::NotStaffMember => Some("NOT_STAFF_MEMBER".to_string()),
|
||||||
|
AppError::PasswordResetRequired => Some("PASSWORD_RESET_REQUIRED".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ErrorResponse {
|
||||||
|
error: err.to_string(),
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
mod ssr_impl {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let status = match &self {
|
||||||
|
AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
AppError::Validation(_) => StatusCode::BAD_REQUEST,
|
||||||
|
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
|
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
AppError::InvalidCredentials => StatusCode::UNAUTHORIZED,
|
||||||
|
AppError::AccountSuspended => StatusCode::FORBIDDEN,
|
||||||
|
AppError::NotStaffMember => StatusCode::FORBIDDEN,
|
||||||
|
AppError::PasswordResetRequired => StatusCode::FORBIDDEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ErrorResponse::from(self);
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/chattyness-shared/Cargo.toml
Normal file
9
crates/chattyness-shared/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "chattyness-shared"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chattyness-error.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
8
crates/chattyness-shared/src/lib.rs
Normal file
8
crates/chattyness-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
//! Shared utilities for chattyness.
|
||||||
|
//!
|
||||||
|
//! This crate provides common validation functions and utilities
|
||||||
|
//! used across the application.
|
||||||
|
|
||||||
|
pub mod validation;
|
||||||
|
|
||||||
|
pub use validation::*;
|
||||||
298
crates/chattyness-shared/src/validation.rs
Normal file
298
crates/chattyness-shared/src/validation.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
//! Shared validation utilities.
|
||||||
|
//!
|
||||||
|
//! This module provides common validation functions and pre-compiled regex patterns
|
||||||
|
//! for validating user input consistently across the application.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Pre-compiled Regex Patterns
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Slug pattern: 1-50 characters, lowercase alphanumeric and hyphens.
|
||||||
|
/// Must start and end with alphanumeric (unless 1-2 chars).
|
||||||
|
static SLUG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$|^[a-z0-9]{1,2}$").expect("Invalid slug regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Hex color pattern: #RRGGBB or #RRGGBBAA format.
|
||||||
|
static HEX_COLOR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$").expect("Invalid hex color regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Username pattern: 3-30 characters, starts with lowercase letter,
|
||||||
|
/// contains only lowercase letters, numbers, and underscores.
|
||||||
|
static USERNAME_REGEX: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9_]{2,29}$").expect("Invalid username regex"));
|
||||||
|
|
||||||
|
/// Email pattern: basic check for @ symbol with characters before and after.
|
||||||
|
static EMAIL_REGEX: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").expect("Invalid email regex"));
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Validation Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Validate a slug (URL-friendly identifier).
|
||||||
|
///
|
||||||
|
/// # Rules
|
||||||
|
/// - 1-50 characters
|
||||||
|
/// - Lowercase alphanumeric and hyphens only
|
||||||
|
/// - Must start and end with alphanumeric (except for 1-2 character slugs)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if valid
|
||||||
|
/// - `Err(AppError::Validation)` if invalid
|
||||||
|
pub fn validate_slug(slug: &str) -> Result<(), AppError> {
|
||||||
|
if !SLUG_REGEX.is_match(slug) {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"Slug must be 1-50 characters, lowercase alphanumeric and hyphens only".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a slug is valid without returning an error.
|
||||||
|
pub fn is_valid_slug(slug: &str) -> bool {
|
||||||
|
SLUG_REGEX.is_match(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a hex color string.
|
||||||
|
///
|
||||||
|
/// # Rules
|
||||||
|
/// - Must match #RRGGBB or #RRGGBBAA format
|
||||||
|
/// - Case insensitive for hex digits
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if valid
|
||||||
|
/// - `Err(AppError::Validation)` if invalid
|
||||||
|
pub fn validate_hex_color(color: &str) -> Result<(), AppError> {
|
||||||
|
if !HEX_COLOR_REGEX.is_match(color) {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"Color must be a valid hex color (#RRGGBB or #RRGGBBAA)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a hex color is valid without returning an error.
|
||||||
|
pub fn is_valid_hex_color(color: &str) -> bool {
|
||||||
|
HEX_COLOR_REGEX.is_match(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an optional hex color string.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(())` if the color is `None` or a valid hex color.
|
||||||
|
pub fn validate_optional_hex_color(color: Option<&str>) -> Result<(), AppError> {
|
||||||
|
if let Some(c) = color {
|
||||||
|
validate_hex_color(c)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a username.
|
||||||
|
///
|
||||||
|
/// # Rules
|
||||||
|
/// - 3-30 characters
|
||||||
|
/// - Must start with a lowercase letter
|
||||||
|
/// - Can contain lowercase letters, numbers, and underscores
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if valid
|
||||||
|
/// - `Err(AppError::Validation)` if invalid
|
||||||
|
pub fn validate_username(username: &str) -> Result<(), AppError> {
|
||||||
|
if !USERNAME_REGEX.is_match(username) {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"Username must be 3-30 characters, start with a letter, and contain only lowercase letters, numbers, and underscores".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a username is valid without returning an error.
|
||||||
|
pub fn is_valid_username(username: &str) -> bool {
|
||||||
|
USERNAME_REGEX.is_match(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an email address.
|
||||||
|
///
|
||||||
|
/// # Rules
|
||||||
|
/// - Must contain @ with characters before and after
|
||||||
|
/// - Must have a domain with at least one dot
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if valid
|
||||||
|
/// - `Err(AppError::Validation)` if invalid
|
||||||
|
pub fn validate_email(email: &str) -> Result<(), AppError> {
|
||||||
|
let email = email.trim();
|
||||||
|
if email.is_empty() || !EMAIL_REGEX.is_match(email) {
|
||||||
|
return Err(AppError::Validation("Invalid email address".to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an email is valid without returning an error.
|
||||||
|
pub fn is_valid_email(email: &str) -> bool {
|
||||||
|
let email = email.trim();
|
||||||
|
!email.is_empty() && EMAIL_REGEX.is_match(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string is not empty after trimming.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if the string is non-empty
|
||||||
|
/// - `Err(AppError::Validation)` with the provided field name if empty
|
||||||
|
pub fn validate_non_empty(value: &str, field_name: &str) -> Result<(), AppError> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"{} cannot be empty",
|
||||||
|
field_name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a string length is within bounds.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if length is within bounds
|
||||||
|
/// - `Err(AppError::Validation)` if too short or too long
|
||||||
|
pub fn validate_length(value: &str, field_name: &str, min: usize, max: usize) -> Result<(), AppError> {
|
||||||
|
let len = value.len();
|
||||||
|
if len < min || len > max {
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"{} must be {}-{} characters",
|
||||||
|
field_name, min, max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a number is within a range.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if the number is within the range
|
||||||
|
/// - `Err(AppError::Validation)` if out of range
|
||||||
|
pub fn validate_range<T: std::cmp::PartialOrd + std::fmt::Display>(
|
||||||
|
value: T,
|
||||||
|
field_name: &str,
|
||||||
|
min: T,
|
||||||
|
max: T,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if value < min || value > max {
|
||||||
|
return Err(AppError::Validation(format!(
|
||||||
|
"{} must be between {} and {}",
|
||||||
|
field_name, min, max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a password meets minimum requirements.
|
||||||
|
///
|
||||||
|
/// # Rules
|
||||||
|
/// - At least 8 characters
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if valid
|
||||||
|
/// - `Err(AppError::Validation)` if too short
|
||||||
|
pub fn validate_password(password: &str) -> Result<(), AppError> {
|
||||||
|
if password.len() < 8 {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"Password must be at least 8 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that two passwords match.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// - `Ok(())` if passwords match
|
||||||
|
/// - `Err(AppError::Validation)` if they don't match
|
||||||
|
pub fn validate_passwords_match(password: &str, confirm: &str) -> Result<(), AppError> {
|
||||||
|
if password != confirm {
|
||||||
|
return Err(AppError::Validation("Passwords do not match".to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_slug() {
|
||||||
|
// Valid slugs
|
||||||
|
assert!(validate_slug("a").is_ok());
|
||||||
|
assert!(validate_slug("ab").is_ok());
|
||||||
|
assert!(validate_slug("abc").is_ok());
|
||||||
|
assert!(validate_slug("my-realm").is_ok());
|
||||||
|
assert!(validate_slug("realm123").is_ok());
|
||||||
|
assert!(validate_slug("my-awesome-realm-2024").is_ok());
|
||||||
|
|
||||||
|
// Invalid slugs
|
||||||
|
assert!(validate_slug("").is_err());
|
||||||
|
assert!(validate_slug("-starts-with-dash").is_err());
|
||||||
|
assert!(validate_slug("ends-with-dash-").is_err());
|
||||||
|
assert!(validate_slug("HAS-UPPERCASE").is_err());
|
||||||
|
assert!(validate_slug("has spaces").is_err());
|
||||||
|
assert!(validate_slug("has_underscore").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_hex_color() {
|
||||||
|
// Valid colors
|
||||||
|
assert!(validate_hex_color("#ffffff").is_ok());
|
||||||
|
assert!(validate_hex_color("#FFFFFF").is_ok());
|
||||||
|
assert!(validate_hex_color("#000000").is_ok());
|
||||||
|
assert!(validate_hex_color("#ff5733").is_ok());
|
||||||
|
assert!(validate_hex_color("#FF573380").is_ok()); // With alpha
|
||||||
|
|
||||||
|
// Invalid colors
|
||||||
|
assert!(validate_hex_color("").is_err());
|
||||||
|
assert!(validate_hex_color("ffffff").is_err()); // Missing #
|
||||||
|
assert!(validate_hex_color("#fff").is_err()); // Too short
|
||||||
|
assert!(validate_hex_color("#fffffff").is_err()); // 7 chars
|
||||||
|
assert!(validate_hex_color("#gggggg").is_err()); // Invalid hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_username() {
|
||||||
|
// Valid usernames
|
||||||
|
assert!(validate_username("abc").is_ok());
|
||||||
|
assert!(validate_username("user123").is_ok());
|
||||||
|
assert!(validate_username("my_user_name").is_ok());
|
||||||
|
|
||||||
|
// Invalid usernames
|
||||||
|
assert!(validate_username("ab").is_err()); // Too short
|
||||||
|
assert!(validate_username("123abc").is_err()); // Starts with number
|
||||||
|
assert!(validate_username("_user").is_err()); // Starts with underscore
|
||||||
|
assert!(validate_username("User").is_err()); // Uppercase
|
||||||
|
assert!(validate_username("user-name").is_err()); // Has hyphen
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_email() {
|
||||||
|
// Valid emails
|
||||||
|
assert!(validate_email("user@example.com").is_ok());
|
||||||
|
assert!(validate_email("test.user@domain.org").is_ok());
|
||||||
|
|
||||||
|
// Invalid emails
|
||||||
|
assert!(validate_email("").is_err());
|
||||||
|
assert!(validate_email("noatsign").is_err());
|
||||||
|
assert!(validate_email("@nodomain").is_err());
|
||||||
|
assert!(validate_email("no@dotcom").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_password() {
|
||||||
|
assert!(validate_password("12345678").is_ok());
|
||||||
|
assert!(validate_password("longpassword123!").is_ok());
|
||||||
|
assert!(validate_password("1234567").is_err()); // Too short
|
||||||
|
assert!(validate_password("").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
80
crates/chattyness-user-ui/Cargo.toml
Normal file
80
crates/chattyness-user-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
[package]
|
||||||
|
name = "chattyness-user-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chattyness-db = { workspace = true }
|
||||||
|
chattyness-error = { workspace = true, optional = true }
|
||||||
|
chattyness-shared = { workspace = true, optional = true }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
tracing = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# Leptos
|
||||||
|
leptos = { workspace = true }
|
||||||
|
leptos_meta = { workspace = true }
|
||||||
|
leptos_router = { workspace = true }
|
||||||
|
|
||||||
|
# SSR-only dependencies
|
||||||
|
axum = { workspace = true, optional = true }
|
||||||
|
sqlx = { workspace = true, optional = true }
|
||||||
|
tower = { workspace = true, optional = true }
|
||||||
|
tower-sessions = { workspace = true, optional = true }
|
||||||
|
tower-sessions-sqlx-store = { workspace = true, optional = true }
|
||||||
|
tokio = { workspace = true, optional = true }
|
||||||
|
sha2 = { workspace = true, optional = true }
|
||||||
|
hex = { workspace = true, optional = true }
|
||||||
|
rand = { workspace = true, optional = true }
|
||||||
|
reqwest = { workspace = true, optional = true }
|
||||||
|
image = { workspace = true, optional = true }
|
||||||
|
futures = { workspace = true, optional = true }
|
||||||
|
dashmap = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# Hydrate-only dependencies
|
||||||
|
gloo-net = { workspace = true, optional = true }
|
||||||
|
gloo-timers = { workspace = true, optional = true }
|
||||||
|
web-sys = { workspace = true, optional = true }
|
||||||
|
wasm-bindgen = { workspace = true, optional = true }
|
||||||
|
console_error_panic_hook = { workspace = true, optional = true }
|
||||||
|
js-sys = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = [
|
||||||
|
"leptos/ssr",
|
||||||
|
"leptos_meta/ssr",
|
||||||
|
"leptos_router/ssr",
|
||||||
|
"chattyness-db/ssr",
|
||||||
|
"chattyness-error/ssr",
|
||||||
|
"dep:chattyness-error",
|
||||||
|
"dep:chattyness-shared",
|
||||||
|
"dep:axum",
|
||||||
|
"dep:sqlx",
|
||||||
|
"dep:tower",
|
||||||
|
"dep:tower-sessions",
|
||||||
|
"dep:tower-sessions-sqlx-store",
|
||||||
|
"dep:tracing",
|
||||||
|
"dep:tokio",
|
||||||
|
"dep:sha2",
|
||||||
|
"dep:hex",
|
||||||
|
"dep:rand",
|
||||||
|
"dep:reqwest",
|
||||||
|
"dep:image",
|
||||||
|
"dep:futures",
|
||||||
|
"dep:dashmap",
|
||||||
|
]
|
||||||
|
hydrate = [
|
||||||
|
"leptos/hydrate",
|
||||||
|
"dep:gloo-net",
|
||||||
|
"dep:gloo-timers",
|
||||||
|
"dep:web-sys",
|
||||||
|
"dep:wasm-bindgen",
|
||||||
|
"dep:console_error_panic_hook",
|
||||||
|
"dep:js-sys",
|
||||||
|
]
|
||||||
11
crates/chattyness-user-ui/src/api.rs
Normal file
11
crates/chattyness-user-ui/src/api.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//! REST API module for user UI.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod avatars;
|
||||||
|
pub mod realms;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod scenes;
|
||||||
|
pub mod websocket;
|
||||||
|
|
||||||
|
pub use routes::*;
|
||||||
|
pub use websocket::WebSocketState;
|
||||||
474
crates/chattyness-user-ui/src/api/auth.rs
Normal file
474
crates/chattyness-user-ui/src/api/auth.rs
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
//! Authentication API handlers.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{
|
||||||
|
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
|
||||||
|
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
|
||||||
|
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
|
||||||
|
SignupRequest, SignupResponse, UserSummary,
|
||||||
|
},
|
||||||
|
queries::{guests, memberships, realms, users},
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
session::{
|
||||||
|
hash_token, generate_token, SESSION_CURRENT_REALM_KEY, SESSION_GUEST_ID_KEY,
|
||||||
|
SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY,
|
||||||
|
},
|
||||||
|
AuthUser, OptionalAuthUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Get current user info.
|
||||||
|
pub async fn get_current_user(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
OptionalAuthUser(user): OptionalAuthUser,
|
||||||
|
) -> Result<Json<CurrentUserResponse>, AppError> {
|
||||||
|
match user {
|
||||||
|
Some(user) => {
|
||||||
|
// Get staff role if any
|
||||||
|
let staff_role = memberships::get_user_staff_role(&pool, user.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(CurrentUserResponse {
|
||||||
|
user: Some(AuthenticatedUser {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
staff_role,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(Json(CurrentUserResponse { user: None })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login handler.
|
||||||
|
pub async fn login(
|
||||||
|
rls_conn: crate::auth::RlsConn,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
session: Session,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, AppError> {
|
||||||
|
// Validate the request
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// Verify credentials
|
||||||
|
let user = users::verify_password_with_reset_flag(&pool, &req.username, &req.password)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::InvalidCredentials)?;
|
||||||
|
|
||||||
|
// Set RLS context to the authenticated user for subsequent operations
|
||||||
|
rls_conn.set_user_id(user.id).await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
||||||
|
|
||||||
|
// Check account status
|
||||||
|
if user.status != AccountStatus::Active {
|
||||||
|
return Err(AppError::AccountSuspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user summary for response
|
||||||
|
let user_summary = UserSummary {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username.clone(),
|
||||||
|
display_name: user.display_name.clone(),
|
||||||
|
avatar_url: user.avatar_url.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle based on login type
|
||||||
|
match req.login_type {
|
||||||
|
LoginType::Staff => {
|
||||||
|
// Verify user is a staff member
|
||||||
|
let staff_role = memberships::get_user_staff_role(&pool, user.id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotStaffMember)?;
|
||||||
|
|
||||||
|
// Store session data
|
||||||
|
session
|
||||||
|
.insert(SESSION_USER_ID_KEY, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_LOGIN_TYPE_KEY, "staff")
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
// Check for forced password reset
|
||||||
|
if user.force_pw_reset {
|
||||||
|
session
|
||||||
|
.insert(SESSION_ORIGINAL_DEST_KEY, "/staff")
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
return Ok(Json(LoginResponse {
|
||||||
|
user: user_summary,
|
||||||
|
redirect_url: "/password-reset".to_string(),
|
||||||
|
requires_pw_reset: true,
|
||||||
|
is_member: None,
|
||||||
|
original_destination: Some("/staff".to_string()),
|
||||||
|
staff_role: Some(staff_role),
|
||||||
|
realm: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
user: user_summary,
|
||||||
|
redirect_url: "/staff".to_string(),
|
||||||
|
requires_pw_reset: false,
|
||||||
|
is_member: None,
|
||||||
|
original_destination: None,
|
||||||
|
staff_role: Some(staff_role),
|
||||||
|
realm: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
LoginType::Realm => {
|
||||||
|
let realm_slug = req.realm_slug.as_ref().ok_or_else(|| {
|
||||||
|
AppError::Validation("Realm slug is required for realm login".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get the realm
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, realm_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", realm_slug)))?;
|
||||||
|
|
||||||
|
// Check if user is a member
|
||||||
|
let is_member = memberships::is_member(&pool, user.id, realm.id).await?;
|
||||||
|
|
||||||
|
// Store session data
|
||||||
|
session
|
||||||
|
.insert(SESSION_USER_ID_KEY, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
let redirect_url = format!("/realms/{}", realm.slug);
|
||||||
|
|
||||||
|
// Check for forced password reset
|
||||||
|
if user.force_pw_reset {
|
||||||
|
session
|
||||||
|
.insert(SESSION_ORIGINAL_DEST_KEY, redirect_url.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
return Ok(Json(LoginResponse {
|
||||||
|
user: user_summary,
|
||||||
|
redirect_url: "/password-reset".to_string(),
|
||||||
|
requires_pw_reset: true,
|
||||||
|
is_member: Some(is_member),
|
||||||
|
original_destination: Some(redirect_url),
|
||||||
|
staff_role: None,
|
||||||
|
realm: Some(RealmSummary {
|
||||||
|
id: realm.id,
|
||||||
|
name: realm.name,
|
||||||
|
slug: realm.slug,
|
||||||
|
tagline: realm.tagline,
|
||||||
|
privacy: realm.privacy,
|
||||||
|
is_nsfw: realm.is_nsfw,
|
||||||
|
thumbnail_path: realm.thumbnail_path,
|
||||||
|
member_count: realm.member_count,
|
||||||
|
current_user_count: realm.current_user_count,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not a member, include realm info for join confirmation
|
||||||
|
if !is_member {
|
||||||
|
return Ok(Json(LoginResponse {
|
||||||
|
user: user_summary,
|
||||||
|
redirect_url: redirect_url.clone(),
|
||||||
|
requires_pw_reset: false,
|
||||||
|
is_member: Some(false),
|
||||||
|
original_destination: None,
|
||||||
|
staff_role: None,
|
||||||
|
realm: Some(RealmSummary {
|
||||||
|
id: realm.id,
|
||||||
|
name: realm.name,
|
||||||
|
slug: realm.slug,
|
||||||
|
tagline: realm.tagline,
|
||||||
|
privacy: realm.privacy,
|
||||||
|
is_nsfw: realm.is_nsfw,
|
||||||
|
thumbnail_path: realm.thumbnail_path,
|
||||||
|
member_count: realm.member_count,
|
||||||
|
current_user_count: realm.current_user_count,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is a member, update last visited (using RLS connection)
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
memberships::update_last_visited_conn(&mut *conn, user.id, realm.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
user: user_summary,
|
||||||
|
redirect_url,
|
||||||
|
requires_pw_reset: false,
|
||||||
|
is_member: Some(true),
|
||||||
|
original_destination: None,
|
||||||
|
staff_role: None,
|
||||||
|
realm: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout response.
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct LogoutResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub redirect_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout handler.
|
||||||
|
pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError> {
|
||||||
|
// Flush the session (removes all data and invalidates the session)
|
||||||
|
session
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(LogoutResponse {
|
||||||
|
success: true,
|
||||||
|
redirect_url: "/".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signup handler.
|
||||||
|
pub async fn signup(
|
||||||
|
rls_conn: crate::auth::RlsConn,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
session: Session,
|
||||||
|
Json(req): Json<SignupRequest>,
|
||||||
|
) -> Result<Json<SignupResponse>, AppError> {
|
||||||
|
// Validate the request
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// Check username availability (can use pool for read-only checks)
|
||||||
|
if users::username_exists(&pool, &req.username).await? {
|
||||||
|
return Err(AppError::Conflict("Username already taken".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email availability if provided
|
||||||
|
if let Some(ref email) = req.email {
|
||||||
|
let email_trimmed = email.trim();
|
||||||
|
if !email_trimmed.is_empty() && users::email_exists(&pool, email_trimmed).await? {
|
||||||
|
return Err(AppError::Conflict("Email already registered".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the realm
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &req.realm_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
|
||||||
|
|
||||||
|
// Create the user using RLS connection
|
||||||
|
let email_opt = req.email.as_ref().and_then(|e| {
|
||||||
|
let trimmed = e.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
let user_id =
|
||||||
|
users::create_user_conn(&mut *conn, &req.username, email_opt, req.display_name.trim(), &req.password)
|
||||||
|
.await?;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
// Set RLS context to the new user for membership creation
|
||||||
|
rls_conn.set_user_id(user_id).await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to set RLS context: {}", e)))?;
|
||||||
|
|
||||||
|
// Create membership using RLS connection (now has user context)
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
let membership_id =
|
||||||
|
memberships::create_membership_conn(&mut *conn, user_id, realm.id, RealmRole::Member).await?;
|
||||||
|
|
||||||
|
// Set up session (user is logged in)
|
||||||
|
session
|
||||||
|
.insert(SESSION_USER_ID_KEY, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_LOGIN_TYPE_KEY, "realm")
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
let redirect_url = format!("/realms/{}", realm.slug);
|
||||||
|
|
||||||
|
Ok(Json(SignupResponse {
|
||||||
|
user: UserSummary {
|
||||||
|
id: user_id,
|
||||||
|
username: req.username,
|
||||||
|
display_name: req.display_name.trim().to_string(),
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
redirect_url,
|
||||||
|
membership_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guest login handler.
|
||||||
|
pub async fn guest_login(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
session: Session,
|
||||||
|
Json(req): Json<GuestLoginRequest>,
|
||||||
|
) -> Result<Json<GuestLoginResponse>, AppError> {
|
||||||
|
// Validate the request
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// Get the realm
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &req.realm_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
|
||||||
|
|
||||||
|
// Check if realm allows guest access
|
||||||
|
if !realm.allow_guest_access {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"This realm does not allow guest access".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate guest name and session token
|
||||||
|
let guest_name = guests::generate_guest_name();
|
||||||
|
let token = generate_token();
|
||||||
|
let token_hash = hash_token(&token);
|
||||||
|
let expires_at = guests::guest_session_expiry();
|
||||||
|
|
||||||
|
// Create guest session in database
|
||||||
|
let guest_id = guests::create_guest_session(
|
||||||
|
&pool,
|
||||||
|
&guest_name,
|
||||||
|
realm.id,
|
||||||
|
&token_hash,
|
||||||
|
None, // user_agent
|
||||||
|
None, // ip_address
|
||||||
|
expires_at,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Set up tower session
|
||||||
|
session
|
||||||
|
.insert(SESSION_GUEST_ID_KEY, guest_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_LOGIN_TYPE_KEY, "guest")
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_CURRENT_REALM_KEY, realm.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
let redirect_url = format!("/realms/{}", realm.slug);
|
||||||
|
|
||||||
|
Ok(Json(GuestLoginResponse {
|
||||||
|
guest_name,
|
||||||
|
guest_id,
|
||||||
|
redirect_url,
|
||||||
|
realm: RealmSummary {
|
||||||
|
id: realm.id,
|
||||||
|
name: realm.name,
|
||||||
|
slug: realm.slug,
|
||||||
|
tagline: realm.tagline,
|
||||||
|
privacy: realm.privacy,
|
||||||
|
is_nsfw: realm.is_nsfw,
|
||||||
|
thumbnail_path: realm.thumbnail_path,
|
||||||
|
member_count: realm.member_count,
|
||||||
|
current_user_count: realm.current_user_count,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join realm handler.
|
||||||
|
pub async fn join_realm(
|
||||||
|
rls_conn: crate::auth::RlsConn,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Json(req): Json<JoinRealmRequest>,
|
||||||
|
) -> Result<Json<JoinRealmResponse>, AppError> {
|
||||||
|
// Get the realm to verify it exists and check privacy
|
||||||
|
let realm = realms::get_realm_by_id(&pool, req.realm_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Realm not found".to_string()))?;
|
||||||
|
|
||||||
|
// Check if user is already a member
|
||||||
|
let is_member = memberships::is_member(&pool, user.id, realm.id).await?;
|
||||||
|
if is_member {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Already a member of this realm".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For private realms, don't allow direct join (would need invitation)
|
||||||
|
if realm.privacy == chattyness_db::models::RealmPrivacy::Private {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Cannot join private realms without an invitation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the membership using RLS connection (policy requires user_id = current_user_id)
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
let membership_id =
|
||||||
|
memberships::create_membership_conn(&mut *conn, user.id, realm.id, RealmRole::Member).await?;
|
||||||
|
|
||||||
|
Ok(Json(JoinRealmResponse {
|
||||||
|
success: true,
|
||||||
|
membership_id,
|
||||||
|
redirect_url: format!("/realms/{}", realm.slug),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Password reset handler.
|
||||||
|
pub async fn reset_password(
|
||||||
|
rls_conn: crate::auth::RlsConn,
|
||||||
|
session: Session,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Json(req): Json<PasswordResetRequest>,
|
||||||
|
) -> Result<Json<PasswordResetResponse>, AppError> {
|
||||||
|
// Validate the request
|
||||||
|
req.validate()?;
|
||||||
|
|
||||||
|
// Update the password using RLS connection (required for RLS policy)
|
||||||
|
let mut conn = rls_conn.acquire().await;
|
||||||
|
users::update_password_conn(&mut *conn, user.id, &req.new_password).await?;
|
||||||
|
|
||||||
|
// Get the original destination from session
|
||||||
|
let original_dest: Option<String> = session
|
||||||
|
.get(SESSION_ORIGINAL_DEST_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
// Clear the original destination from session
|
||||||
|
session
|
||||||
|
.remove::<String>(SESSION_ORIGINAL_DEST_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
|
||||||
|
|
||||||
|
let redirect_url = original_dest.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
Ok(Json(PasswordResetResponse {
|
||||||
|
success: true,
|
||||||
|
redirect_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
39
crates/chattyness-user-ui/src/api/avatars.rs
Normal file
39
crates/chattyness-user-ui/src/api/avatars.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
//! Avatar API handlers for user UI.
|
||||||
|
//!
|
||||||
|
//! Handles avatar rendering data retrieval.
|
||||||
|
//! Note: Emotion switching is now handled via WebSocket.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use chattyness_db::{
|
||||||
|
models::AvatarRenderData,
|
||||||
|
queries::{avatars, realms},
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
|
/// Get current avatar render data.
|
||||||
|
///
|
||||||
|
/// GET /api/realms/{slug}/avatar/current
|
||||||
|
///
|
||||||
|
/// Returns the render data for the user's active avatar in this realm.
|
||||||
|
pub async fn get_current_avatar(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
AuthUser(user): AuthUser,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<AvatarRenderData>, AppError> {
|
||||||
|
// Get realm
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
// Get render data
|
||||||
|
let render_data = avatars::get_avatar_render_data(&pool, user.id, realm.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(render_data))
|
||||||
|
}
|
||||||
80
crates/chattyness-user-ui/src/api/realms.rs
Normal file
80
crates/chattyness-user-ui/src/api/realms.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
//! Realm API handlers for user UI (READ-ONLY).
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{RealmSummary, RealmWithUserRole},
|
||||||
|
queries::{memberships, realms},
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
use crate::auth::OptionalAuthUser;
|
||||||
|
|
||||||
|
/// List query params.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListParams {
|
||||||
|
pub include_nsfw: Option<bool>,
|
||||||
|
pub page: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ListResponse {
|
||||||
|
pub realms: Vec<RealmSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List public realms.
|
||||||
|
pub async fn list_realms(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(params): Query<ListParams>,
|
||||||
|
) -> Result<Json<ListResponse>, AppError> {
|
||||||
|
let limit = params.limit.unwrap_or(20).min(100);
|
||||||
|
let offset = params.page.unwrap_or(0) * limit;
|
||||||
|
let include_nsfw = params.include_nsfw.unwrap_or(false);
|
||||||
|
|
||||||
|
let realm_list = realms::list_public_realms(&pool, include_nsfw, limit, offset).await?;
|
||||||
|
|
||||||
|
Ok(Json(ListResponse { realms: realm_list }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a realm by slug with user role.
|
||||||
|
pub async fn get_realm(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
OptionalAuthUser(maybe_user): OptionalAuthUser,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<RealmWithUserRole>, AppError> {
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
// Get the user's role if authenticated
|
||||||
|
let user_role = if let Some(user) = maybe_user {
|
||||||
|
let membership = memberships::get_user_membership(&pool, user.id, realm.id).await?;
|
||||||
|
membership.map(|m| m.role)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(RealmWithUserRole { realm, user_role }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check slug availability response.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlugAvailableResponse {
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a realm slug is available.
|
||||||
|
pub async fn check_slug_available(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<SlugAvailableResponse>, AppError> {
|
||||||
|
let available = realms::is_slug_available(&pool, &slug).await?;
|
||||||
|
Ok(Json(SlugAvailableResponse { available }))
|
||||||
|
}
|
||||||
57
crates/chattyness-user-ui/src/api/routes.rs
Normal file
57
crates/chattyness-user-ui/src/api/routes.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
//! API routes for user UI.
|
||||||
|
//!
|
||||||
|
//! This router provides READ-ONLY access to realms, scenes, and spots.
|
||||||
|
//! All create/update/delete operations are handled by the admin-ui.
|
||||||
|
//! Channel presence is handled via WebSocket.
|
||||||
|
|
||||||
|
use axum::{routing::get, Router};
|
||||||
|
|
||||||
|
use super::{auth, avatars, realms, scenes, websocket};
|
||||||
|
use crate::app::AppState;
|
||||||
|
|
||||||
|
/// Build the API router for user UI.
|
||||||
|
///
|
||||||
|
/// Note: This router is READ-ONLY for realms/scenes/spots.
|
||||||
|
/// Auth routes (login, logout, signup, join-realm) are allowed.
|
||||||
|
/// Channel presence (join, leave, position, emotion, members) is handled via WebSocket.
|
||||||
|
pub fn api_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
// Auth routes (these are user-facing operations)
|
||||||
|
.route("/auth/me", get(auth::get_current_user))
|
||||||
|
.route("/auth/login", axum::routing::post(auth::login))
|
||||||
|
.route("/auth/logout", axum::routing::post(auth::logout))
|
||||||
|
.route("/auth/signup", axum::routing::post(auth::signup))
|
||||||
|
.route("/auth/guest", axum::routing::post(auth::guest_login))
|
||||||
|
.route("/auth/join-realm", axum::routing::post(auth::join_realm))
|
||||||
|
.route(
|
||||||
|
"/auth/reset-password",
|
||||||
|
axum::routing::post(auth::reset_password),
|
||||||
|
)
|
||||||
|
// Realm routes (READ-ONLY)
|
||||||
|
.route("/realms", get(realms::list_realms))
|
||||||
|
.route("/realms/{slug}", get(realms::get_realm))
|
||||||
|
.route("/realms/{slug}/available", get(realms::check_slug_available))
|
||||||
|
// Scene routes (READ-ONLY)
|
||||||
|
.route("/realms/{slug}/entry-scene", get(scenes::get_entry_scene))
|
||||||
|
.route("/realms/{slug}/scenes", get(scenes::list_scenes))
|
||||||
|
.route("/realms/{slug}/scenes/{scene_slug}", get(scenes::get_scene))
|
||||||
|
// Spot routes (READ-ONLY)
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/scenes/{scene_slug}/spots",
|
||||||
|
get(scenes::list_spots),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/scenes/{scene_slug}/spots/{spot_id}",
|
||||||
|
get(scenes::get_spot),
|
||||||
|
)
|
||||||
|
// WebSocket route for channel presence (handles join, leave, position, emotion, members)
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/channels/{channel_id}/ws",
|
||||||
|
get(websocket::ws_handler::<AppState>),
|
||||||
|
)
|
||||||
|
// Avatar routes (require authentication)
|
||||||
|
.route(
|
||||||
|
"/realms/{slug}/avatar/current",
|
||||||
|
get(avatars::get_current_avatar),
|
||||||
|
)
|
||||||
|
}
|
||||||
92
crates/chattyness-user-ui/src/api/scenes.rs
Normal file
92
crates/chattyness-user-ui/src/api/scenes.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
//! Scene and Spot API handlers for user UI (READ-ONLY).
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{Scene, SceneSummary, Spot, SpotSummary},
|
||||||
|
queries::{realms, scenes, spots},
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
/// Get the entry scene for a realm.
|
||||||
|
///
|
||||||
|
/// GET /api/realms/{slug}/entry-scene
|
||||||
|
///
|
||||||
|
/// Returns the realm's default/entry scene. This endpoint is public.
|
||||||
|
pub async fn get_entry_scene(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<Scene>, AppError> {
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let scene = scenes::get_entry_scene_for_realm(&pool, realm.id, realm.default_scene_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("No entry scene found for this realm".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(scene))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List scenes for a realm.
|
||||||
|
pub async fn list_scenes(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<SceneSummary>>, AppError> {
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let scene_list = scenes::list_scenes_for_realm(&pool, realm.id).await?;
|
||||||
|
Ok(Json(scene_list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a scene by slug.
|
||||||
|
pub async fn get_scene(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path((slug, scene_slug)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<Scene>, AppError> {
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let scene = scenes::get_scene_by_slug(&pool, realm.id, &scene_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Scene '{}' not found", scene_slug)))?;
|
||||||
|
|
||||||
|
Ok(Json(scene))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List spots for a scene.
|
||||||
|
pub async fn list_spots(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path((slug, scene_slug)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<Vec<SpotSummary>>, AppError> {
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
let scene = scenes::get_scene_by_slug(&pool, realm.id, &scene_slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Scene '{}' not found", scene_slug)))?;
|
||||||
|
|
||||||
|
let spot_list = spots::list_spots_for_scene(&pool, scene.id).await?;
|
||||||
|
Ok(Json(spot_list))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a spot by ID.
|
||||||
|
pub async fn get_spot(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path((_slug, _scene_slug, spot_id)): Path<(String, String, Uuid)>,
|
||||||
|
) -> Result<Json<Spot>, AppError> {
|
||||||
|
let spot = spots::get_spot_by_id(&pool, spot_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(spot))
|
||||||
|
}
|
||||||
399
crates/chattyness-user-ui/src/api/websocket.rs
Normal file
399
crates/chattyness-user-ui/src/api/websocket.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
//! WebSocket handler for channel presence.
|
||||||
|
//!
|
||||||
|
//! Handles real-time position updates, emotion changes, and member synchronization.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
FromRef, Path, State,
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_db::{
|
||||||
|
models::{AvatarRenderData, ChannelMemberWithAvatar, User},
|
||||||
|
queries::{avatars, channel_members, realms, scenes},
|
||||||
|
ws_messages::{ClientMessage, ServerMessage},
|
||||||
|
};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
|
/// Channel state for broadcasting updates.
|
||||||
|
pub struct ChannelState {
|
||||||
|
/// Broadcast sender for this channel.
|
||||||
|
tx: broadcast::Sender<ServerMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global state for all WebSocket connections.
|
||||||
|
pub struct WebSocketState {
|
||||||
|
/// Map of channel_id -> ChannelState.
|
||||||
|
channels: DashMap<Uuid, Arc<ChannelState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WebSocketState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebSocketState {
|
||||||
|
/// Create a new WebSocket state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
channels: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create a channel state.
|
||||||
|
fn get_or_create_channel(&self, channel_id: Uuid) -> Arc<ChannelState> {
|
||||||
|
self.channels
|
||||||
|
.entry(channel_id)
|
||||||
|
.or_insert_with(|| {
|
||||||
|
let (tx, _) = broadcast::channel(256);
|
||||||
|
Arc::new(ChannelState { tx })
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket upgrade handler.
|
||||||
|
///
|
||||||
|
/// GET /api/realms/{slug}/channels/{channel_id}/ws
|
||||||
|
pub async fn ws_handler<S>(
|
||||||
|
Path((slug, channel_id)): Path<(String, Uuid)>,
|
||||||
|
auth_result: Result<AuthUser, crate::auth::AuthError>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
State(ws_state): State<Arc<WebSocketState>>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> Result<impl IntoResponse, AppError>
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
PgPool: FromRef<S>,
|
||||||
|
Arc<WebSocketState>: FromRef<S>,
|
||||||
|
{
|
||||||
|
// Log auth result before checking
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(
|
||||||
|
"[WS] Connection attempt to {}/channels/{} - auth: {:?}",
|
||||||
|
slug,
|
||||||
|
channel_id,
|
||||||
|
auth_result.as_ref().map(|a| a.0.id).map_err(|e| format!("{:?}", e))
|
||||||
|
);
|
||||||
|
|
||||||
|
let AuthUser(user) = auth_result.map_err(|e| {
|
||||||
|
tracing::warn!("[WS] Auth failed for {}/channels/{}: {:?}", slug, channel_id, e);
|
||||||
|
AppError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Verify realm exists
|
||||||
|
let realm = realms::get_realm_by_slug(&pool, &slug)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||||
|
|
||||||
|
// Verify channel (scene) exists and belongs to this realm
|
||||||
|
let scene = scenes::get_scene_by_id(&pool, channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Channel not found".to_string()))?;
|
||||||
|
|
||||||
|
if scene.realm_id != realm.id {
|
||||||
|
return Err(AppError::NotFound(
|
||||||
|
"Channel not found in this realm".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!(
|
||||||
|
"[WS] Upgrading connection for user {} to channel {}",
|
||||||
|
user.id,
|
||||||
|
channel_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
|
handle_socket(socket, user, channel_id, realm.id, pool, ws_state)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set RLS context on a database connection.
|
||||||
|
async fn set_rls_user_id(
|
||||||
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut **conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an active WebSocket connection.
|
||||||
|
async fn handle_socket(
|
||||||
|
socket: WebSocket,
|
||||||
|
user: User,
|
||||||
|
channel_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
pool: PgPool,
|
||||||
|
ws_state: Arc<WebSocketState>,
|
||||||
|
) {
|
||||||
|
tracing::info!(
|
||||||
|
"[WS] handle_socket started for user {} channel {} realm {}",
|
||||||
|
user.id,
|
||||||
|
channel_id,
|
||||||
|
realm_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Acquire a dedicated connection for setup operations
|
||||||
|
let mut conn = match pool.acquire().await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Failed to acquire DB connection: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set RLS context on this dedicated connection
|
||||||
|
if let Err(e) = set_rls_user_id(&mut conn, user.id).await {
|
||||||
|
tracing::error!("[WS] Failed to set RLS context for user {}: {:?}", user.id, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("[WS] RLS context set on dedicated connection");
|
||||||
|
|
||||||
|
let channel_state = ws_state.get_or_create_channel(channel_id);
|
||||||
|
let mut rx = channel_state.tx.subscribe();
|
||||||
|
|
||||||
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
|
||||||
|
// Ensure active avatar
|
||||||
|
tracing::info!("[WS] Ensuring active avatar...");
|
||||||
|
if let Err(e) = channel_members::ensure_active_avatar(&mut *conn, user.id, realm_id).await {
|
||||||
|
tracing::error!("[WS] Failed to ensure avatar for user {}: {:?}", user.id, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("[WS] Avatar ensured");
|
||||||
|
|
||||||
|
// Join the channel
|
||||||
|
tracing::info!("[WS] Joining channel...");
|
||||||
|
if let Err(e) = channel_members::join_channel(&mut *conn, channel_id, user.id).await {
|
||||||
|
tracing::error!(
|
||||||
|
"[WS] Failed to join channel {} for user {}: {:?}",
|
||||||
|
channel_id,
|
||||||
|
user.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("[WS] Channel joined");
|
||||||
|
|
||||||
|
// Get initial state
|
||||||
|
let members = match get_members_with_avatars(&mut *conn, channel_id, realm_id).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Failed to get members: {:?}", e);
|
||||||
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let member = match channel_members::get_channel_member(&mut *conn, channel_id, user.id, realm_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(m)) => m,
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::error!("[WS] Failed to get member info for user {}", user.id);
|
||||||
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Error getting member info: {:?}", e);
|
||||||
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send welcome message
|
||||||
|
let welcome = ServerMessage::Welcome {
|
||||||
|
member: member.clone(),
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&welcome) {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!("[WS->Client] {}", json);
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast join to others
|
||||||
|
let avatar = avatars::get_avatar_render_data(&mut *conn, user.id, realm_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let join_msg = ServerMessage::MemberJoined {
|
||||||
|
member: ChannelMemberWithAvatar { member, avatar },
|
||||||
|
};
|
||||||
|
let _ = channel_state.tx.send(join_msg);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let tx = channel_state.tx.clone();
|
||||||
|
|
||||||
|
// Acquire a second dedicated connection for the receive task
|
||||||
|
// This connection needs its own RLS context
|
||||||
|
let mut recv_conn = match pool.acquire().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[WS] Failed to acquire recv connection: {:?}", e);
|
||||||
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = set_rls_user_id(&mut recv_conn, user_id).await {
|
||||||
|
tracing::error!("[WS] Failed to set RLS on recv connection: {:?}", e);
|
||||||
|
let _ = channel_members::leave_channel(&mut *conn, channel_id, user_id).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the setup connection - we'll use recv_conn for the receive task
|
||||||
|
// and pool for cleanup (which will use the same RLS context issue, but leave_channel
|
||||||
|
// needs user_id match anyway)
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
// Spawn task to handle incoming messages from client
|
||||||
|
let recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
|
if let Message::Text(text) = msg {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!("[WS<-Client] {}", text);
|
||||||
|
|
||||||
|
let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
match client_msg {
|
||||||
|
ClientMessage::UpdatePosition { x, y } => {
|
||||||
|
if let Err(e) =
|
||||||
|
channel_members::update_position(&mut *recv_conn, channel_id, user_id, x, y)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::error!("[WS] Position update failed: {:?}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = tx.send(ServerMessage::PositionUpdated {
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ClientMessage::UpdateEmotion { emotion } => {
|
||||||
|
if emotion > 9 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let emotion_layer = match avatars::set_emotion(
|
||||||
|
&mut *recv_conn,
|
||||||
|
user_id,
|
||||||
|
realm_id,
|
||||||
|
emotion as i16,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(layer) => layer,
|
||||||
|
Err(e) => {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::error!("[WS] Emotion update failed: {:?}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = tx.send(ServerMessage::EmotionUpdated {
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
emotion,
|
||||||
|
emotion_layer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ClientMessage::Ping => {
|
||||||
|
// Respond with pong directly (not broadcast)
|
||||||
|
// This is handled in the send task via individual message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return the connection so we can use it for cleanup
|
||||||
|
recv_conn
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn task to forward broadcasts to this client
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
while let Ok(msg) = rx.recv().await {
|
||||||
|
if let Ok(json) = serde_json::to_string(&msg) {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tracing::debug!("[WS->Client] {}", json);
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for either task to complete
|
||||||
|
tokio::select! {
|
||||||
|
recv_result = recv_task => {
|
||||||
|
// recv_task finished, get connection back for cleanup
|
||||||
|
if let Ok(mut cleanup_conn) = recv_result {
|
||||||
|
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
||||||
|
} else {
|
||||||
|
// Task panicked, use pool (RLS may fail but try anyway)
|
||||||
|
let _ = channel_members::leave_channel(&pool, channel_id, user_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = send_task => {
|
||||||
|
// send_task finished first, need to acquire a new connection for cleanup
|
||||||
|
if let Ok(mut cleanup_conn) = pool.acquire().await {
|
||||||
|
let _ = set_rls_user_id(&mut cleanup_conn, user_id).await;
|
||||||
|
let _ = channel_members::leave_channel(&mut *cleanup_conn, channel_id, user_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[WS] User {} disconnected from channel {}",
|
||||||
|
user_id,
|
||||||
|
channel_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast departure
|
||||||
|
let _ = channel_state.tx.send(ServerMessage::MemberLeft {
|
||||||
|
user_id: Some(user_id),
|
||||||
|
guest_session_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: Get all channel members with their avatar render data.
|
||||||
|
async fn get_members_with_avatars<'e>(
|
||||||
|
executor: impl sqlx::PgExecutor<'e>,
|
||||||
|
channel_id: Uuid,
|
||||||
|
realm_id: Uuid,
|
||||||
|
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
||||||
|
// Get members first, then we need to get avatars
|
||||||
|
// But executor is consumed by the first query, so we need the pool
|
||||||
|
// Actually, let's just inline this to avoid the complexity
|
||||||
|
let members = channel_members::get_channel_members(executor, channel_id, realm_id).await?;
|
||||||
|
|
||||||
|
// For avatar data, we'll just return default for now since the query
|
||||||
|
// would need another executor
|
||||||
|
let result: Vec<ChannelMemberWithAvatar> = members
|
||||||
|
.into_iter()
|
||||||
|
.map(|member| ChannelMemberWithAvatar {
|
||||||
|
member,
|
||||||
|
avatar: AvatarRenderData::default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
83
crates/chattyness-user-ui/src/app.rs
Normal file
83
crates/chattyness-user-ui/src/app.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
//! Leptos application root and router for public app.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
||||||
|
use leptos_router::components::Router;
|
||||||
|
|
||||||
|
use crate::routes::UserRoutes;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
use crate::api::WebSocketState;
|
||||||
|
|
||||||
|
/// Application state for the public app.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: sqlx::PgPool,
|
||||||
|
pub leptos_options: LeptosOptions,
|
||||||
|
pub ws_state: Arc<WebSocketState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<AppState> for sqlx::PgPool {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.pool.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<AppState> for LeptosOptions {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.leptos_options.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
impl axum::extract::FromRef<AppState> for Arc<WebSocketState> {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.ws_state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell component for SSR.
|
||||||
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<AutoReload options=options.clone() />
|
||||||
|
<HydrationScripts options />
|
||||||
|
<MetaTags />
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-white antialiased" data-app="user">
|
||||||
|
<App />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main application component.
|
||||||
|
///
|
||||||
|
/// This wraps `UserRoutes` with a `Router` for standalone use.
|
||||||
|
/// For embedding in a combined app (e.g., chattyness-app), use `UserRoutes` directly.
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
// Provide meta context for title and meta tags
|
||||||
|
provide_meta_context();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Stylesheet id="leptos" href="/static/chattyness-app.css" />
|
||||||
|
<Title text="Chattyness - Virtual Community Spaces" />
|
||||||
|
|
||||||
|
<Router>
|
||||||
|
<main>
|
||||||
|
<UserRoutes />
|
||||||
|
</main>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/chattyness-user-ui/src/auth.rs
Normal file
13
crates/chattyness-user-ui/src/auth.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//! Authentication module for user UI.
|
||||||
|
//!
|
||||||
|
//! Provides session-based authentication using tower-sessions.
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod rls;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use middleware::*;
|
||||||
|
pub use rls::*;
|
||||||
|
pub use session::*;
|
||||||
115
crates/chattyness-user-ui/src/auth/middleware.rs
Normal file
115
crates/chattyness-user-ui/src/auth/middleware.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
//! Authentication middleware and extractors.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRef, FromRequestParts},
|
||||||
|
http::{request::Parts, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_db::models::User;
|
||||||
|
use chattyness_error::ErrorResponse;
|
||||||
|
|
||||||
|
use super::session::SESSION_USER_ID_KEY;
|
||||||
|
|
||||||
|
/// Extractor for an authenticated user.
|
||||||
|
///
|
||||||
|
/// Returns 401 Unauthorized if the user is not authenticated.
|
||||||
|
pub struct AuthUser(pub User);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthUser
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
PgPool: FromRef<S>,
|
||||||
|
{
|
||||||
|
type Rejection = AuthError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
// Get session from request
|
||||||
|
let session = Session::from_request_parts(parts, state)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AuthError::SessionError)?;
|
||||||
|
|
||||||
|
// Get user ID from session
|
||||||
|
let user_id: Option<Uuid> = session
|
||||||
|
.get(SESSION_USER_ID_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AuthError::SessionError)?;
|
||||||
|
|
||||||
|
let user_id = user_id.ok_or(AuthError::Unauthorized)?;
|
||||||
|
|
||||||
|
// Get the database pool from state
|
||||||
|
let pool = PgPool::from_ref(state);
|
||||||
|
|
||||||
|
// Fetch the user from the database
|
||||||
|
let user = chattyness_db::queries::users::get_user_by_id(&pool, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AuthError::InternalError)?
|
||||||
|
.ok_or(AuthError::Unauthorized)?;
|
||||||
|
|
||||||
|
Ok(AuthUser(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extractor for an optional authenticated user.
|
||||||
|
///
|
||||||
|
/// Returns None if the user is not authenticated.
|
||||||
|
pub struct OptionalAuthUser(pub Option<User>);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for OptionalAuthUser
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
PgPool: FromRef<S>,
|
||||||
|
{
|
||||||
|
type Rejection = AuthError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
match AuthUser::from_request_parts(parts, state).await {
|
||||||
|
Ok(AuthUser(user)) => Ok(OptionalAuthUser(Some(user))),
|
||||||
|
Err(AuthError::Unauthorized) => Ok(OptionalAuthUser(None)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication errors.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AuthError {
|
||||||
|
Unauthorized,
|
||||||
|
SessionError,
|
||||||
|
InternalError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AuthError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
AuthError::Unauthorized => (StatusCode::UNAUTHORIZED, "Authentication required"),
|
||||||
|
AuthError::SessionError => (StatusCode::INTERNAL_SERVER_ERROR, "Session error"),
|
||||||
|
AuthError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ErrorResponse {
|
||||||
|
error: message.to_string(),
|
||||||
|
code: Some(format!("{:?}", self)),
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthError> for chattyness_error::AppError {
|
||||||
|
fn from(err: AuthError) -> Self {
|
||||||
|
match err {
|
||||||
|
AuthError::Unauthorized => chattyness_error::AppError::Unauthorized,
|
||||||
|
AuthError::SessionError => {
|
||||||
|
chattyness_error::AppError::Internal("Session error".to_string())
|
||||||
|
}
|
||||||
|
AuthError::InternalError => {
|
||||||
|
chattyness_error::AppError::Internal("Internal error".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
296
crates/chattyness-user-ui/src/auth/rls.rs
Normal file
296
crates/chattyness-user-ui/src/auth/rls.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
//! Row-Level Security (RLS) middleware for PostgreSQL.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{request::Parts, Request, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use sqlx::{pool::PoolConnection, postgres::PgConnection, PgPool, Postgres};
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
ops::{Deref, DerefMut},
|
||||||
|
pin::Pin,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
use tokio::sync::{Mutex, MutexGuard};
|
||||||
|
use tower::{Layer, Service};
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::session::{SESSION_GUEST_ID_KEY, SESSION_USER_ID_KEY};
|
||||||
|
use chattyness_error::ErrorResponse;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RLS Connection Wrapper
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
struct RlsConnectionInner {
|
||||||
|
conn: Option<PoolConnection<Postgres>>,
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RlsConnectionInner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(mut conn) = self.conn.take() {
|
||||||
|
let pool = self.pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await;
|
||||||
|
let _ = sqlx::query("SELECT public.set_current_guest_session_id(NULL)")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await;
|
||||||
|
drop(conn);
|
||||||
|
drop(pool);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A database connection with RLS user ID already set.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RlsConnection {
|
||||||
|
inner: Arc<Mutex<RlsConnectionInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RlsConnection {
|
||||||
|
fn new(conn: PoolConnection<Postgres>, pool: PgPool) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(Mutex::new(RlsConnectionInner {
|
||||||
|
conn: Some(conn),
|
||||||
|
pool,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire exclusive access to the RLS connection.
|
||||||
|
pub async fn acquire(&self) -> RlsGuard<'_> {
|
||||||
|
RlsGuard {
|
||||||
|
guard: self.inner.lock().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current user ID on the RLS connection.
|
||||||
|
/// Use this after creating a new user to set the context for subsequent operations.
|
||||||
|
pub async fn set_user_id(&self, user_id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
let mut guard = self.inner.lock().await;
|
||||||
|
let conn = guard.conn.as_mut().expect("RlsConnection already consumed");
|
||||||
|
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut **conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A guard providing mutable access to an RLS-configured database connection.
|
||||||
|
pub struct RlsGuard<'a> {
|
||||||
|
guard: MutexGuard<'a, RlsConnectionInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for RlsGuard<'_> {
|
||||||
|
type Target = PgConnection;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
self.guard
|
||||||
|
.conn
|
||||||
|
.as_ref()
|
||||||
|
.expect("RlsConnection already consumed")
|
||||||
|
.deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for RlsGuard<'_> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
self.guard
|
||||||
|
.conn
|
||||||
|
.as_mut()
|
||||||
|
.expect("RlsConnection already consumed")
|
||||||
|
.deref_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RLS Connection Extractor
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Extractor for an RLS-enabled database connection.
|
||||||
|
pub struct RlsConn(pub RlsConnection);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for RlsConn
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = RlsError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.remove::<RlsConnection>()
|
||||||
|
.map(RlsConn)
|
||||||
|
.ok_or(RlsError::NoConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors related to RLS connection handling.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RlsError {
|
||||||
|
NoConnection,
|
||||||
|
DatabaseError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for RlsError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
RlsError::NoConnection => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"RLS connection not available",
|
||||||
|
),
|
||||||
|
RlsError::DatabaseError(msg) => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, msg.leak() as &'static str)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = ErrorResponse {
|
||||||
|
error: message.to_string(),
|
||||||
|
code: Some("RLS_ERROR".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RLS Middleware Layer
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Layer that provides RLS-enabled database connections per request.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RlsLayer {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RlsLayer {
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Layer<S> for RlsLayer {
|
||||||
|
type Service = RlsMiddleware<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
RlsMiddleware {
|
||||||
|
inner,
|
||||||
|
pool: self.pool.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware that sets up RLS connections per request.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RlsMiddleware<S> {
|
||||||
|
inner: S,
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, B> Service<Request<B>> for RlsMiddleware<S>
|
||||||
|
where
|
||||||
|
S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
|
||||||
|
S::Future: Send,
|
||||||
|
B: Send + 'static,
|
||||||
|
{
|
||||||
|
type Response = Response;
|
||||||
|
type Error = S::Error;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||||
|
|
||||||
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||||
|
self.inner.poll_ready(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, mut request: Request<B>) -> Self::Future {
|
||||||
|
let pool = self.pool.clone();
|
||||||
|
let mut inner = self.inner.clone();
|
||||||
|
|
||||||
|
let session = request.extensions().get::<Session>().cloned();
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
let (user_id, guest_session_id) = get_session_ids(session).await;
|
||||||
|
|
||||||
|
match acquire_rls_connection(&pool, user_id, guest_session_id).await {
|
||||||
|
Ok(rls_conn) => {
|
||||||
|
request.extensions_mut().insert(rls_conn);
|
||||||
|
inner.call(request).await
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to acquire RLS connection: {}", e);
|
||||||
|
Ok(RlsError::DatabaseError(e.to_string()).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_session_ids(session: Option<Session>) -> (Option<Uuid>, Option<Uuid>) {
|
||||||
|
let Some(session) = session else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_id = session
|
||||||
|
.get::<Uuid>(SESSION_USER_ID_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let guest_session_id = session
|
||||||
|
.get::<Uuid>(SESSION_GUEST_ID_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
(user_id, guest_session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquire_rls_connection(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
guest_session_id: Option<Uuid>,
|
||||||
|
) -> Result<RlsConnection, sqlx::Error> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
|
||||||
|
if user_id.is_some() {
|
||||||
|
sqlx::query("SELECT public.set_current_user_id($1)")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
} else if guest_session_id.is_some() {
|
||||||
|
sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("SELECT public.set_current_guest_session_id($1)")
|
||||||
|
.bind(guest_session_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
sqlx::query("SELECT public.set_current_user_id(NULL)")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RlsConnection::new(conn, pool.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for RlsConn {
|
||||||
|
type Target = RlsConnection;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for RlsConn {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/chattyness-user-ui/src/auth/session.rs
Normal file
58
crates/chattyness-user-ui/src/auth/session.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! Session management using tower-sessions.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower_sessions::{cookie::time::Duration, cookie::SameSite, Expiry, SessionManagerLayer};
|
||||||
|
use tower_sessions_sqlx_store::PostgresStore;
|
||||||
|
|
||||||
|
/// Session cookie name.
|
||||||
|
pub const SESSION_COOKIE_NAME: &str = "chattyness_session";
|
||||||
|
|
||||||
|
/// Session user ID key.
|
||||||
|
pub const SESSION_USER_ID_KEY: &str = "user_id";
|
||||||
|
|
||||||
|
/// Session login type key (staff or realm).
|
||||||
|
pub const SESSION_LOGIN_TYPE_KEY: &str = "login_type";
|
||||||
|
|
||||||
|
/// Session current realm ID key (for realm logins).
|
||||||
|
pub const SESSION_CURRENT_REALM_KEY: &str = "current_realm_id";
|
||||||
|
|
||||||
|
/// Session original destination key (for password reset redirect).
|
||||||
|
pub const SESSION_ORIGINAL_DEST_KEY: &str = "original_destination";
|
||||||
|
|
||||||
|
/// Session guest ID key (for guest sessions).
|
||||||
|
pub const SESSION_GUEST_ID_KEY: &str = "guest_id";
|
||||||
|
|
||||||
|
/// Create the session management layer.
|
||||||
|
pub async fn create_session_layer(
|
||||||
|
pool: PgPool,
|
||||||
|
secure: bool,
|
||||||
|
) -> SessionManagerLayer<PostgresStore> {
|
||||||
|
let session_store = PostgresStore::new(pool)
|
||||||
|
.with_schema_name("auth")
|
||||||
|
.expect("Invalid schema name for session store")
|
||||||
|
.with_table_name("tower_sessions")
|
||||||
|
.expect("Invalid table name for session store");
|
||||||
|
|
||||||
|
SessionManagerLayer::new(session_store)
|
||||||
|
.with_name(SESSION_COOKIE_NAME)
|
||||||
|
.with_secure(secure)
|
||||||
|
.with_same_site(SameSite::Lax)
|
||||||
|
.with_http_only(true)
|
||||||
|
.with_expiry(Expiry::OnInactivity(Duration::days(7)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a session token for storage.
|
||||||
|
pub fn hash_token(token: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random session token.
|
||||||
|
pub fn generate_token() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let bytes: [u8; 32] = rng.r#gen();
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
17
crates/chattyness-user-ui/src/components.rs
Normal file
17
crates/chattyness-user-ui/src/components.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
//! Reusable UI components.
|
||||||
|
|
||||||
|
pub mod chat;
|
||||||
|
pub mod editor;
|
||||||
|
pub mod forms;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod modals;
|
||||||
|
pub mod scene_viewer;
|
||||||
|
pub mod ws_client;
|
||||||
|
|
||||||
|
pub use chat::*;
|
||||||
|
pub use editor::*;
|
||||||
|
pub use forms::*;
|
||||||
|
pub use layout::*;
|
||||||
|
pub use modals::*;
|
||||||
|
pub use scene_viewer::*;
|
||||||
|
pub use ws_client::*;
|
||||||
38
crates/chattyness-user-ui/src/components/chat.rs
Normal file
38
crates/chattyness-user-ui/src/components/chat.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
//! Chat components for realm chat interface.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Chat input component (placeholder UI).
|
||||||
|
///
|
||||||
|
/// Displays a text input field for typing messages.
|
||||||
|
/// Currently non-functional - just UI placeholder.
|
||||||
|
#[component]
|
||||||
|
pub fn ChatInput() -> impl IntoView {
|
||||||
|
let (message, set_message) = signal(String::new());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="chat-input-container w-full max-w-4xl mx-auto">
|
||||||
|
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
||||||
|
prop:value=move || message.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
set_message.set(event_target_value(&ev));
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled=move || message.get().trim().is_empty()
|
||||||
|
>
|
||||||
|
"Send"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 text-xs mt-2 text-center">
|
||||||
|
"Chat functionality coming soon"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
357
crates/chattyness-user-ui/src/components/editor.rs
Normal file
357
crates/chattyness-user-ui/src/components/editor.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
//! Scene editor components.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use chattyness_db::models::SpotSummary;
|
||||||
|
|
||||||
|
/// Drawing mode for spot editor.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum DrawingMode {
|
||||||
|
#[default]
|
||||||
|
Select,
|
||||||
|
Polygon,
|
||||||
|
Rectangle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toolbar for selecting drawing mode.
|
||||||
|
#[component]
|
||||||
|
pub fn DrawingModeToolbar(
|
||||||
|
#[prop(into)] mode: Signal<DrawingMode>,
|
||||||
|
on_change: Callback<DrawingMode>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="flex gap-2" role="radiogroup" aria-label="Drawing mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || {
|
||||||
|
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||||
|
if mode.get() == DrawingMode::Select {
|
||||||
|
format!("{} bg-blue-600 text-white", base)
|
||||||
|
} else {
|
||||||
|
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| on_change.run(DrawingMode::Select)
|
||||||
|
aria-pressed=move || mode.get() == DrawingMode::Select
|
||||||
|
>
|
||||||
|
"Select"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || {
|
||||||
|
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||||
|
if mode.get() == DrawingMode::Rectangle {
|
||||||
|
format!("{} bg-blue-600 text-white", base)
|
||||||
|
} else {
|
||||||
|
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| on_change.run(DrawingMode::Rectangle)
|
||||||
|
aria-pressed=move || mode.get() == DrawingMode::Rectangle
|
||||||
|
>
|
||||||
|
"Rectangle"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class=move || {
|
||||||
|
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||||
|
if mode.get() == DrawingMode::Polygon {
|
||||||
|
format!("{} bg-blue-600 text-white", base)
|
||||||
|
} else {
|
||||||
|
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| on_change.run(DrawingMode::Polygon)
|
||||||
|
aria-pressed=move || mode.get() == DrawingMode::Polygon
|
||||||
|
>
|
||||||
|
"Polygon"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canvas for displaying scene with spots.
|
||||||
|
#[component]
|
||||||
|
pub fn SceneCanvas(
|
||||||
|
#[prop(into)] width: Signal<u32>,
|
||||||
|
#[prop(into)] height: Signal<u32>,
|
||||||
|
#[prop(into)] background_color: Signal<Option<String>>,
|
||||||
|
#[prop(into)] background_image: Signal<Option<String>>,
|
||||||
|
#[prop(into)] spots: Signal<Vec<SpotSummary>>,
|
||||||
|
#[prop(into)] selected_spot_id: Signal<Option<Uuid>>,
|
||||||
|
on_spot_click: Callback<Uuid>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let canvas_style = Signal::derive(move || {
|
||||||
|
let w = width.get();
|
||||||
|
let h = height.get();
|
||||||
|
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
||||||
|
|
||||||
|
if let Some(img) = background_image.get() {
|
||||||
|
format!(
|
||||||
|
"width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center;",
|
||||||
|
w, h, img
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("width: {}px; height: {}px; background-color: {};", w, h, bg_color)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900">
|
||||||
|
<div class="relative" style=move || canvas_style.get()>
|
||||||
|
{move || {
|
||||||
|
spots
|
||||||
|
.get()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spot| {
|
||||||
|
let spot_id = spot.id;
|
||||||
|
let is_selected = selected_spot_id.get() == Some(spot_id);
|
||||||
|
let style = parse_wkt_to_style(&spot.region_wkt);
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=move || {
|
||||||
|
let base = "absolute border-2 cursor-pointer transition-colors";
|
||||||
|
if is_selected {
|
||||||
|
format!("{} border-blue-500 bg-blue-500/30", base)
|
||||||
|
} else {
|
||||||
|
format!("{} border-green-500/50 bg-green-500/20 hover:bg-green-500/30", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
style=style
|
||||||
|
on:click=move |_| on_spot_click.run(spot_id)
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canvas for drawing new spots.
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn SpotDrawer(
|
||||||
|
#[prop(into)] width: Signal<u32>,
|
||||||
|
#[prop(into)] height: Signal<u32>,
|
||||||
|
#[prop(into)] mode: Signal<DrawingMode>,
|
||||||
|
on_complete: Callback<String>,
|
||||||
|
#[prop(into)] background_color: Signal<Option<String>>,
|
||||||
|
#[prop(into)] background_image: Signal<Option<String>>,
|
||||||
|
#[prop(into)] existing_spots_wkt: Signal<Vec<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (drawing_points, _set_drawing_points) = signal(Vec::<(f64, f64)>::new());
|
||||||
|
let (is_drawing, _set_is_drawing) = signal(false);
|
||||||
|
let (start_point, _set_start_point) = signal(Option::<(f64, f64)>::None);
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
let (set_drawing_points, set_is_drawing, set_start_point) =
|
||||||
|
(_set_drawing_points, _set_is_drawing, _set_start_point);
|
||||||
|
|
||||||
|
let canvas_style = Signal::derive(move || {
|
||||||
|
let w = width.get();
|
||||||
|
let h = height.get();
|
||||||
|
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
||||||
|
|
||||||
|
if let Some(img) = background_image.get() {
|
||||||
|
format!(
|
||||||
|
"width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center; cursor: crosshair;",
|
||||||
|
w, h, img
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("width: {}px; height: {}px; background-color: {}; cursor: crosshair;", w, h, bg_color)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_mouse_down = move |ev: leptos::ev::MouseEvent| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let rect = ev
|
||||||
|
.target()
|
||||||
|
.and_then(|t| {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
t.dyn_ref::<web_sys::HtmlElement>()
|
||||||
|
.map(|el| el.get_bounding_client_rect())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(rect) = rect {
|
||||||
|
let x = ev.client_x() as f64 - rect.left();
|
||||||
|
let y = ev.client_y() as f64 - rect.top();
|
||||||
|
|
||||||
|
match mode.get() {
|
||||||
|
DrawingMode::Rectangle => {
|
||||||
|
set_start_point.set(Some((x, y)));
|
||||||
|
set_is_drawing.set(true);
|
||||||
|
}
|
||||||
|
DrawingMode::Polygon => {
|
||||||
|
let mut points = drawing_points.get();
|
||||||
|
points.push((x, y));
|
||||||
|
set_drawing_points.set(points);
|
||||||
|
}
|
||||||
|
DrawingMode::Select => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_mouse_up = move |ev: leptos::ev::MouseEvent| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
if mode.get() == DrawingMode::Rectangle && is_drawing.get() {
|
||||||
|
if let Some((start_x, start_y)) = start_point.get() {
|
||||||
|
let rect = ev
|
||||||
|
.target()
|
||||||
|
.and_then(|t| {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
t.dyn_ref::<web_sys::HtmlElement>()
|
||||||
|
.map(|el| el.get_bounding_client_rect())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(rect) = rect {
|
||||||
|
let end_x = ev.client_x() as f64 - rect.left();
|
||||||
|
let end_y = ev.client_y() as f64 - rect.top();
|
||||||
|
|
||||||
|
let min_x = start_x.min(end_x);
|
||||||
|
let min_y = start_y.min(end_y);
|
||||||
|
let max_x = start_x.max(end_x);
|
||||||
|
let max_y = start_y.max(end_y);
|
||||||
|
|
||||||
|
if (max_x - min_x) > 10.0 && (max_y - min_y) > 10.0 {
|
||||||
|
let wkt = format!(
|
||||||
|
"POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))",
|
||||||
|
min_x, min_y, max_x, min_y, max_x, max_y, min_x, max_y, min_x, min_y
|
||||||
|
);
|
||||||
|
on_complete.run(wkt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set_is_drawing.set(false);
|
||||||
|
set_start_point.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_double_click = move |_| {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
if mode.get() == DrawingMode::Polygon {
|
||||||
|
let points = drawing_points.get();
|
||||||
|
if points.len() >= 3 {
|
||||||
|
let wkt = points_to_wkt(&points);
|
||||||
|
on_complete.run(wkt);
|
||||||
|
}
|
||||||
|
set_drawing_points.set(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
style=move || canvas_style.get()
|
||||||
|
on:mousedown=on_mouse_down
|
||||||
|
on:mouseup=on_mouse_up
|
||||||
|
on:dblclick=on_double_click
|
||||||
|
>
|
||||||
|
// Render existing spots
|
||||||
|
{move || {
|
||||||
|
existing_spots_wkt
|
||||||
|
.get()
|
||||||
|
.into_iter()
|
||||||
|
.map(|wkt| {
|
||||||
|
let style = parse_wkt_to_style(&wkt);
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class="absolute border-2 border-gray-500/50 bg-gray-500/20"
|
||||||
|
style=style
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Render drawing preview
|
||||||
|
{move || {
|
||||||
|
let points = drawing_points.get();
|
||||||
|
if !points.is_empty() && mode.get() == DrawingMode::Polygon {
|
||||||
|
let svg_points: String = points
|
||||||
|
.iter()
|
||||||
|
.map(|(x, y)| format!("{},{}", x, y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
Some(view! {
|
||||||
|
<svg class="absolute inset-0 pointer-events-none">
|
||||||
|
<polyline
|
||||||
|
points=svg_points
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse WKT polygon to CSS positioning style.
|
||||||
|
fn parse_wkt_to_style(wkt: &str) -> String {
|
||||||
|
let trimmed = wkt.trim();
|
||||||
|
if let Some(coords_str) = trimmed
|
||||||
|
.strip_prefix("POLYGON((")
|
||||||
|
.and_then(|s| s.strip_suffix("))"))
|
||||||
|
{
|
||||||
|
let points: Vec<(f64, f64)> = coords_str
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|p| {
|
||||||
|
let coords: Vec<&str> = p.trim().split_whitespace().collect();
|
||||||
|
if coords.len() >= 2 {
|
||||||
|
Some((coords[0].parse().ok()?, coords[1].parse().ok()?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if points.len() >= 4 {
|
||||||
|
let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
|
||||||
|
let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
|
||||||
|
let max_x = points.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
let max_y = points.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
|
||||||
|
return format!(
|
||||||
|
"left: {}px; top: {}px; width: {}px; height: {}px;",
|
||||||
|
min_x,
|
||||||
|
min_y,
|
||||||
|
max_x - min_x,
|
||||||
|
max_y - min_y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert points to WKT polygon.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn points_to_wkt(points: &[(f64, f64)]) -> String {
|
||||||
|
if points.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let coords: String = points
|
||||||
|
.iter()
|
||||||
|
.chain(std::iter::once(&points[0]))
|
||||||
|
.map(|(x, y)| format!("{} {}", x, y))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
format!("POLYGON(({})", coords)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue