chore: add client code

This commit is contained in:
Jesús Pérex 2025-07-07 23:05:46 +01:00
parent 6b16df8737
commit 80d441fe36
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
41 changed files with 10619 additions and 0 deletions

50
client/Cargo.toml Normal file
View File

@ -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"]

66
client/build.rs Normal file
View File

@ -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),
};
}

294
client/src/app.rs Normal file
View File

@ -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! { <div class="text-center">"Page not found."</div> }
}
/// Navigation menu component, maps over ROUTES.
#[component]
pub fn NavMenu(path: ReadSignal<String>, set_path: WriteSignal<String>) -> 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! {
<nav class="rounded-lg border shadow-lg overflow-hidden p-2 bg-white border-stone-200 shadow-stone-950/5 mx-auto w-full max-w-screen-xl">
<div class="flex items-center">
<NavbarLogo size="small".to_string() />
<hr class="ml-1 mr-1.5 hidden h-5 w-px border-l border-t-0 border-secondary-dark lg:block" />
<div class="hidden lg:block">
<ul class="mt-4 flex flex-col gap-x-3 gap-y-1.5 lg:mt-0 lg:flex-row lg:items-center">
{menu_items.menu.into_iter().map(|item| {
let on_link_click = on_link_click.clone();
let route = item.route.clone();
let route_for_click = route.clone();
let route_for_aria = route.clone();
let lang_val = i18n.lang_code();
let label = match lang_val.as_str() {
"es" => item.label.es.clone(),
_ => item.label.en.clone(),
};
view! {
<li><a
href={route.clone()}
on:click=move |ev| on_link_click(ev, Box::leak(route_for_click.clone().into_boxed_str()))
class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"
aria-current=move || if path.get() == route_for_aria { Some("page") } else { None as Option<&'static str> }
>
<svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 18H10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 14H7.5H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 10H8.5H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 2L16.5 2L21 6.5V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 20.5V6.5C3 5.67157 3.67157 5 4.5 5H14.2515C14.4106 5 14.5632 5.06321 14.6757 5.17574L17.8243 8.32426C17.9368 8.43679 18 8.5894 18 8.74853V20.5C18 21.3284 17.3284 22 16.5 22H4.5C3.67157 22 3 21.3284 3 20.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 5V8.4C14 8.73137 14.2686 9 14.6 9H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
{label}</a>
</li>
}
}).collect_view()}
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary">
<svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 18H10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 14H7.5H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 10H8.5H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 2L16.5 2L21 6.5V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 20.5V6.5C3 5.67157 3.67157 5 4.5 5H14.2515C14.4106 5 14.5632 5.06321 14.6757 5.17574L17.8243 8.32426C17.9368 8.43679 18 8.5894 18 8.74853V20.5C18 21.3284 17.3284 22 16.5 22H4.5C3.67157 22 3 21.3284 3 20.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 5V8.4C14 8.73137 14.2686 9 14.6 9H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
{i18n.t("pages")}</a>
</li>
<li>
<LanguageSelector class="ml-2".to_string() />
</li>
// <li>
// <a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4.271 18.3457C4.271 18.3457 6.50002 15.5 12 15.5C17.5 15.5 19.7291 18.3457 19.7291 18.3457" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 12C13.6569 12 15 10.6569 15 9C15 7.34315 13.6569 6 12 6C10.3431 6 9 7.34315 9 9C9 10.6569 10.3431 12 12 12Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>Account</a>
// </li>
// <li>
// <a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3.52844 7.29357L11.7086 11.8381C11.8898 11.9388 12.1102 11.9388 12.2914 11.8381L20.5 7.27777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 21L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M11.6914 11.8285L3.89139 7.49521C3.49147 7.27304 3 7.56222 3 8.01971V16.647C3 16.8649 3.11813 17.0656 3.30861 17.1715L11.1086 21.5048C11.5085 21.727 12 21.4378 12 20.9803V12.353C12 12.1351 11.8819 11.9344 11.6914 11.8285Z" fill="currentColor" stroke="currentColor" stroke-linejoin="round"></path></svg>Blocks</a>
// </li>
// <li>
// <a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 6L17 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 9L17 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 17H15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 12H2.6C2.26863 12 2 12.2686 2 12.6V21.4C2 21.7314 2.26863 22 2.6 22H21.4C21.7314 22 22 21.7314 22 21.4V12.6C22 12.2686 21.7314 12 21.4 12H21M3 12V2.6C3 2.26863 3.26863 2 3.6 2H20.4C20.7314 2 21 2.26863 21 2.6V12M3 12H21" stroke="currentColor"></path></svg>Docs</a>
// </li>
</ul>
</div>
<div class="ml-auto flex items-center space-x-2">
<LanguageSelector />
<div class="w-40">
<div class="relative w-full">
<input placeholder="Search here..." type="search" class="w-full aria-disabled:cursor-not-allowed outline-none focus:outline-none text-stone-800 dark:text-white placeholder:text-stone-600/60 ring-transparent border border-stone-200 transition-all ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-sm py-1.5 pl-8 pr-2 ring shadow-sm bg-white rounded-lg duration-100 hover:border-stone-300 hover:ring-none focus:border-stone-400 focus:ring-none peer" />
<span class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-stone-600/70 peer-focus:text-stone-800 peer-focus:text-stone-800 dark:peer-hover:text-white dark:peer-focus:text-white transition-all duration-300 ease-in overflow-hidden w-4 h-4"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-full w-full"><path d="M17 17L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</span>
</div>
</div>
</div>
<button data-dui-toggle="collapse" data-dui-target="#navbar-collapse-search" aria-expanded="false" aria-controls="navbar-collapse-search" class="place-items-center border align-middle select-none font-sans font-medium text-center transition-all duration-300 ease-in disabled:opacity-50 disabled:shadow-none disabled:pointer-events-none text-sm min-w-[34px] min-h-[34px] rounded-md bg-transparent border-transparent text-stone-800 hover:bg-stone-800/5 hover:border-stone-800/5 shadow-none hover:shadow-none ml-1 grid lg:hidden"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M3 5H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 12H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 19H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
<div class="overflow-hidden transition-[max-height] duration-300 ease-in-out max-h-0 lg:hidden" id="navbar-collapse-search">
<ul class="flex flex-col gap-0.5 mt-2">
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 18H10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 14H7.5H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 10H8.5H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 2L16.5 2L21 6.5V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 20.5V6.5C3 5.67157 3.67157 5 4.5 5H14.2515C14.4106 5 14.5632 5.06321 14.6757 5.17574L17.8243 8.32426C17.9368 8.43679 18 8.5894 18 8.74853V20.5C18 21.3284 17.3284 22 16.5 22H4.5C3.67157 22 3 21.3284 3 20.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 5V8.4C14 8.73137 14.2686 9 14.6 9H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>{i18n.t("pages")}</a>
</li>
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4.271 18.3457C4.271 18.3457 6.50002 15.5 12 15.5C17.5 15.5 19.7291 18.3457 19.7291 18.3457" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 12C13.6569 12 15 10.6569 15 9C15 7.34315 13.6569 6 12 6C10.3431 6 9 7.34315 9 9C9 10.6569 10.3431 12 12 12Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>Account</a>
</li>
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3.52844 7.29357L11.7086 11.8381C11.8898 11.9388 12.1102 11.9388 12.2914 11.8381L20.5 7.27777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 21L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M11.6914 11.8285L3.89139 7.49521C3.49147 7.27304 3 7.56222 3 8.01971V16.647C3 16.8649 3.11813 17.0656 3.30861 17.1715L11.1086 21.5048C11.5085 21.727 12 21.4378 12 20.9803V12.353C12 12.1351 11.8819 11.9344 11.6914 11.8285Z" fill="currentColor" stroke="currentColor" stroke-linejoin="round"></path></svg>Blocks</a>
</li>
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 6L17 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 9L17 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 17H15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 12H2.6C2.26863 12 2 12.2686 2 12.6V21.4C2 21.7314 2.26863 22 2.6 22H21.4C21.7314 22 22 21.7314 22 21.4V12.6C22 12.2686 21.7314 12 21.4 12H21M3 12V2.6C3 2.26863 3.26863 2 3.6 2H20.4C20.7314 2 21 2.26863 21 2.6V12M3 12H21" stroke="currentColor"></path></svg>Docs</a>
</li>
</ul>
</div>
</nav>
}
// view! {
// <nav class="bg-white shadow-sm border-b">
// <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 items-center">
// <a href="/" on:click={ let on_link_click = on_link_click.clone(); move |ev| on_link_click(ev, "/") } class="text-xl font-bold text-gray-900">"Leptos App"</a>
// </div>
// <div class="flex items-center space-x-4">
// //{ROUTES.iter().map(|(route, label)| {
// {menu_items.menu.into_iter().map(|item| {
// let on_link_click = on_link_click.clone();
// let route = item.route.clone();
// let route_for_click = route.clone();
// let route_for_aria = route.clone();
// let lang_val = lang.get();
// let label = match lang_val.as_str() {
// "es" => item.label.es.as_str(),
// _ => item.label.en.as_str(),
// };
// view! {
// <a
// href={route.as_str()}
// on:click=move |ev| on_link_click(ev, Box::leak(route_for_click.clone().into_boxed_str()))
// class=NAV_LINK_CLASS
// aria-current=move || if path.get() == route_for_aria { Some("page") } else { None }
// >{label}</a>
// }
// }).collect_view()}
// { view! {
// <div class=move || if path.get() != "/" {
// "flex flex-1 justify-end"
// } else {
// "hidden"
// }>
// <a href="/" on:click=move |ev| on_link_click(ev, "/")
// aria-current=move || Some("page")
// >
// <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>
// }}
// </div>
// </div>
// </div>
// </nav>
//}
}
/// 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! {
<GlobalStateProvider>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<AuthProvider>
<UserProvider>
<AppStateProvider>
<Title text="Welcome to Leptos"/>
<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>
}
}

