chore: add client code
This commit is contained in:
parent
6b16df8737
commit
80d441fe36
50
client/Cargo.toml
Normal file
50
client/Cargo.toml
Normal 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
66
client/build.rs
Normal 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
294
client/src/app.rs
Normal 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
889
client/src/auth/context.rs
Normal 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()
|
||||
}
|
||||
677
client/src/auth/context_simple.rs
Normal file
677
client/src/auth/context_simple.rs
Normal 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()
|
||||
}
|
||||
196
client/src/auth/error_display.rs
Normal file
196
client/src/auth/error_display.rs
Normal 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
163
client/src/auth/errors.rs
Normal 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
254
client/src/auth/login.rs
Normal 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
11
client/src/auth/mod.rs
Normal 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
484
client/src/auth/register.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
318
client/src/auth/two_factor.rs
Normal file
318
client/src/auth/two_factor.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
246
client/src/auth/two_factor_login.rs
Normal file
246
client/src/auth/two_factor_login.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
25
client/src/components/Counter.rs
Normal file
25
client/src/components/Counter.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
128
client/src/components/Logo.rs
Normal file
128
client/src/components/Logo.rs
Normal 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
|
||||
/>
|
||||
}
|
||||
}
|
||||
365
client/src/components/admin/AdminLayout.rs
Normal file
365
client/src/components/admin/AdminLayout.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
3
client/src/components/admin/mod.rs
Normal file
3
client/src/components/admin/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
#[allow(non_snake_case)]
|
||||
pub mod AdminLayout;
|
||||
pub use AdminLayout::*;
|
||||
253
client/src/components/daisy_example.rs
Normal file
253
client/src/components/daisy_example.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
468
client/src/components/forms/contact_form.rs
Normal file
468
client/src/components/forms/contact_form.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
17
client/src/components/forms/mod.rs
Normal file
17
client/src/components/forms/mod.rs
Normal 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;
|
||||
690
client/src/components/forms/support_form.rs
Normal file
690
client/src/components/forms/support_form.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
14
client/src/components/mod.rs
Normal file
14
client/src/components/mod.rs
Normal 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
5
client/src/defs.rs
Normal 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";
|
||||
319
client/src/examples/admin_integration.rs
Normal file
319
client/src/examples/admin_integration.rs
Normal 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
291
client/src/i18n/mod.rs
Normal 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
213
client/src/lib.rs
Normal 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
5
client/src/main.rs
Normal 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
53
client/src/pages/About.rs
Normal 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>
|
||||
28
client/src/pages/DaisyUI.rs
Normal file
28
client/src/pages/DaisyUI.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
91
client/src/pages/FeaturesDemo.rs
Normal file
91
client/src/pages/FeaturesDemo.rs
Normal 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
65
client/src/pages/Home.rs
Normal 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>
|
||||
830
client/src/pages/admin/Content.rs
Normal file
830
client/src/pages/admin/Content.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
455
client/src/pages/admin/Dashboard.rs
Normal file
455
client/src/pages/admin/Dashboard.rs
Normal 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))
|
||||
}
|
||||
982
client/src/pages/admin/Roles.rs
Normal file
982
client/src/pages/admin/Roles.rs
Normal 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(())
|
||||
}
|
||||
893
client/src/pages/admin/Users.rs
Normal file
893
client/src/pages/admin/Users.rs
Normal 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>,
|
||||
}
|
||||
9
client/src/pages/admin/mod.rs
Normal file
9
client/src/pages/admin/mod.rs
Normal 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
250
client/src/pages/contact.rs
Normal 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
11
client/src/pages/mod.rs
Normal 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
42
client/src/state/mod.rs
Normal 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
243
client/src/state/theme.rs
Normal 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
142
client/src/utils.rs
Normal 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
81
client/uno.config.ts
Normal 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()],
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user