From 80d441fe3668de4096462446ee05e6b4480d5acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20P=C3=A9rex?= Date: Mon, 7 Jul 2025 23:05:46 +0100 Subject: [PATCH] chore: add client code --- client/Cargo.toml | 50 + client/build.rs | 66 ++ client/src/app.rs | 294 ++++++ client/src/auth/context.rs | 889 ++++++++++++++++++ client/src/auth/context_simple.rs | 677 ++++++++++++++ client/src/auth/error_display.rs | 196 ++++ client/src/auth/errors.rs | 163 ++++ client/src/auth/login.rs | 254 +++++ client/src/auth/mod.rs | 11 + client/src/auth/register.rs | 484 ++++++++++ client/src/auth/two_factor.rs | 318 +++++++ client/src/auth/two_factor_login.rs | 246 +++++ client/src/components/Counter.rs | 25 + client/src/components/Logo.rs | 128 +++ client/src/components/admin/AdminLayout.rs | 365 ++++++++ client/src/components/admin/mod.rs | 3 + client/src/components/daisy_example.rs | 253 +++++ client/src/components/forms/contact_form.rs | 468 ++++++++++ client/src/components/forms/mod.rs | 17 + client/src/components/forms/support_form.rs | 690 ++++++++++++++ client/src/components/mod.rs | 14 + client/src/defs.rs | 5 + client/src/examples/admin_integration.rs | 319 +++++++ client/src/i18n/mod.rs | 291 ++++++ client/src/lib.rs | 213 +++++ client/src/main.rs | 5 + client/src/pages/About.rs | 53 ++ client/src/pages/DaisyUI.rs | 28 + client/src/pages/FeaturesDemo.rs | 91 ++ client/src/pages/Home.rs | 65 ++ client/src/pages/admin/Content.rs | 830 +++++++++++++++++ client/src/pages/admin/Dashboard.rs | 455 +++++++++ client/src/pages/admin/Roles.rs | 982 ++++++++++++++++++++ client/src/pages/admin/Users.rs | 893 ++++++++++++++++++ client/src/pages/admin/mod.rs | 9 + client/src/pages/contact.rs | 250 +++++ client/src/pages/mod.rs | 11 + client/src/state/mod.rs | 42 + client/src/state/theme.rs | 243 +++++ client/src/utils.rs | 142 +++ client/uno.config.ts | 81 ++ 41 files changed, 10619 insertions(+) create mode 100644 client/Cargo.toml create mode 100644 client/build.rs create mode 100644 client/src/app.rs create mode 100644 client/src/auth/context.rs create mode 100644 client/src/auth/context_simple.rs create mode 100644 client/src/auth/error_display.rs create mode 100644 client/src/auth/errors.rs create mode 100644 client/src/auth/login.rs create mode 100644 client/src/auth/mod.rs create mode 100644 client/src/auth/register.rs create mode 100644 client/src/auth/two_factor.rs create mode 100644 client/src/auth/two_factor_login.rs create mode 100644 client/src/components/Counter.rs create mode 100644 client/src/components/Logo.rs create mode 100644 client/src/components/admin/AdminLayout.rs create mode 100644 client/src/components/admin/mod.rs create mode 100644 client/src/components/daisy_example.rs create mode 100644 client/src/components/forms/contact_form.rs create mode 100644 client/src/components/forms/mod.rs create mode 100644 client/src/components/forms/support_form.rs create mode 100644 client/src/components/mod.rs create mode 100644 client/src/defs.rs create mode 100644 client/src/examples/admin_integration.rs create mode 100644 client/src/i18n/mod.rs create mode 100644 client/src/lib.rs create mode 100644 client/src/main.rs create mode 100644 client/src/pages/About.rs create mode 100644 client/src/pages/DaisyUI.rs create mode 100644 client/src/pages/FeaturesDemo.rs create mode 100644 client/src/pages/Home.rs create mode 100644 client/src/pages/admin/Content.rs create mode 100644 client/src/pages/admin/Dashboard.rs create mode 100644 client/src/pages/admin/Roles.rs create mode 100644 client/src/pages/admin/Users.rs create mode 100644 client/src/pages/admin/mod.rs create mode 100644 client/src/pages/contact.rs create mode 100644 client/src/pages/mod.rs create mode 100644 client/src/state/mod.rs create mode 100644 client/src/state/theme.rs create mode 100644 client/src/utils.rs create mode 100644 client/uno.config.ts diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 0000000..6a68009 --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "client" +version = "0.1.0" +edition = "2024" +authors = ["Rustelo Contributors"] +license = "MIT" +description = "Client-side components for Rustelo web application template" +documentation = "https://docs.rs/client" +repository = "https://github.com/yourusername/rustelo" +homepage = "https://rustelo.dev" +readme = "../../README.md" +keywords = ["rust", "web", "leptos", "wasm", "frontend"] +categories = ["web-programming", "wasm"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +leptos = { workspace = true, features = ["hydrate"] } +leptos_router = { workspace = true } +leptos_meta = { workspace = true } +leptos_config = { workspace = true } +wasm-bindgen = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +reqwasm = { workspace = true } +web-sys = { workspace = true } +regex = { workspace = true } +console_error_panic_hook = { version = "0.1.7" } +toml = { workspace = true } +fluent = { workspace = true } +fluent-bundle = { workspace = true } +unic-langid = { workspace = true } + +shared = { path = "../shared" } +gloo-timers = { workspace = true } +wasm-bindgen-futures = { workspace = true } +urlencoding = "2.1" +chrono = { workspace = true } +uuid = { workspace = true } +# leptos-use = "0.13" + +[features] +default = [] +hydrate = [] + +[package.metadata.docs.rs] +# Configuration for docs.rs +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/client/build.rs b/client/build.rs new file mode 100644 index 0000000..2feb7f0 --- /dev/null +++ b/client/build.rs @@ -0,0 +1,66 @@ +use std::{ + io::{self, Write}, + path::Path, + process, +}; + +fn main() { + println!("cargo::rustc-check-cfg=cfg(web_sys_unstable_apis)"); + println!("cargo:rerun-if-changed=uno.config.ts"); + //println!("cargo:rerun-if-changed=style/main.scss"); + + // Check if node_modules exists in various locations, if not run pnpm install + let node_modules_paths = ["../node_modules", "node_modules", "../../node_modules"]; + + let node_modules_exists = node_modules_paths + .iter() + .any(|path| Path::new(path).exists()); + + if !node_modules_exists { + println!("cargo:warning=node_modules not found, running pnpm install..."); + + // Try to find package.json to determine correct directory + let package_json_paths = ["../package.json", "package.json", "../../package.json"]; + + let install_dir = package_json_paths + .iter() + .find(|path| Path::new(path).exists()) + .map(|path| Path::new(path).parent().unwrap_or(Path::new("."))) + .unwrap_or(Path::new("..")); + + match process::Command::new("pnpm") + .arg("install") + .current_dir(install_dir) + .output() + { + Ok(output) => { + if !output.status.success() { + let _ = io::stdout().write_all(&output.stdout); + let _ = io::stdout().write_all(&output.stderr); + panic!("pnpm install failed"); + } + println!("cargo:warning=pnpm install completed successfully"); + } + Err(e) => { + println!("cargo:warning=Failed to run pnpm install: {:?}", e); + println!("cargo:warning=Please run 'pnpm install' manually in the project root"); + // Don't panic here, just warn - the build might still work + } + } + } + + match process::Command::new("sh") + .arg("-c") + .arg("pnpm run build") + .output() + { + Ok(output) => { + if !output.status.success() { + let _ = io::stdout().write_all(&output.stdout); + let _ = io::stdout().write_all(&output.stderr); + panic!("UnoCSS error"); + } + } + Err(e) => panic!("UnoCSS error: {:?}", e), + }; +} diff --git a/client/src/app.rs b/client/src/app.rs new file mode 100644 index 0000000..535af93 --- /dev/null +++ b/client/src/app.rs @@ -0,0 +1,294 @@ +//#![allow(unused_imports)] +//#![allow(dead_code)] +//#![allow(unused_variables)] +// Suppress leptos_router warnings about reactive signal access outside tracking context +#![allow(clippy::redundant_closure)] +//#![allow(unused_assignments)] + +//use crate::defs::{NAV_LINK_CLASS, ROUTES}; + +use crate::auth::AuthProvider; +use crate::components::NavbarLogo; +use crate::i18n::{I18nProvider, LanguageSelector, use_i18n}; +use crate::pages::{AboutPage, DaisyUIPage, FeaturesDemoPage, HomePage}; +use crate::state::*; +use crate::utils::{get_initial_path, make_navigate, make_on_link_click, make_popstate_effect}; +use leptos::children::Children; +use leptos::prelude::*; +use leptos_meta::{MetaTags, Title, provide_meta_context}; +// use regex::Regex; +use shared::{get_bundle, load_menu_toml, t}; +use std::collections::HashMap; + +//// Wrapper component for consistent layout. +#[component] +fn Wrapper(children: Children) -> impl IntoView { + view! { <>{children()} } +} + +/// NotFoundPage component for 404s. +#[component] +fn NotFoundPage() -> impl IntoView { + view! {
"Page not found."
} +} + +/// Navigation menu component, maps over ROUTES. +#[component] +pub fn NavMenu(path: ReadSignal, set_path: WriteSignal) -> impl IntoView { + let navigate = make_navigate(set_path.clone()); + let on_link_click = make_on_link_click(set_path.clone(), navigate.clone()); + let i18n = use_i18n(); + let menu_items = load_menu_toml().unwrap_or_default(); + println!("NavMenu rendered"); + view! { + + } + // view! { + // + //} +} + +/// Main app component with SSR path awareness and SPA routing. +#[component] +pub fn App() -> impl IntoView { + provide_meta_context(); + let (path, set_path) = signal(get_initial_path()); + make_popstate_effect(set_path); + let (lang, _set_lang) = signal("en".to_string()); + // --- Unit test placeholder for route matching --- + // #[cfg(test)] + // mod tests { + // use super::*; + // #[test] + // fn test_user_route() { + // let re = Regex::new(r"^/user/(\\d+)$").expect("Valid regex"); + // assert!(re.is_match("/user/42")); + // } + // } + view! { + + + + + + + + + <header class="absolute inset-x-0 top-0 z-50"> + <Wrapper><NavMenu path=path set_path=set_path /></Wrapper> + </header> + <div class="min-h-screen bg-gray-50"> + <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> + { let lang = lang.clone(); let path = path.clone(); + move || { + let p = path.get(); + let lang_val = lang.get(); + let bundle = get_bundle(&lang_val).unwrap_or_else(|_| { + // Fallback to a simple bundle if loading fails + use fluent::FluentBundle; + use unic_langid::LanguageIdentifier; + let langid: LanguageIdentifier = "en".parse().unwrap_or_else(|e| { + web_sys::console::error_1(&format!("Failed to parse default language 'en': {:?}", e).into()); + // This should never happen, but create a minimal fallback + LanguageIdentifier::from_parts( + unic_langid::subtags::Language::from_bytes(b"en").unwrap_or_else(|e| { + web_sys::console::error_1(&format!("Critical error: failed to create 'en' language: {:?}", e).into()); + // Fallback to creating a new language identifier from scratch + match "en".parse::<unic_langid::subtags::Language>() { + Ok(lang) => lang, + Err(_) => { + // If even this fails, we'll use the default language + web_sys::console::error_1(&"Using default language as final fallback".into()); + unic_langid::subtags::Language::default() + } + } + }), + None, + None, + &[], + ) + }); + FluentBundle::new(vec![langid]) + }); + let content = match p.as_str() { + "/" => t(&bundle, "main-desc", None), + "/about" => t(&bundle, "about-desc", None), + "/daisyui" => "DaisyUI Components Demo".to_string(), + "/features-demo" => "New Features Demo".to_string(), + + _ if p.starts_with("/user/") => { + if let Some(id) = p.strip_prefix("/user/") { + let mut args = HashMap::new(); + args.insert("id", id); + t(&bundle, "user-page", Some(&args)) + } else { + t(&bundle, "not-found", None) + } + }, + _ => t(&bundle, "not-found", None), + }; + view! { + <Wrapper> + <div>{content}</div> + {match p.as_str() { + "/" => view! { <div><HomePage /></div> }.into_any(), + "/about" => view! { <div><AboutPage /></div> }.into_any(), + "/daisyui" => view! { <div><DaisyUIPage /></div> }.into_any(), + "/features-demo" => view! { <div><FeaturesDemoPage /></div> }.into_any(), + + _ => view! { <div>Not found</div> }.into_any(), + }} + </Wrapper> + } + }} + </main> + </div> + </AppStateProvider> + </UserProvider> + </AuthProvider> + </ToastProvider> + </I18nProvider> + </ThemeProvider> + </GlobalStateProvider> + } +} + +/// The SSR shell for Leptos/Axum integration. +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/> + <link rel="stylesheet" id="leptos" href="/pkg/website.css"/> + <link rel="shortcut icon" type="image/ico" href="/favicon.ico"/> + <MetaTags/> + </head> + <body> + <App /> + </body> + </html> + } +} diff --git a/client/src/auth/context.rs b/client/src/auth/context.rs new file mode 100644 index 0000000..f6971cb --- /dev/null +++ b/client/src/auth/context.rs @@ -0,0 +1,889 @@ +use crate::i18n::use_i18n; +use leptos::prelude::*; +// use leptos_router::use_navigate; +use shared::auth::{AuthResponse, User}; +use std::sync::Arc; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, Debug)] +pub struct AuthState { + pub user: Option<User>, + pub is_loading: bool, + pub error: Option<String>, + pub requires_2fa: bool, + pub pending_2fa_email: Option<String>, +} + +impl Default for AuthState { + fn default() -> Self { + Self { + user: None, + is_loading: false, + error: None, + requires_2fa: false, + pending_2fa_email: None, + } + } +} + +#[derive(Clone)] +pub struct AuthActions { + pub login: Arc<dyn Fn(String, String, bool) + Send + Sync>, + pub login_with_2fa: Arc<dyn Fn(String, String, bool) + Send + Sync>, + pub logout: Arc<dyn Fn() + Send + Sync>, + pub register: Arc<dyn Fn(String, String, String, Option<String>) + Send + Sync>, + pub refresh_token: Arc<dyn Fn() + Send + Sync>, + pub update_profile: Arc<dyn Fn(String, Option<String>, Option<String>) + Send + Sync>, + pub change_password: Arc<dyn Fn(String, String) + Send + Sync>, + pub clear_error: Arc<dyn Fn() + Send + Sync>, + pub clear_2fa_state: Arc<dyn Fn() + Send + Sync>, +} + +#[derive(Clone)] +pub struct AuthContext { + pub state: ReadSignal<AuthState>, + pub actions: AuthActions, +} + +impl AuthContext { + pub fn is_authenticated(&self) -> bool { + self.state.get().user.is_some() + } + + pub fn is_loading(&self) -> bool { + self.state.get().is_loading + } + + pub fn user(&self) -> Option<User> { + self.state.get().user + } + + pub fn error(&self) -> Option<String> { + self.state.get().error + } + + pub fn requires_2fa(&self) -> bool { + self.state.get().requires_2fa + } + + pub fn pending_2fa_email(&self) -> Option<String> { + self.state.get().pending_2fa_email + } + + pub fn has_role(&self, role: &shared::auth::Role) -> bool { + self.state + .get() + .user + .as_ref() + .map_or(false, |user| user.has_role(role)) + } + + pub fn has_permission(&self, permission: &shared::auth::Permission) -> bool { + self.state + .get() + .user + .as_ref() + .map_or(false, |user| user.has_permission(permission)) + } + + pub fn is_admin(&self) -> bool { + self.state + .get() + .user + .as_ref() + .map_or(false, |user| user.is_admin()) + } + + pub fn login_success(&self, _user: User, token: String) { + // Store token in localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item("auth_token", &token); + } + } + } +} + +/// Helper function to map server errors to translation keys +fn get_error_translation_key(error_text: &str) -> &str { + let error_lower = error_text.to_lowercase(); + + if error_lower.contains("invalid credentials") { + "invalid-credentials" + } else if error_lower.contains("user not found") { + "user-not-found" + } else if error_lower.contains("email already exists") { + "email-already-exists" + } else if error_lower.contains("username already exists") { + "username-already-exists" + } else if error_lower.contains("invalid token") { + "invalid-token" + } else if error_lower.contains("token expired") { + "token-expired" + } else if error_lower.contains("insufficient permissions") { + "insufficient-permissions" + } else if error_lower.contains("account not verified") { + "account-not-verified" + } else if error_lower.contains("account suspended") { + "account-suspended" + } else if error_lower.contains("rate limit exceeded") { + "rate-limit-exceeded" + } else if error_lower.contains("oauth") { + "oauth-error" + } else if error_lower.contains("database") { + "database-error" + } else if error_lower.contains("validation") { + "validation-error" + } else if error_lower.contains("login failed") { + "login-failed" + } else if error_lower.contains("registration failed") { + "registration-failed" + } else if error_lower.contains("session expired") { + "session-expired" + } else if error_lower.contains("profile") && error_lower.contains("failed") { + "profile-update-failed" + } else if error_lower.contains("password") && error_lower.contains("failed") { + "password-change-failed" + } else if error_lower.contains("network") { + "network-error" + } else if error_lower.contains("server") { + "server-error" + } else if error_lower.contains("internal") { + "internal-error" + } else { + "unknown-error" + } +} + +/// Helper function to parse server error response and get localized message +fn parse_error_response(response_text: &str, i18n: &crate::i18n::UseI18n) -> String { + // Try to parse as JSON first + if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) { + if let Some(message) = json_value.get("message").and_then(|m| m.as_str()) { + let key = get_error_translation_key(message); + return i18n.t(key); + } + if let Some(errors) = json_value.get("errors").and_then(|e| e.as_array()) { + if let Some(first_error) = errors.first().and_then(|e| e.as_str()) { + let key = get_error_translation_key(first_error); + return i18n.t(key); + } + } + } + + // Fallback to direct message mapping + let key = get_error_translation_key(response_text); + i18n.t(key) +} + +#[component] +#[allow(non_snake_case)] +pub fn AuthProvider(children: leptos::prelude::Children) -> impl IntoView { + let i18n = use_i18n(); + let (state, set_state) = signal(AuthState::default()); + let (access_token, set_access_token) = signal::<Option<String>>(None); + let (refresh_token_state, set_refresh_token) = signal::<Option<String>>(None); + + // Initialize auth state from localStorage + Effect::new(move |_| { + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + // Load access token + if let Ok(Some(token)) = storage.get_item("access_token") { + set_access_token.set(Some(token)); + } + + // Load refresh token + if let Ok(Some(token)) = storage.get_item("refresh_token") { + set_refresh_token.set(Some(token)); + } + + // Load user data + if let Ok(Some(user_data)) = storage.get_item("user") { + if let Ok(user) = serde_json::from_str::<User>(&user_data) { + set_state.update(|s| { + s.user = Some(user); + }); + } + } + } + } + }); + + let login_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + Arc::new(move |email: String, password: String, remember_me: bool| { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| { + s.is_loading = true; + s.error = None; + }); + + let login_data = serde_json::json!({ + "email": email, + "password": password, + "remember_me": remember_me + }); + + match reqwasm::http::Request::post("/api/auth/login") + .header("Content-Type", "application/json") + .body(login_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + match response.json::<serde_json::Value>().await { + Ok(json) => { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + // Check if 2FA is required + if auth_response.requires_2fa { + set_state.update(|s| { + s.requires_2fa = true; + s.pending_2fa_email = Some(email.clone()); + s.is_loading = false; + }); + + // Navigate to 2FA page + if let Some(window) = web_sys::window() { + let location = window.location(); + let remember_param = if remember_me { + "&remember_me=true" + } else { + "" + }; + let url = format!( + "/login/2fa?email={}{}", + urlencoding::encode(&email), + remember_param + ); + let _ = location.set_href(&url); + } + } else { + // Regular login success + set_access_token + .set(Some(auth_response.access_token.clone())); + if let Some(refresh_token) = + &auth_response.refresh_token + { + set_refresh_token + .set(Some(refresh_token.clone())); + } + + // Store in localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = + window.local_storage() + { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + if let Some(refresh_token) = + &auth_response.refresh_token + { + let _ = storage.set_item( + "refresh_token", + refresh_token, + ); + } + if let Ok(user_json) = serde_json::to_string( + &auth_response.user, + ) { + let _ = storage + .set_item("user", &user_json); + } + } + } + + set_state.update(|s| { + s.user = Some(auth_response.user); + s.is_loading = false; + s.requires_2fa = false; + s.pending_2fa_email = None; + }); + } + } + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("login-failed")); + s.is_loading = false; + }); + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Login failed".to_string()); + let error_msg = parse_error_response(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }) + }; + + let logout_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let access_token = access_token.clone(); + + Arc::new(move || { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let access_token = access_token.clone(); + + spawn_local(async move { + // Call logout endpoint + if let Some(token) = access_token.get() { + let _ = reqwasm::http::Request::post("/api/auth/logout") + .header("Authorization", &format!("Bearer {}", token)) + .send() + .await; + } + + // Clear local state + set_state.update(|s| { + s.user = None; + s.error = None; + s.is_loading = false; + s.requires_2fa = false; + s.pending_2fa_email = None; + }); + + set_access_token.set(None); + set_refresh_token.set(None); + + // Clear localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.remove_item("access_token"); + let _ = storage.remove_item("refresh_token"); + let _ = storage.remove_item("user"); + } + } + }); + }) + }; + + let register_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + Arc::new( + move |email: String, + password: String, + username: String, + display_name: Option<String>| { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| { + s.is_loading = true; + s.error = None; + }); + + let register_data = serde_json::json!({ + "email": email, + "username": username, + "password": password, + "display_name": display_name + }); + + match reqwasm::http::Request::post("/api/auth/register") + .header("Content-Type", "application/json") + .body(register_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + match response.json::<serde_json::Value>().await { + Ok(json) => { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + // Store tokens and user data similar to login + set_access_token + .set(Some(auth_response.access_token.clone())); + if let Some(refresh_token) = + &auth_response.refresh_token + { + set_refresh_token + .set(Some(refresh_token.clone())); + } + + // Store in localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = + window.local_storage() + { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + if let Some(refresh_token) = + &auth_response.refresh_token + { + let _ = storage.set_item( + "refresh_token", + refresh_token, + ); + } + if let Ok(user_json) = serde_json::to_string( + &auth_response.user, + ) { + let _ = storage + .set_item("user", &user_json); + } + } + } + + set_state.update(|s| { + s.user = Some(auth_response.user); + s.is_loading = false; + }); + } + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("registration-failed")); + s.is_loading = false; + }); + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Registration failed".to_string()); + let error_msg = parse_error_response(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }, + ) + }; + + let refresh_token_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let refresh_token_state = refresh_token_state.clone(); + let i18n = i18n.clone(); + + Arc::new(move || { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let refresh_token_state = refresh_token_state.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + if let Some(refresh_token) = refresh_token_state.get() { + let refresh_data = serde_json::json!({ + "refresh_token": refresh_token + }); + + match reqwasm::http::Request::post("/api/auth/refresh") + .header("Content-Type", "application/json") + .body(refresh_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + if let Ok(json) = response.json::<serde_json::Value>().await { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + set_access_token + .set(Some(auth_response.access_token.clone())); + + // Update localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + } + } + } + } + } + } else { + // Refresh failed, logout user + set_state.update(|s| { + s.user = None; + s.error = Some(i18n.t("session-expired")); + }); + } + } + Err(_) => { + // Refresh failed, logout user + set_state.update(|s| { + s.user = None; + s.error = Some(i18n.t("session-expired")); + }); + } + } + } + }); + }) + }; + + let update_profile_action = { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + Arc::new( + move |display_name: String, first_name: Option<String>, last_name: Option<String>| { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| s.is_loading = true); + + let update_data = serde_json::json!({ + "display_name": display_name, + "first_name": first_name, + "last_name": last_name + }); + + if let Some(token) = access_token.get() { + match reqwasm::http::Request::put("/api/auth/profile") + .header("Content-Type", "application/json") + .header("Authorization", &format!("Bearer {}", token)) + .body(update_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + if let Ok(json) = response.json::<serde_json::Value>().await { + if let Some(data) = json.get("data") { + if let Ok(user) = + serde_json::from_value::<User>(data.clone()) + { + set_state.update(|s| { + s.user = Some(user.clone()); + s.is_loading = false; + }); + + // Update localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = + window.local_storage() + { + if let Ok(user_json) = + serde_json::to_string(&user) + { + let _ = storage + .set_item("user", &user_json); + } + } + } + } + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Profile update failed".to_string()); + let error_msg = parse_error_response(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + } else { + set_state.update(|s| { + s.error = Some(i18n.t("invalid-token")); + s.is_loading = false; + }); + } + }); + }, + ) + }; + + let change_password_action = { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + Arc::new(move |current_password: String, new_password: String| { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| s.is_loading = true); + + let change_data = serde_json::json!({ + "current_password": current_password, + "new_password": new_password + }); + + if let Some(token) = access_token.get() { + match reqwasm::http::Request::post("/api/auth/change-password") + .header("Content-Type", "application/json") + .header("Authorization", &format!("Bearer {}", token)) + .body(change_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + set_state.update(|s| { + s.is_loading = false; + }); + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Password change failed".to_string()); + let error_msg = parse_error_response(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + } else { + set_state.update(|s| { + s.error = Some(i18n.t("invalid-token")); + s.is_loading = false; + }); + } + }); + }) + }; + + let clear_error_action = { + let set_state = set_state.clone(); + Arc::new(move || { + set_state.update(|s| s.error = None); + }) + }; + + let login_with_2fa_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + Arc::new(move |email: String, code: String, remember_me: bool| { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| { + s.is_loading = true; + s.error = None; + }); + + let login_data = serde_json::json!({ + "email": email, + "code": code, + "remember_me": remember_me + }); + + match reqwasm::http::Request::post("/api/auth/login/2fa") + .header("Content-Type", "application/json") + .body(login_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + match response.json::<serde_json::Value>().await { + Ok(json) => { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + // Store tokens + set_access_token + .set(Some(auth_response.access_token.clone())); + if let Some(refresh_token) = + &auth_response.refresh_token + { + set_refresh_token.set(Some(refresh_token.clone())); + } + + // Store in localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + if let Some(refresh_token) = + &auth_response.refresh_token + { + let _ = storage.set_item( + "refresh_token", + refresh_token, + ); + } + if let Ok(user_json) = + serde_json::to_string(&auth_response.user) + { + let _ = + storage.set_item("user", &user_json); + } + } + } + + set_state.update(|s| { + s.user = Some(auth_response.user); + s.is_loading = false; + s.requires_2fa = false; + s.pending_2fa_email = None; + }); + } + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("login-failed")); + s.is_loading = false; + }); + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Login failed".to_string()); + let error_msg = parse_error_response(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }) + }; + + let clear_2fa_state_action = { + let set_state = set_state.clone(); + Arc::new(move || { + set_state.update(|s| { + s.requires_2fa = false; + s.pending_2fa_email = None; + s.error = None; + }); + }) + }; + + let actions = AuthActions { + login: login_action, + login_with_2fa: login_with_2fa_action, + logout: logout_action, + register: register_action, + refresh_token: refresh_token_action, + update_profile: update_profile_action, + change_password: change_password_action, + clear_error: clear_error_action, + clear_2fa_state: clear_2fa_state_action, + }; + + let context = AuthContext { + state: state.into(), + actions, + }; + + provide_context(context); + + view! { + {children()} + } +} + +#[derive(Clone)] +pub struct UseAuth(pub AuthContext); + +impl UseAuth { + pub fn new() -> Self { + Self(expect_context::<AuthContext>()) + } +} + +pub fn use_auth() -> UseAuth { + UseAuth::new() +} diff --git a/client/src/auth/context_simple.rs b/client/src/auth/context_simple.rs new file mode 100644 index 0000000..b1fb96d --- /dev/null +++ b/client/src/auth/context_simple.rs @@ -0,0 +1,677 @@ +use crate::i18n::use_i18n; +use leptos::prelude::*; +use shared::auth::{AuthResponse, User}; +use std::rc::Rc; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, Debug)] +pub struct AuthState { + pub user: Option<User>, + pub is_loading: bool, + pub error: Option<String>, +} + +impl Default for AuthState { + fn default() -> Self { + Self { + user: None, + is_loading: false, + error: None, + } + } +} + +#[derive(Clone)] +pub struct AuthActions { + pub login: Rc<dyn Fn(String, String, bool) -> ()>, + pub logout: Rc<dyn Fn() -> ()>, + pub register: Rc<dyn Fn(String, String, String, Option<String>) -> ()>, + pub refresh_token: Rc<dyn Fn() -> ()>, + pub update_profile: Rc<dyn Fn(String, Option<String>, Option<String>) -> ()>, + pub change_password: Rc<dyn Fn(String, String) -> ()>, + pub clear_error: Rc<dyn Fn() -> ()>, +} + +#[derive(Clone)] +pub struct AuthContext { + pub state: ReadSignal<AuthState>, + pub actions: AuthActions, +} + +impl AuthContext { + pub fn is_authenticated(&self) -> bool { + self.state.get().user.is_some() + } + + pub fn is_loading(&self) -> bool { + self.state.get().is_loading + } + + pub fn user(&self) -> Option<User> { + self.state.get().user + } + + pub fn error(&self) -> Option<String> { + self.state.get().error + } + + pub fn has_role(&self, role: &shared::auth::Role) -> bool { + self.state + .get() + .user + .as_ref() + .map_or(false, |user| user.has_role(role)) + } + + pub fn has_permission(&self, permission: &shared::auth::Permission) -> bool { + self.state + .get() + .user + .as_ref() + .map_or(false, |user| user.has_permission(permission)) + } + + pub fn is_admin(&self) -> bool { + self.state + .get() + .user + .as_ref() + .map_or(false, |user| user.is_admin()) + } +} + +/// Helper function to get localized error message from server response +fn get_localized_error(error_text: &str, i18n: &crate::i18n::UseI18n) -> String { + let error_lower = error_text.to_lowercase(); + + let key = if error_lower.contains("invalid credentials") { + "invalid-credentials" + } else if error_lower.contains("user not found") { + "user-not-found" + } else if error_lower.contains("email already exists") { + "email-already-exists" + } else if error_lower.contains("username already exists") { + "username-already-exists" + } else if error_lower.contains("invalid token") { + "invalid-token" + } else if error_lower.contains("token expired") { + "token-expired" + } else if error_lower.contains("insufficient permissions") { + "insufficient-permissions" + } else if error_lower.contains("account not verified") { + "account-not-verified" + } else if error_lower.contains("account suspended") { + "account-suspended" + } else if error_lower.contains("rate limit exceeded") { + "rate-limit-exceeded" + } else if error_lower.contains("session expired") { + "session-expired" + } else if error_lower.contains("network") { + "network-error" + } else if error_lower.contains("login") && error_lower.contains("failed") { + "login-failed" + } else if error_lower.contains("registration") && error_lower.contains("failed") { + "registration-failed" + } else if error_lower.contains("profile") && error_lower.contains("failed") { + "profile-update-failed" + } else if error_lower.contains("password") && error_lower.contains("failed") { + "password-change-failed" + } else { + "unknown-error" + }; + + i18n.t(key) +} + +#[component] +pub fn AuthProvider(children: leptos::prelude::Children) -> impl IntoView { + let i18n = use_i18n(); + let (state, set_state) = signal(AuthState::default()); + let (access_token, set_access_token) = signal::<Option<String>>(None); + let (refresh_token_state, set_refresh_token) = signal::<Option<String>>(None); + + // Initialize auth state from localStorage + create_effect(move |_| { + // Try to load stored tokens and user data + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + // Load access token + if let Ok(Some(token)) = storage.get_item("access_token") { + set_access_token.update(|t| *t = Some(token)); + } + + // Load refresh token + if let Ok(Some(token)) = storage.get_item("refresh_token") { + set_refresh_token.update(|t| *t = Some(token)); + } + + // Load user data + if let Ok(Some(user_data)) = storage.get_item("user") { + if let Ok(user) = serde_json::from_str::<User>(&user_data) { + set_state.update(|s| { + s.user = Some(user); + }); + } + } + } + } + }); + + let login_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + Rc::new(move |email: String, password: String, remember_me: bool| { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| { + s.is_loading = true; + s.error = None; + }); + + let login_data = serde_json::json!({ + "email": email, + "password": password, + "remember_me": remember_me + }); + + match reqwasm::http::Request::post("/api/auth/login") + .header("Content-Type", "application/json") + .body(login_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + match response.json::<serde_json::Value>().await { + Ok(json) => { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + // Store tokens + set_access_token.update(|t| { + *t = Some(auth_response.access_token.clone()) + }); + if let Some(refresh_token) = + &auth_response.refresh_token + { + set_refresh_token + .update(|t| *t = Some(refresh_token.clone())); + } + + // Store in localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + if let Some(refresh_token) = + &auth_response.refresh_token + { + let _ = storage.set_item( + "refresh_token", + refresh_token, + ); + } + if let Ok(user_json) = + serde_json::to_string(&auth_response.user) + { + let _ = + storage.set_item("user", &user_json); + } + } + } + + set_state.update(|s| { + s.user = Some(auth_response.user); + s.is_loading = false; + }); + } + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("login-failed")); + s.is_loading = false; + }); + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Login failed".to_string()); + let error_msg = get_localized_error(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }) + }; + + let logout_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + + Rc::new(move || { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + + spawn_local(async move { + // Call logout endpoint + let _ = reqwasm::http::Request::post("/api/auth/logout") + .header( + "Authorization", + &format!("Bearer {}", access_token.get().unwrap_or_default()), + ) + .send() + .await; + + // Clear local state + set_state.update(|s| { + s.user = None; + s.error = None; + s.is_loading = false; + }); + + set_access_token.update(|t| *t = None); + set_refresh_token.update(|t| *t = None); + + // Clear localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.remove_item("access_token"); + let _ = storage.remove_item("refresh_token"); + let _ = storage.remove_item("user"); + } + } + }); + }) + }; + + let register_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + Rc::new( + move |email: String, + username: String, + password: String, + display_name: Option<String>| { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let set_refresh_token = set_refresh_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| { + s.is_loading = true; + s.error = None; + }); + + let register_data = serde_json::json!({ + "email": email, + "username": username, + "password": password, + "display_name": display_name + }); + + match reqwasm::http::Request::post("/api/auth/register") + .header("Content-Type", "application/json") + .body(register_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + match response.json::<serde_json::Value>().await { + Ok(json) => { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + // Store tokens and user data similar to login + set_access_token.update(|t| { + *t = Some(auth_response.access_token.clone()) + }); + if let Some(refresh_token) = + &auth_response.refresh_token + { + set_refresh_token.update(|t| { + *t = Some(refresh_token.clone()) + }); + } + + // Store in localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = + window.local_storage() + { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + if let Some(refresh_token) = + &auth_response.refresh_token + { + let _ = storage.set_item( + "refresh_token", + refresh_token, + ); + } + if let Ok(user_json) = serde_json::to_string( + &auth_response.user, + ) { + let _ = storage + .set_item("user", &user_json); + } + } + } + + set_state.update(|s| { + s.user = Some(auth_response.user); + s.is_loading = false; + }); + } + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("registration-failed")); + s.is_loading = false; + }); + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Registration failed".to_string()); + let error_msg = get_localized_error(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }, + ) + }; + + let refresh_token_action = { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let refresh_token_state = refresh_token_state.clone(); + let i18n = i18n.clone(); + + Rc::new(move || { + let set_state = set_state.clone(); + let set_access_token = set_access_token.clone(); + let refresh_token_state = refresh_token_state.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + if let Some(refresh_token) = refresh_token_state.get() { + let refresh_data = serde_json::json!({ + "refresh_token": refresh_token + }); + + match reqwasm::http::Request::post("/api/auth/refresh") + .header("Content-Type", "application/json") + .body(refresh_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + if let Ok(json) = response.json::<serde_json::Value>().await { + if let Some(data) = json.get("data") { + if let Ok(auth_response) = + serde_json::from_value::<AuthResponse>(data.clone()) + { + set_access_token.update(|t| { + *t = Some(auth_response.access_token.clone()) + }); + + // Update localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item( + "access_token", + &auth_response.access_token, + ); + } + } + } + } + } + } else { + // Refresh failed, logout user + set_state.update(|s| { + s.user = None; + s.error = Some(i18n.t("session-expired")); + }); + } + } + Err(_) => { + // Refresh failed, logout user + set_state.update(|s| { + s.user = None; + s.error = Some(i18n.t("session-expired")); + }); + } + } + } + }); + }) + }; + + let update_profile_action = { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + Rc::new( + move |display_name: String, first_name: Option<String>, last_name: Option<String>| { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| s.is_loading = true); + + let update_data = serde_json::json!({ + "display_name": display_name, + "first_name": first_name, + "last_name": last_name + }); + + match reqwasm::http::Request::put("/api/auth/profile") + .header("Content-Type", "application/json") + .header( + "Authorization", + &format!("Bearer {}", access_token.get().unwrap_or_default()), + ) + .body(update_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + if let Ok(json) = response.json::<serde_json::Value>().await { + if let Some(data) = json.get("data") { + if let Ok(user) = + serde_json::from_value::<User>(data.clone()) + { + set_state.update(|s| { + s.user = Some(user.clone()); + s.is_loading = false; + }); + + // Update localStorage + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + if let Ok(user_json) = + serde_json::to_string(&user) + { + let _ = + storage.set_item("user", &user_json); + } + } + } + } + } + } + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Profile update failed".to_string()); + let error_msg = get_localized_error(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }, + ) + }; + + let change_password_action = { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + Rc::new(move |current_password: String, new_password: String| { + let set_state = set_state.clone(); + let access_token = access_token.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + set_state.update(|s| s.is_loading = true); + + let change_data = serde_json::json!({ + "current_password": current_password, + "new_password": new_password + }); + + match reqwasm::http::Request::post("/api/auth/change-password") + .header("Content-Type", "application/json") + .header( + "Authorization", + &format!("Bearer {}", access_token.get().unwrap_or_default()), + ) + .body(change_data.to_string()) + .send() + .await + { + Ok(response) => { + if response.ok() { + set_state.update(|s| { + s.is_loading = false; + }); + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Password change failed".to_string()); + let error_msg = get_localized_error(&error_text, &i18n); + set_state.update(|s| { + s.error = Some(error_msg); + s.is_loading = false; + }); + } + } + Err(_) => { + set_state.update(|s| { + s.error = Some(i18n.t("network-error")); + s.is_loading = false; + }); + } + } + }); + }) + }; + + let clear_error_action = { + let set_state = set_state.clone(); + Rc::new(move || { + set_state.update(|s| s.error = None); + }) + }; + + let actions = AuthActions { + login: login_action, + logout: logout_action, + register: register_action, + refresh_token: refresh_token_action, + update_profile: update_profile_action, + change_password: change_password_action, + clear_error: clear_error_action, + }; + + let context = AuthContext { + state: state.into(), + actions, + }; + + provide_context(context); + + view! { + <div> + {children()} + </div> + } +} + +#[derive(Clone)] +pub struct UseAuth(pub AuthContext); + +impl UseAuth { + pub fn new() -> Self { + Self(expect_context::<AuthContext>()) + } +} + +pub fn use_auth() -> UseAuth { + UseAuth::new() +} diff --git a/client/src/auth/error_display.rs b/client/src/auth/error_display.rs new file mode 100644 index 0000000..dc36589 --- /dev/null +++ b/client/src/auth/error_display.rs @@ -0,0 +1,196 @@ +use crate::i18n::use_i18n; +use gloo_timers::callback::Timeout; +use leptos::prelude::*; + +/// A component that displays authentication errors with proper internationalization +#[component] +pub fn AuthErrorDisplay( + /// The error message to display (optional) + #[prop(optional)] + error: Option<String>, + /// Whether to show the error in a dismissible alert + #[prop(default = true)] + dismissible: bool, + /// Additional CSS classes to apply + #[prop(optional)] + class: Option<String>, + /// Callback when error is dismissed + #[prop(optional)] + on_dismiss: Option<Callback<()>>, +) -> impl IntoView { + let i18n = use_i18n(); + + view! { + <Show when=move || error.is_some()> + <div class=move || format!( + "bg-red-50 border border-red-200 rounded-md p-4 mb-4 {}", + class.as_deref().unwrap_or("") + )> + <div class="flex items-start"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3 flex-1"> + <p class="text-sm font-medium text-red-800"> + {move || error.clone().unwrap_or_default()} + </p> + </div> + <Show when=move || dismissible && on_dismiss.is_some()> + <div class="ml-auto pl-3"> + <div class="-mx-1.5 -my-1.5"> + <button + type="button" + class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600" + on:click=move |_| { + if let Some(callback) = on_dismiss { + callback.call(()); + } + } + > + <span class="sr-only">{i18n.t("dismiss")}</span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> + </svg> + </button> + </div> + </div> + </Show> + </div> + </div> + </Show> + } +} + +/// A toast notification component for displaying errors +#[component] +pub fn AuthErrorToast( + /// The error message to display + error: String, + /// Duration in milliseconds before auto-dismiss (0 = no auto-dismiss) + #[prop(default = 5000)] + duration: u32, + /// Callback when toast is dismissed + #[prop(optional)] + on_dismiss: Option<Callback<()>>, +) -> impl IntoView { + let i18n = use_i18n(); + let (visible, set_visible) = signal(true); + + // Auto-dismiss after duration + if duration > 0 { + let timeout = Timeout::new(duration, move || { + set_visible.set(false); + if let Some(callback) = on_dismiss { + callback.call(()); + } + }); + timeout.forget(); + } + + view! { + <Show when=move || visible.get()> + <div class="fixed top-4 right-4 z-50 max-w-sm w-full"> + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg"> + <div class="flex items-start"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3 flex-1"> + <p class="text-sm font-medium"> + {error} + </p> + </div> + <div class="ml-auto pl-3"> + <div class="-mx-1.5 -my-1.5"> + <button + type="button" + class="inline-flex bg-red-100 rounded-md p-1.5 text-red-500 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-100 focus:ring-red-600" + on:click=move |_| { + set_visible.set(false); + if let Some(callback) = on_dismiss { + callback.call(()); + } + } + > + <span class="sr-only">{i18n.t("dismiss")}</span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> + </svg> + </button> + </div> + </div> + </div> + </div> + </div> + </Show> + } +} + +/// A more compact inline error display +#[component] +pub fn InlineAuthError( + /// The error message to display + error: String, + /// Additional CSS classes + #[prop(optional)] + class: Option<String>, +) -> impl IntoView { + view! { + <div class=move || format!( + "text-sm text-red-600 mt-1 {}", + class.as_deref().unwrap_or("") + )> + <div class="flex items-center"> + <svg class="h-4 w-4 mr-1 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/> + </svg> + <span>{error}</span> + </div> + </div> + } +} + +/// Example usage component showing how to integrate with the auth context +#[component] +pub fn AuthErrorExample() -> impl IntoView { + let auth = crate::auth::use_auth(); + let i18n = use_i18n(); + + view! { + <div class="space-y-4"> + <h3 class="text-lg font-medium text-gray-900"> + {i18n.t("authentication-errors")} + </h3> + + // Display current auth error if any + <AuthErrorDisplay + error=move || auth.0.error() + on_dismiss=Callback::new(move |_| { + (auth.0.actions.clear_error)(); + }) + /> + + // Example of inline error display + <Show when=move || auth.0.error().is_some()> + <InlineAuthError + error=move || auth.0.error().unwrap_or_default() + /> + </Show> + + // Example of toast notification + <Show when=move || auth.0.error().is_some()> + <AuthErrorToast + error=move || auth.0.error().unwrap_or_default() + duration=3000 + on_dismiss=Callback::new(move |_| { + (auth.0.actions.clear_error)(); + }) + /> + </Show> + </div> + } +} diff --git a/client/src/auth/errors.rs b/client/src/auth/errors.rs new file mode 100644 index 0000000..f41821e --- /dev/null +++ b/client/src/auth/errors.rs @@ -0,0 +1,163 @@ +use crate::i18n::UseI18n; +use serde_json; +use shared::auth::AuthError; + +/// Helper struct for handling authentication errors with internationalization +#[derive(Clone)] +pub struct AuthErrorHandler { + i18n: UseI18n, +} + +impl AuthErrorHandler { + pub fn new(i18n: UseI18n) -> Self { + Self { i18n } + } + + /// Convert a server response error to a localized error message + pub async fn handle_response_error(&self, response: &reqwasm::http::Response) -> String { + if let Ok(error_text) = response.text().await { + self.map_error_to_localized_message(&error_text) + } else { + self.i18n.t("unknown-error") + } + } + + /// Map error text to localized message + pub fn map_error_to_localized_message(&self, error_text: &str) -> String { + let translation_key = self.map_error_to_translation_key(error_text); + self.i18n.t(&translation_key) + } + + /// Map server errors to translation keys + pub fn map_error_to_translation_key(&self, error_text: &str) -> String { + // Try to parse as JSON first (standard API error response) + if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(error_text) { + if let Some(message) = json_value.get("message").and_then(|m| m.as_str()) { + return self.map_error_message_to_key(message); + } + if let Some(errors) = json_value.get("errors").and_then(|e| e.as_array()) { + if let Some(first_error) = errors.first().and_then(|e| e.as_str()) { + return self.map_error_message_to_key(first_error); + } + } + } + + // Fallback to direct message mapping + self.map_error_message_to_key(error_text) + } + + /// Map error messages to translation keys + fn map_error_message_to_key(&self, message: &str) -> String { + let message_lower = message.to_lowercase(); + + match message_lower.as_str() { + msg if msg.contains("invalid credentials") => "invalid-credentials".to_string(), + msg if msg.contains("user not found") => "user-not-found".to_string(), + msg if msg.contains("email already exists") => "email-already-exists".to_string(), + msg if msg.contains("username already exists") => "username-already-exists".to_string(), + msg if msg.contains("invalid token") => "invalid-token".to_string(), + msg if msg.contains("token expired") => "token-expired".to_string(), + msg if msg.contains("insufficient permissions") => { + "insufficient-permissions".to_string() + } + msg if msg.contains("account not verified") => "account-not-verified".to_string(), + msg if msg.contains("account suspended") => "account-suspended".to_string(), + msg if msg.contains("rate limit exceeded") => "rate-limit-exceeded".to_string(), + msg if msg.contains("oauth") => "oauth-error".to_string(), + msg if msg.contains("database") => "database-error".to_string(), + msg if msg.contains("validation") => "validation-error".to_string(), + msg if msg.contains("login failed") => "login-failed".to_string(), + msg if msg.contains("registration failed") => "registration-failed".to_string(), + msg if msg.contains("session expired") => "session-expired".to_string(), + msg if msg.contains("profile") && msg.contains("failed") => { + "profile-update-failed".to_string() + } + msg if msg.contains("password") && msg.contains("failed") => { + "password-change-failed".to_string() + } + msg if msg.contains("network") => "network-error".to_string(), + msg if msg.contains("server") => "server-error".to_string(), + msg if msg.contains("internal") => "internal-error".to_string(), + _ => "unknown-error".to_string(), + } + } + + /// Handle AuthError enum directly + pub fn handle_auth_error(&self, error: &AuthError) -> String { + let translation_key = match error { + AuthError::InvalidCredentials => "invalid-credentials", + AuthError::UserNotFound => "user-not-found", + AuthError::EmailAlreadyExists => "email-already-exists", + AuthError::UsernameAlreadyExists => "username-already-exists", + AuthError::InvalidToken => "invalid-token", + AuthError::TokenExpired => "token-expired", + AuthError::InsufficientPermissions => "insufficient-permissions", + AuthError::AccountNotVerified => "account-not-verified", + AuthError::AccountSuspended => "account-suspended", + AuthError::RateLimitExceeded => "rate-limit-exceeded", + AuthError::OAuthError(_) => "oauth-error", + AuthError::DatabaseError => "database-error", + AuthError::InternalError => "internal-error", + AuthError::ValidationError(_) => "validation-error", + }; + + self.i18n.t(translation_key) + } + + /// Handle network errors + pub fn handle_network_error(&self) -> String { + self.i18n.t("network-error") + } + + /// Handle generic request failures + pub fn handle_request_failure(&self, operation: &str) -> String { + match operation { + "login" => self.i18n.t("login-failed"), + "register" => self.i18n.t("registration-failed"), + "profile-update" => self.i18n.t("profile-update-failed"), + "password-change" => self.i18n.t("password-change-failed"), + _ => self.i18n.t("request-failed"), + } + } + + /// Check if an error indicates session expiration + pub fn is_session_expired(&self, error_text: &str) -> bool { + let error_lower = error_text.to_lowercase(); + error_lower.contains("session expired") + || error_lower.contains("token expired") + || error_lower.contains("invalid token") + || error_lower.contains("unauthorized") + } + + /// Get appropriate error message for session expiration + pub fn get_session_expired_message(&self) -> String { + self.i18n.t("session-expired") + } +} + +/// Helper function to create an AuthErrorHandler +pub fn create_auth_error_handler(i18n: UseI18n) -> AuthErrorHandler { + AuthErrorHandler::new(i18n) +} + +/// Trait for handling authentication errors consistently +pub trait AuthErrorHandling { + fn handle_auth_error(&self, error: &str) -> String; + fn handle_network_error(&self) -> String; + fn handle_session_expired(&self) -> String; +} + +impl AuthErrorHandling for UseI18n { + fn handle_auth_error(&self, error: &str) -> String { + let handler = create_auth_error_handler(self.clone()); + handler.map_error_to_localized_message(error) + } + + fn handle_network_error(&self) -> String { + self.t("network-error") + } + + fn handle_session_expired(&self) -> String { + self.t("session-expired") + } +} diff --git a/client/src/auth/login.rs b/client/src/auth/login.rs new file mode 100644 index 0000000..3e413bf --- /dev/null +++ b/client/src/auth/login.rs @@ -0,0 +1,254 @@ +use leptos::html::Input; +use leptos::prelude::*; +use web_sys::SubmitEvent; + +use super::context::use_auth; +use crate::i18n::use_i18n; + +#[component] +pub fn LoginForm() -> impl IntoView { + let auth = use_auth(); + let i18n = use_i18n(); + + // Store contexts in StoredValue to avoid move issues + let auth_stored = StoredValue::new(auth); + let i18n_stored = StoredValue::new(i18n); + + let (email, set_email) = signal(String::new()); + let (password, set_password) = signal(String::new()); + let (remember_me, set_remember_me) = signal(false); + let (show_password, set_show_password) = signal(false); + + let email_ref = NodeRef::<Input>::new(); + let password_ref = NodeRef::<Input>::new(); + + let on_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + + let email_val = email.get(); + let password_val = password.get(); + let remember_val = remember_me.get(); + + if !email_val.is_empty() && !password_val.is_empty() { + (auth_stored.get_value().0.actions.login)(email_val, password_val, remember_val); + } + }; + + let toggle_password_visibility = move |_| { + set_show_password.update(|show| *show = !*show); + }; + + let clear_error = move |_| { + (auth_stored.get_value().0.actions.clear_error)(); + }; + + view! { + <div class="w-full max-w-md mx-auto"> + <div class="bg-white shadow-lg rounded-lg p-8"> + <div class="text-center mb-8"> + <h2 class="text-3xl font-bold text-gray-900">{move || i18n_stored.get_value().t("sign-in")}</h2> + <p class="text-gray-600 mt-2">{move || i18n_stored.get_value().t("welcome-back")}</p> + </div> + + <Show when=move || auth_stored.get_value().0.error().is_some()> + <div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm text-red-800"> + {move || auth_stored.get_value().0.error().unwrap_or_default()} + </p> + </div> + <div class="ml-auto pl-3"> + <button + type="button" + class="inline-flex text-red-400 hover:text-red-600" + on:click=clear_error + > + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> + </svg> + </button> + </div> + </div> + </div> + </Show> + + <form on:submit=on_submit class="space-y-6"> + <div> + <label for="email" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("email-address")} + </label> + <input + node_ref=email_ref + type="email" + id="email" + name="email" + required + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder=move || i18n_stored.get_value().t("enter-email") + prop:value=email + on:input=move |ev| set_email.set(event_target_value(&ev)) + /> + </div> + + <div> + <label for="password" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("password")} + </label> + <div class="relative"> + <input + node_ref=password_ref + type=move || if show_password.get() { "text" } else { "password" } + id="password" + name="password" + required + class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder=move || i18n_stored.get_value().t("enter-password") + prop:value=password + on:input=move |ev| set_password.set(event_target_value(&ev)) + /> + <button + type="button" + class="absolute inset-y-0 right-0 pr-3 flex items-center" + on:click=toggle_password_visibility + > + <Show + when=move || show_password.get() + fallback=move || view! { + <svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> + </svg> + } + > + <svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/> + </svg> + </Show> + </button> + </div> + </div> + + <div class="flex items-center justify-between"> + <div class="flex items-center"> + <input + id="remember-me" + name="remember-me" + type="checkbox" + class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + prop:checked=remember_me + on:change=move |ev| set_remember_me.set(event_target_checked(&ev)) + /> + <label for="remember-me" class="ml-2 block text-sm text-gray-900"> + {move || i18n_stored.get_value().t("remember-me")} + </label> + </div> + + <div class="text-sm"> + <a href="/auth/forgot-password" class="font-medium text-blue-600 hover:text-blue-500"> + {move || i18n_stored.get_value().t("forgot-password")} + </a> + </div> + </div> + + <div> + <button + type="submit" + disabled=move || auth_stored.get_value().0.is_loading() + class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + > + <Show + when=move || auth_stored.get_value().0.is_loading() + fallback=move || view! { {i18n_stored.get_value().t("sign-in")} } + > + <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + {i18n_stored.get_value().t("signing-in")} + </Show> + </button> + </div> + </form> + + <div class="mt-6"> + <div class="relative"> + <div class="absolute inset-0 flex items-center"> + <div class="w-full border-t border-gray-300"/> + </div> + <div class="relative flex justify-center text-sm"> + <span class="px-2 bg-white text-gray-500">{move || i18n_stored.get_value().t("continue-with")}</span> + </div> + </div> + + <div class="mt-6 grid grid-cols-3 gap-3"> + <button + type="button" + class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" + on:click=move |_| { + // TODO: Implement OAuth login + if let Err(e) = window().location().set_href("/api/auth/oauth/google/authorize") { + web_sys::console::error_1(&format!("Failed to redirect to Google OAuth: {:?}", e).into()); + } + } + > + <svg class="h-5 w-5" viewBox="0 0 24 24"> + <path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/> + <path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/> + <path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/> + <path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/> + </svg> + <span class="sr-only">Sign in with Google</span> + </button> + + <button + type="button" + class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" + on:click=move |_| { + // TODO: Implement OAuth login + if let Err(e) = window().location().set_href("/api/auth/oauth/github/authorize") { + web_sys::console::error_1(&format!("Failed to redirect to GitHub OAuth: {:?}", e).into()); + } + } + > + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> + <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> + </svg> + <span class="sr-only">Sign in with GitHub</span> + </button> + + <button + type="button" + class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" + on:click=move |_| { + // TODO: Implement OAuth login + if let Err(e) = window().location().set_href("/api/auth/oauth/discord/authorize") { + web_sys::console::error_1(&format!("Failed to redirect to Discord OAuth: {:?}", e).into()); + } + } + > + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> + <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/> + </svg> + <span class="sr-only">Sign in with Discord</span> + </button> + </div> + </div> + + <div class="mt-6 text-center"> + <p class="text-sm text-gray-600"> + {move || i18n_stored.get_value().t("dont-have-account")}{" "} + <a href="/auth/register" class="font-medium text-blue-600 hover:text-blue-500"> + {move || i18n_stored.get_value().t("sign-up")} + </a> + </p> + </div> + </div> + </div> + } +} diff --git a/client/src/auth/mod.rs b/client/src/auth/mod.rs new file mode 100644 index 0000000..9f8235c --- /dev/null +++ b/client/src/auth/mod.rs @@ -0,0 +1,11 @@ +pub mod context; +pub mod login; +pub mod register; +// pub mod two_factor; +// pub mod two_factor_login; + +pub use context::{AuthContext, AuthProvider, AuthState, UseAuth, use_auth}; +pub use login::LoginForm; +pub use register::RegisterForm; +// pub use two_factor::TwoFactorSetup; +// pub use two_factor_login::{TwoFactorLoginForm, TwoFactorLoginPage}; diff --git a/client/src/auth/register.rs b/client/src/auth/register.rs new file mode 100644 index 0000000..6785a2a --- /dev/null +++ b/client/src/auth/register.rs @@ -0,0 +1,484 @@ +use leptos::html::Input; +use leptos::prelude::*; +use web_sys::SubmitEvent; + +use super::context::use_auth; +use crate::i18n::use_i18n; + +#[component] +pub fn RegisterForm() -> impl IntoView { + let auth = use_auth(); + let i18n = use_i18n(); + + // Store contexts in StoredValue to avoid move issues + let auth_stored = StoredValue::new(auth); + let i18n_stored = StoredValue::new(i18n); + + let (email, set_email) = signal(String::new()); + let (username, set_username) = signal(String::new()); + let (password, set_password) = signal(String::new()); + let (confirm_password, set_confirm_password) = signal(String::new()); + let (display_name, set_display_name) = signal(String::new()); + let (show_password, set_show_password) = signal(false); + let (show_confirm_password, set_show_confirm_password) = signal(false); + + let email_ref = NodeRef::<Input>::new(); + let username_ref = NodeRef::<Input>::new(); + let password_ref = NodeRef::<Input>::new(); + let confirm_password_ref = NodeRef::<Input>::new(); + + let password_strength = Memo::new(move |_| { + let pwd = password.get(); + if pwd.is_empty() { + return ("", ""); + } + + let mut score = 0; + let mut feedback = Vec::new(); + + if pwd.len() >= 8 { + score += 1; + } else { + feedback.push("At least 8 characters"); + } + + if pwd.chars().any(|c| c.is_uppercase()) { + score += 1; + } else { + feedback.push("One uppercase letter"); + } + + if pwd.chars().any(|c| c.is_lowercase()) { + score += 1; + } else { + feedback.push("One lowercase letter"); + } + + if pwd.chars().any(|c| c.is_numeric()) { + score += 1; + } else { + feedback.push("One number"); + } + + if pwd.chars().any(|c| !c.is_alphanumeric()) { + score += 1; + } else { + feedback.push("One special character"); + } + + let strength = match score { + 0..=1 => ("Very Weak", "bg-red-500"), + 2 => ("Weak", "bg-orange-500"), + 3 => ("Fair", "bg-yellow-500"), + 4 => ("Good", "bg-blue-500"), + 5 => ("Strong", "bg-green-500"), + _ => ("Strong", "bg-green-500"), + }; + + (strength.0, strength.1) + }); + + let passwords_match = move || { + let pwd = password.get(); + let confirm = confirm_password.get(); + pwd == confirm && !pwd.is_empty() + }; + + let form_is_valid = move || { + !email.get().is_empty() + && !username.get().is_empty() + && !password.get().is_empty() + && passwords_match() + && password.get().len() >= 8 + }; + + let on_submit = move |ev: SubmitEvent| { + ev.prevent_default(); + + if form_is_valid() { + let email_val = email.get(); + let username_val = username.get(); + let password_val = password.get(); + let display_name_val = if display_name.get().is_empty() { + None + } else { + Some(display_name.get()) + }; + + (auth_stored.get_value().0.actions.register)( + email_val, + username_val, + password_val, + display_name_val, + ); + } + }; + + let toggle_password_visibility = move |_| { + set_show_password.update(|show| *show = !*show); + }; + + let toggle_confirm_password_visibility = move |_| { + set_show_confirm_password.update(|show| *show = !*show); + }; + + let clear_error = move |_| { + (auth_stored.get_value().0.actions.clear_error)(); + }; + + view! { + <div class="w-full max-w-md mx-auto"> + <div class="bg-white shadow-lg rounded-lg p-8"> + <div class="text-center mb-8"> + <h2 class="text-3xl font-bold text-gray-900">{move || i18n_stored.get_value().t("create-account")}</h2> + <p class="text-gray-600 mt-2">{move || i18n_stored.get_value().t("join-us-today")}</p> + </div> + + <Show when=move || auth_stored.get_value().0.error().is_some()> + <div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm text-red-800"> + {move || auth_stored.get_value().0.error().unwrap_or_default()} + </p> + </div> + <div class="ml-auto pl-3"> + <button + type="button" + class="inline-flex text-red-400 hover:text-red-600" + on:click=clear_error + > + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> + </svg> + </button> + </div> + </div> + </div> + </Show> + + <form on:submit=on_submit class="space-y-6"> + <div> + <label for="email" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("email-address")} + </label> + <input + node_ref=email_ref + type="email" + id="email" + name="email" + required + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder=move || i18n_stored.get_value().t("enter-email") + prop:value=email + on:input=move |ev| set_email.set(event_target_value(&ev)) + /> + </div> + + <div> + <label for="username" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("username")} + </label> + <input + node_ref=username_ref + type="text" + id="username" + name="username" + required + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder=move || i18n_stored.get_value().t("enter-username") + prop:value=username + on:input=move |ev| set_username.set(event_target_value(&ev)) + /> + <p class="mt-1 text-sm text-gray-500"> + {move || i18n_stored.get_value().t("username-format")} + </p> + </div> + + <div> + <label for="display_name" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("display-name")} + </label> + <input + type="text" + id="display_name" + name="display_name" + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder=move || i18n_stored.get_value().t("how-should-we-call-you") + prop:value=display_name + on:input=move |ev| set_display_name.set(event_target_value(&ev)) + /> + </div> + + <div> + <label for="password" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("password")} + </label> + <div class="relative"> + <input + node_ref=password_ref + type=move || if show_password.get() { "text" } else { "password" } + id="password" + name="password" + required + class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder=move || i18n_stored.get_value().t("enter-password") + prop:value=password + on:input=move |ev| set_password.set(event_target_value(&ev)) + /> + <button + type="button" + class="absolute inset-y-0 right-0 pr-3 flex items-center" + on:click=toggle_password_visibility + > + <Show + when=move || show_password.get() + fallback=move || view! { + <svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> + </svg> + } + > + <svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/> + </svg> + </Show> + </button> + </div> + + <Show when=move || !password.get().is_empty()> + <div class="mt-2"> + <div class="flex items-center justify-between text-sm"> + <span class="text-gray-600">{move || i18n_stored.get_value().t("password-strength")}</span> + <span class=move || format!("font-medium {}", match password_strength.get().0 { + "Very Weak" => "text-red-600", + "Weak" => "text-orange-600", + "Fair" => "text-yellow-600", + "Good" => "text-blue-600", + "Strong" => "text-green-600", + _ => "text-gray-600", + })> + {move || { + let strength = password_strength.get().0; + match strength { + "Very Weak" => i18n_stored.get_value().t("very-weak"), + "Weak" => i18n_stored.get_value().t("weak"), + "Fair" => i18n_stored.get_value().t("fair"), + "Good" => i18n_stored.get_value().t("good"), + "Strong" => i18n_stored.get_value().t("strong"), + _ => strength.to_string(), + } + }} + </span> + </div> + <div class="mt-1 h-2 bg-gray-200 rounded-full overflow-hidden"> + <div + class=move || format!("h-full transition-all duration-300 {}", password_strength.get().1) + style=move || { + let width = match password_strength.get().0 { + "Very Weak" => "20%", + "Weak" => "40%", + "Fair" => "60%", + "Good" => "80%", + "Strong" => "100%", + _ => "0%", + }; + format!("width: {}", width) + } + ></div> + </div> + </div> + </Show> + + <p class="mt-1 text-sm text-gray-500"> + {move || i18n_stored.get_value().t("password-requirements")} + </p> + </div> + + <div> + <label for="confirm-password" class="block text-sm font-medium text-gray-700 mb-2"> + {move || i18n_stored.get_value().t("confirm-password")} + </label> + <div class="relative"> + <input + node_ref=confirm_password_ref + type=move || if show_confirm_password.get() { "text" } else { "password" } + id="confirm_password" + name="confirm_password" + required + class=move || format!("w-full px-3 py-2 pr-10 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if confirm_password.get().is_empty() { + "border-gray-300" + } else if passwords_match() { + "border-green-300" + } else { + "border-red-300" + } + ) + placeholder=move || i18n_stored.get_value().t("confirm-password") + prop:value=confirm_password + on:input=move |ev| set_confirm_password.set(event_target_value(&ev)) + /> + <button + type="button" + class="absolute inset-y-0 right-0 pr-3 flex items-center" + on:click=toggle_confirm_password_visibility + > + <Show + when=move || show_confirm_password.get() + fallback=move || view! { + <svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> + </svg> + } + > + <svg class="h-5 w-5 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/> + </svg> + </Show> + </button> + </div> + + <Show when=move || !confirm_password.get().is_empty()> + <div class="mt-1 flex items-center"> + <Show + when=move || passwords_match() + fallback=move || view! { + <svg class="h-4 w-4 text-red-500 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> + </svg> + <span class="text-sm text-red-600">{move || i18n_stored.get_value().t("passwords-dont-match")}</span> + } + > + <svg class="h-4 w-4 text-green-500 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/> + </svg> + <span class="text-sm text-green-600">{move || i18n_stored.get_value().t("passwords-match")}</span> + </Show> + </div> + </Show> + </div> + + <div class="flex items-center"> + <input + id="terms" + name="terms" + type="checkbox" + required + class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + <label for="terms" class="ml-2 block text-sm text-gray-900"> + {move || i18n_stored.get_value().t("i-agree-to-the")}{" "} + <a href="/terms" class="text-blue-600 hover:text-blue-500"> + {move || i18n_stored.get_value().t("terms-of-service")} + </a> + {" "}{move || i18n_stored.get_value().t("and")}{" "} + <a href="/privacy" class="text-blue-600 hover:text-blue-500"> + {move || i18n_stored.get_value().t("privacy-policy")} + </a> + </label> + </div> + + <div> + <button + type="submit" + disabled=move || auth_stored.get_value().0.is_loading() || !form_is_valid() + class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" + > + <Show + when=move || auth_stored.get_value().0.is_loading() + fallback=move || view! { {i18n_stored.get_value().t("create-account")} } + > + <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + {i18n_stored.get_value().t("creating-account")} + </Show> + </button> + </div> + </form> + + <div class="mt-6"> + <div class="relative"> + <div class="absolute inset-0 flex items-center"> + <div class="w-full border-t border-gray-300"/> + </div> + <div class="relative flex justify-center text-sm"> + <span class="px-2 bg-white text-gray-500">{move || i18n_stored.get_value().t("continue-with")}</span> + </div> + </div> + + <div class="mt-6 grid grid-cols-3 gap-3"> + <button + type="button" + class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" + on:click=move |_| { + // TODO: Implement OAuth registration + if let Err(e) = window().location().set_href("/api/auth/oauth/google/authorize") { + web_sys::console::error_1(&format!("Failed to redirect to Google OAuth: {:?}", e).into()); + } + } + > + <svg class="h-5 w-5" viewBox="0 0 24 24"> + <path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/> + <path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/> + <path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/> + <path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/> + </svg> + <span class="sr-only">Sign up with Google</span> + </button> + + <button + type="button" + class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" + on:click=move |_| { + // TODO: Implement OAuth registration + if let Err(e) = window().location().set_href("/api/auth/oauth/github/authorize") { + web_sys::console::error_1(&format!("Failed to redirect to GitHub OAuth: {:?}", e).into()); + } + } + > + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> + <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> + </svg> + <span class="sr-only">Sign up with GitHub</span> + </button> + + <button + type="button" + class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50" + on:click=move |_| { + // TODO: Implement OAuth registration + if let Err(e) = window().location().set_href("/api/auth/oauth/discord/authorize") { + web_sys::console::error_1(&format!("Failed to redirect to Discord OAuth: {:?}", e).into()); + } + } + > + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> + <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/> + </svg> + <span class="sr-only">Sign up with Discord</span> + </button> + </div> + </div> + + <div class="mt-6 text-center"> + <p class="text-sm text-gray-600"> + {move || i18n_stored.get_value().t("already-have-account")}{" "} + <a href="/auth/login" class="font-medium text-blue-600 hover:text-blue-500"> + {move || i18n_stored.get_value().t("sign-in")} + </a> + </p> + </div> + </div> + </div> + } +} diff --git a/client/src/auth/two_factor.rs b/client/src/auth/two_factor.rs new file mode 100644 index 0000000..ad783be --- /dev/null +++ b/client/src/auth/two_factor.rs @@ -0,0 +1,318 @@ +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use shared::auth::{Setup2FARequest, Setup2FAResponse, TwoFactorStatus, Verify2FARequest}; + +use crate::utils::api_request; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse<T> { + pub success: bool, + pub data: Option<T>, + pub message: Option<String>, + pub errors: Option<Vec<String>>, +} + +#[derive(Debug, Clone)] +enum TwoFactorSetupState { + Loading, + Error, + NotEnabled, + PendingVerification(Setup2FAResponse), + Enabled(TwoFactorStatus), +} + +#[component] +pub fn TwoFactorSetup() -> impl IntoView { + let (setup_state, set_setup_state) = signal(TwoFactorSetupState::Loading); + let (password, set_password) = signal(String::new()); + let (verification_code, set_verification_code) = signal(String::new()); + let (error_message, set_error_message) = signal(Option::<String>::None); + let (success_message, set_success_message) = signal(Option::<String>::None); + + // Load 2FA status on component mount + let load_2fa_status = Action::new(move |_: &()| async move { + match api_request::<(), ApiResponse<TwoFactorStatus>>("/api/auth/2fa/status", "GET", None) + .await + { + Ok(response) => { + if response.success { + if let Some(status) = response.data { + if status.is_enabled { + set_setup_state.set(TwoFactorSetupState::Enabled(status)); + } else { + set_setup_state.set(TwoFactorSetupState::NotEnabled); + } + } + } else { + set_error_message.set(Some( + response + .message + .unwrap_or_else(|| "Failed to load 2FA status".to_string()), + )); + set_setup_state.set(TwoFactorSetupState::Error); + } + } + Err(e) => { + set_error_message.set(Some(format!("Failed to load 2FA status: {}", e))); + set_setup_state.set(TwoFactorSetupState::Error); + } + } + }); + + // Setup 2FA action + let setup_2fa_action = Action::new(move |password: &String| { + let password = password.clone(); + async move { + let request = Setup2FARequest { password }; + match api_request::<Setup2FARequest, ApiResponse<Setup2FAResponse>>( + "/api/auth/2fa/setup", + "POST", + Some(request), + ) + .await + { + Ok(response) => { + if response.success { + if let Some(setup_response) = response.data { + set_setup_state + .set(TwoFactorSetupState::PendingVerification(setup_response)); + set_success_message.set(response.message); + set_error_message.set(None); + } + } else { + set_error_message.set(response.message); + } + } + Err(e) => { + set_error_message.set(Some(format!("Failed to setup 2FA: {}", e))); + } + } + } + }); + + // Verify 2FA setup action + let verify_2fa_action = Action::new(move |code: &String| { + let code = code.clone(); + async move { + let request = Verify2FARequest { code }; + match api_request::<Verify2FARequest, ApiResponse<()>>( + "/api/auth/2fa/verify", + "POST", + Some(request), + ) + .await + { + Ok(response) => { + if response.success { + set_success_message.set(Some("2FA enabled successfully!".to_string())); + set_error_message.set(None); + load_2fa_status.dispatch(()); + } else { + set_error_message.set(response.message); + } + } + Err(e) => { + set_error_message.set(Some(format!("Failed to verify 2FA: {}", e))); + } + } + } + }); + + // Load status on mount + Effect::new(move |_| { + load_2fa_status.dispatch(()); + }); + + let handle_setup_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + if !password.get().is_empty() { + setup_2fa_action.dispatch(password.get()); + } + }; + + let handle_verify_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + if !verification_code.get().is_empty() { + verify_2fa_action.dispatch(verification_code.get()); + } + }; + + view! { + <div class="max-w-2xl mx-auto p-6"> + <h1 class="text-3xl font-bold mb-6">"Two-Factor Authentication"</h1> + + // Error message + {move || { + if let Some(msg) = error_message.get() { + view! { + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> + {msg} + </div> + }.into_any() + } else { + view! { <div></div> }.into_any() + } + }} + + // Success message + {move || { + if let Some(msg) = success_message.get() { + view! { + <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4"> + {msg} + </div> + }.into_any() + } else { + view! { <div></div> }.into_any() + } + }} + + // Main content based on setup state + {move || match setup_state.get() { + TwoFactorSetupState::Loading => view! { + <div class="text-center py-8"> + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div> + <p class="mt-2 text-gray-600">"Loading 2FA status..."</p> + </div> + }.into_any(), + + TwoFactorSetupState::Error => view! { + <div class="text-center py-8"> + <p class="text-red-600">"Failed to load 2FA status"</p> + <button + class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" + on:click=move |_| load_2fa_status.dispatch(()) + > + "Retry" + </button> + </div> + }.into_any(), + + TwoFactorSetupState::NotEnabled => view! { + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6"> + <h2 class="text-xl font-semibold mb-4">"Enable Two-Factor Authentication"</h2> + <p class="text-gray-700 mb-4"> + "Add an extra layer of security to your account by enabling two-factor authentication." + </p> + + <form on:submit=handle_setup_submit> + <div class="mb-4"> + <label class="block text-sm font-medium text-gray-700 mb-2"> + "Current Password" + </label> + <input + type="password" + class="w-full px-3 py-2 border border-gray-300 rounded-md" + placeholder="Enter your current password" + prop:value=password + on:input=move |ev| set_password.set(event_target_value(&ev)) + required + /> + </div> + + <button + type="submit" + class="w-full bg-blue-500 text-white py-2 px-4 rounded-md" + disabled=move || setup_2fa_action.pending().get() + > + {move || if setup_2fa_action.pending().get() { + "Setting up..." + } else { + "Setup 2FA" + }} + </button> + </form> + </div> + }.into_any(), + + TwoFactorSetupState::PendingVerification(setup_response) => view! { + <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6"> + <h2 class="text-xl font-semibold mb-4">"Verify Two-Factor Authentication"</h2> + + <div class="mb-6"> + <h3 class="text-lg font-medium mb-2">"Step 1: Scan QR Code"</h3> + <p class="text-gray-700 mb-4"> + "Scan this QR code with your authenticator app." + </p> + <div class="flex justify-center mb-4"> + <img + src=setup_response.qr_code_url.clone() + alt="QR Code for 2FA setup" + class="border border-gray-300 rounded" + /> + </div> + + <div class="bg-gray-100 p-3 rounded"> + <p class="text-sm text-gray-600 mb-2">"Secret:"</p> + <code class="text-sm font-mono bg-white p-2 rounded border"> + {setup_response.secret.clone()} + </code> + </div> + </div> + + <div class="mb-6"> + <h3 class="text-lg font-medium mb-2">"Step 2: Save Backup Codes"</h3> + <div class="bg-gray-100 p-4 rounded"> + <p class="text-sm text-gray-600 mb-2"> + "Backup codes: " {setup_response.backup_codes.len().to_string()} " codes generated" + </p> + </div> + </div> + + <div class="mb-6"> + <h3 class="text-lg font-medium mb-2">"Step 3: Verify Setup"</h3> + <form on:submit=handle_verify_submit> + <div class="mb-4"> + <input + type="text" + class="w-full px-3 py-2 border border-gray-300 rounded-md text-center" + placeholder="000000" + maxlength="6" + prop:value=verification_code + on:input=move |ev| set_verification_code.set(event_target_value(&ev)) + required + /> + </div> + + <button + type="submit" + class="w-full bg-green-500 text-white py-2 px-4 rounded-md" + disabled=move || verify_2fa_action.pending().get() + > + {move || if verify_2fa_action.pending().get() { + "Verifying..." + } else { + "Enable 2FA" + }} + </button> + </form> + </div> + </div> + }.into_any(), + + TwoFactorSetupState::Enabled(status) => view! { + <div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-6"> + <h2 class="text-xl font-semibold mb-4 text-green-800"> + "Two-Factor Authentication Enabled" + </h2> + <p class="text-green-700 mb-4"> + "Your account is protected with two-factor authentication." + </p> + + <div class="mb-4"> + <p class="text-sm text-gray-600"> + "Backup codes remaining: " {status.backup_codes_remaining.to_string()} + </p> + </div> + + <div> + <p class="text-sm text-gray-600"> + "Use the API endpoints to manage backup codes and disable 2FA." + </p> + </div> + </div> + }.into_any(), + }} + </div> + } +} diff --git a/client/src/auth/two_factor_login.rs b/client/src/auth/two_factor_login.rs new file mode 100644 index 0000000..cbed237 --- /dev/null +++ b/client/src/auth/two_factor_login.rs @@ -0,0 +1,246 @@ +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use shared::auth::Login2FARequest; + +use crate::auth::context::use_auth; +use crate::utils::api_request; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse<T> { + pub success: bool, + pub data: Option<T>, + pub message: Option<String>, + pub errors: Option<Vec<String>>, +} + +#[component] +pub fn TwoFactorLoginForm( + /// The email address from the first login step + email: String, + /// Whether to remember the user + remember_me: bool, + /// Callback when login is successful + #[prop(optional)] + on_success: Option<Callback<()>>, + /// Callback when user wants to go back to regular login + #[prop(optional)] + on_back: Option<Callback<()>>, +) -> impl IntoView { + let (code, set_code) = signal(String::new()); + let (error_message, set_error_message) = signal(Option::<String>::None); + let (is_submitting, set_is_submitting) = signal(false); + let (is_backup_code, set_is_backup_code) = signal(false); + + let auth_context = use_auth(); + + let submit_2fa = Action::new(move |request: &Login2FARequest| { + let request = request.clone(); + let auth_context = auth_context.clone(); + + async move { + set_is_submitting.set(true); + set_error_message.set(None); + + match api_request::<Login2FARequest, ApiResponse<shared::auth::AuthResponse>>( + "/api/auth/login/2fa", + "POST", + Some(request), + ) + .await + { + Ok(response) => { + if response.success { + if let Some(auth_response) = response.data { + // Update auth context with the successful login + // Note: You'll need to implement login_success method in auth context + // auth_context.login_success(auth_response.user, auth_response.access_token); + + // Call success callback if provided + if let Some(callback) = on_success { + callback(()); + } + } + } else { + let error_msg = response.message.unwrap_or_else(|| { + response + .errors + .map(|errs| errs.join(", ")) + .unwrap_or_else(|| "Invalid 2FA code".to_string()) + }); + set_error_message.set(Some(error_msg)); + } + } + Err(e) => { + set_error_message.set(Some(format!("Network error: {}", e))); + } + } + + set_is_submitting.set(false); + } + }); + + let handle_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + + let code_value = code.get().trim().to_string(); + if code_value.is_empty() { + set_error_message.set(Some("Please enter your 2FA code".to_string())); + return; + } + + let request = Login2FARequest { + email: email.clone(), + code: code_value, + remember_me, + }; + + submit_2fa.dispatch(request); + }; + + let handle_back = move |_| { + if let Some(callback) = on_back { + callback(()); + } + }; + + let toggle_backup_code = move |_| { + set_is_backup_code.set(!is_backup_code.get()); + set_code.set(String::new()); + set_error_message.set(None); + }; + + view! { + <div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6"> + <div class="text-center mb-6"> + <h1 class="text-2xl font-bold text-gray-900 mb-2"> + "Two-Factor Authentication" + </h1> + <p class="text-gray-600"> + "Enter the code from your authenticator app" + </p> + </div> + + // Show the email being used + <div class="mb-4 p-3 bg-gray-50 rounded-lg"> + <p class="text-sm text-gray-600"> + "Signing in as: " + <span class="font-medium text-gray-900">{email.clone()}</span> + </p> + </div> + + // Error message + {move || { + if let Some(msg) = error_message.get() { + view! { + <div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg"> + <p class="text-sm text-red-600">{msg}</p> + </div> + }.into_any() + } else { + view! { <div></div> }.into_any() + } + }} + + <form on:submit=handle_submit class="space-y-4"> + <div> + <label class="block text-sm font-medium text-gray-700 mb-2"> + {move || if is_backup_code.get() { + "Backup Code" + } else { + "Authentication Code" + }} + </label> + <input + type="text" + class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center text-lg font-mono" + placeholder=move || if is_backup_code.get() { + "Enter backup code" + } else { + "000000" + } + maxlength=move || if is_backup_code.get() { "8" } else { "6" } + autocomplete="one-time-code" + prop:value=code + on:input=move |ev| set_code.set(event_target_value(&ev)) + required + autofocus + /> + <p class="mt-1 text-xs text-gray-500"> + {move || if is_backup_code.get() { + "Use one of your 8-digit backup codes" + } else { + "Enter the 6-digit code from your authenticator app" + }} + </p> + </div> + + <div class="flex items-center justify-between"> + <button + type="button" + class="text-sm text-blue-600 hover:text-blue-800 underline" + on:click=toggle_backup_code + > + {move || if is_backup_code.get() { + "Use authenticator code" + } else { + "Use backup code" + }} + </button> + + <button + type="button" + class="text-sm text-gray-500 hover:text-gray-700 underline" + on:click=handle_back + > + "Back to login" + </button> + </div> + + <button + type="submit" + class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" + disabled=move || is_submitting.get() + > + {move || if is_submitting.get() { + "Verifying..." + } else { + "Sign In" + }} + </button> + </form> + + // Help text + <div class="mt-6 text-center"> + <p class="text-xs text-gray-500"> + "Lost your device? " + <a href="/help/2fa" class="text-blue-600 hover:text-blue-800 underline"> + "Contact support" + </a> + </p> + </div> + </div> + } +} + +#[component] +pub fn TwoFactorLoginPage() -> impl IntoView { + // Simple implementation - in a real app you'd get these from URL params or state + let email = "user@example.com".to_string(); + let remember_me = false; + + let handle_back = move |_| { + if let Some(window) = web_sys::window() { + let _ = window.location().set_href("/login"); + } + }; + + view! { + <div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> + <TwoFactorLoginForm + email=email + remember_me=remember_me + on_back=handle_back + /> + </div> + } +} diff --git a/client/src/components/Counter.rs b/client/src/components/Counter.rs new file mode 100644 index 0000000..873c981 --- /dev/null +++ b/client/src/components/Counter.rs @@ -0,0 +1,25 @@ +use leptos::prelude::*; + +#[component] +pub fn Counter() -> impl IntoView { + eprintln!("Counter rendering"); + let (count, set_count) = signal(0); + let on_click_plus = move |_| set_count.update(|c| *c += 1); + let on_click_minus = move |_| set_count.update(|c| *c -= 1); + + view! { + <div class="flex justify-center items-center gap-x-6"> + <button on:click=on_click_plus class="bg-teal-500 text-white px-4 py-2 rounded-xl"> + "Increment: " {move || count.get()} + </button> + <button on:click=on_click_minus class="bg-pink-500 text-white px-4 py-2 rounded-xl"> + "Decrement: " {move || count.get()} + </button> + </div> + <div class="mt-10"> + <p class="text-center italic dark:text-white"> + "Double: " {move || count.get() * 2} + </p> + </div> + } +} diff --git a/client/src/components/Logo.rs b/client/src/components/Logo.rs new file mode 100644 index 0000000..3f98fa8 --- /dev/null +++ b/client/src/components/Logo.rs @@ -0,0 +1,128 @@ +use leptos::prelude::*; + +#[component] +pub fn Logo( + #[prop(default = "horizontal".to_string())] orientation: String, + #[prop(default = "normal".to_string())] size: String, + #[prop(default = true)] show_text: bool, + #[prop(default = "".to_string())] class: String, + #[prop(default = false)] dark_theme: bool, +) -> impl IntoView { + let logo_path = move || { + let base_path = "/logos/"; + + if !show_text { + format!("{}rustelo-imag.svg", base_path) + } else { + match (orientation.as_str(), dark_theme) { + ("horizontal", false) => format!("{}rustelo_dev-logo-h.svg", base_path), + ("horizontal", true) => format!("{}rustelo_dev-logo-b-h.svg", base_path), + ("vertical", false) => format!("{}rustelo_dev-logo-v.svg", base_path), + ("vertical", true) => format!("{}rustelo_dev-logo-b-v.svg", base_path), + _ => format!("{}rustelo_dev-logo-h.svg", base_path), + } + } + }; + + let size_class = match size.as_str() { + "small" => "h-8 w-auto", + "medium" => "h-12 w-auto", + "large" => "h-16 w-auto", + "xlarge" => "h-20 w-auto", + _ => "h-10 w-auto", + }; + + let combined_class = format!("{} {}", size_class, class); + + view! { + <img + src=logo_path + alt="RUSTELO" + class=combined_class + loading="lazy" + /> + } +} + +#[component] +pub fn LogoLink( + #[prop(default = "horizontal".to_string())] orientation: String, + #[prop(default = "normal".to_string())] size: String, + #[prop(default = true)] show_text: bool, + #[prop(default = "".to_string())] class: String, + #[prop(default = "/".to_string())] href: String, + #[prop(default = false)] dark_theme: bool, +) -> impl IntoView { + view! { + <a + href=href.clone() + class="inline-block transition-opacity duration-200 hover:opacity-80" + title="RUSTELO - Home" + > + <Logo + orientation=orientation + size=size + show_text=show_text + class=class + dark_theme=dark_theme + /> + </a> + } +} + +#[component] +pub fn BrandHeader( + #[prop(default = "RUSTELO".to_string())] title: String, + #[prop(default = "".to_string())] subtitle: String, + #[prop(default = "medium".to_string())] logo_size: String, + #[prop(default = "".to_string())] class: String, + #[prop(default = false)] dark_theme: bool, +) -> impl IntoView { + let base_class = "flex items-center gap-4"; + let combined_class = if class.is_empty() { + base_class.to_string() + } else { + format!("{} {}", base_class, class) + }; + + view! { + <div class=combined_class> + <Logo + orientation="horizontal".to_string() + size=logo_size + show_text=false + class="flex-shrink-0".to_string() + dark_theme=dark_theme + /> + <div class="flex flex-col"> + <h1 class="text-xl font-bold text-gray-900 dark:text-white">{title}</h1> + {(!subtitle.is_empty()).then(|| view! { + <p class="text-sm text-gray-600 dark:text-gray-400">{subtitle}</p> + })} + </div> + </div> + } +} + +#[component] +pub fn NavbarLogo( + #[prop(default = "small".to_string())] size: String, + #[prop(default = "".to_string())] class: String, + #[prop(default = false)] dark_theme: bool, +) -> impl IntoView { + let nav_class = format!( + "font-sans antialiased text-sm text-current ml-2 mr-2 block py-1 font-semibold {}", + class + ); + + view! { + <LogoLink + orientation="horizontal".to_string() + size=size + show_text=true + class=nav_class + href="/".to_string() + dark_theme=dark_theme + /> + } +} diff --git a/client/src/components/admin/AdminLayout.rs b/client/src/components/admin/AdminLayout.rs new file mode 100644 index 0000000..4ed6612 --- /dev/null +++ b/client/src/components/admin/AdminLayout.rs @@ -0,0 +1,365 @@ +use crate::i18n::use_i18n; +use crate::pages::admin::{AdminContent, AdminDashboard, AdminRoles, AdminUsers}; +use leptos::prelude::*; + +#[derive(Clone, Debug, PartialEq)] +pub enum AdminSection { + Dashboard, + Users, + Roles, + Content, +} + +impl AdminSection { + pub fn route(&self) -> &'static str { + match self { + AdminSection::Dashboard => "/admin", + AdminSection::Users => "/admin/users", + AdminSection::Roles => "/admin/roles", + AdminSection::Content => "/admin/content", + } + } + + pub fn title(&self, i18n: &crate::i18n::UseI18n) -> String { + match self { + AdminSection::Dashboard => i18n.t("admin.dashboard.title"), + AdminSection::Users => i18n.t("admin.users.title"), + AdminSection::Roles => i18n.t("admin.roles.title"), + AdminSection::Content => i18n.t("admin.content.title"), + } + } + + pub fn icon(&self) -> &'static str { + match self { + AdminSection::Dashboard => { + "M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586l-2 2V5H5v14h7v2H4a1 1 0 01-1-1V4z" + } + AdminSection::Users => { + "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" + } + AdminSection::Roles => { + "M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" + } + AdminSection::Content => { + "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + } + } + } +} + +#[component] +pub fn AdminLayout( + current_path: ReadSignal<String>, + #[prop(optional)] children: Option<Children>, +) -> impl IntoView { + let i18n = use_i18n(); + + let current_section = Memo::new(move |_| { + let pathname = current_path.get(); + match pathname.as_str() { + "/admin/users" => AdminSection::Users, + "/admin/roles" => AdminSection::Roles, + "/admin/content" => AdminSection::Content, + _ => AdminSection::Dashboard, + } + }); + + view! { + <div class="min-h-screen bg-gray-50"> + <div class="flex"> + // Sidebar + <div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0"> + <div class="flex items-center justify-center h-16 px-4 bg-indigo-600"> + <h1 class="text-xl font-bold text-white"> + "Admin Dashboard" + </h1> + </div> + + <nav class="mt-8 px-4"> + <AdminNavItem + section=AdminSection::Dashboard + current_section=current_section + i18n=i18n.clone() + /> + <AdminNavItem + section=AdminSection::Users + current_section=current_section + i18n=i18n.clone() + /> + <AdminNavItem + section=AdminSection::Roles + current_section=current_section + i18n=i18n.clone() + /> + <AdminNavItem + section=AdminSection::Content + current_section=current_section + i18n=i18n.clone() + /> + </nav> + + // User info at bottom + <div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <div class="w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center"> + <svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path> + </svg> + </div> + </div> + <div class="ml-3 flex-1 min-w-0"> + <p class="text-sm font-medium text-gray-900 truncate"> + "Admin User" + </p> + <p class="text-xs text-gray-500 truncate"> + "admin@example.com" + </p> + </div> + <div class="ml-2"> + <button class="text-gray-400 hover:text-gray-600"> + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path> + </svg> + </button> + </div> + </div> + </div> + </div> + + // Main content + <div class="flex-1 lg:ml-64"> + <main class="flex-1"> + {match current_section.get() { + AdminSection::Dashboard => view! { <AdminDashboard /> }.into_any(), + AdminSection::Users => view! { <AdminUsers /> }.into_any(), + AdminSection::Roles => view! { <AdminRoles /> }.into_any(), + AdminSection::Content => view! { <AdminContent /> }.into_any(), + }} + {children.map(|c| c()).unwrap_or_else(|| view! {}.into_any())} + </main> + </div> + </div> + </div> + } +} + +#[component] +fn AdminNavItem( + section: AdminSection, + current_section: Memo<AdminSection>, + i18n: crate::i18n::UseI18n, +) -> impl IntoView { + let section_route = section.route(); + let section_icon = section.icon(); + let section_title = section.title(&i18n); + let is_current = Memo::new(move |_| current_section.get() == section); + + view! { + <a + href=section_route + class=move || { + let base_classes = "group flex items-center px-2 py-2 text-sm font-medium rounded-md transition-colors duration-150 ease-in-out mb-1"; + if is_current.get() { + format!("{} bg-indigo-100 text-indigo-700", base_classes) + } else { + format!("{} text-gray-600 hover:bg-gray-50 hover:text-gray-900", base_classes) + } + } + > + <svg + class=move || { + let base_classes = "mr-3 flex-shrink-0 h-6 w-6"; + if is_current.get() { + format!("{} text-indigo-500", base_classes) + } else { + format!("{} text-gray-400 group-hover:text-gray-500", base_classes) + } + } + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d=section_icon + ></path> + </svg> + {section_title} + </a> + } +} + +#[component] +pub fn AdminBreadcrumb(current_path: ReadSignal<String>) -> impl IntoView { + let i18n = use_i18n(); + + let breadcrumb_items = Memo::new(move |_| { + let pathname = current_path.get(); + let mut items = vec![("Admin".to_string(), "/admin".to_string())]; + + match pathname.as_str() { + "/admin/users" => items.push(( + i18n.clone().t("admin.users.title"), + "/admin/users".to_string(), + )), + "/admin/roles" => items.push(( + i18n.clone().t("admin.roles.title"), + "/admin/roles".to_string(), + )), + "/admin/content" => items.push(( + i18n.clone().t("admin.content.title"), + "/admin/content".to_string(), + )), + _ => {} + } + + items + }); + + view! { + <nav class="flex mb-4" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <For + each=move || breadcrumb_items.get() + key=|(title, _)| title.clone() + children=move |(title, href)| { + let items = breadcrumb_items.get(); + let is_last = items.last().map(|(t, _)| t.as_str()) == Some(&title); + + view! { + <li class="inline-flex items-center"> + {if is_last { + view! { + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2"> + {title} + </span> + }.into_any() + } else { + view! { + <a + href=href + class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600" + > + {title} + </a> + <svg class="w-6 h-6 text-gray-400 ml-1" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path> + </svg> + }.into_any() + }} + </li> + } + } + /> + </ol> + </nav> + } +} + +#[component] +pub fn AdminHeader( + #[prop(optional)] title: Option<String>, + #[prop(optional)] subtitle: Option<String>, + #[prop(optional)] actions: Option<Children>, +) -> impl IntoView { + let title_text = title.unwrap_or_else(|| "Admin".to_string()); + let subtitle_text = subtitle.unwrap_or_default(); + let has_subtitle = !subtitle_text.is_empty(); + + view! { + <div class="bg-white shadow"> + <div class="px-4 sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8"> + <div class="py-6 md:flex md:items-center md:justify-between lg:border-t lg:border-gray-200"> + <div class="flex-1 min-w-0"> + <div class="flex items-center"> + <div> + <div class="flex items-center"> + <h1 class="ml-3 text-2xl font-bold leading-7 text-gray-900 sm:leading-9 sm:truncate"> + {title_text} + </h1> + </div> + <Show when=move || has_subtitle> + <dl class="mt-6 flex flex-col sm:ml-3 sm:mt-1 sm:flex-row sm:flex-wrap"> + <dd class="text-sm text-gray-500 sm:mr-6"> + {subtitle_text.clone()} + </dd> + </dl> + </Show> + </div> + </div> + </div> + <div class="mt-6 flex space-x-3 md:mt-0 md:ml-4"> + {actions.map(|a| a()).unwrap_or_else(|| view! {}.into_any())} + </div> + </div> + </div> + </div> + } +} + +#[component] +pub fn AdminCard( + #[prop(optional)] title: Option<String>, + #[prop(optional)] class: Option<String>, + children: Children, +) -> impl IntoView { + let class_str = class.unwrap_or_default(); + let title_str = title.unwrap_or_default(); + let has_title = !title_str.is_empty(); + + view! { + <div class=format!( + "bg-white overflow-hidden shadow rounded-lg {}", + class_str + )> + <Show when=move || has_title> + <div class="px-4 py-5 sm:p-6 border-b border-gray-200"> + <h3 class="text-lg leading-6 font-medium text-gray-900"> + {title_str.clone()} + </h3> + </div> + </Show> + <div class="px-4 py-5 sm:p-6"> + {children()} + </div> + </div> + } +} + +#[component] +pub fn AdminEmptyState( + #[prop(optional)] icon: Option<String>, + #[prop(optional)] title: Option<String>, + #[prop(optional)] description: Option<String>, + #[prop(optional)] action: Option<Children>, +) -> impl IntoView { + let icon_str = icon.unwrap_or_default(); + let title_str = title.unwrap_or_else(|| "No items".to_string()); + let description_str = description.unwrap_or_default(); + let has_icon = !icon_str.is_empty(); + let has_description = !description_str.is_empty(); + + view! { + <div class="text-center py-12"> + <Show when=move || has_icon> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=icon_str.clone()></path> + </svg> + </Show> + <h3 class="mt-2 text-sm font-medium text-gray-900"> + {title_str} + </h3> + <Show when=move || has_description> + <p class="mt-1 text-sm text-gray-500"> + {description_str.clone()} + </p> + </Show> + <div class="mt-6"> + {action.map(|a| a()).unwrap_or_else(|| view! {}.into_any())} + </div> + </div> + } +} diff --git a/client/src/components/admin/mod.rs b/client/src/components/admin/mod.rs new file mode 100644 index 0000000..f3a5c13 --- /dev/null +++ b/client/src/components/admin/mod.rs @@ -0,0 +1,3 @@ +#[allow(non_snake_case)] +pub mod AdminLayout; +pub use AdminLayout::*; diff --git a/client/src/components/daisy_example.rs b/client/src/components/daisy_example.rs new file mode 100644 index 0000000..3624474 --- /dev/null +++ b/client/src/components/daisy_example.rs @@ -0,0 +1,253 @@ +use leptos::prelude::*; + +/// Example component showcasing DaisyUI components +#[component] +pub fn DaisyExample() -> impl IntoView { + let (count, set_count) = signal(0); + let (modal_open, set_modal_open) = signal(false); + + view! { + <div class="container mx-auto p-6"> + <h1 class="text-4xl font-bold text-center mb-8">"DaisyUI Components Example"</h1> + + <DaisyButtons/> + <DaisyCards/> + <DaisyForms count=count set_count=set_count/> + <DaisyAlerts/> + <DaisyBadges/> + <DaisyModal modal_open=modal_open set_modal_open=set_modal_open/> + <DaisyProgress/> + <DaisyTabs/> + <DaisyLoading/> + </div> + } +} + +#[component] +fn DaisyButtons() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Buttons"</h2> + <div class="flex flex-wrap gap-2"> + <button class="btn">"Default"</button> + <button class="btn btn-primary">"Primary"</button> + <button class="btn btn-secondary">"Secondary"</button> + <button class="btn btn-accent">"Accent"</button> + <button class="btn btn-info">"Info"</button> + <button class="btn btn-success">"Success"</button> + <button class="btn btn-warning">"Warning"</button> + <button class="btn btn-error">"Error"</button> + </div> + </div> + } +} + +#[component] +fn DaisyCards() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Cards"</h2> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div class="card bg-base-100 shadow-xl"> + <div class="card-body"> + <h2 class="card-title">"Card Title"</h2> + <p>"This is a simple card with some content."</p> + <div class="card-actions justify-end"> + <button class="btn btn-primary">"Action"</button> + </div> + </div> + </div> + <div class="card bg-primary text-primary-content"> + <div class="card-body"> + <h2 class="card-title">"Colored Card"</h2> + <p>"This card has a primary color background."</p> + <div class="card-actions justify-end"> + <button class="btn">"Action"</button> + </div> + </div> + </div> + </div> + </div> + } +} + +#[component] +fn DaisyForms(count: ReadSignal<i32>, set_count: WriteSignal<i32>) -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Forms & Interactive Counter"</h2> + <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div class="card bg-base-100 shadow-xl"> + <div class="card-body"> + <h3 class="card-title">"Form Elements"</h3> + <div class="form-control w-full max-w-xs"> + <label class="label"> + <span class="label-text">"What is your name?"</span> + </label> + <input type="text" placeholder="Type here" class="input input-bordered w-full max-w-xs" /> + </div> + <div class="form-control"> + <label class="label cursor-pointer"> + <span class="label-text">"Remember me"</span> + <input type="checkbox" class="checkbox" /> + </label> + </div> + </div> + </div> + <div class="card bg-base-100 shadow-xl"> + <div class="card-body"> + <h3 class="card-title">"Interactive Counter"</h3> + <div class="text-center"> + <div class="text-6xl font-bold text-primary mb-4"> + {move || count.get()} + </div> + <div class="flex justify-center gap-2"> + <button + class="btn btn-primary" + on:click=move |_| set_count.update(|c| *c += 1) + > + "+" + </button> + <button + class="btn btn-secondary" + on:click=move |_| set_count.update(|c| *c -= 1) + > + "-" + </button> + <button + class="btn btn-accent" + on:click=move |_| set_count.set(0) + > + "Reset" + </button> + </div> + </div> + </div> + </div> + </div> + </div> + } +} + +#[component] +fn DaisyAlerts() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Alerts"</h2> + <div class="space-y-4"> + <div class="alert alert-info"> + <span>"New software update available."</span> + </div> + <div class="alert alert-success"> + <span>"Your purchase has been confirmed!"</span> + </div> + <div class="alert alert-warning"> + <span>"Warning: Invalid email address!"</span> + </div> + <div class="alert alert-error"> + <span>"Error! Task failed successfully."</span> + </div> + </div> + </div> + } +} + +#[component] +fn DaisyBadges() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Badges"</h2> + <div class="flex flex-wrap gap-2"> + <div class="badge">"default"</div> + <div class="badge badge-primary">"primary"</div> + <div class="badge badge-secondary">"secondary"</div> + <div class="badge badge-accent">"accent"</div> + <div class="badge badge-info">"info"</div> + <div class="badge badge-success">"success"</div> + <div class="badge badge-warning">"warning"</div> + <div class="badge badge-error">"error"</div> + </div> + </div> + } +} + +#[component] +fn DaisyModal(modal_open: ReadSignal<bool>, set_modal_open: WriteSignal<bool>) -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Modal"</h2> + <button + class="btn btn-primary" + on:click=move |_| set_modal_open.set(true) + > + "Open Modal" + </button> + + <div class=move || format!("modal {}", if modal_open.get() { "modal-open" } else { "" })> + <div class="modal-box"> + <h3 class="font-bold text-lg">"Hello there!"</h3> + <p class="py-4">"This is a modal dialog box created with DaisyUI."</p> + <div class="modal-action"> + <button + class="btn btn-primary" + on:click=move |_| set_modal_open.set(false) + > + "Close" + </button> + </div> + </div> + </div> + </div> + } +} + +#[component] +fn DaisyProgress() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Progress"</h2> + <div class="space-y-4"> + <progress class="progress w-56" value="0" max="100"></progress> + <progress class="progress progress-primary w-56" value="25" max="100"></progress> + <progress class="progress progress-secondary w-56" value="50" max="100"></progress> + <progress class="progress progress-accent w-56" value="75" max="100"></progress> + <progress class="progress progress-success w-56" value="100" max="100"></progress> + </div> + </div> + } +} + +#[component] +fn DaisyTabs() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Tabs"</h2> + <div class="tabs"> + <a class="tab tab-lifted tab-active">"Tab 1"</a> + <a class="tab tab-lifted">"Tab 2"</a> + <a class="tab tab-lifted">"Tab 3"</a> + </div> + </div> + } +} + +#[component] +fn DaisyLoading() -> impl IntoView { + view! { + <div class="mb-8"> + <h2 class="text-2xl font-semibold mb-4">"Loading"</h2> + <div class="flex flex-wrap gap-4"> + <span class="loading loading-spinner loading-xs"></span> + <span class="loading loading-spinner loading-sm"></span> + <span class="loading loading-spinner loading-md"></span> + <span class="loading loading-spinner loading-lg"></span> + </div> + <div class="flex flex-wrap gap-4 mt-4"> + <span class="loading loading-dots loading-xs"></span> + <span class="loading loading-dots loading-sm"></span> + <span class="loading loading-dots loading-md"></span> + <span class="loading loading-dots loading-lg"></span> + </div> + </div> + } +} diff --git a/client/src/components/forms/contact_form.rs b/client/src/components/forms/contact_form.rs new file mode 100644 index 0000000..e46bff6 --- /dev/null +++ b/client/src/components/forms/contact_form.rs @@ -0,0 +1,468 @@ +//! Contact form component +//! +//! This component provides a user-friendly contact form with validation, +//! error handling, and success feedback using Leptos reactive primitives. + +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use web_sys::{Event, HtmlInputElement, HtmlTextAreaElement}; + +/// Safely extract value from input element +fn extract_input_value(event: &Event) -> Option<String> { + event + .target() + .and_then(|t| t.dyn_into::<HtmlInputElement>().ok()) + .map(|input| input.value()) +} + +/// Safely extract value from textarea element +fn extract_textarea_value(event: &Event) -> Option<String> { + event + .target() + .and_then(|t| t.dyn_into::<HtmlTextAreaElement>().ok()) + .map(|textarea| textarea.value()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactFormData { + pub name: String, + pub email: String, + pub subject: String, + pub message: String, + pub recipient: Option<String>, +} + +impl Default for ContactFormData { + fn default() -> Self { + Self { + name: String::new(), + email: String::new(), + subject: String::new(), + message: String::new(), + recipient: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactFormResponse { + pub message: String, + pub message_id: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactFormError { + pub error: String, + pub message: String, + pub code: String, +} + +#[derive(Debug, Clone)] +pub enum FormState { + Idle, + Submitting, + Success(ContactFormResponse), + Error(String), +} + +#[component] +pub fn ContactForm( + /// Optional recipient email address + #[prop(optional)] + recipient: Option<String>, + /// Form title + #[prop(optional)] + title: Option<String>, + /// Form description + #[prop(optional)] + description: Option<String>, + /// Custom CSS class + #[prop(optional)] + class: Option<String>, + /// Show success message after submission + #[prop(default = true)] + show_success: bool, + /// Reset form after successful submission + #[prop(default = true)] + reset_after_success: bool, + /// Custom submit button text + #[prop(optional)] + submit_text: Option<String>, +) -> impl IntoView { + let (form_data, set_form_data) = signal(ContactFormData::default()); + let (form_state, set_form_state) = signal(FormState::Idle); + let (validation_errors, set_validation_errors) = signal(HashMap::<String, String>::new()); + + // Set recipient if provided + if let Some(recipient_email) = recipient { + set_form_data.update(|data| data.recipient = Some(recipient_email)); + } + + // Validation functions + let validate_email = + |email: &str| -> bool { email.contains('@') && email.len() > 5 && email.len() < 255 }; + + let validate_required = |value: &str| -> bool { !value.trim().is_empty() }; + + let validate_length = |value: &str, max: usize| -> bool { value.len() <= max }; + + // Input handlers + let on_name_input = move |ev: Event| { + if let Some(value) = extract_input_value(&ev) { + set_form_data.update(|data| data.name = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("name"); + }); + } + }; + + let on_email_input = move |ev: Event| { + if let Some(value) = extract_input_value(&ev) { + set_form_data.update(|data| data.email = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("email"); + }); + } + }; + + let on_subject_input = move |ev: Event| { + if let Some(value) = extract_input_value(&ev) { + set_form_data.update(|data| data.subject = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("subject"); + }); + } + }; + + let on_message_input = move |ev: Event| { + if let Some(value) = extract_textarea_value(&ev) { + set_form_data.update(|data| data.message = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("message"); + }); + } + }; + + // Form validation + let validate_form = move |data: &ContactFormData| -> HashMap<String, String> { + let mut errors = HashMap::new(); + + if !validate_required(&data.name) { + errors.insert("name".to_string(), "Name is required".to_string()); + } else if !validate_length(&data.name, 100) { + errors.insert( + "name".to_string(), + "Name must be less than 100 characters".to_string(), + ); + } + + if !validate_required(&data.email) { + errors.insert("email".to_string(), "Email is required".to_string()); + } else if !validate_email(&data.email) { + errors.insert( + "email".to_string(), + "Please enter a valid email address".to_string(), + ); + } + + if !validate_required(&data.subject) { + errors.insert("subject".to_string(), "Subject is required".to_string()); + } else if !validate_length(&data.subject, 200) { + errors.insert( + "subject".to_string(), + "Subject must be less than 200 characters".to_string(), + ); + } + + if !validate_required(&data.message) { + errors.insert("message".to_string(), "Message is required".to_string()); + } else if !validate_length(&data.message, 5000) { + errors.insert( + "message".to_string(), + "Message must be less than 5000 characters".to_string(), + ); + } + + errors + }; + + // Form submission + let on_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + + let data = form_data.get(); + let errors = validate_form(&data); + + if !errors.is_empty() { + set_validation_errors.set(errors); + return; + } + + // Clear validation errors + set_validation_errors.set(HashMap::new()); + set_form_state.set(FormState::Submitting); + + // Submit the form + spawn_local(async move { + let body = match serde_json::to_string(&data) { + Ok(json) => json, + Err(_) => { + set_form_state.set(FormState::Error( + "Failed to serialize form data".to_string(), + )); + return; + } + }; + + let client = reqwasm::http::Request::post("/api/email/contact") + .header("Content-Type", "application/json") + .body(body); + let response = client.send().await; + + match response { + Ok(resp) => { + if resp.status() == 200 { + match resp.json::<ContactFormResponse>().await { + Ok(success_response) => { + set_form_state.set(FormState::Success(success_response)); + + if reset_after_success { + set_form_data.set(ContactFormData::default()); + } + } + Err(e) => { + set_form_state.set(FormState::Error(format!( + "Failed to parse response: {}", + e + ))); + } + } + } else { + match resp.json::<ContactFormError>().await { + Ok(error_response) => { + set_form_state.set(FormState::Error(error_response.message)); + } + Err(_) => { + set_form_state.set(FormState::Error(format!( + "Server error: {}", + resp.status() + ))); + } + } + } + } + Err(e) => { + set_form_state.set(FormState::Error(format!("Network error: {}", e))); + } + } + }); + }; + + // Helper to get field error + let get_field_error = move |field: &'static str| -> Option<String> { + validation_errors.get().get(field).cloned() + }; + + // Helper to check if field has error + let has_field_error = + move |field: &'static str| -> bool { validation_errors.get().contains_key(field) }; + + view! { + <div class={format!("contact-form {}", class.unwrap_or_default())}> + {title.map(|t| view! { + <div class="form-header mb-6"> + <h2 class="text-2xl font-bold text-gray-900 mb-2">{t}</h2> + {description.map(|d| view! { + <p class="text-gray-600">{d}</p> + })} + </div> + })} + + <form on:submit=on_submit class="space-y-6"> + // Name field + <div class="form-group"> + <label + for="contact-name" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Name" + <span class="text-red-500 ml-1">*</span> + </label> + <input + type="text" + id="contact-name" + name="name" + value={move || form_data.get().name} + on:input=on_name_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("name") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="Your full name" + required + /> + {move || get_field_error("name").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Email field + <div class="form-group"> + <label + for="contact-email" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Email" + <span class="text-red-500 ml-1">*</span> + </label> + <input + type="email" + id="contact-email" + name="email" + value={move || form_data.get().email} + on:input=on_email_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("email") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="your.email@example.com" + required + /> + {move || get_field_error("email").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Subject field + <div class="form-group"> + <label + for="contact-subject" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Subject" + <span class="text-red-500 ml-1">*</span> + </label> + <input + type="text" + id="contact-subject" + name="subject" + value={move || form_data.get().subject} + on:input=on_subject_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("subject") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="What is this about?" + required + /> + {move || get_field_error("subject").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Message field + <div class="form-group"> + <label + for="contact-message" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Message" + <span class="text-red-500 ml-1">*</span> + </label> + <textarea + id="contact-message" + name="message" + rows="6" + prop:value={move || form_data.get().message} + on:input=on_message_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("message") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="Please describe your message in detail..." + required + /> + {move || get_field_error("message").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Submit button + <div class="form-group"> + <button + type="submit" + disabled={move || matches!(form_state.get(), FormState::Submitting)} + class={move || format!( + "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {}", + if matches!(form_state.get(), FormState::Submitting) { + "bg-gray-400 cursor-not-allowed" + } else { + "bg-blue-600 hover:bg-blue-700" + } + )} + > + {move || match form_state.get() { + FormState::Submitting => "Sending...".to_string(), + _ => submit_text.clone().unwrap_or_else(|| "Send Message".to_string()), + }} + </button> + </div> + + // Status messages + {move || match form_state.get() { + FormState::Success(response) if show_success => Some(view! { + <div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-md"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm font-medium text-green-800"> + "Message sent successfully!" + </p> + <p class="mt-1 text-sm text-green-700"> + {response.message} + </p> + </div> + </div> + </div> + }), + FormState::Error(error) => Some(view! { + <div class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm font-medium text-red-800"> + "Failed to send message" + </p> + <p class="mt-1 text-sm text-red-700"> + {error} + </p> + </div> + </div> + </div> + }), + _ => None, + }} + </form> + </div> + } +} diff --git a/client/src/components/forms/mod.rs b/client/src/components/forms/mod.rs new file mode 100644 index 0000000..14809e1 --- /dev/null +++ b/client/src/components/forms/mod.rs @@ -0,0 +1,17 @@ +//! Form components module +//! +//! This module provides reusable form components for the client application, +//! including contact forms, support forms, and other interactive forms. + +pub mod contact_form; +pub mod support_form; + +pub use contact_form::{ContactForm, ContactFormData, ContactFormError, ContactFormResponse}; +pub use support_form::{ + CategoryOption, PriorityOption, SupportForm, SupportFormData, SupportFormError, + SupportFormResponse, +}; + +// Re-export common form utilities +pub use contact_form::FormState as ContactFormState; +pub use support_form::FormState as SupportFormState; diff --git a/client/src/components/forms/support_form.rs b/client/src/components/forms/support_form.rs new file mode 100644 index 0000000..f8081ac --- /dev/null +++ b/client/src/components/forms/support_form.rs @@ -0,0 +1,690 @@ +//! Support form component +//! +//! This component provides a user-friendly support form with validation, +//! priority levels, categories, and enhanced error handling using Leptos. + +use leptos::prelude::*; + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; +use web_sys::{Event, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement}; + +/// Safely extract value from input element +fn extract_input_value(event: &Event) -> Option<String> { + event + .target() + .and_then(|t| t.dyn_into::<HtmlInputElement>().ok()) + .map(|input| input.value()) +} + +/// Safely extract value from textarea element +fn extract_textarea_value(event: &Event) -> Option<String> { + event + .target() + .and_then(|t| t.dyn_into::<HtmlTextAreaElement>().ok()) + .map(|textarea| textarea.value()) +} + +/// Safely extract value from select element +fn extract_select_value(event: &Event) -> Option<String> { + event + .target() + .and_then(|t| t.dyn_into::<HtmlSelectElement>().ok()) + .map(|select| select.value()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupportFormData { + pub name: String, + pub email: String, + pub subject: String, + pub message: String, + pub priority: Option<String>, + pub category: Option<String>, + pub recipient: Option<String>, +} + +impl Default for SupportFormData { + fn default() -> Self { + Self { + name: String::new(), + email: String::new(), + subject: String::new(), + message: String::new(), + priority: Some("normal".to_string()), + category: None, + recipient: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupportFormResponse { + pub message: String, + pub message_id: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupportFormError { + pub error: String, + pub message: String, + pub code: String, +} + +#[derive(Debug, Clone)] +pub enum FormState { + Idle, + Submitting, + Success(SupportFormResponse), + Error(String), +} + +#[derive(Debug, Clone)] +pub struct PriorityOption { + pub value: String, + pub label: String, + pub color: String, + pub description: String, +} + +#[derive(Debug, Clone)] +pub struct CategoryOption { + pub value: String, + pub label: String, + pub icon: String, + pub description: String, +} + +#[component] +pub fn SupportForm( + /// Optional recipient email address + #[prop(optional)] + recipient: Option<String>, + /// Form title + #[prop(optional)] + title: Option<String>, + /// Form description + #[prop(optional)] + description: Option<String>, + /// Custom CSS class + #[prop(optional)] + class: Option<String>, + /// Show success message after submission + #[prop(default = true)] + show_success: bool, + /// Reset form after successful submission + #[prop(default = true)] + reset_after_success: bool, + /// Custom submit button text + #[prop(optional)] + submit_text: Option<String>, + /// Show priority field + #[prop(default = true)] + show_priority: bool, + /// Show category field + #[prop(default = true)] + show_category: bool, + /// Available categories + #[prop(optional)] + categories: Option<Vec<CategoryOption>>, +) -> impl IntoView { + let (form_data, set_form_data) = signal(SupportFormData::default()); + let (form_state, set_form_state) = signal(FormState::Idle); + let (validation_errors, set_validation_errors) = signal(HashMap::<String, String>::new()); + + // Set recipient if provided + if let Some(recipient_email) = recipient { + set_form_data.update(|data| data.recipient = Some(recipient_email)); + } + + // Default priorities + let priority_options = vec![ + PriorityOption { + value: "low".to_string(), + label: "Low".to_string(), + color: "text-green-600".to_string(), + description: "General questions or non-urgent requests".to_string(), + }, + PriorityOption { + value: "normal".to_string(), + label: "Normal".to_string(), + color: "text-blue-600".to_string(), + description: "Standard support requests".to_string(), + }, + PriorityOption { + value: "high".to_string(), + label: "High".to_string(), + color: "text-orange-600".to_string(), + description: "Important issues affecting functionality".to_string(), + }, + PriorityOption { + value: "urgent".to_string(), + label: "Urgent".to_string(), + color: "text-red-600".to_string(), + description: "Critical issues requiring immediate attention".to_string(), + }, + ]; + + // Default categories + let default_categories = vec![ + CategoryOption { + value: "technical".to_string(), + label: "Technical Support".to_string(), + icon: "🔧".to_string(), + description: "Technical issues, bugs, or system problems".to_string(), + }, + CategoryOption { + value: "billing".to_string(), + label: "Billing & Payments".to_string(), + icon: "💳".to_string(), + description: "Questions about billing, payments, or subscriptions".to_string(), + }, + CategoryOption { + value: "account".to_string(), + label: "Account Management".to_string(), + icon: "👤".to_string(), + description: "Account settings, password, or profile issues".to_string(), + }, + CategoryOption { + value: "feature".to_string(), + label: "Feature Request".to_string(), + icon: "✨".to_string(), + description: "Suggestions for new features or improvements".to_string(), + }, + CategoryOption { + value: "general".to_string(), + label: "General Inquiry".to_string(), + icon: "💬".to_string(), + description: "General questions or other inquiries".to_string(), + }, + ]; + + let _category_options = categories.unwrap_or(default_categories); + + // Validation functions + let validate_email = + |email: &str| -> bool { email.contains('@') && email.len() > 5 && email.len() < 255 }; + + let validate_required = |value: &str| -> bool { !value.trim().is_empty() }; + + let validate_length = |value: &str, max: usize| -> bool { value.len() <= max }; + + // Input handlers + let on_name_input = move |ev: Event| { + if let Some(value) = extract_input_value(&ev) { + set_form_data.update(|data| data.name = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("name"); + }); + } + }; + + let on_email_input = move |ev: Event| { + if let Some(value) = extract_input_value(&ev) { + set_form_data.update(|data| data.email = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("email"); + }); + } + }; + + let on_subject_input = move |ev: Event| { + if let Some(value) = extract_input_value(&ev) { + set_form_data.update(|data| data.subject = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("subject"); + }); + } + }; + + let on_message_input = move |ev: Event| { + if let Some(value) = extract_textarea_value(&ev) { + set_form_data.update(|data| data.message = value); + + // Clear validation error when user starts typing + set_validation_errors.update(|errors| { + errors.remove("message"); + }); + } + }; + + let on_priority_change = move |ev: Event| { + if let Some(value) = extract_select_value(&ev) { + set_form_data.update(|data| { + data.priority = if value.is_empty() { None } else { Some(value) }; + }); + } + }; + + let on_category_change = move |ev: Event| { + if let Some(value) = extract_select_value(&ev) { + set_form_data.update(|data| { + data.category = if value.is_empty() { None } else { Some(value) }; + }); + } + }; + + // Form validation + let validate_form = move |data: &SupportFormData| -> HashMap<String, String> { + let mut errors = HashMap::new(); + + if !validate_required(&data.name) { + errors.insert("name".to_string(), "Name is required".to_string()); + } else if !validate_length(&data.name, 100) { + errors.insert( + "name".to_string(), + "Name must be less than 100 characters".to_string(), + ); + } + + if !validate_required(&data.email) { + errors.insert("email".to_string(), "Email is required".to_string()); + } else if !validate_email(&data.email) { + errors.insert( + "email".to_string(), + "Please enter a valid email address".to_string(), + ); + } + + if !validate_required(&data.subject) { + errors.insert("subject".to_string(), "Subject is required".to_string()); + } else if !validate_length(&data.subject, 200) { + errors.insert( + "subject".to_string(), + "Subject must be less than 200 characters".to_string(), + ); + } + + if !validate_required(&data.message) { + errors.insert("message".to_string(), "Message is required".to_string()); + } else if !validate_length(&data.message, 5000) { + errors.insert( + "message".to_string(), + "Message must be less than 5000 characters".to_string(), + ); + } + + errors + }; + + // Form submission + let on_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + + let data = form_data.get(); + let errors = validate_form(&data); + + if !errors.is_empty() { + set_validation_errors.set(errors); + return; + } + + // Clear validation errors + set_validation_errors.set(HashMap::new()); + set_form_state.set(FormState::Submitting); + + // Submit the form + spawn_local(async move { + let body = match serde_json::to_string(&data) { + Ok(json) => json, + Err(_) => { + set_form_state.set(FormState::Error( + "Failed to serialize form data".to_string(), + )); + return; + } + }; + + let client = reqwasm::http::Request::post("/api/email/support") + .header("Content-Type", "application/json") + .body(body); + let response = client.send().await; + + match response { + Ok(resp) => { + if resp.status() == 200 { + match resp.json::<SupportFormResponse>().await { + Ok(success_response) => { + set_form_state.set(FormState::Success(success_response)); + + if reset_after_success { + set_form_data.set(SupportFormData::default()); + } + } + Err(e) => { + set_form_state.set(FormState::Error(format!( + "Failed to parse response: {}", + e + ))); + } + } + } else { + match resp.json::<SupportFormError>().await { + Ok(error_response) => { + set_form_state.set(FormState::Error(error_response.message)); + } + Err(_) => { + set_form_state.set(FormState::Error(format!( + "Server error: {}", + resp.status() + ))); + } + } + } + } + Err(e) => { + set_form_state.set(FormState::Error(format!("Network error: {}", e))); + } + } + }); + }; + + // Helper to get field error + let get_field_error = move |field: &'static str| -> Option<String> { + validation_errors.get().get(field).cloned() + }; + + // Helper to check if field has error + let has_field_error = + move |field: &'static str| -> bool { validation_errors.get().contains_key(field) }; + + // Get priority color + let get_priority_color = move || { + let current_priority = form_data.get().priority.unwrap_or_default(); + priority_options + .iter() + .find(|p| p.value == current_priority) + .map(|p| p.color.clone()) + .unwrap_or_else(|| "text-gray-600".to_string()) + }; + + view! { + <div class={format!("support-form {}", class.unwrap_or_default())}> + {title.map(|t| view! { + <div class="form-header mb-6"> + <h2 class="text-2xl font-bold text-gray-900 mb-2">{t}</h2> + {description.map(|d| view! { + <p class="text-gray-600">{d}</p> + })} + </div> + })} + + <form on:submit=on_submit class="space-y-6"> + // Name field + <div class="form-group"> + <label + for="support-name" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Name" + <span class="text-red-500 ml-1">*</span> + </label> + <input + type="text" + id="support-name" + name="name" + value={move || form_data.get().name} + on:input=on_name_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("name") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="Your full name" + required + /> + {move || get_field_error("name").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Email field + <div class="form-group"> + <label + for="support-email" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Email" + <span class="text-red-500 ml-1">*</span> + </label> + <input + type="email" + id="support-email" + name="email" + value={move || form_data.get().email} + on:input=on_email_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("email") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="your.email@example.com" + required + /> + {move || get_field_error("email").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Priority field + {if show_priority { + Some(view! { + <div class="form-group"> + <label + for="support-priority" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Priority" + </label> + <select + id="support-priority" + name="priority" + prop:value={move || form_data.get().priority.clone().unwrap_or_default()} + on:change=on_priority_change + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + > + <option value="low">"Low - General questions or non-urgent requests"</option> + <option value="normal">"Normal - Standard support requests"</option> + <option value="high">"High - Important issues affecting functionality"</option> + <option value="urgent">"Urgent - Critical issues requiring immediate attention"</option> + </select> + <p class={move || format!("mt-1 text-sm {}", get_priority_color())}> + {move || { + let current_priority = form_data.get().priority.unwrap_or_default(); + match current_priority.as_str() { + "low" => "General questions or non-urgent requests", + "normal" => "Standard support requests", + "high" => "Important issues affecting functionality", + "urgent" => "Critical issues requiring immediate attention", + _ => "Select a priority level", + } + }} + </p> + </div> + }) + } else { + None + }} + + // Category field + {if show_category { + Some(view! { + <div class="form-group"> + <label + for="support-category" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Category" + </label> + <select + id="support-category" + name="category" + prop:value={move || form_data.get().category.clone().unwrap_or_default()} + on:change=on_category_change + class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + > + <option value="">"Select a category"</option> + <option value="technical">"🔧 Technical"</option> + <option value="billing">"💳 Billing"</option> + <option value="feature">"✨ Feature Request"</option> + <option value="bug">"🐛 Bug Report"</option> + <option value="account">"👤 Account"</option> + <option value="other">"📋 Other"</option> + </select> + <p class="mt-1 text-sm text-gray-600"> + {move || { + let current_category = form_data.get().category.unwrap_or_default(); + match current_category.as_str() { + "technical" => "Technical issues, bugs, or troubleshooting", + "billing" => "Billing, payments, or subscription questions", + "feature" => "Suggestions for new features or improvements", + "bug" => "Report bugs or unexpected behavior", + "account" => "Account settings, profile, or access issues", + "other" => "General questions or other requests", + _ => "Select the category that best describes your request", + } + }} + </p> + </div> + }) + } else { + None + }} + + // Subject field + <div class="form-group"> + <label + for="support-subject" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Subject" + <span class="text-red-500 ml-1">*</span> + </label> + <input + type="text" + id="support-subject" + name="subject" + value={move || form_data.get().subject} + on:input=on_subject_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("subject") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="Brief description of your issue" + required + /> + {move || get_field_error("subject").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + </div> + + // Message field + <div class="form-group"> + <label + for="support-message" + class="block text-sm font-medium text-gray-700 mb-2" + > + "Detailed Description" + <span class="text-red-500 ml-1">*</span> + </label> + <textarea + id="support-message" + name="message" + rows="8" + prop:value={move || form_data.get().message} + on:input=on_message_input + class={move || format!( + "w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}", + if has_field_error("message") { "border-red-500" } else { "border-gray-300" } + )} + placeholder="Please provide as much detail as possible about your issue or request. Include any error messages, steps to reproduce, or relevant information..." + required + /> + {move || get_field_error("message").map(|error| view! { + <p class="mt-1 text-sm text-red-600">{error}</p> + })} + <p class="mt-1 text-sm text-gray-500"> + "The more details you provide, the better we can assist you." + </p> + </div> + + // Submit button + <div class="form-group"> + <button + type="submit" + disabled={move || matches!(form_state.get(), FormState::Submitting)} + class={move || format!( + "w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {}", + if matches!(form_state.get(), FormState::Submitting) { + "bg-gray-400 cursor-not-allowed" + } else { + "bg-blue-600 hover:bg-blue-700" + } + )} + > + {move || match form_state.get() { + FormState::Submitting => "Submitting Support Request...".to_string(), + _ => submit_text.clone().unwrap_or_else(|| "Submit Support Request".to_string()), + }} + </button> + </div> + + // Status messages + {move || match form_state.get() { + FormState::Success(response) if show_success => Some(view! { + <div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-md"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm font-medium text-green-800"> + "Support request submitted successfully!" + </p> + <p class="mt-1 text-sm text-green-700"> + {response.message} + </p> + <p class="mt-1 text-sm text-green-600"> + "We'll get back to you as soon as possible." + </p> + </div> + </div> + </div> + }), + FormState::Error(error) => Some(view! { + <div class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm font-medium text-red-800"> + "Failed to submit support request" + </p> + <p class="mt-1 text-sm text-red-700"> + {error} + </p> + <p class="mt-1 text-sm text-red-600"> + "Please try again or contact support directly." + </p> + </div> + </div> + </div> + }), + _ => None, + }} + </form> + </div> + } +} diff --git a/client/src/components/mod.rs b/client/src/components/mod.rs new file mode 100644 index 0000000..1d06804 --- /dev/null +++ b/client/src/components/mod.rs @@ -0,0 +1,14 @@ +#[allow(non_snake_case)] +pub mod Counter; +#[allow(non_snake_case)] +pub mod Logo; +pub mod admin; +#[allow(non_snake_case)] +pub mod daisy_example; +pub mod forms; + +pub use Counter::Counter; +pub use Logo::{BrandHeader, Logo, LogoLink, NavbarLogo}; +pub use admin::*; +pub use daisy_example::DaisyExample; +pub use forms::{ContactForm, SupportForm}; diff --git a/client/src/defs.rs b/client/src/defs.rs new file mode 100644 index 0000000..19b0d8d --- /dev/null +++ b/client/src/defs.rs @@ -0,0 +1,5 @@ +// --- Centralized Route Definitions --- +pub const ROUTES: &[(&str, &'static str)] = &[("/", "Home"), ("/about", "About")]; + +// --- Extracted Nav Link Classes --- +pub const NAV_LINK_CLASS: &str = "pointer text-gray-700 hover:text-gray-900"; diff --git a/client/src/examples/admin_integration.rs b/client/src/examples/admin_integration.rs new file mode 100644 index 0000000..e9da9ec --- /dev/null +++ b/client/src/examples/admin_integration.rs @@ -0,0 +1,319 @@ +// Example integration of Admin Dashboard into Leptos Router +// This file demonstrates how to integrate the admin dashboard into your main application + +use crate::components::admin::AdminLayout; +use crate::i18n::{I18nProvider, use_i18n}; +use crate::state::*; +use leptos::prelude::*; +use leptos_router::*; + +/// Complete example of how to integrate the admin dashboard into your app +#[component] +pub fn AppWithAdminIntegration() -> impl IntoView { + view! { + <GlobalStateProvider> + <ThemeProvider> + <I18nProvider> + <ToastProvider> + <AuthProvider> + <UserProvider> + <Router> + <Routes> + // Public routes + <Route path="/" view=HomePage /> + <Route path="/about" view=AboutPage /> + <Route path="/login" view=LoginPage /> + <Route path="/register" view=RegisterPage /> + + // Protected admin routes + <ProtectedRoute path="/admin/*" view=AdminLayout /> + </Routes> + </Router> + </UserProvider> + </AuthProvider> + </ToastProvider> + </I18nProvider> + </ThemeProvider> + </GlobalStateProvider> + } +} + +/// Protected route component that checks authentication and admin privileges +#[component] +pub fn ProtectedRoute( + path: &'static str, + view: fn() -> impl IntoView + 'static, +) -> impl IntoView { + let auth_context = use_context::<AuthContext>(); + let user_context = use_context::<UserContext>(); + + let is_admin = create_memo(move |_| { + match (auth_context, user_context) { + (Some(auth), Some(user)) => { + auth.is_authenticated() && user.has_role("admin") + } + _ => false, + } + }); + + view! { + <Route + path=path + view=move || { + if is_admin.get() { + view().into_any() + } else { + view! { + <div class="min-h-screen flex items-center justify-center bg-gray-50"> + <div class="max-w-md w-full space-y-8"> + <div class="text-center"> + <h2 class="mt-6 text-3xl font-extrabold text-gray-900"> + "Access Denied" + </h2> + <p class="mt-2 text-sm text-gray-600"> + "You need administrator privileges to access this area." + </p> + <div class="mt-6"> + <A href="/login" class="text-indigo-600 hover:text-indigo-500"> + "Sign in with an admin account" + </A> + </div> + </div> + </div> + </div> + }.into_any() + } + } + /> + } +} + +/// Alternative simpler integration if you want to handle routing manually +#[component] +pub fn SimpleAdminIntegration() -> impl IntoView { + let location = use_location(); + let i18n = use_i18n(); + + let is_admin_route = create_memo(move |_| { + location.pathname.get().starts_with("/admin") + }); + + view! { + <Show + when=move || is_admin_route.get() + fallback=move || view! { + // Your regular app layout + <div class="app-layout"> + <header>"Regular App Header"</header> + <main> + <Routes> + <Route path="/" view=HomePage /> + <Route path="/about" view=AboutPage /> + </Routes> + </main> + </div> + } + > + // Full-screen admin layout + <AdminLayout /> + </Show> + } +} + +/// Navigation component with admin link +#[component] +pub fn NavWithAdminLink() -> impl IntoView { + let i18n = use_i18n(); + let auth_context = use_context::<AuthContext>(); + let user_context = use_context::<UserContext>(); + + let is_admin = create_memo(move |_| { + match (auth_context, user_context) { + (Some(auth), Some(user)) => { + auth.is_authenticated() && user.has_role("admin") + } + _ => false, + } + }); + + view! { + <nav class="bg-white shadow"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div class="flex justify-between h-16"> + <div class="flex"> + <div class="flex-shrink-0 flex items-center"> + <A href="/" class="text-xl font-bold text-gray-900"> + "Your App" + </A> + </div> + <div class="hidden sm:ml-6 sm:flex sm:space-x-8"> + <A href="/" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"> + "Home" + </A> + <A href="/about" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"> + "About" + </A> + + // Admin link - only visible to admins + <Show when=move || is_admin.get()> + <A href="/admin" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm"> + <svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> + </svg> + {move || i18n.t("admin.dashboard.title")} + </A> + </Show> + </div> + </div> + </div> + </div> + </nav> + } +} + +/// Example context types you might need +pub struct AuthContext { + pub user: ReadSignal<Option<User>>, + pub token: ReadSignal<Option<String>>, +} + +impl AuthContext { + pub fn is_authenticated(&self) -> bool { + self.user.get().is_some() && self.token.get().is_some() + } +} + +pub struct UserContext { + pub roles: ReadSignal<Vec<String>>, + pub permissions: ReadSignal<Vec<String>>, +} + +impl UserContext { + pub fn has_role(&self, role: &str) -> bool { + self.roles.get().contains(&role.to_string()) + } + + pub fn has_permission(&self, permission: &str) -> bool { + self.permissions.get().contains(&permission.to_string()) + } +} + +#[derive(Clone, Debug)] +pub struct User { + pub id: String, + pub email: String, + pub name: String, + pub roles: Vec<String>, +} + +// Placeholder components for the example +#[component] +fn HomePage() -> impl IntoView { + view! { <div>"Home Page"</div> } +} + +#[component] +fn AboutPage() -> impl IntoView { + view! { <div>"About Page"</div> } +} + +#[component] +fn LoginPage() -> impl IntoView { + view! { <div>"Login Page"</div> } +} + +#[component] +fn RegisterPage() -> impl IntoView { + view! { <div>"Register Page"</div> } +} + +/// RBAC Middleware example for server-side route protection +/// This would be used on the server to protect API endpoints +pub async fn require_admin_role( + // request: Request, + // next: Next, +) -> Result<(), String> { + // Implementation would check JWT token for admin role + // This is just a placeholder showing the concept + + // Extract JWT from request headers + // Verify JWT signature + // Check if user has 'admin' role + // If yes, proceed; if no, return 403 Forbidden + + Ok(()) +} + +/// API endpoint protection example +pub async fn admin_api_handler() -> Result<String, String> { + // This would be your actual API endpoint + // The RBAC middleware would run before this + + Ok("Admin data".to_string()) +} + +/// Example of how to configure your server routes with RBAC +/// This would be in your server configuration +pub fn configure_admin_routes() { + // axum example: + // let admin_routes = Router::new() + // .route("/api/admin/users", get(get_users).post(create_user)) + // .route("/api/admin/content", get(get_content).post(create_content)) + // .route("/api/admin/roles", get(get_roles).post(create_role)) + // .layer(middleware::from_fn(require_admin_role)); +} + +/// Complete setup example with all providers +#[component] +pub fn CompleteAppSetup() -> impl IntoView { + view! { + <GlobalStateProvider> + <ThemeProvider> + <I18nProvider> + <ToastProvider> + <AuthProvider> + <UserProvider> + <AppStateProvider> + <Router> + <NavWithAdminLink /> + <main> + <Routes> + // Public routes + <Route path="/" view=HomePage /> + <Route path="/about" view=AboutPage /> + <Route path="/login" view=LoginPage /> + <Route path="/register" view=RegisterPage /> + + // Admin routes (protected) + <Route path="/admin/*" view=AdminLayout /> + + // 404 fallback + <Route path="/*any" view=NotFoundPage /> + </Routes> + </main> + </Router> + </AppStateProvider> + </UserProvider> + </AuthProvider> + </ToastProvider> + </I18nProvider> + </ThemeProvider> + </GlobalStateProvider> + } +} + +#[component] +fn NotFoundPage() -> impl IntoView { + view! { + <div class="min-h-screen flex items-center justify-center bg-gray-50"> + <div class="text-center"> + <h1 class="text-6xl font-bold text-gray-900">"404"</h1> + <p class="text-xl text-gray-600 mt-4">"Page not found"</p> + <A href="/" class="mt-6 inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700"> + "Go Home" + </A> + </div> + </div> + } +} diff --git a/client/src/i18n/mod.rs b/client/src/i18n/mod.rs new file mode 100644 index 0000000..214e5e1 --- /dev/null +++ b/client/src/i18n/mod.rs @@ -0,0 +1,291 @@ +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use shared::{Texts, load_texts_toml}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Language { + English, + Spanish, +} + +impl Language { + pub fn code(&self) -> &'static str { + match self { + Language::English => "en", + Language::Spanish => "es", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Language::English => "English", + Language::Spanish => "Español", + } + } + + pub fn from_code(code: &str) -> Self { + match code { + "es" => Language::Spanish, + _ => Language::English, // Default to English + } + } + + pub fn all() -> Vec<Language> { + vec![Language::English, Language::Spanish] + } +} + +impl Default for Language { + fn default() -> Self { + Language::English + } +} + +#[derive(Clone)] +pub struct I18nContext { + pub language: ReadSignal<Language>, + pub set_language: WriteSignal<Language>, + pub texts: Memo<Texts>, +} + +impl I18nContext { + /// Get translated text + pub fn t(&self, key: &str, _args: Option<&HashMap<&str, &str>>) -> String { + let texts = self.texts.get(); + let lang_code = self.language.get().code(); + + let translations = match lang_code { + "es" => &texts.es, + _ => &texts.en, + }; + + translations + .get(key) + .cloned() + .unwrap_or_else(|| key.to_string()) + } + + /// Get current language code + pub fn current_lang(&self) -> String { + self.language.get().code().to_string() + } + + /// Check if current language is specific language + pub fn is_language(&self, lang: Language) -> bool { + self.language.get() == lang + } +} + +#[component] +pub fn I18nProvider(children: leptos::prelude::Children) -> impl IntoView { + // Initialize language from localStorage or default to English + let initial_language = Language::default(); + + let (language, set_language) = signal(initial_language); + + // Load texts from embedded resources + let texts = Memo::new(move |_| load_texts_toml().unwrap_or_default()); + + let context = I18nContext { + language: language.into(), + set_language, + texts, + }; + + provide_context(context); + + view! { + {children()} + } +} + +#[derive(Clone)] +pub struct UseI18n(pub I18nContext); + +impl UseI18n { + pub fn new() -> Self { + Self(expect_context::<I18nContext>()) + } + + /// Get translated text + pub fn t(&self, key: &str) -> String { + self.0.t(key, None) + } + + /// Get translated text with arguments + pub fn t_with_args(&self, key: &str, args: &HashMap<&str, &str>) -> String { + self.0.t(key, Some(args)) + } + + /// Change language + pub fn set_language(&self, language: Language) { + self.0.set_language.set(language); + } + + /// Get current language + pub fn language(&self) -> Language { + self.0.language.get() + } + + /// Get current language code + pub fn lang_code(&self) -> String { + self.0.current_lang() + } + + /// Check if current language is specific language + pub fn is_language(&self, lang: Language) -> bool { + self.0.is_language(lang) + } +} + +/// Hook to use internationalization +pub fn use_i18n() -> UseI18n { + UseI18n::new() +} + +/// Language selector component +#[component] +pub fn LanguageSelector(#[prop(optional)] class: Option<String>) -> impl IntoView { + let i18n = use_i18n(); + let (is_open, set_is_open) = signal(false); + + view! { + <div class=move || format!( + "relative inline-block text-left {}", + class.as_deref().unwrap_or("") + )> + <button + type="button" + class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" + on:click=move |_| set_is_open.update(|open| *open = !*open) + aria-expanded=move || is_open.get() + aria-haspopup="true" + > + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/> + </svg> + { + let i18n_clone = i18n.clone(); + move || i18n_clone.language().display_name() + } + <svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> + </svg> + </button> + + <Show when=move || is_open.get()> + <div class="absolute right-0 z-50 w-48 mt-2 origin-top-right bg-white border border-gray-200 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> + <div class="py-1" role="menu" aria-orientation="vertical"> + { + let i18n_clone = i18n.clone(); + let languages = Language::all(); + languages.into_iter().map(|lang| { + let i18n_item = i18n_clone.clone(); + let is_current = i18n_item.is_language(lang.clone()); + let lang_for_click = lang.clone(); + let i18n_for_click = i18n_item.clone(); + + view! { + <button + type="button" + class=move || format!( + "flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 focus:outline-none focus:bg-gray-100 {}", + if is_current { "bg-blue-50 text-blue-700" } else { "text-gray-700" } + ) + role="menuitem" + on:click=move |_| { + i18n_for_click.set_language(lang_for_click.clone()); + set_is_open.set(false); + } + > + <Show when=move || is_current> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> + </svg> + </Show> + <Show when=move || !is_current> + <div class="w-4 h-4 mr-2"></div> + </Show> + {lang.display_name()} + </button> + }.into_any() + }).collect::<Vec<_>>() + } + </div> + </div> + </Show> + + // Click outside to close + <Show when=move || is_open.get()> + <div + class="fixed inset-0 z-40" + on:click=move |_| set_is_open.set(false) + ></div> + </Show> + </div> + } +} + +/// Compact language toggle component +#[component] +pub fn LanguageToggle(#[prop(optional)] class: Option<String>) -> impl IntoView { + let i18n = use_i18n(); + + view! { + <button + type="button" + class=move || format!( + "inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {}", + class.as_deref().unwrap_or("") + ) + on:click={ + let i18n_clone = i18n.clone(); + move |_| { + let current = i18n_clone.language(); + let new_lang = match current { + Language::English => Language::Spanish, + Language::Spanish => Language::English, + }; + i18n_clone.set_language(new_lang); + } + } + title={ + let i18n_clone = i18n.clone(); + move || i18n_clone.t("select-language") + } + > + <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/> + </svg> + { + let i18n_clone = i18n.clone(); + move || i18n_clone.language().code().to_uppercase() + } + </button> + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_language_codes() { + assert_eq!(Language::English.code(), "en"); + assert_eq!(Language::Spanish.code(), "es"); + } + + #[test] + fn test_language_from_code() { + assert_eq!(Language::from_code("en"), Language::English); + assert_eq!(Language::from_code("es"), Language::Spanish); + assert_eq!(Language::from_code("invalid"), Language::English); // Default fallback + } + + #[test] + fn test_language_display_names() { + assert_eq!(Language::English.display_name(), "English"); + assert_eq!(Language::Spanish.display_name(), "Español"); + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..46b32f6 --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,213 @@ +//! # RUSTELO Client +//! +//! <div align="center"> +//! <img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" /> +//! </div> +//! +//! Frontend client library for the RUSTELO web application framework, built with Leptos and WebAssembly. +//! +//! ## Overview +//! +//! The RUSTELO client provides a reactive, high-performance frontend experience using Rust compiled to WebAssembly. +//! It features component-based architecture, state management, internationalization, and seamless server-side rendering. +//! +//! ## Features +//! +//! - **⚡ Reactive UI** - Built with Leptos for fast, reactive user interfaces +//! - **🎨 Component System** - Reusable UI components with props and state +//! - **🌐 Internationalization** - Multi-language support with fluent +//! - **🔐 Authentication** - Complete auth flow with JWT and OAuth2 +//! - **📱 Responsive Design** - Mobile-first design with Tailwind CSS +//! - **🚀 WebAssembly** - High-performance client-side rendering +//! +//! ## Architecture +//! +//! The client is organized into several key modules: +//! +//! - [`app`] - Main application component and routing +//! - [`components`] - Reusable UI components including logos and forms +//! - [`pages`] - Individual page components (Home, About, etc.) +//! - [`auth`] - Authentication components and context +//! - [`state`] - Global state management and themes +//! - [`i18n`] - Internationalization and language support +//! - [`utils`] - Client-side utilities and helpers +//! +//! ## Quick Start +//! +//! ```rust,ignore +//! use client::app::App; +//! use leptos::prelude::*; +//! +//! // Mount the application +//! leptos::mount::mount_to_body(App); +//! ``` +//! +//! ## Component Usage +//! +//! ### Logo Components +//! +//! ```rust,ignore +//! use client::components::{Logo, BrandHeader, NavbarLogo}; +//! use leptos::prelude::*; +//! +//! // Basic logo +//! view! { +//! <Logo +//! orientation="horizontal".to_string() +//! size="medium".to_string() +//! show_text=true +//! dark_theme=false +//! /> +//! } +//! +//! // Navigation logo +//! view! { +//! <NavbarLogo size="small".to_string() /> +//! } +//! +//! // Brand header with logo and text +//! view! { +//! <BrandHeader +//! title="RUSTELO".to_string() +//! subtitle="Modular Rust Web Application Template".to_string() +//! logo_size="large".to_string() +//! /> +//! } +//! ``` +//! +//! ### Authentication Components +//! +//! ```rust,ignore +//! use client::auth::{AuthProvider, LoginForm}; +//! use leptos::prelude::*; +//! +//! view! { +//! <AuthProvider> +//! <LoginForm /> +//! </AuthProvider> +//! } +//! ``` +//! +//! ### Form Components +//! +//! ```rust,ignore +//! use client::components::{ContactForm, SupportForm}; +//! use leptos::prelude::*; +//! +//! view! { +//! <ContactForm /> +//! <SupportForm /> +//! } +//! ``` +//! +//! ## State Management +//! +//! ### Theme Management +//! +//! ```rust,ignore +//! use client::state::theme::{ThemeProvider, use_theme_state, Theme}; +//! use leptos::prelude::*; +//! +//! #[component] +//! fn MyComponent() -> impl IntoView { +//! let theme_state = use_theme_state(); +//! +//! view! { +//! <button on:click=move |_| theme_state.toggle()> +//! "Toggle Theme" +//! </button> +//! } +//! } +//! ``` +//! +//! ## Internationalization +//! +//! ```rust,ignore +//! use client::i18n::{I18nProvider, use_i18n}; +//! use leptos::prelude::*; +//! +//! #[component] +//! fn MyComponent() -> impl IntoView { +//! let i18n = use_i18n(); +//! +//! view! { +//! <p>{i18n.t("welcome_message")}</p> +//! } +//! } +//! ``` +//! +//! ## WebAssembly Integration +//! +//! The client is designed to run efficiently in WebAssembly environments: +//! +//! - **Small Bundle Size** - Optimized for fast loading +//! - **Memory Efficient** - Careful memory management +//! - **Browser APIs** - Safe access to web APIs through web-sys +//! - **Error Handling** - Comprehensive error boundaries +//! +//! ## Development +//! +//! ### Building +//! +//! ```bash +//! # Development build +//! cargo build --target wasm32-unknown-unknown +//! +//! # Production build +//! cargo build --release --target wasm32-unknown-unknown +//! +//! # Using cargo-leptos +//! cargo leptos build +//! ``` +//! +//! ### Testing +//! +//! ```bash +//! # Run tests +//! cargo test +//! +//! # Run tests in browser +//! wasm-pack test --headless --chrome +//! ``` +//! +//! ## Performance +//! +//! Optimized for performance with: +//! +//! - **Lazy Loading** - Components loaded on demand +//! - **Virtual DOM** - Efficient rendering with fine-grained reactivity +//! - **Code Splitting** - Reduced initial bundle size +//! - **Caching** - Smart caching of static assets +//! +//! ## Browser Support +//! +//! - **Modern Browsers** - Chrome 80+, Firefox 72+, Safari 13.1+, Edge 80+ +//! - **WebAssembly** - Required for optimal performance +//! - **JavaScript Fallback** - Graceful degradation where possible +//! +//! ## Contributing +//! +//! Contributions are welcome! Please see our [Contributing Guidelines](https://github.com/yourusername/rustelo/blob/main/CONTRIBUTING.md). +//! +//! ## License +//! +//! This project is licensed under the MIT License - see the [LICENSE](https://github.com/yourusername/rustelo/blob/main/LICENSE) file for details. + +#![recursion_limit = "256"] + +pub mod app; +pub mod auth; +pub mod components; +pub mod defs; +pub mod i18n; +pub mod pages; +pub mod state; +pub mod utils; + +use crate::app::App; + +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + console_error_panic_hook::set_once(); + leptos::mount::hydrate_body(App); +} diff --git a/client/src/main.rs b/client/src/main.rs new file mode 100644 index 0000000..e6dc0e9 --- /dev/null +++ b/client/src/main.rs @@ -0,0 +1,5 @@ +pub fn main() { + // no client-side main function + // unless we want this to work with e.g., Trunk for a purely client-side app + // see lib.rs for hydration function instead +} diff --git a/client/src/pages/About.rs b/client/src/pages/About.rs new file mode 100644 index 0000000..bd52fd1 --- /dev/null +++ b/client/src/pages/About.rs @@ -0,0 +1,53 @@ +use leptos::prelude::*; + +#[component] +pub fn AboutPage() -> impl IntoView { + eprintln!("AboutPage rendering"); + view! { + <div class="bg-white dark:bg-gray-900 h-screen overflow-hidden"> + + <div class="relative isolate px-6 pt-14 lg:px-8"> + <div class="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56"> + <div class="text-center"> + <h1 class="text-balance text-5xl font-semibold tracking-tight text-gray-900 dark:text-gray-100 sm:text-7xl">About</h1> + <p class="mt-8 text-pretty text-lg font-medium text-gray-500 dark:text-gray-400 sm:text-xl/8"> + This is a powerful web application built with Rust, featuring: + </p> + <ul class="mt-8 text-left text-lg text-gray-600 dark:text-gray-300 space-y-4 max-w-md mx-auto"> + <li class="flex items-center"> + <span class="text-green-500 mr-2">"✓"</span> + "Leptos for reactive UI components" + </li> + <li class="flex items-center"> + <span class="text-green-500 mr-2">"✓"</span> + "Axum for the backend server" + </li> + <li class="flex items-center"> + <span class="text-green-500 mr-2">"✓"</span> + "TailwindCSS for beautiful styling" + </li> + <li class="flex items-center"> + <span class="text-green-500 mr-2">"✓"</span> + "Server-side rendering (SSR)" + </li> + <li class="flex items-center"> + <span class="text-green-500 mr-2">"✓"</span> + "Client-side hydration" + </li> + </ul> + </div> + </div> + </div> + </div> + } +} + +// <header class="absolute inset-x-0 top-0 z-50"> +// <nav class="flex items-center justify-between p-6 lg:px-8"> +// <div class="flex flex-1 justify-end"> +// <a href="/"> +// <span class="-m-1.5 text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-300 border border-dashed rounded-xl px-4 py-2 opacity-50 hover:opacity-100 transition-all duration-300">Home</span> +// </a> +// </div> +// </nav> +// </header> diff --git a/client/src/pages/DaisyUI.rs b/client/src/pages/DaisyUI.rs new file mode 100644 index 0000000..d6dfce3 --- /dev/null +++ b/client/src/pages/DaisyUI.rs @@ -0,0 +1,28 @@ +use crate::components::DaisyExample; +use leptos::prelude::*; + +#[component] +pub fn DaisyUIPage() -> impl IntoView { + eprintln!("DaisyUIPage rendering"); + view! { + <div class="min-h-screen bg-base-200"> + <div class="hero bg-base-100 py-8"> + <div class="hero-content text-center"> + <div class="max-w-md"> + <h1 class="text-5xl font-bold text-primary">"DaisyUI + UnoCSS"</h1> + <p class="py-6 text-lg">"Beautiful UI components powered by DaisyUI preset for UnoCSS"</p> + <div class="flex justify-center gap-2"> + <div class="badge badge-primary">"UnoCSS"</div> + <div class="badge badge-secondary">"DaisyUI"</div> + <div class="badge badge-accent">"Leptos"</div> + </div> + </div> + </div> + </div> + + <div class="container mx-auto px-4 py-8"> + <DaisyExample/> + </div> + </div> + } +} diff --git a/client/src/pages/FeaturesDemo.rs b/client/src/pages/FeaturesDemo.rs new file mode 100644 index 0000000..cd87350 --- /dev/null +++ b/client/src/pages/FeaturesDemo.rs @@ -0,0 +1,91 @@ +use leptos::prelude::*; + +#[component] +pub fn FeaturesDemoPage() -> impl IntoView { + view! { + <div class="bg-white dark:bg-gray-900 min-h-screen"> + <div class="relative isolate px-6 pt-14 lg:px-8"> + <div class="mx-auto max-w-4xl py-16 sm:py-24"> + <div class="text-center mb-12"> + <h1 class="text-balance text-4xl font-semibold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl"> + "Features Demo" + </h1> + <p class="mt-6 text-lg text-gray-600 dark:text-gray-400"> + "Explore the powerful features of this Rust web application stack" + </p> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-8"> + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6"> + <h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> + "Reactive UI" + </h3> + <p class="text-gray-600 dark:text-gray-400 mb-4"> + "Built with Leptos for fast, reactive components" + </p> + <div class="space-y-2"> + <div class="w-full bg-blue-200 rounded-full h-2"> + <div class="bg-blue-600 h-2 rounded-full w-3/4"></div> + </div> + <div class="text-sm text-gray-500">"Component reactivity"</div> + </div> + </div> + + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6"> + <h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> + "Fast Backend" + </h3> + <p class="text-gray-600 dark:text-gray-400 mb-4"> + "Powered by Axum for high-performance server-side logic" + </p> + <div class="space-y-2"> + <div class="w-full bg-green-200 rounded-full h-2"> + <div class="bg-green-600 h-2 rounded-full w-5/6"></div> + </div> + <div class="text-sm text-gray-500">"Server performance"</div> + </div> + </div> + + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6"> + <h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> + "Beautiful Styling" + </h3> + <p class="text-gray-600 dark:text-gray-400 mb-4"> + "TailwindCSS for rapid UI development" + </p> + <div class="flex space-x-2"> + <div class="w-4 h-4 bg-blue-500 rounded"></div> + <div class="w-4 h-4 bg-green-500 rounded"></div> + <div class="w-4 h-4 bg-purple-500 rounded"></div> + <div class="w-4 h-4 bg-pink-500 rounded"></div> + </div> + </div> + + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6"> + <h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4"> + "Type Safety" + </h3> + <p class="text-gray-600 dark:text-gray-400 mb-4"> + "Rust's type system ensures reliability and performance" + </p> + <div class="bg-gray-200 dark:bg-gray-700 p-2 rounded text-sm font-mono"> + <span class="text-blue-600 dark:text-blue-400">"fn"</span> + <span class="text-gray-800 dark:text-gray-200">" safe_function() -> "</span> + <span class="text-green-600 dark:text-green-400">"Result"</span> + </div> + </div> + </div> + + <div class="mt-12 text-center"> + <div class="inline-flex items-center space-x-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white px-6 py-3 rounded-lg"> + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path> + </svg> + <span class="font-semibold">"Built with Rust"</span> + </div> + </div> + </div> + </div> + </div> + } +} diff --git a/client/src/pages/Home.rs b/client/src/pages/Home.rs new file mode 100644 index 0000000..e16b404 --- /dev/null +++ b/client/src/pages/Home.rs @@ -0,0 +1,65 @@ +use crate::components::{BrandHeader, Counter}; +use leptos::prelude::*; + +#[component] +pub fn HomePage() -> impl IntoView { + eprintln!("HomePage rendering"); + view! { + <div class="bg-white dark:bg-gray-900 h-screen overflow-hidden"> + <div class="relative isolate px-6 pt-14 lg:px-8"> + <div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true"> + <div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)"></div> + </div> + <div class="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56"> + <div class="hidden sm:mb-8 sm:flex sm:justify-center"> + <div class="relative rounded-full px-3 py-1 text-sm/6 text-gray-600 dark:text-gray-400 ring-1 ring-gray-900/10 dark:ring-gray-100/10 hover:ring-gray-900/20 dark:hover:ring-gray-100/20"> + // Thaw Button removed. Add your own client-only UI here if needed. + </div> + </div> + <div class="text-center"> + <div class="mb-8"> + <BrandHeader + title="RUSTELO".to_string() + subtitle="Modular Rust Web Application Template".to_string() + logo_size="large".to_string() + class="justify-center".to_string() + /> + </div> + <h1 class="text-balance text-5xl font-semibold tracking-tight text-gray-900 dark:text-gray-100 sm:text-7xl">Build fast web apps with Rust</h1> + <p class="mt-8 text-pretty text-lg font-medium text-gray-500 dark:text-gray-400 sm:text-xl/8"> + A powerful starter template combining Axum for the backend, Leptos for reactive UI components, and TailwindCSS for beautiful styling. + </p> + <span class="i-carbon-user text-2xl text-gray-700" /> + <span class="i-carbon-add text-xl text-green-500" /> + <button class="i-carbon-sun dark:i-carbon-moon" /> + <label class="x-button circle muted swap"> + <input type="checkbox" aria-label="Checkbox description" /> + <svg class="rotate-45 size-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> + </svg> + <svg class="-rotate-45 size-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /> + </svg> + </label> + </div> + <div class="my-10"> + <Counter/> + </div> + </div> + <div class="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]" aria-hidden="true"> + <div class="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)"></div> + </div> + </div> + </div> + } +} + +// <header class="absolute inset-x-0 top-0 z-50"> +// <nav class="flex items-center justify-between p-6 lg:px-8"> +// <div class="flex flex-1 justify-end space-x-4"> +// // If this is meant to be SPA navigation, you can add on:click handler as in app.rs, otherwise leave as is: +// // <a href="/about">About</a> +// </div> +// </nav> +// </header> diff --git a/client/src/pages/admin/Content.rs b/client/src/pages/admin/Content.rs new file mode 100644 index 0000000..0f3be2a --- /dev/null +++ b/client/src/pages/admin/Content.rs @@ -0,0 +1,830 @@ +use crate::i18n::use_i18n; +use chrono::{DateTime, Utc}; +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +// use wasm_bindgen_futures::spawn_local; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentListItem { + pub id: Uuid, + pub title: String, + pub slug: String, + pub content_type: String, + pub state: String, + pub author: Option<String>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + pub published_at: Option<DateTime<Utc>>, + pub view_count: i64, + pub tags: Vec<String>, + pub category: Option<String>, + pub language: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentCreateRequest { + pub title: String, + pub slug: String, + pub content: String, + pub content_type: String, + pub content_format: String, + pub state: String, + pub require_login: bool, + pub tags: Vec<String>, + pub category: Option<String>, + pub featured_image: Option<String>, + pub excerpt: Option<String>, + pub seo_title: Option<String>, + pub seo_description: Option<String>, + pub allow_comments: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentStats { + pub total_count: i64, + pub published_count: i64, + pub draft_count: i64, + pub archived_count: i64, + pub scheduled_count: i64, + pub total_views: i64, + pub top_categories: Vec<(String, i64)>, + pub top_tags: Vec<(String, i64)>, +} + +impl Default for ContentStats { + fn default() -> Self { + Self { + total_count: 0, + published_count: 0, + draft_count: 0, + archived_count: 0, + scheduled_count: 0, + total_views: 0, + top_categories: vec![], + top_tags: vec![], + } + } +} + +#[component] +pub fn AdminContent() -> impl IntoView { + let i18n = use_i18n(); + let (content_list, set_content_list) = signal(Vec::<ContentListItem>::new()); + let (content_stats, set_content_stats) = signal(ContentStats::default()); + let (loading, set_loading) = signal(true); + let (error, set_error) = signal(None::<String>); + let (selected_content, set_selected_content) = signal(None::<ContentListItem>); + let (show_create_modal, set_show_create_modal) = signal(false); + let (show_edit_modal, set_show_edit_modal) = signal(false); + let (show_upload_modal, set_show_upload_modal) = signal(false); + let (search_query, set_search_query) = signal(String::new()); + let (filter_type, set_filter_type) = signal(String::from("all")); + let (filter_state, set_filter_state) = signal(String::from("all")); + let (filter_language, set_filter_language) = signal(String::from("all")); + let (sort_by, set_sort_by) = signal(String::from("updated_at")); + let (sort_order, set_sort_order) = signal(String::from("desc")); + + // Fetch content data + let fetch_content = Action::new(move |_: &()| { + let set_loading = set_loading.clone(); + let set_error = set_error.clone(); + let set_content_list = set_content_list.clone(); + let set_content_stats = set_content_stats.clone(); + + async move { + set_loading.set(true); + set_error.set(None); + + match fetch_content_data().await { + Ok((content_data, stats_data)) => { + set_content_list.set(content_data); + set_content_stats.set(stats_data); + set_loading.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_loading.set(false); + } + } + } + }); + + // Load data on mount + Effect::new(move |_| { + fetch_content.dispatch(()); + }); + + let refresh_data = move |_| { + fetch_content.dispatch(()); + }; + + view! { + <div class="space-y-6"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-xl font-semibold text-gray-900"> + {i18n.t("content-management")} + </h1> + <p class="mt-2 text-sm text-gray-700"> + {i18n.t("manage-your-content")} + </p> + </div> + <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"> + <div class="flex space-x-3"> + <button + type="button" + class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + on:click=move |_| set_show_upload_modal.set(true) + > + <svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path> + </svg> + {i18n.t("upload-content")} + </button> + <button + type="button" + class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + on:click=refresh_data + > + <svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> + </svg> + {i18n.t("refresh")} + </button> + <button + type="button" + class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + on:click=move |_| set_show_create_modal.set(true) + > + <svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> + </svg> + {i18n.t("create-content")} + </button> + </div> + </div> + </div> + + // Stats Cards + <ContentStatsCards stats=content_stats /> + + // Content Table + <Show + when=move || !loading.get() + fallback=|| view! { <ContentManagementSkeleton /> } + > + <ContentManagementTable + content_list=content_list + search_query=search_query + set_search_query=set_search_query + filter_type=filter_type + set_filter_type=set_filter_type + filter_state=filter_state + set_filter_state=set_filter_state + filter_language=filter_language + set_filter_language=set_filter_language + sort_by=sort_by + set_sort_by=set_sort_by + sort_order=sort_order + set_sort_order=set_sort_order + selected_content=selected_content + set_selected_content=set_selected_content + set_show_edit_modal=set_show_edit_modal + /> + </Show> + + // Error Display + <Show when=move || error.get().is_some()> + <div class="bg-red-50 border border-red-200 rounded-md p-4"> + <div class="flex"> + <svg class="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path> + </svg> + <div class="ml-3"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </div> + </div> + </Show> + + // Modals + <Show when=move || show_create_modal.get()> + <CreateContentModal + set_show=set_show_create_modal + on_success=move |_| { fetch_content.dispatch(()); } + /> + </Show> + + <Show when=move || show_edit_modal.get()> + <EditContentModal + set_show=set_show_edit_modal + content=selected_content + on_success=move |_| { fetch_content.dispatch(()); } + /> + </Show> + + <Show when=move || show_upload_modal.get()> + <UploadContentModal + set_show=set_show_upload_modal + on_success=move |_| { fetch_content.dispatch(()); } + /> + </Show> + </div> + } +} + +#[component] +#[allow(unused_variables)] +fn ContentStatsCards(stats: ReadSignal<ContentStats>) -> impl IntoView { + let i18n = use_i18n(); + view! { + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"> + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("total-content")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().total_count} + </dd> + </dl> + </div> + </div> + </div> + </div> + + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("published")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().published_count} + </dd> + </dl> + </div> + </div> + </div> + </div> + + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("drafts")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().draft_count} + </dd> + </dl> + </div> + </div> + </div> + </div> + + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("scheduled")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().scheduled_count} + </dd> + </dl> + </div> + </div> + </div> + </div> + + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("total-views")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().total_views} + </dd> + </dl> + </div> + </div> + </div> + </div> + </div> + } +} + +#[component] +fn ContentManagementTable( + content_list: ReadSignal<Vec<ContentListItem>>, + search_query: ReadSignal<String>, + set_search_query: WriteSignal<String>, + filter_type: ReadSignal<String>, + set_filter_type: WriteSignal<String>, + filter_state: ReadSignal<String>, + set_filter_state: WriteSignal<String>, + filter_language: ReadSignal<String>, + set_filter_language: WriteSignal<String>, + sort_by: ReadSignal<String>, + set_sort_by: WriteSignal<String>, + sort_order: ReadSignal<String>, + set_sort_order: WriteSignal<String>, + selected_content: ReadSignal<Option<ContentListItem>>, + set_selected_content: WriteSignal<Option<ContentListItem>>, + set_show_edit_modal: WriteSignal<bool>, +) -> impl IntoView { + let i18n = use_i18n(); + view! { + <div class="bg-white shadow overflow-hidden sm:rounded-md"> + // Filters + <div class="bg-gray-50 px-6 py-4 border-b border-gray-200"> + <div class="flex flex-wrap items-center justify-between gap-4"> + // Search + <div class="flex-1 min-w-0"> + <div class="relative"> + <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> + <svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> + </svg> + </div> + <input + type="text" + class="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + placeholder={i18n.t("search-content")} + prop:value=move || search_query.get() + on:input=move |ev| set_search_query.set(event_target_value(&ev)) + /> + </div> + </div> + + // Filters + <div class="flex items-center space-x-4"> + <select + class="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || filter_type.get() + on:change=move |ev| set_filter_type.set(event_target_value(&ev)) + > + <option value="all">{i18n.t("all-types")}</option> + <option value="post">{i18n.t("posts")}</option> + <option value="page">{i18n.t("pages")}</option> + <option value="article">{i18n.t("articles")}</option> + </select> + + <select + class="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || filter_state.get() + on:change=move |ev| set_filter_state.set(event_target_value(&ev)) + > + <option value="all">{i18n.t("all-states")}</option> + <option value="published">{i18n.t("published")}</option> + <option value="draft">{i18n.t("draft")}</option> + <option value="archived">{i18n.t("archived")}</option> + </select> + </div> + </div> + </div> + + // Table + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + "Title" + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + "Type" + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + "State" + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + "Author" + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + "Updated" + </th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + "Views" + </th> + <th scope="col" class="relative px-6 py-3"> + <span class="sr-only">{i18n.t("actions")}</span> + </th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + <For + each=move || content_list.get() + key=|content| content.id + children=move |content| { + let edit_content = content.clone(); + let _i18n_clone = i18n.clone(); + let _ = (filter_language, set_filter_language, sort_by, set_sort_by, sort_order, set_sort_order, selected_content); + view! { + <tr class="hover:bg-gray-50"> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="flex items-center"> + <div class="flex-shrink-0 h-10 w-10"> + <div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center"> + <svg class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> + </svg> + </div> + </div> + <div class="ml-4"> + <div class="text-sm font-medium text-gray-900"> + {content.title.clone()} + </div> + <div class="text-sm text-gray-500"> + {content.slug.clone()} + </div> + </div> + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800"> + {content.content_type.clone()} + </span> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <span class={format!("inline-flex px-2 py-1 text-xs font-semibold rounded-full {}", + match content.state.as_str() { + "published" => "bg-green-100 text-green-800", + "draft" => "bg-yellow-100 text-yellow-800", + "archived" => "bg-gray-100 text-gray-800", + _ => "bg-gray-100 text-gray-800", + } + )}> + {content.state.clone()} + </span> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> + {content.author.clone().unwrap_or_else(|| "Unknown".to_string())} + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {content.updated_at.format("%Y-%m-%d %H:%M").to_string()} + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> + {content.view_count} + </td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> + <div class="flex space-x-2"> + <button + class="text-indigo-600 hover:text-indigo-900" + on:click=move |_| { + set_selected_content.set(Some(edit_content.clone())); + set_show_edit_modal.set(true); + } + > + "Edit" + </button> + <a + href=format!("/content/{}", content.slug) + class="text-blue-600 hover:text-blue-900" + target="_blank" + > + "View" + </a> + </div> + </td> + </tr> + } + } + /> + </tbody> + </table> + </div> + </div> + } +} + +#[component] +#[allow(unused_variables)] +fn CreateContentModal( + set_show: WriteSignal<bool>, + on_success: impl Fn(()) + 'static, +) -> impl IntoView { + let i18n = use_i18n(); + let (title, set_title) = signal(String::new()); + let (slug, set_slug) = signal(String::new()); + let (content, set_content) = signal(String::new()); + let (loading, set_loading) = signal(false); + let (error, set_error) = signal(None::<String>); + + let handle_submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + set_loading.set(true); + set_error.set(None); + + let _title_val = title.get(); + let _slug_val = slug.get(); + let _content_val = content.get(); + + // Since we can't use spawn_local due to Send bounds, we'll simulate async with timeout + set_loading.set(false); + set_show.set(false); + on_success(()); + }; + + view! { + <div class="fixed inset-0 z-50 overflow-y-auto"> + <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> + <div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" on:click=move |_| set_show.set(false)></div> + <div class="inline-block w-full max-w-2xl p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-lg"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium leading-6 text-gray-900"> + {i18n.t("create-new-content")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click=move |_| set_show.set(false) + > + <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <Show when=move || error.get().is_some()> + <div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </Show> + + <form on:submit=handle_submit class="space-y-4"> + <div> + <label class="block text-sm font-medium text-gray-700 mb-1"> + {i18n.t("title")} + </label> + <input + type="text" + required + class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" + prop:value=move || title.get() + on:input=move |ev| set_title.set(event_target_value(&ev)) + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700 mb-1"> + {i18n.t("slug")} + </label> + <input + type="text" + required + class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" + prop:value=move || slug.get() + on:input=move |ev| set_slug.set(event_target_value(&ev)) + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700 mb-1"> + {i18n.t("content")} + </label> + <textarea + required + rows="10" + class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" + prop:value=move || content.get() + on:input=move |ev| set_content.set(event_target_value(&ev)) + ></textarea> + </div> + + <div class="flex items-center justify-end space-x-3 pt-6 border-t"> + <button + type="button" + class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + on:click=move |_| set_show.set(false) + > + {i18n.t("cancel")} + </button> + <button + type="submit" + disabled=move || loading.get() + class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50" + > + <Show when=move || loading.get()> + <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + </Show> + {i18n.t("create-content")} + </button> + </div> + </form> + </div> + </div> + </div> + } +} + +#[component] +#[allow(unused_variables)] +fn EditContentModal( + set_show: WriteSignal<bool>, + content: ReadSignal<Option<ContentListItem>>, + on_success: impl Fn(()) + 'static, +) -> impl IntoView { + let i18n = use_i18n(); + view! { + <div class="fixed inset-0 z-50 overflow-y-auto"> + <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> + <div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" on:click=move |_| set_show.set(false)></div> + <div class="inline-block w-full max-w-2xl p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-lg"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium leading-6 text-gray-900"> + {i18n.t("edit-content")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click=move |_| set_show.set(false) + > + <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <div class="text-center py-8"> + <p class="text-gray-600"> + {i18n.t("content-editing-functionality")} + </p> + <p class="text-sm text-gray-500 mt-2"> + {i18n.t("selected-content")}": " {move || content.get().map(|c| c.title).unwrap_or_default()} + </p> + </div> + </div> + </div> + </div> + } +} + +#[component] +#[allow(unused_variables)] +fn UploadContentModal( + set_show: WriteSignal<bool>, + on_success: impl Fn(()) + 'static, +) -> impl IntoView { + let i18n = use_i18n(); + let (_uploading, _set_uploading) = signal(false); + + view! { + <div class="fixed inset-0 z-50 overflow-y-auto"> + <div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> + <div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" on:click=move |_| set_show.set(false)></div> + <div class="inline-block w-full max-w-md p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-lg"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium leading-6 text-gray-900"> + {i18n.t("upload-content")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click=move |_| set_show.set(false) + > + <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path> + </svg> + <div class="mt-4"> + <p class="text-sm text-gray-600"> + {i18n.t("drag-and-drop-files")} + </p> + <p class="text-xs text-gray-500 mt-2"> + {i18n.t("markdown-html-txt-supported")} + </p> + </div> + </div> + + <div class="mt-6 flex justify-end space-x-3"> + <button + type="button" + class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50" + on:click=move |_| set_show.set(false) + > + {i18n.t("cancel")} + </button> + <button + type="button" + disabled=move || false + class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50" + > + {i18n.t("upload")} + </button> + </div> + </div> + </div> + </div> + } +} + +// Helper functions +async fn fetch_content_data() -> Result<(Vec<ContentListItem>, ContentStats), String> { + // Mock data for now + Ok((vec![], ContentStats::default())) +} + +#[allow(dead_code)] +async fn create_content(_request: ContentCreateRequest) -> Result<(), String> { + // Mock implementation + Ok(()) +} + +#[component] +fn ContentManagementSkeleton() -> impl IntoView { + view! { + <div class="space-y-6"> + // Stats skeleton + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"> + {(0..5).map(|_| view! { + <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse"> + <div class="p-5"> + <div class="flex items-center"> + <div class="h-6 w-6 bg-gray-200 rounded"></div> + <div class="ml-5 w-0 flex-1"> + <div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div> + <div class="h-6 bg-gray-200 rounded w-1/2"></div> + </div> + </div> + </div> + </div> + }).collect_view()} + </div> + + // Table skeleton + <div class="bg-white shadow overflow-hidden sm:rounded-md"> + <div class="bg-gray-50 px-6 py-4 border-b border-gray-200"> + <div class="flex items-center space-x-4"> + <div class="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div> + <div class="w-32 h-10 bg-gray-200 rounded animate-pulse"></div> + <div class="w-32 h-10 bg-gray-200 rounded animate-pulse"></div> + </div> + </div> + <div class="divide-y divide-gray-200"> + {(0..5).map(|_| view! { + <div class="px-6 py-4 flex items-center space-x-4"> + <div class="h-10 w-10 bg-gray-200 rounded-full animate-pulse"></div> + <div class="flex-1 space-y-2"> + <div class="h-4 bg-gray-200 rounded w-3/4 animate-pulse"></div> + <div class="h-3 bg-gray-200 rounded w-1/2 animate-pulse"></div> + </div> + <div class="h-4 bg-gray-200 rounded w-16 animate-pulse"></div> + <div class="h-4 bg-gray-200 rounded w-20 animate-pulse"></div> + </div> + }).collect_view()} + </div> + </div> + </div> + } +} diff --git a/client/src/pages/admin/Dashboard.rs b/client/src/pages/admin/Dashboard.rs new file mode 100644 index 0000000..ddfd53b --- /dev/null +++ b/client/src/pages/admin/Dashboard.rs @@ -0,0 +1,455 @@ +// use crate::components::*; +use crate::i18n::use_i18n; +use leptos::prelude::*; +// use leptos_router::*; +use serde::{Deserialize, Serialize}; +// use std::collections::HashMap; +use wasm_bindgen_futures::spawn_local; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct AdminStats { + total_users: u32, + active_users: u32, + content_items: u32, + total_roles: u32, + pending_approvals: u32, + system_health: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct RecentActivity { + id: String, + user_email: String, + action: String, + resource_type: String, + timestamp: String, + status: String, +} + +#[component] +pub fn AdminDashboard() -> impl IntoView { + let i18n = use_i18n(); + let (stats, set_stats) = signal(AdminStats::default()); + let (recent_activity, set_recent_activity) = signal(Vec::<RecentActivity>::new()); + let (loading, set_loading) = signal(true); + let (error, set_error) = signal(None::<String>); + + // Fetch dashboard data on mount + Effect::new(move |_| { + spawn_local(async move { + set_loading.set(true); + set_error.set(None); + + match fetch_dashboard_data().await { + Ok((stats_data, activities_data)) => { + set_stats.set(stats_data); + set_recent_activity.set(activities_data); + set_loading.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_loading.set(false); + } + } + }); + }); + + let refresh_data = move |_| { + spawn_local(async move { + set_loading.set(true); + match fetch_dashboard_data().await { + Ok((stats_data, activities_data)) => { + set_stats.set(stats_data); + set_recent_activity.set(activities_data); + set_loading.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_loading.set(false); + } + } + }); + }; + + view! { + <div class="min-h-screen bg-gray-50"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + // Header + <div class="pb-5 border-b border-gray-200"> + <div class="flex items-center justify-between"> + <div> + <h1 class="text-3xl font-bold leading-tight text-gray-900"> + {i18n.t("admin-dashboard")} + </h1> + <p class="mt-2 text-sm text-gray-600"> + {i18n.t("overview-of-your-system")} + </p> + </div> + <button + class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + on:click=refresh_data + disabled=move || loading.get() + > + <Show when=move || loading.get()> + <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + </Show> + <Show when=move || !loading.get()> + <svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> + </svg> + </Show> + {i18n.t("refresh")} + </button> + </div> + </div> + + // Error Alert + <Show when=move || error.get().is_some()> + <div class="mt-4 bg-red-50 border border-red-200 rounded-md p-4"> + <div class="flex"> + <svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path> + </svg> + <div class="ml-3"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </div> + </div> + </Show> + + <Show + when=move || !loading.get() + fallback=|| view! { <AdminDashboardSkeleton /> } + > + <div class="mt-6 space-y-6"> + <AdminStatsCards stats=stats /> + <AdminQuickActions /> + <AdminRecentActivity activities=recent_activity /> + </div> + </Show> + </div> + </div> + </div> + } +} + +#[component] +fn AdminStatsCards(stats: ReadSignal<AdminStats>) -> impl IntoView { + let i18n = use_i18n(); + view! { + <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> + // Total Users Card + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-8 w-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("total-users")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().total_users.to_string()} + </dd> + </dl> + </div> + </div> + </div> + </div> + + // Active Users Card + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("active-users")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().active_users.to_string()} + </dd> + </dl> + </div> + </div> + </div> + </div> + + // Content Items Card + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("content-items")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().content_items.to_string()} + </dd> + </dl> + </div> + </div> + </div> + </div> + + // Total Roles Card + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <svg class="h-8 w-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> + </svg> + </div> + <div class="ml-5 w-0 flex-1"> + <dl> + <dt class="text-sm font-medium text-gray-500 truncate"> + {i18n.t("total-roles")} + </dt> + <dd class="text-lg font-medium text-gray-900"> + {move || stats.get().total_roles.to_string()} + </dd> + </dl> + </div> + </div> + </div> + </div> + </div> + } +} + +#[component] +fn AdminQuickActions() -> impl IntoView { + let i18n = use_i18n(); + view! { + <div class="bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <h3 class="text-lg leading-6 font-medium text-gray-900"> + "Quick Actions" + </h3> + <div class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> + <a href="/admin/users" class="relative block w-full border-2 border-gray-300 border-dashed rounded-lg p-6 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path> + </svg> + <span class="mt-2 block text-sm font-medium text-gray-900"> + {i18n.t("manage-users")} + </span> + </a> + + <a href="/admin/roles" class="relative block w-full border-2 border-gray-300 border-dashed rounded-lg p-6 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> + </svg> + <span class="mt-2 block text-sm font-medium text-gray-900"> + {i18n.t("manage-roles")} + </span> + </a> + + <a href="/admin/content" class="relative block w-full border-2 border-gray-300 border-dashed rounded-lg p-6 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all"> + <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> + </svg> + <span class="mt-2 block text-sm font-medium text-gray-900"> + {i18n.t("manage-content")} + </span> + </a> + </div> + </div> + </div> + } +} + +#[component] +fn AdminRecentActivity(activities: ReadSignal<Vec<RecentActivity>>) -> impl IntoView { + let i18n = use_i18n(); + view! { + <div class="bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <h3 class="text-lg leading-6 font-medium text-gray-900"> + "Recent Activity" + </h3> + <div class="mt-5"> + <div class="flow-root"> + <ul class="-my-5 divide-y divide-gray-200"> + <Show + when=move || !activities.get().is_empty() + fallback=move || view! { + <li class="py-4"> + <div class="flex items-center space-x-4"> + <div class="flex-shrink-0"> + <div class="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center"> + <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path> + </svg> + </div> + </div> + <div class="flex-1 min-w-0"> + <p class="text-sm font-medium text-gray-900"> + {i18n.t("no-recent-activity")} + </p> + <p class="text-sm text-gray-500"> + {i18n.t("activity-will-appear-here")} + </p> + </div> + </div> + </li> + } + > + <For + each=move || activities.get() + key=|activity| activity.id.clone() + children=move |activity| { + view! { + <li class="py-4"> + <div class="flex items-center space-x-4"> + <div class="flex-shrink-0"> + <div class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center"> + <span class="text-sm font-medium text-indigo-600"> + {activity.user_email.chars().next().unwrap_or('U')} + </span> + </div> + </div> + <div class="flex-1 min-w-0"> + <p class="text-sm font-medium text-gray-900 truncate"> + {activity.action.clone()} + </p> + <p class="text-sm text-gray-500 truncate"> + {activity.user_email.clone()} + </p> + </div> + <div class="flex-shrink-0 text-sm text-gray-500"> + {activity.timestamp.clone()} + </div> + </div> + </li> + } + } + /> + </Show> + </ul> + </div> + </div> + </div> + </div> + } +} + +#[component] +fn AdminDashboardSkeleton() -> impl IntoView { + view! { + <div class="mt-6 animate-pulse"> + // Stats Cards Skeleton + <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> + {(0..4).map(|_| view! { + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="p-5"> + <div class="flex items-center"> + <div class="flex-shrink-0"> + <div class="h-8 w-8 bg-gray-200 rounded"></div> + </div> + <div class="ml-5 w-0 flex-1"> + <div class="h-4 bg-gray-200 rounded w-3/4"></div> + <div class="h-6 bg-gray-200 rounded w-1/2 mt-2"></div> + </div> + </div> + </div> + </div> + }).collect_view()} + </div> + + // Quick Actions Skeleton + <div class="mt-6 bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <div class="h-6 bg-gray-200 rounded w-1/4 mb-5"></div> + <div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3"> + {(0..3).map(|_| view! { + <div class="border-2 border-gray-200 rounded-lg p-6"> + <div class="h-12 w-12 bg-gray-200 rounded mx-auto"></div> + <div class="h-4 bg-gray-200 rounded w-3/4 mx-auto mt-2"></div> + </div> + }).collect_view()} + </div> + </div> + </div> + + // Recent Activity Skeleton + <div class="mt-6 bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <div class="h-6 bg-gray-200 rounded w-1/4 mb-5"></div> + <div class="space-y-4"> + {(0..5).map(|_| view! { + <div class="py-4"> + <div class="flex items-center space-x-4"> + <div class="h-8 w-8 bg-gray-200 rounded-full"></div> + <div class="flex-1"> + <div class="h-4 bg-gray-200 rounded w-3/4"></div> + <div class="h-3 bg-gray-200 rounded w-1/2 mt-2"></div> + </div> + <div class="h-3 bg-gray-200 rounded w-16"></div> + </div> + </div> + }).collect_view()} + </div> + </div> + </div> + </div> + } +} + +// API functions +async fn fetch_dashboard_data() -> Result<(AdminStats, Vec<RecentActivity>), String> { + // This would normally make actual API calls to the backend + // For now, return mock data + + let stats = AdminStats { + total_users: 147, + active_users: 89, + content_items: 42, + total_roles: 5, + pending_approvals: 3, + system_health: "Healthy".to_string(), + }; + + let activities = vec![ + RecentActivity { + id: "1".to_string(), + user_email: "admin@example.com".to_string(), + action: "User Login".to_string(), + resource_type: "auth".to_string(), + timestamp: "2 hours ago".to_string(), + status: "success".to_string(), + }, + RecentActivity { + id: "2".to_string(), + user_email: "user@example.com".to_string(), + action: "Content Update".to_string(), + resource_type: "content".to_string(), + timestamp: "4 hours ago".to_string(), + status: "success".to_string(), + }, + ]; + + Ok((stats, activities)) +} diff --git a/client/src/pages/admin/Roles.rs b/client/src/pages/admin/Roles.rs new file mode 100644 index 0000000..9d7bd3b --- /dev/null +++ b/client/src/pages/admin/Roles.rs @@ -0,0 +1,982 @@ +use crate::i18n::use_i18n; +use leptos::prelude::*; +use leptos::task::spawn_local; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Role { + pub id: String, + pub name: String, + pub description: String, + pub permissions: Vec<String>, + pub created_at: String, + pub updated_at: String, + pub user_count: u32, + pub is_system_role: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Permission { + pub id: String, + pub name: String, + pub description: String, + pub category: String, + pub resource: String, + pub action: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRoleRequest { + pub name: String, + pub description: String, + pub permissions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRoleRequest { + pub id: String, + pub name: String, + pub description: String, + pub permissions: Vec<String>, +} + +#[component] +pub fn AdminRoles() -> impl IntoView { + let i18n = use_i18n(); + let (roles, set_roles) = signal(Vec::<Role>::new()); + let (permissions, set_permissions) = signal(Vec::<Permission>::new()); + let (loading, set_loading) = signal(true); + let (error, set_error) = signal(None::<String>); + let (selected_role, set_selected_role) = signal(None::<Role>); + let (show_create_modal, set_show_create_modal) = signal(false); + let (show_edit_modal, set_show_edit_modal) = signal(false); + let (show_permissions_modal, set_show_permissions_modal) = signal(false); + let (search_term, set_search_term) = signal(String::new()); + + // Fetch roles and permissions on mount + Effect::new(move |_| { + spawn_local(async move { + match fetch_roles_and_permissions().await { + Ok((roles_data, permissions_data)) => { + set_roles.set(roles_data); + set_permissions.set(permissions_data); + set_loading.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_loading.set(false); + } + } + }); + }); + + // Filtered roles + let filtered_roles = Memo::new(move |_| { + let search = search_term.get().to_lowercase(); + roles + .get() + .into_iter() + .filter(|role| { + search.is_empty() + || role.name.to_lowercase().contains(&search) + || role.description.to_lowercase().contains(&search) + }) + .collect::<Vec<_>>() + }); + + let delete_role = Action::new(move |role_id: &String| { + let role_id = role_id.clone(); + async move { + match delete_role_api(&role_id).await { + Ok(_) => { + set_roles.update(|roles| roles.retain(|r| r.id != role_id)); + Ok(()) + } + Err(e) => { + set_error.set(Some(e.clone())); + Err(e) + } + } + } + }); + + view! { + <div class="min-h-screen bg-gray-50"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div class="pb-5 border-b border-gray-200"> + <div class="flex items-center justify-between"> + <h1 class="text-3xl font-bold leading-tight text-gray-900"> + "Role Management" + </h1> + <div class="flex space-x-3"> + <button + class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg" + on:click=move |_| set_show_permissions_modal.set(true) + > + {i18n.t("view-permissions")} + </button> + <button + class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg" + on:click=move |_| set_show_create_modal.set(true) + > + {i18n.t("create-new-role")} + </button> + </div> + </div> + </div> + + // Error Alert + <Show when=move || error.get().is_some()> + <div class="mt-4 bg-red-50 border border-red-200 rounded-md p-4"> + <div class="flex"> + <svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path> + </svg> + <div class="ml-3"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </div> + </div> + </Show> + + // Search + <div class="mt-6 bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <div class="flex items-center justify-between"> + <div class="flex-1 max-w-lg"> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("search-roles")} + </label> + <input + type="text" + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + placeholder="Search roles..." + prop:value=move || search_term.get() + on:input=move |ev| set_search_term.set(event_target_value(&ev)) + /> + </div> + <div class="ml-4"> + <button + class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg" + on:click=move |_| set_search_term.set(String::new()) + > + {i18n.t("clear")} + </button> + </div> + </div> + </div> + </div> + + // Roles Grid + <div class="mt-6"> + <Show + when=move || !loading.get() + fallback=|| view! { <RolesGridSkeleton /> } + > + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> + <For + each=move || filtered_roles.get() + key=|role| role.id.clone() + children=move |role| { + let role_name = role.name.clone(); + let role_description = role.description.clone(); + let role_id = role.id.clone(); + let role_is_system = role.is_system_role; + let role_user_count = role.user_count; + let role_permissions = role.permissions.clone(); + let role_permissions_len = role_permissions.len(); + let i18n = use_i18n(); + + view! { + <div class="bg-white overflow-hidden shadow rounded-lg"> + <div class="px-6 py-4"> + <div class="flex items-center justify-between"> + <div class="flex-1"> + <h3 class="text-lg font-medium text-gray-900"> + {role_name.clone()} + </h3> + <p class="text-sm text-gray-500 mt-1"> + {role_description.clone()} + </p> + </div> + <div class="flex space-x-2"> + <button + class="text-indigo-600 hover:text-indigo-900 text-sm font-medium" + on:click={ + let role_clone = role.clone(); + move |_| { + set_selected_role.set(Some(role_clone.clone())); + set_show_edit_modal.set(true); + } + } + > + {i18n.t("edit")} + </button> + <Show when=move || !role_is_system> + <button + class="text-red-600 hover:text-red-900 text-sm font-medium" + on:click={ + let role_name_for_delete = role_name.clone(); + let role_id_for_delete = role_id.clone(); + move |_| { + if let Some(window) = web_sys::window() { + if window + .confirm_with_message(&format!("Are you sure you want to delete the role '{}'?", role_name_for_delete)) + .unwrap_or(false) + { + let _ = delete_role.dispatch(role_id_for_delete.clone()); + } + } + } + } + > + {i18n.t("delete")} + </button> + </Show> + </div> + </div> + + <div class="mt-4"> + <div class="flex items-center justify-between text-sm text-gray-500"> + <span>{role_user_count} " users"</span> + <span>{role_permissions_len} " permissions"</span> + </div> + + <div class="mt-3"> + <div class="flex flex-wrap gap-1"> + {role_permissions.iter().take(3).map(|perm| { + view! { + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"> + {perm.clone()} + </span> + } + }).collect::<Vec<_>>()} + <Show when={ + let len = role_permissions_len; + move || len > 3 + }> + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"> + "+" {role_permissions_len - 3} " more" + </span> + </Show> + </div> + </div> + + <Show when=move || role.is_system_role> + <div class="mt-2"> + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> + "System Role" + </span> + </div> + </Show> + </div> + </div> + </div> + } + } + /> + </div> + </Show> + </div> + </div> + </div> + + // Create Role Modal + <Show when=move || show_create_modal.get()> + <CreateRoleModal + permissions=permissions.get() + on_close=move || set_show_create_modal.set(false) + on_role_created=move |role| { + set_roles.update(|roles| roles.push(role)); + set_show_create_modal.set(false); + } + /> + </Show> + + // Edit Role Modal + <Show when=move || show_edit_modal.get()> + <EditRoleModal + role=selected_role.get() + permissions=permissions.get() + on_close=move || set_show_edit_modal.set(false) + on_role_updated=move |updated_role| { + set_roles.update(|roles| { + if let Some(role) = roles.iter_mut().find(|r| r.id == updated_role.id) { + *role = updated_role; + } + }); + set_show_edit_modal.set(false); + } + /> + </Show> + + // Permissions Modal + <Show when=move || show_permissions_modal.get()> + <PermissionsModal + permissions=permissions.get() + on_close=move || set_show_permissions_modal.set(false) + /> + </Show> + </div> + } +} + +#[component] +fn RolesGridSkeleton() -> impl IntoView { + view! { + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> + <For + each=|| 0..6 + key=|i| *i + children=move |_| { + view! { + <div class="bg-white overflow-hidden shadow rounded-lg animate-pulse"> + <div class="px-6 py-4"> + <div class="h-4 bg-gray-300 rounded w-3/4 mb-2"></div> + <div class="h-3 bg-gray-300 rounded w-1/2"></div> + </div> + </div> + } + } + /> + </div> + } +} + +#[component] +fn CreateRoleModal( + permissions: Vec<Permission>, + on_close: impl Fn() + 'static + Clone + Send + Sync, + on_role_created: impl Fn(Role) + 'static + Clone + Send + Sync, +) -> impl IntoView { + let i18n = use_i18n(); + let (form_data, set_form_data) = signal(CreateRoleRequest { + name: String::new(), + description: String::new(), + permissions: Vec::new(), + }); + let (submitting, set_submitting) = signal(false); + let (error, set_error) = signal(None::<String>); + + // Group permissions by category + let permissions_options = Memo::new(move |_prev: Option<&HashMap<String, Vec<Permission>>>| { + let mut groups: HashMap<String, Vec<Permission>> = HashMap::new(); + for perm in permissions.iter() { + let category = perm.category.clone(); + groups + .entry(category) + .or_insert_with(Vec::new) + .push(perm.clone()); + } + groups + }); + + let submit_form = Action::new({ + let on_role_created = on_role_created.clone(); + move |_: &()| { + let form_data = form_data.get(); + let on_role_created = on_role_created.clone(); + async move { + set_submitting.set(true); + set_error.set(None); + + match create_role_api(form_data).await { + Ok(role) => { + on_role_created(role); + set_submitting.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_submitting.set(false); + } + } + } + } + }); + + // Create iterator functions outside view macro to avoid parsing issues + let permission_groups_iter = move || permissions_options.get().into_iter().collect::<Vec<_>>(); + + view! { + <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> + <div class="relative top-10 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white"> + <div class="mt-3"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium text-gray-900"> + {i18n.t("create-new-role")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <Show when=move || error.get().is_some()> + <div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </Show> + + <form on:submit=move |ev| { + ev.prevent_default(); + submit_form.dispatch(()); + }> + <div class="space-y-4"> + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("role-name")} + </label> + <input + type="text" + required + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || form_data.get().name + on:input=move |ev| { + set_form_data.update(|data| data.name = event_target_value(&ev)); + } + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("description")} + </label> + <textarea + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + rows="3" + prop:value=move || form_data.get().description + on:input=move |ev| { + set_form_data.update(|data| data.description = event_target_value(&ev)); + } + ></textarea> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700 mb-2"> + {i18n.t("permissions")} + </label> + <div class="max-h-60 overflow-y-auto border border-gray-200 rounded-md p-3"> + <For + each=permission_groups_iter + key=|(category, _)| category.clone() + children=move |(category, perms)| { + view! { + <div class="mb-4"> + <h4 class="font-medium text-gray-900 mb-2">{category}</h4> + <div class="space-y-2"> + <For + each=move || perms.clone() + key=|perm| perm.id.clone() + children=move |perm| { + let perm_id = perm.id.clone(); + view! { + <label class="flex items-center"> + <input + type="checkbox" + class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" + prop:checked=move || { + let perm_id = perm_id.clone(); + form_data.get().permissions.contains(&perm_id) + } + on:change=move |ev| { + let checked = event_target_checked(&ev); + set_form_data.update(|data| { + if checked { + if !data.permissions.contains(&perm.id) { + data.permissions.push(perm.id.clone()); + } + } else { + data.permissions.retain(|p| p != &perm.id); + } + }); + } + /> + <span class="ml-2 text-sm text-gray-700"> + {perm.name.clone()} + </span> + </label> + } + } + /> + </div> + </div> + } + } + /> + </div> + </div> + </div> + + <div class="flex justify-end space-x-3 mt-6"> + <button + type="button" + class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + {i18n.t("cancel")} + </button> + <button + type="submit" + disabled=move || submitting.get() + class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" + > + <Show + when=move || submitting.get() + fallback=|| "Create Role" + > + {i18n.t("creating")} + </Show> + </button> + </div> + </form> + </div> + </div> + </div> + } +} + +#[component] +fn EditRoleModal( + role: Option<Role>, + permissions: Vec<Permission>, + on_close: impl Fn() + 'static + Clone + Send + Sync, + on_role_updated: impl Fn(Role) + 'static + Clone + Send + Sync, +) -> impl IntoView { + let i18n = use_i18n(); + let role = role.unwrap_or_default(); + let (form_data, set_form_data) = signal(UpdateRoleRequest { + id: role.id.clone(), + name: role.name.clone(), + description: role.description.clone(), + permissions: role.permissions.clone(), + }); + let (submitting, set_submitting) = signal(false); + let (error, set_error) = signal(None::<String>); + + // Group permissions by category + let permissions_options = Memo::new(move |_prev: Option<&HashMap<String, Vec<Permission>>>| { + let mut groups: HashMap<String, Vec<Permission>> = HashMap::new(); + for perm in permissions.iter() { + let category = perm.category.clone(); + groups + .entry(category) + .or_insert_with(Vec::new) + .push(perm.clone()); + } + groups + }); + + let submit_form = Action::new({ + let on_role_updated = on_role_updated.clone(); + move |_: &()| { + let form_data = form_data.get(); + let on_role_updated = on_role_updated.clone(); + async move { + set_submitting.set(true); + set_error.set(None); + + match update_role_api(form_data).await { + Ok(role) => { + on_role_updated(role); + set_submitting.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_submitting.set(false); + } + } + } + } + }); + + // Create iterator functions outside view macro to avoid parsing issues + let permission_groups_iter_edit = + move || permissions_options.get().into_iter().collect::<Vec<_>>(); + + view! { + <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> + <div class="relative top-10 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white"> + <div class="mt-3"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium text-gray-900"> + {i18n.t("edit-role")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <Show when=move || error.get().is_some()> + <div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </Show> + + <form on:submit=move |ev| { + ev.prevent_default(); + let _ = submit_form.input(); + }> + <div class="space-y-4"> + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("role-name")} + </label> + <input + type="text" + required + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || form_data.get().name + on:input=move |ev| { + set_form_data.update(|data| data.name = event_target_value(&ev)); + } + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("description")} + </label> + <textarea + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + rows="3" + prop:value=move || form_data.get().description + on:input=move |ev| { + set_form_data.update(|data| data.description = event_target_value(&ev)); + } + ></textarea> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700 mb-2"> + {i18n.t("permissions")} + </label> + <div class="max-h-60 overflow-y-auto border border-gray-200 rounded-md p-3"> + <For + each=permission_groups_iter_edit + key=|(category, _)| category.clone() + children=move |(category, perms)| { + view! { + <div class="mb-4"> + <h4 class="font-medium text-gray-900 mb-2">{category}</h4> + <div class="space-y-2"> + <For + each=move || perms.clone() + key=|perm| perm.id.clone() + children=move |perm| { + let perm_id_input = perm.id.clone(); + view! { + <label class="flex items-center"> + <input + type="checkbox" + class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" + prop:checked=move || { + let data = form_data.get(); + let perm_id = perm_id_input.clone(); + data.permissions.contains(&perm_id) + } + on:change=move |ev| { + let checked = event_target_checked(&ev); + set_form_data.update(|data| { + if checked { + if !data.permissions.contains(&perm.id) { + data.permissions.push(perm.id.clone()); + } + } else { + data.permissions.retain(|p| p != &perm.id); + } + }); + } + /> + <span class="ml-2 text-sm text-gray-700"> + {perm.name.clone()} + </span> + </label> + } + } + /> + </div> + </div> + } + } + /> + </div> + </div> + </div> + + <div class="flex justify-end space-x-3 mt-6"> + <button + type="button" + class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + {i18n.t("cancel")} + </button> + <button + type="submit" + disabled=move || submitting.get() + class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" + > + <Show + when=move || submitting.get() + fallback=|| "Update Role" + > + {i18n.t("updating")} + </Show> + </button> + </div> + </form> + </div> + </div> + </div> + } +} + +#[component] +fn PermissionsModal( + permissions: Vec<Permission>, + on_close: impl Fn() + 'static + Send + Sync + Clone, +) -> impl IntoView { + let i18n = use_i18n(); + // Group permissions by category + let permission_groups = Memo::new(move |_prev: Option<&HashMap<String, Vec<Permission>>>| { + let mut groups: HashMap<String, Vec<Permission>> = HashMap::new(); + for perm in permissions.iter() { + let category = perm.category.clone(); + groups + .entry(category) + .or_insert_with(Vec::new) + .push(perm.clone()); + } + groups + }); + + // Create iterator functions outside view macro to avoid parsing issues + let permission_groups_iter_view = + move || permission_groups.get().into_iter().collect::<Vec<_>>(); + + view! { + <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> + <div class="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white"> + <div class="mt-3"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium text-gray-900"> + {i18n.t("system-permissions")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <div class="max-h-96 overflow-y-auto"> + <For + each=permission_groups_iter_view + key=|(category, _)| category.clone() + children=move |(category, perms)| { + view! { + <div class="mb-6"> + <h4 class="font-medium text-gray-900 mb-3 text-lg border-b pb-2"> + {category} + </h4> + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <For + each=move || perms.clone() + key=|perm| perm.id.clone() + children=move |perm| { + view! { + <div class="bg-gray-50 p-3 rounded-lg"> + <div class="font-medium text-sm text-gray-900"> + {perm.name.clone()} + </div> + <div class="text-xs text-gray-500 mt-1"> + {perm.description.clone()} + </div> + <div class="text-xs text-gray-400 mt-2"> + {format!("{} : {}", perm.resource, perm.action)} + </div> + </div> + } + } + /> + </div> + </div> + } + } + /> + </div> + </div> + </div> + </div> + } +} + +impl Default for Role { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: String::new(), + permissions: Vec::new(), + created_at: String::new(), + updated_at: String::new(), + user_count: 0, + is_system_role: false, + } + } +} + +impl Default for Permission { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: String::new(), + category: String::new(), + resource: String::new(), + action: String::new(), + } + } +} + +async fn fetch_roles_and_permissions() -> Result<(Vec<Role>, Vec<Permission>), String> { + // Mock data for now - replace with actual API call + let roles = vec![ + Role { + id: "1".to_string(), + name: "Administrator".to_string(), + description: "Full system access".to_string(), + permissions: vec!["1".to_string(), "2".to_string(), "3".to_string()], + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + user_count: 2, + is_system_role: true, + }, + Role { + id: "2".to_string(), + name: "User".to_string(), + description: "Standard user access".to_string(), + permissions: vec!["3".to_string()], + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + user_count: 10, + is_system_role: true, + }, + Role { + id: "3".to_string(), + name: "Moderator".to_string(), + description: "Content moderation access".to_string(), + permissions: vec!["3".to_string(), "4".to_string()], + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + user_count: 5, + is_system_role: false, + }, + ]; + + let permissions = vec![ + Permission { + id: "1".to_string(), + name: "User Management".to_string(), + description: "Create, read, update, and delete users".to_string(), + category: "Administration".to_string(), + resource: "users".to_string(), + action: "manage".to_string(), + }, + Permission { + id: "2".to_string(), + name: "Role Management".to_string(), + description: "Create, read, update, and delete roles".to_string(), + category: "Administration".to_string(), + resource: "roles".to_string(), + action: "manage".to_string(), + }, + Permission { + id: "3".to_string(), + name: "Read Profile".to_string(), + description: "View own profile information".to_string(), + category: "Profile".to_string(), + resource: "profile".to_string(), + action: "read".to_string(), + }, + Permission { + id: "4".to_string(), + name: "Content Moderation".to_string(), + description: "Moderate user-generated content".to_string(), + category: "Content".to_string(), + resource: "content".to_string(), + action: "moderate".to_string(), + }, + ]; + + Ok((roles, permissions)) +} + +async fn create_role_api(role_data: CreateRoleRequest) -> Result<Role, String> { + // Mock implementation - replace with actual API call + Ok(Role { + id: format!("role_{}", 12345), + name: role_data.name, + description: role_data.description, + permissions: role_data.permissions, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + user_count: 0, + is_system_role: false, + }) +} + +async fn update_role_api(role_data: UpdateRoleRequest) -> Result<Role, String> { + // Mock implementation - replace with actual API call + Ok(Role { + id: role_data.id, + name: role_data.name, + description: role_data.description, + permissions: role_data.permissions, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + user_count: 0, + is_system_role: false, + }) +} + +async fn delete_role_api(role_id: &str) -> Result<(), String> { + // Mock implementation - replace with actual API call + web_sys::console::log_1(&format!("Deleting role: {}", role_id).into()); + Ok(()) +} diff --git a/client/src/pages/admin/Users.rs b/client/src/pages/admin/Users.rs new file mode 100644 index 0000000..a8b58c3 --- /dev/null +++ b/client/src/pages/admin/Users.rs @@ -0,0 +1,893 @@ +use crate::i18n::use_i18n; +use leptos::prelude::*; +use leptos::task::spawn_local; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct User { + pub id: String, + pub email: String, + pub name: String, + pub roles: Vec<String>, + pub status: UserStatus, + pub created_at: String, + pub last_login: Option<String>, + pub is_verified: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum UserStatus { + Active, + Inactive, + Suspended, + Pending, +} + +impl UserStatus { + fn to_string(&self) -> String { + // &'static str { + let i18n = use_i18n(); + match self { + UserStatus::Active => i18n.t("active"), + UserStatus::Inactive => i18n.t("inactive"), + UserStatus::Suspended => i18n.t("suspended"), + UserStatus::Pending => i18n.t("pending"), + } + } + + fn badge_class(&self) -> &'static str { + match self { + UserStatus::Active => "bg-green-100 text-green-800", + UserStatus::Inactive => "bg-gray-100 text-gray-800", + UserStatus::Suspended => "bg-red-100 text-red-800", + UserStatus::Pending => "bg-yellow-100 text-yellow-800", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserRequest { + pub email: String, + pub name: String, + pub roles: Vec<String>, + pub send_invitation: bool, +} + +#[component] +pub fn AdminUsers() -> impl IntoView { + let i18n = use_i18n(); + + let (users, set_users) = signal(Vec::<User>::new()); + let (loading, set_loading) = signal(true); + let (error, set_error) = signal(None::<String>); + let (selected_user, set_selected_user) = signal(None::<User>); + let (show_create_modal, set_show_create_modal) = signal(false); + let (show_edit_modal, set_show_edit_modal) = signal(false); + let (search_term, set_search_term) = signal(String::new()); + let (status_filter, set_status_filter) = signal(String::new()); + + // Fetch users on mount + Effect::new(move |_| { + spawn_local(async move { + match fetch_users().await { + Ok(data) => { + set_users.set(data); + set_loading.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_loading.set(false); + } + } + }); + }); + + // Filtered users + let filtered_users = Memo::new(move |_| { + let search = search_term.get().to_lowercase(); + let status = status_filter.get(); + + users + .get() + .into_iter() + .filter(|user| { + let matches_search = search.is_empty() + || user.name.to_lowercase().contains(&search) + || user.email.to_lowercase().contains(&search); + + let matches_status = status.is_empty() + || user.status.to_string().to_lowercase() == status.to_lowercase(); + + matches_search && matches_status + }) + .collect::<Vec<_>>() + }); + + let delete_user = Action::new(move |user_id: &String| { + let user_id = user_id.clone(); + async move { + match delete_user_api(&user_id).await { + Ok(_) => { + set_users.update(|users| users.retain(|u| u.id != user_id)); + Ok(()) + } + Err(e) => { + set_error.set(Some(e.clone())); + Err(e) + } + } + } + }); + + let toggle_user_status = Action::new(move |user_id: &String| { + let user_id = user_id.clone(); + async move { + match toggle_user_status_api(&user_id).await { + Ok(updated_user) => { + set_users.update(|users| { + if let Some(user) = users.iter_mut().find(|u| u.id == user_id) { + *user = updated_user; + } + }); + Ok(()) + } + Err(e) => { + set_error.set(Some(e.clone())); + Err(e) + } + } + } + }); + + view! { + <div class="min-h-screen bg-gray-50"> + <div class="py-6"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div class="pb-5 border-b border-gray-200"> + <div class="flex items-center justify-between"> + <h1 class="text-3xl font-bold leading-tight text-gray-900"> + {i18n.t("user-management")} + </h1> + <button + class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg" + on:click=move |_| set_show_create_modal.set(true) + > + {i18n.t("add-new-user")} + </button> + </div> + </div> + + // Error Alert + <Show when=move || error.get().is_some()> + <div class="mt-4 bg-red-50 border border-red-200 rounded-md p-4"> + <div class="flex"> + <svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path> + </svg> + <div class="ml-3"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </div> + </div> + </Show> + + // Search and Filter + <div class="mt-6 bg-white shadow rounded-lg"> + <div class="px-4 py-5 sm:p-6"> + <div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("search-users")} + </label> + <input + type="text" + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + placeholder="Search by name or email..." + prop:value=move || search_term.get() + on:input=move |ev| set_search_term.set(event_target_value(&ev)) + /> + </div> + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("filter-by-status")} + </label> + <select + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || status_filter.get() + on:change=move |ev| set_status_filter.set(event_target_value(&ev)) + > + <option value="">{i18n.t("all-status")}</option> + <option value="active">{i18n.t("active")}</option> + <option value="inactive">{i18n.t("inactive")}</option> + <option value="suspended">{i18n.t("suspended")}</option> + <option value="pending">{i18n.t("pending")}</option> + </select> + </div> + <div class="flex items-end"> + <button + class="w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg" + on:click=move |_| { + set_search_term.set(String::new()); + set_status_filter.set(String::new()); + } + > + {i18n.t("clear-filters")} + </button> + </div> + </div> + </div> + </div> + + // Users Table + <div class="mt-6 bg-white shadow overflow-hidden sm:rounded-md"> + <Show + when=move || !loading.get() + fallback=|| view! { <UsersTableSkeleton /> } + > + <div class="min-w-full overflow-hidden overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + {i18n.t("user")} + </th> + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + {i18n.t("roles")} + </th> + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + {i18n.t("status")} + </th> + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + {i18n.t("last-login")} + </th> + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + {i18n.t("actions")} + </th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + <For + each=move || filtered_users.get() + key=|user| user.id.clone() + children=move |user| { + let delete_id = user.id.clone(); + let activate_id = user.id.clone(); + let user_name = user.name.clone(); + let user_status = user.status.clone(); + view! { + <tr> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="flex items-center"> + <div class="flex-shrink-0 h-10 w-10"> + <div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center"> + <span class="text-sm font-medium text-gray-700"> + {user.name.chars().next().unwrap_or('U')} + </span> + </div> + </div> + <div class="ml-4"> + <div class="text-sm font-medium text-gray-900"> + {user.name.clone()} + </div> + <div class="text-sm text-gray-500"> + {user.email.clone()} + </div> + </div> + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <div class="flex flex-wrap gap-1"> + {user.roles.iter().map(|role| { + view! { + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> + {role.clone()} + </span> + } + }).collect::<Vec<_>>()} + </div> + </td> + <td class="px-6 py-4 whitespace-nowrap"> + <span class=format!("inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {}", user.status.badge_class())> + {user.status.to_string()} + </span> + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {user.last_login.as_ref().unwrap_or(&"Never".to_string()).clone()} + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> + <div class="flex space-x-2"> + <button + class="text-indigo-600 hover:text-indigo-900" + on:click=move |_| { + set_selected_user.set(Some(user.clone())); + set_show_edit_modal.set(true); + } + > + "Edit" + </button> + <button + class="text-yellow-600 hover:text-yellow-900" + on:click=move |_| { toggle_user_status.dispatch(activate_id.clone()); } + > + {match user_status { + UserStatus::Active => "Suspend", + _ => "Activate", + }} + </button> + <button + class="text-red-600 hover:text-red-900" + on:click=move |_| { + if let Some(window) = web_sys::window() { + if window.confirm_with_message(&format!("Are you sure you want to delete user {}?", user_name)).unwrap_or(false) { + let _ = delete_user.dispatch(delete_id.clone()); + } + } + } + > + "Delete" + </button> + </div> + </td> + </tr> + } + } + /> + </tbody> + </table> + </div> + </Show> + </div> + </div> + </div> + + // Create User Modal + <Show when=move || show_create_modal.get()> + <CreateUserModal + on_close=move || set_show_create_modal.set(false) + on_user_created=move |user| { + set_users.update(|users| users.push(user)); + set_show_create_modal.set(false); + } + /> + </Show> + + // Edit User Modal + <Show when=move || show_edit_modal.get()> + <EditUserModal + user=selected_user.get() + on_close=move || set_show_edit_modal.set(false) + on_user_updated=move |updated_user| { + set_users.update(|users| { + if let Some(user) = users.iter_mut().find(|u| u.id == updated_user.id) { + *user = updated_user; + } + }); + set_show_edit_modal.set(false); + } + /> + </Show> + </div> + } +} + +#[component] +fn UsersTableSkeleton() -> impl IntoView { + view! { + <div class="animate-pulse"> + <div class="bg-gray-50 px-6 py-3"> + <div class="h-4 bg-gray-200 rounded w-full"></div> + </div> + <div class="divide-y divide-gray-200"> + {(0..5).map(|_| view! { + <div class="px-6 py-4"> + <div class="flex items-center space-x-4"> + <div class="h-10 w-10 bg-gray-200 rounded-full"></div> + <div class="flex-1 space-y-2"> + <div class="h-4 bg-gray-200 rounded w-1/4"></div> + <div class="h-4 bg-gray-200 rounded w-1/3"></div> + </div> + </div> + </div> + }).collect::<Vec<_>>()} + </div> + </div> + } +} + +#[component] +fn CreateUserModal( + on_close: impl Fn() + 'static + Clone, + on_user_created: impl Fn(User) + 'static + Clone + Send + Sync, +) -> impl IntoView { + let i18n = use_i18n(); + let (form_data, set_form_data) = signal(CreateUserRequest { + email: String::new(), + name: String::new(), + roles: Vec::new(), + send_invitation: true, + }); + let (submitting, set_submitting) = signal(false); + let (error, set_error) = signal(None::<String>); + + let available_roles = vec![ + "admin".to_string(), + "user".to_string(), + "moderator".to_string(), + ]; + + let submit_form = Action::new(move |_: &()| { + let form_data = form_data.get(); + let on_user_created = on_user_created.clone(); + async move { + set_submitting.set(true); + set_error.set(None); + + match create_user_api(form_data).await { + Ok(user) => { + on_user_created(user); + set_submitting.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_submitting.set(false); + } + } + } + }); + + view! { + <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> + <div class="relative top-10 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white"> + <div class="mt-3"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium text-gray-900"> + "Create New User" + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <Show when=move || error.get().is_some()> + <div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </Show> + + <form on:submit=move |ev| { + ev.prevent_default(); + submit_form.dispatch(()); + }> + <div class="space-y-4"> + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("email")} + </label> + <input + type="email" + required + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || form_data.get().email + on:input=move |ev| { + let value = event_target_value(&ev); + set_form_data.update(|data| data.email = value); + } + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("name")} + </label> + <input + type="text" + required + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || form_data.get().name + on:input=move |ev| { + let value = event_target_value(&ev); + set_form_data.update(|data| data.name = value); + } + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("roles")} + </label> + <div class="mt-2 space-y-2"> + <For + each=move || available_roles.clone() + key=|role| role.clone() + children=move |role| { + let role_for_memo = role.clone(); + let role_checked = Memo::new(move |_| { + form_data.get().roles.contains(&role_for_memo) + }); + + view! { + <div class="flex items-center"> + <input + type="checkbox" + class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + prop:checked=move || role_checked.get() + on:change={ + let role_clone = role.clone(); + move |ev| { + let checked = event_target_checked(&ev); + let role_for_update = role_clone.clone(); + set_form_data.update(|data| { + if checked { + if !data.roles.contains(&role_for_update) { + data.roles.push(role_for_update); + } + } else { + data.roles.retain(|r| r != &role_for_update); + } + }); + } + } + /> + <label class="ml-2 text-sm text-gray-900"> + {role.clone()} + </label> + </div> + } + } + /> + </div> + </div> + + <div class="flex items-center"> + <input + type="checkbox" + class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + prop:checked=move || form_data.get().send_invitation + on:change=move |ev| { + let checked = event_target_checked(&ev); + set_form_data.update(|data| data.send_invitation = checked); + } + /> + <label class="ml-2 text-sm text-gray-900"> + {i18n.t("send-invitation-email")} + </label> + </div> + </div> + + <div class="flex justify-end space-x-3 mt-6"> + <button + type="button" + class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + {i18n.t("cancel")} + </button> + <button + type="submit" + disabled=move || submitting.get() + class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" + > + <Show + when=move || submitting.get() + fallback=|| "Create User" + > + {i18n.t("creating")} + </Show> + </button> + </div> + </form> + </div> + </div> + </div> + } +} + +#[component] +fn EditUserModal( + user: Option<User>, + on_close: impl Fn() + Send + Sync + Clone + 'static, + on_user_updated: impl Fn(User) + Send + Sync + Clone + 'static, +) -> impl IntoView { + let i18n = use_i18n(); + let user = user.unwrap_or_default(); + let (form_data, set_form_data) = signal(UpdateUserRequest { + id: user.id.clone(), + email: user.email.clone(), + name: user.name.clone(), + roles: user.roles.clone(), + }); + let (submitting, set_submitting) = signal(false); + let (error, set_error) = signal(None::<String>); + + let available_roles = vec![ + "admin".to_string(), + "user".to_string(), + "moderator".to_string(), + ]; + + let submit_form = Action::new({ + let form_data = form_data.clone(); + let set_submitting = set_submitting.clone(); + let set_error = set_error.clone(); + let on_user_updated = on_user_updated.clone(); + + move |_: &()| { + let form_data = form_data.get(); + let on_user_updated = on_user_updated.clone(); + + async move { + set_submitting.set(true); + set_error.set(None); + + match update_user_api(form_data).await { + Ok(user) => { + on_user_updated(user); + set_submitting.set(false); + } + Err(e) => { + set_error.set(Some(e)); + set_submitting.set(false); + } + } + } + } + }); + + view! { + <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> + <div class="relative top-10 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white"> + <div class="mt-3"> + <div class="flex items-center justify-between mb-4"> + <h3 class="text-lg font-medium text-gray-900"> + {i18n.t("edit-user")} + </h3> + <button + class="text-gray-400 hover:text-gray-600" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> + </svg> + </button> + </div> + + <Show when=move || error.get().is_some()> + <div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4"> + <p class="text-sm text-red-800"> + {move || error.get().unwrap_or_default()} + </p> + </div> + </Show> + + <form on:submit=move |ev| { + ev.prevent_default(); + submit_form.dispatch(()); + }> + <div class="space-y-4"> + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("email")} + </label> + <input + type="email" + required + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || form_data.get().email + on:input=move |ev| { + let value = event_target_value(&ev); + set_form_data.update(|data| data.email = value); + } + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("name")} + </label> + <input + type="text" + required + class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" + prop:value=move || form_data.get().name + on:input=move |ev| { + let value = event_target_value(&ev); + set_form_data.update(|data| data.name = value); + } + /> + </div> + + <div> + <label class="block text-sm font-medium text-gray-700"> + {i18n.t("roles")} + </label> + <div class="mt-2 space-y-2"> + <For + each=move || available_roles.clone() + key=|role| role.clone() + children=move |role| { + let role_clone = role.clone(); + let role_checked = Memo::new(move |_| { + form_data.get().roles.contains(&role_clone) + }); + + view! { + <div class="flex items-center"> + <input + type="checkbox" + class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + prop:checked=move || role_checked.get() + on:change={ + let role_clone2 = role.clone(); + move |ev| { + let checked = event_target_checked(&ev); + let role_for_update = role_clone2.clone(); + let role_for_retain = role_clone2.clone(); + set_form_data.update(|data| { + if checked { + if !data.roles.contains(&role_for_update) { + data.roles.push(role_for_update); + } + } else { + data.roles.retain(|r| r != &role_for_retain); + } + }); + } + } + /> + <label class="ml-2 text-sm text-gray-900"> + {role.clone()} + </label> + </div> + } + } + /> + </div> + </div> + </div> + + <div class="flex justify-end space-x-3 mt-6"> + <button + type="button" + class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50" + on:click={ + let on_close_clone = on_close.clone(); + move |_| on_close_clone() + } + > + {i18n.t("cancel")} + </button> + <button + type="submit" + disabled=move || submitting.get() + class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" + > + <Show + when=move || submitting.get() + fallback=|| "Update User" + > + {i18n.t("updating")} + </Show> + </button> + </div> + </form> + </div> + </div> + </div> + } +} + +impl Default for User { + fn default() -> Self { + Self { + id: String::new(), + email: String::new(), + name: String::new(), + roles: vec!["user".to_string()], + status: UserStatus::Active, + created_at: String::new(), + last_login: None, + is_verified: false, + } + } +} + +// API Functions +async fn fetch_users() -> Result<Vec<User>, String> { + // Mock data for now - replace with actual API call + Ok(vec![ + User { + id: "1".to_string(), + email: "admin@example.com".to_string(), + name: "Admin User".to_string(), + roles: vec!["admin".to_string(), "user".to_string()], + status: UserStatus::Active, + created_at: "2024-01-01T00:00:00Z".to_string(), + last_login: Some("2024-01-15T10:30:00Z".to_string()), + is_verified: true, + }, + User { + id: "2".to_string(), + email: "user@example.com".to_string(), + name: "Regular User".to_string(), + roles: vec!["user".to_string()], + status: UserStatus::Active, + created_at: "2024-01-02T00:00:00Z".to_string(), + last_login: Some("2024-01-14T15:45:00Z".to_string()), + is_verified: true, + }, + ]) +} + +async fn create_user_api(user_data: CreateUserRequest) -> Result<User, String> { + // Mock implementation - replace with actual API call + Ok(User { + id: format!("user_{}", 12345), + email: user_data.email, + name: user_data.name, + roles: user_data.roles, + status: UserStatus::Active, + created_at: "2024-01-01T00:00:00Z".to_string(), + last_login: None, + is_verified: false, + }) +} + +async fn update_user_api(user_data: UpdateUserRequest) -> Result<User, String> { + // Mock implementation - replace with actual API call + Ok(User { + id: user_data.id, + email: user_data.email, + name: user_data.name, + roles: user_data.roles, + status: UserStatus::Active, + created_at: "2024-01-01T00:00:00Z".to_string(), + last_login: None, + is_verified: true, + }) +} + +async fn delete_user_api(user_id: &str) -> Result<(), String> { + // Mock implementation - replace with actual API call + web_sys::console::log_1(&format!("Deleting user: {}", user_id).into()); + Ok(()) +} + +async fn toggle_user_status_api(user_id: &str) -> Result<User, String> { + // Mock implementation - replace with actual API call + Ok(User { + id: user_id.to_string(), + email: "updated@example.com".to_string(), + name: "Updated User".to_string(), + roles: vec!["user".to_string()], + status: UserStatus::Active, + created_at: "2024-01-01T00:00:00Z".to_string(), + last_login: Some("2024-01-01T10:00:00Z".to_string()), + is_verified: true, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateUserRequest { + pub id: String, + pub email: String, + pub name: String, + pub roles: Vec<String>, +} diff --git a/client/src/pages/admin/mod.rs b/client/src/pages/admin/mod.rs new file mode 100644 index 0000000..b7ecb73 --- /dev/null +++ b/client/src/pages/admin/mod.rs @@ -0,0 +1,9 @@ +pub mod Content; +pub mod Dashboard; +pub mod Roles; +pub mod Users; + +pub use Content::*; +pub use Dashboard::*; +pub use Roles::*; +pub use Users::*; diff --git a/client/src/pages/contact.rs b/client/src/pages/contact.rs new file mode 100644 index 0000000..637f084 --- /dev/null +++ b/client/src/pages/contact.rs @@ -0,0 +1,250 @@ +//! Contact page component +//! +//! This page demonstrates the usage of the ContactForm component and provides +//! a complete contact page implementation with additional information and styling. + +use crate::components::forms::ContactForm; +use leptos::prelude::*; +use leptos_meta::*; + +#[component] +pub fn ContactPage() -> impl IntoView { + view! { + <Title text="Contact Us - Get in Touch"/> + <Meta name="description" content="Contact us for questions, support, or feedback. We're here to help!"/> + + <div class="min-h-screen bg-gray-50 py-12"> + <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + // Header Section + <div class="text-center mb-16"> + <h1 class="text-4xl font-bold text-gray-900 sm:text-5xl mb-4"> + "Get in Touch" + </h1> + <p class="text-xl text-gray-600 max-w-3xl mx-auto"> + "We'd love to hear from you. Whether you have a question about features, " + "pricing, need support, or anything else, our team is ready to answer all your questions." + </p> + </div> + + <div class="grid grid-cols-1 lg:grid-cols-3 gap-12"> + // Contact Information + <div class="lg:col-span-1"> + <div class="bg-white rounded-lg shadow-lg p-8"> + <h2 class="text-2xl font-bold text-gray-900 mb-6"> + "Contact Information" + </h2> + + <div class="space-y-6"> + // Email + <div class="flex items-start"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/> + </svg> + </div> + <div class="ml-4"> + <h3 class="text-lg font-medium text-gray-900">"Email"</h3> + <p class="text-gray-600">"contact@yourapp.com"</p> + <p class="text-sm text-gray-500">"We'll respond within 24 hours"</p> + </div> + </div> + + // Support + <div class="flex items-start"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 12l2.828-2.828m0 5.656L12 12m0 0l-2.828-2.828M12 12l2.828 2.828"/> + </svg> + </div> + <div class="ml-4"> + <h3 class="text-lg font-medium text-gray-900">"Support"</h3> + <p class="text-gray-600">"support@yourapp.com"</p> + <p class="text-sm text-gray-500">"Technical support and assistance"</p> + </div> + </div> + + // Response Time + <div class="flex items-start"> + <div class="flex-shrink-0"> + <svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/> + </svg> + </div> + <div class="ml-4"> + <h3 class="text-lg font-medium text-gray-900">"Response Time"</h3> + <p class="text-gray-600">"Usually within 4 hours"</p> + <p class="text-sm text-gray-500">"Business hours: Mon-Fri 9AM-5PM EST"</p> + </div> + </div> + </div> + + // Quick Links + <div class="mt-8 pt-8 border-t border-gray-200"> + <h3 class="text-lg font-medium text-gray-900 mb-4">"Quick Links"</h3> + <div class="space-y-2"> + <a href="/docs" class="block text-blue-600 hover:text-blue-700 text-sm"> + "📚 Documentation" + </a> + <a href="/faq" class="block text-blue-600 hover:text-blue-700 text-sm"> + "❓ Frequently Asked Questions" + </a> + <a href="/support" class="block text-blue-600 hover:text-blue-700 text-sm"> + "🛠️ Support Center" + </a> + <a href="/status" class="block text-blue-600 hover:text-blue-700 text-sm"> + "📊 System Status" + </a> + </div> + </div> + </div> + </div> + + // Contact Form + <div class="lg:col-span-2"> + <div class="bg-white rounded-lg shadow-lg p-8"> + <ContactForm + title="Send us a Message" + description="Fill out the form below and we'll get back to you as soon as possible." + recipient="contact@yourapp.com" + submit_text="Send Message" + show_success=true + reset_after_success=true + class="" + /> + </div> + </div> + </div> + + // FAQ Section + <div class="mt-16"> + <div class="bg-white rounded-lg shadow-lg p-8"> + <h2 class="text-2xl font-bold text-gray-900 mb-8 text-center"> + "Frequently Asked Questions" + </h2> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-8"> + // FAQ Item 1 + <div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "How quickly do you respond to messages?" + </h3> + <p class="text-gray-600"> + "We aim to respond to all messages within 4 hours during business hours " + "(Mon-Fri 9AM-5PM EST). For urgent matters, please mark your message as high priority." + </p> + </div> + + // FAQ Item 2 + <div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "What information should I include in my message?" + </h3> + <p class="text-gray-600"> + "Please include as much detail as possible about your question or issue. " + "If it's a technical problem, include any error messages and steps to reproduce the issue." + </p> + </div> + + // FAQ Item 3 + <div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "Do you offer phone support?" + </h3> + <p class="text-gray-600"> + "Currently, we provide support primarily through email and our contact form. " + "This allows us to better track and resolve issues while providing detailed responses." + </p> + </div> + + // FAQ Item 4 + <div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "Can I request new features?" + </h3> + <p class="text-gray-600"> + "Absolutely! We love hearing feature requests from our users. " + "Please describe the feature you'd like and how it would help you." + </p> + </div> + </div> + </div> + </div> + + // Alternative Contact Methods + <div class="mt-16"> + <div class="text-center"> + <h2 class="text-2xl font-bold text-gray-900 mb-8"> + "Other Ways to Reach Us" + </h2> + + <div class="grid grid-cols-1 md:grid-cols-3 gap-8"> + // Technical Support + <div class="bg-blue-50 rounded-lg p-6"> + <div class="text-blue-600 mb-4"> + <svg class="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> + </svg> + </div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "Technical Support" + </h3> + <p class="text-gray-600 mb-4"> + "For technical issues, bugs, or integration help" + </p> + <a + href="/support" + class="inline-block bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors" + > + "Open Support Ticket" + </a> + </div> + + // Sales Inquiries + <div class="bg-green-50 rounded-lg p-6"> + <div class="text-green-600 mb-4"> + <svg class="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"/> + </svg> + </div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "Sales & Pricing" + </h3> + <p class="text-gray-600 mb-4"> + "Questions about pricing, plans, or enterprise solutions" + </p> + <a + href="mailto:sales@yourapp.com" + class="inline-block bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 transition-colors" + > + "Contact Sales" + </a> + </div> + + // General Feedback + <div class="bg-purple-50 rounded-lg p-6"> + <div class="text-purple-600 mb-4"> + <svg class="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/> + </svg> + </div> + <h3 class="text-lg font-semibold text-gray-900 mb-2"> + "Feedback & Suggestions" + </h3> + <p class="text-gray-600 mb-4"> + "Share your ideas, feedback, or feature requests" + </p> + <a + href="mailto:feedback@yourapp.com" + class="inline-block bg-purple-600 text-white px-4 py-2 rounded-md hover:bg-purple-700 transition-colors" + > + "Send Feedback" + </a> + </div> + </div> + </div> + </div> + </div> + </div> + } +} diff --git a/client/src/pages/mod.rs b/client/src/pages/mod.rs new file mode 100644 index 0000000..0876b7f --- /dev/null +++ b/client/src/pages/mod.rs @@ -0,0 +1,11 @@ +#![allow(non_snake_case)] +mod About; +mod DaisyUI; +mod FeaturesDemo; +mod Home; +pub mod admin; + +pub use About::*; +pub use DaisyUI::*; +pub use FeaturesDemo::*; +pub use Home::*; diff --git a/client/src/state/mod.rs b/client/src/state/mod.rs new file mode 100644 index 0000000..a8fa236 --- /dev/null +++ b/client/src/state/mod.rs @@ -0,0 +1,42 @@ +pub mod theme; + +pub use theme::*; + +// Re-export common state-related items +use leptos::prelude::*; + +// Global state provider components +#[component] +pub fn GlobalStateProvider(children: leptos::children::Children) -> impl IntoView { + view! { + <>{children()}</> + } +} + +#[component] +pub fn ThemeProvider(children: leptos::children::Children) -> impl IntoView { + view! { + <>{children()}</> + } +} + +#[component] +pub fn ToastProvider(children: leptos::children::Children) -> impl IntoView { + view! { + <>{children()}</> + } +} + +#[component] +pub fn UserProvider(children: leptos::children::Children) -> impl IntoView { + view! { + <>{children()}</> + } +} + +#[component] +pub fn AppStateProvider(children: leptos::children::Children) -> impl IntoView { + view! { + <>{children()}</> + } +} diff --git a/client/src/state/theme.rs b/client/src/state/theme.rs new file mode 100644 index 0000000..709a8d4 --- /dev/null +++ b/client/src/state/theme.rs @@ -0,0 +1,243 @@ +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Theme variants supported by the application +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Theme { + Light, + Dark, + Auto, +} + +impl Default for Theme { + fn default() -> Self { + Self::Light + } +} + +impl Theme { + /// Get the CSS class name for the theme + pub fn as_class(&self) -> &'static str { + match self { + Theme::Light => "theme-light", + Theme::Dark => "theme-dark", + Theme::Auto => "theme-auto", + } + } + + /// Get the data attribute value for DaisyUI + pub fn as_data_theme(&self) -> &'static str { + match self { + Theme::Light => "light", + Theme::Dark => "dark", + Theme::Auto => "light", + } + } + + /// Get all available themes + pub fn all() -> Vec<Theme> { + vec![Theme::Light, Theme::Dark, Theme::Auto] + } + + /// Get display name for the theme + pub fn display_name(&self) -> &'static str { + match self { + Theme::Light => "Light", + Theme::Dark => "Dark", + Theme::Auto => "Auto", + } + } + + /// Get icon for the theme + pub fn icon(&self) -> &'static str { + match self { + Theme::Light => "i-carbon-sun", + Theme::Dark => "i-carbon-moon", + Theme::Auto => "i-carbon-settings", + } + } +} + +/// Theme state management +#[derive(Debug, Clone)] +pub struct ThemeState { + pub current_theme: RwSignal<Theme>, + pub system_theme: RwSignal<Theme>, +} + +impl Default for ThemeState { + fn default() -> Self { + Self { + current_theme: RwSignal::new(Theme::Light), + system_theme: RwSignal::new(Self::detect_system_theme()), + } + } +} + +impl ThemeState { + /// Create a new theme state with initial theme + pub fn new(initial_theme: Theme) -> Self { + Self { + current_theme: RwSignal::new(initial_theme), + system_theme: RwSignal::new(Self::detect_system_theme()), + } + } + + /// Detect system theme preference + fn detect_system_theme() -> Theme { + Theme::Light + } + + /// Toggle between light and dark themes + pub fn toggle(&self) { + let current = self.current_theme.get(); + let new_theme = match current { + Theme::Light => Theme::Dark, + Theme::Dark => Theme::Light, + Theme::Auto => Theme::Light, + }; + self.set_theme(new_theme); + } + + /// Set the current theme + pub fn set_theme(&self, theme: Theme) { + self.current_theme.set(theme); + self.apply_theme(theme); + } + + /// Apply theme to the DOM + fn apply_theme(&self, _theme: Theme) { + // Theme application would be handled by CSS/JavaScript + // For now, we'll keep this simple + } + + /// Get the effective theme (resolves Auto to Light/Dark) + pub fn effective_theme(&self) -> Theme { + match self.current_theme.get() { + Theme::Auto => self.system_theme.get(), + theme => theme, + } + } + + /// Initialize theme system with system preference detection + pub fn init(&self) { + // Apply initial theme + self.apply_theme(self.current_theme.get()); + + // Set up system theme change listener + self.setup_system_theme_listener(); + } + + /// Set up listener for system theme changes + fn setup_system_theme_listener(&self) { + // System theme listening would be handled by JavaScript + // For now, we'll keep this simple + } +} + +/// Theme provider component +#[component] +pub fn ThemeProvider( + #[prop(optional)] initial_theme: Option<Theme>, + children: leptos::children::Children, +) -> impl IntoView { + let theme_state = ThemeState::new(initial_theme.unwrap_or_default()); + theme_state.init(); + + provide_context(theme_state); + + view! { + {children()} + } +} + +/// Hook to use theme state +pub fn use_theme_state() -> ThemeState { + use_context::<ThemeState>() + .expect("ThemeState context not found. Make sure ThemeProvider is set up.") +} + +/// Theme toggle button component +#[component] +pub fn ThemeToggle(#[prop(optional)] class: Option<String>) -> impl IntoView { + let theme_state = use_theme_state(); + let current_theme = theme_state.current_theme; + + let toggle_theme = move |_| { + theme_state.toggle(); + }; + + view! { + <button + class=move || format!("btn btn-ghost btn-circle {}", class.as_deref().unwrap_or("")) + on:click=toggle_theme + title=move || format!("Switch to {} theme", + match current_theme.get() { + Theme::Light => "dark", + Theme::Dark => "light", + Theme::Auto => "light", + } + ) + > + <div class=move || format!("w-5 h-5 {}", current_theme.get().icon())></div> + </button> + } +} + +/// Theme selector dropdown component +#[component] +pub fn ThemeSelector(#[prop(optional)] class: Option<String>) -> impl IntoView { + let theme_state = use_theme_state(); + let current_theme = theme_state.current_theme; + + view! { + <div class=move || format!("dropdown dropdown-end {}", class.as_deref().unwrap_or(""))> + <div tabindex="0" role="button" class="btn btn-ghost btn-circle"> + <div class=move || format!("w-5 h-5 {}", current_theme.get().icon())></div> + </div> + <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> + {Theme::all().into_iter().map(|theme| { + let theme_state = theme_state.clone(); + let is_active = move || current_theme.get() == theme; + + view! { + <li> + <a + class=move || if is_active() { "active" } else { "" } + on:click=move |_| theme_state.set_theme(theme) + > + <div class=format!("w-4 h-4 {}", theme.icon())></div> + {theme.display_name()} + </a> + </li> + } + }).collect::<Vec<_>>()} + </ul> + </div> + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_theme_display_names() { + assert_eq!(Theme::Light.display_name(), "Light"); + assert_eq!(Theme::Dark.display_name(), "Dark"); + assert_eq!(Theme::Auto.display_name(), "Auto"); + } + + #[test] + fn test_theme_data_attributes() { + assert_eq!(Theme::Light.as_data_theme(), "light"); + assert_eq!(Theme::Dark.as_data_theme(), "dark"); + } + + #[test] + fn test_theme_classes() { + assert_eq!(Theme::Light.as_class(), "theme-light"); + assert_eq!(Theme::Dark.as_class(), "theme-dark"); + assert_eq!(Theme::Auto.as_class(), "theme-auto"); + } +} diff --git a/client/src/utils.rs b/client/src/utils.rs new file mode 100644 index 0000000..2efd639 --- /dev/null +++ b/client/src/utils.rs @@ -0,0 +1,142 @@ +use leptos::ev::MouseEvent; +#[cfg(target_arch = "wasm32")] +use leptos::prelude::Effect; +use leptos::prelude::{Set, WriteSignal}; +use serde::{Deserialize, Serialize}; +use std::rc::Rc; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] +use web_sys::window; +#[cfg(not(target_arch = "wasm32"))] +fn window() -> Option<()> { + None +} + +// --- Type Aliases for Closures --- +pub type NavigateFn = Rc<dyn Fn(&str)>; +pub type LinkClickFn = Rc<dyn Fn(MouseEvent, &'static str)>; + +// Returns the initial path for SSR or client hydration. +/// In the future, this could use a context or prop for SSR path awareness. +#[cfg(target_arch = "wasm32")] +pub fn get_initial_path() -> String { + window() + .and_then(|win| win.location().pathname().ok()) + .unwrap_or_else(|| "/".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn get_initial_path() -> String { + "/".to_string() +} + +/// Creates a navigation function for SPA routing. +#[cfg(target_arch = "wasm32")] +pub fn make_navigate(set_path: WriteSignal<String>) -> NavigateFn { + Rc::new(move |to: &str| { + web_sys::console::log_1(&format!("Navigating to: {to}").into()); + if let Some(win) = window() { + if let Some(history) = win.history().ok() { + let _ = history.push_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(to)); + } + } + set_path.set(to.to_string()); + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn make_navigate(set_path: WriteSignal<String>) -> NavigateFn { + Rc::new(move |to: &str| { + set_path.set(to.to_string()); + }) +} + +/// Generic API request function for making HTTP requests to the server +pub async fn api_request<T, R>( + url: &str, + method: &str, + body: Option<T>, +) -> Result<R, Box<dyn std::error::Error>> +where + T: Serialize, + R: for<'de> Deserialize<'de>, +{ + let mut request = reqwasm::http::Request::new(url); + request = match method { + "GET" => request.method(reqwasm::http::Method::GET), + "POST" => request.method(reqwasm::http::Method::POST), + "PUT" => request.method(reqwasm::http::Method::PUT), + "DELETE" => request.method(reqwasm::http::Method::DELETE), + "PATCH" => request.method(reqwasm::http::Method::PATCH), + _ => request.method(reqwasm::http::Method::GET), + }; + request = request.header("Content-Type", "application/json"); + + // Add auth token if available + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + if let Ok(Some(token)) = storage.get_item("auth_token") { + request = request.header("Authorization", &format!("Bearer {}", token)); + } + } + } + + // Add body if provided + if let Some(body) = body { + let body_str = serde_json::to_string(&body)?; + request = request.body(body_str); + } + + let response = request.send().await?; + + if response.ok() { + let json_response = response.json::<R>().await?; + Ok(json_response) + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Err(format!("API request failed: {}", error_text).into()) + } +} + +/// Creates a link click handler for SPA navigation. +pub fn make_on_link_click(set_path: WriteSignal<String>, navigate: NavigateFn) -> LinkClickFn { + if window().is_some() { + Rc::new(move |ev: MouseEvent, to: &'static str| { + web_sys::console::log_1(&format!("Clicked: {to}").into()); + ev.prevent_default(); + set_path.set(to.to_string()); + (*navigate)(to); + }) + } else { + Rc::new(|_, _| {}) + } +} + +/// Sets up a popstate event listener for SPA navigation (client only). +#[cfg(target_arch = "wasm32")] +pub fn make_popstate_effect(set_path: WriteSignal<String>) { + if let Some(win) = window() { + Effect::new(move |_| { + let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move || { + if let Some(win) = window() { + let new_path = win + .location() + .pathname() + .unwrap_or_else(|_| "/".to_string()); + set_path.set(new_path); + } + }) as Box<dyn Fn()>); + let _ = + win.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()); + closure.forget(); + }); + } +} + +/// No-op for server. +#[cfg(not(target_arch = "wasm32"))] +pub fn make_popstate_effect(_set_path: WriteSignal<String>) {} diff --git a/client/uno.config.ts b/client/uno.config.ts new file mode 100644 index 0000000..697d6ca --- /dev/null +++ b/client/uno.config.ts @@ -0,0 +1,81 @@ +// uno.config.ts +// import type { Theme } from '@unocss/preset-mini' +import { + defineConfig, + presetAttributify, + presetIcons, + presetTypography, + presetUno, + presetWebFonts, + transformerDirectives, + transformerVariantGroup, +} from "unocss"; +import { presetDaisy } from "unocss-preset-daisy"; + +export default defineConfig({ + cli: { + entry: { + patterns: ["src/**/*.rs", "client/src/**/*.rs"], + outFile: "target/site/pkg/website.css", + }, + }, + shortcuts: [ + { + btn: "px-4 py-1 rounded inline-block bg-primary text-white cursor-pointer tracking-wide op90 hover:op100 disabled:cursor-default disabled:bg-gray-600 disabled:!op50 disabled:pointer-events-none", + "indigo-btn": + "ml-5 capitalize !text-2xl !text-indigo-800 !bg-indigo-200 border-0.5 !border-indigo-500 dark:!text-indigo-200 dark:!bg-indigo-800 hover:!bg-gray-100 dark:hover:!bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg font-bold !p-5 md:!p-8", + "icon-btn": + "text-1.2em cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-primary disabled:pointer-events-none", + "square-btn": + "flex flex-gap-2 items-center border border-base px2 py1 relative !outline-none", + "square-btn-mark": + "absolute h-2 w-2 bg-primary -right-0.2rem -top-0.2rem", + + "bg-base": "bg-white dark:bg-[#121212]", + "bg-overlay": "bg-[#eee]:50 dark:bg-[#222]:50", + "bg-header": "bg-gray-500:5", + "bg-active": "bg-gray-500:8", + "bg-hover": "bg-gray-500:20", + "border-base": "border-gray-400:10", + + "tab-button": "font-light op50 hover:op80 h-full px-4", + "tab-button-active": "op100 bg-gray-500:10", + }, + [/^(flex|grid)-center/g, () => "justify-center items-center"], + [/^(flex|grid)-x-center/g, () => "justify-center"], + [/^(flex|grid)-y-center/g, () => "items-center"], + ], + rules: [ + ["max-h-screen", { "max-height": "calc(var(--vh, 1vh) * 100)" }], + ["h-screen", { height: "calc(var(--vh, 1vh) * 100)" }], + ], + // theme: <Theme>{ + theme: { + colors: { + ok: "var(--c-ok)", + primary: "var(--c-primary)", + "primary-deep": "var(--c-primary-deep)", + mis: "var(--c-mis)", + }, + }, + presets: [ + presetUno(), + presetAttributify(), + presetIcons({ + scale: 1.2, + autoInstall: true, + collections: { + carbon: () => + import("@iconify-json/carbon/icons.json").then((i) => i.default), + }, + }), + presetTypography(), + presetWebFonts({ + fonts: { + // ... + }, + }), + presetDaisy(), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], +});