889
client/src/auth/context.rs Normal file
View File

@ -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()
}

View File

@ -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()
}

View File

@ -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>
}
}

163
client/src/auth/errors.rs Normal file
View File

@ -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")
}
}

254
client/src/auth/login.rs Normal file
View File

@ -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>
}
}

11
client/src/auth/mod.rs Normal file
View File

@ -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};

484
client/src/auth/register.rs Normal file
View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -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
/>
}
}

View File

@ -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>
}
}

View File

@ -0,0 +1,3 @@
#[allow(non_snake_case)]
pub mod AdminLayout;
pub use AdminLayout::*;

View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -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;

View File

@ -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>
}
}

View File

@ -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};

5
client/src/defs.rs Normal file
View File

@ -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";

View File

@ -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>
}
}

291
client/src/i18n/mod.rs Normal file
View File

@ -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");
}
}

213
client/src/lib.rs Normal file
View File

@ -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);
}

5
client/src/main.rs Normal file
View File

@ -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
}

53
client/src/pages/About.rs Normal file
View File

@ -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>

View File

@ -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>
}
}

View File

@ -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>
}
}

65
client/src/pages/Home.rs Normal file
View File

@ -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>

View File

@ -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>
}
}

View File

@ -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))
}

View File

@ -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(())
}

View File

@ -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>,
}

View File

@ -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::*;

250
client/src/pages/contact.rs Normal file
View File

@ -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>
}
}

11
client/src/pages/mod.rs Normal file
View File

@ -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::*;

42
client/src/state/mod.rs Normal file
View File

@ -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()}</>
}
}

243
client/src/state/theme.rs Normal file
View File

@ -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");
}
}

142
client/src/utils.rs Normal file
View File

@ -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>) {}

81
client/uno.config.ts Normal file
View File

@ -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()],
});