diff --git a/client/Cargo.toml b/client/Cargo.toml
new file mode 100644
index 0000000..6a68009
--- /dev/null
+++ b/client/Cargo.toml
@@ -0,0 +1,50 @@
+[package]
+name = "client"
+version = "0.1.0"
+edition = "2024"
+authors = ["Rustelo Contributors"]
+license = "MIT"
+description = "Client-side components for Rustelo web application template"
+documentation = "https://docs.rs/client"
+repository = "https://github.com/yourusername/rustelo"
+homepage = "https://rustelo.dev"
+readme = "../../README.md"
+keywords = ["rust", "web", "leptos", "wasm", "frontend"]
+categories = ["web-programming", "wasm"]
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+leptos = { workspace = true, features = ["hydrate"] }
+leptos_router = { workspace = true }
+leptos_meta = { workspace = true }
+leptos_config = { workspace = true }
+wasm-bindgen = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+reqwasm = { workspace = true }
+web-sys = { workspace = true }
+regex = { workspace = true }
+console_error_panic_hook = { version = "0.1.7" }
+toml = { workspace = true }
+fluent = { workspace = true }
+fluent-bundle = { workspace = true }
+unic-langid = { workspace = true }
+
+shared = { path = "../shared" }
+gloo-timers = { workspace = true }
+wasm-bindgen-futures = { workspace = true }
+urlencoding = "2.1"
+chrono = { workspace = true }
+uuid = { workspace = true }
+# leptos-use = "0.13"
+
+[features]
+default = []
+hydrate = []
+
+[package.metadata.docs.rs]
+# Configuration for docs.rs
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]
diff --git a/client/build.rs b/client/build.rs
new file mode 100644
index 0000000..2feb7f0
--- /dev/null
+++ b/client/build.rs
@@ -0,0 +1,66 @@
+use std::{
+ io::{self, Write},
+ path::Path,
+ process,
+};
+
+fn main() {
+ println!("cargo::rustc-check-cfg=cfg(web_sys_unstable_apis)");
+ println!("cargo:rerun-if-changed=uno.config.ts");
+ //println!("cargo:rerun-if-changed=style/main.scss");
+
+ // Check if node_modules exists in various locations, if not run pnpm install
+ let node_modules_paths = ["../node_modules", "node_modules", "../../node_modules"];
+
+ let node_modules_exists = node_modules_paths
+ .iter()
+ .any(|path| Path::new(path).exists());
+
+ if !node_modules_exists {
+ println!("cargo:warning=node_modules not found, running pnpm install...");
+
+ // Try to find package.json to determine correct directory
+ let package_json_paths = ["../package.json", "package.json", "../../package.json"];
+
+ let install_dir = package_json_paths
+ .iter()
+ .find(|path| Path::new(path).exists())
+ .map(|path| Path::new(path).parent().unwrap_or(Path::new(".")))
+ .unwrap_or(Path::new(".."));
+
+ match process::Command::new("pnpm")
+ .arg("install")
+ .current_dir(install_dir)
+ .output()
+ {
+ Ok(output) => {
+ if !output.status.success() {
+ let _ = io::stdout().write_all(&output.stdout);
+ let _ = io::stdout().write_all(&output.stderr);
+ panic!("pnpm install failed");
+ }
+ println!("cargo:warning=pnpm install completed successfully");
+ }
+ Err(e) => {
+ println!("cargo:warning=Failed to run pnpm install: {:?}", e);
+ println!("cargo:warning=Please run 'pnpm install' manually in the project root");
+ // Don't panic here, just warn - the build might still work
+ }
+ }
+ }
+
+ match process::Command::new("sh")
+ .arg("-c")
+ .arg("pnpm run build")
+ .output()
+ {
+ Ok(output) => {
+ if !output.status.success() {
+ let _ = io::stdout().write_all(&output.stdout);
+ let _ = io::stdout().write_all(&output.stderr);
+ panic!("UnoCSS error");
+ }
+ }
+ Err(e) => panic!("UnoCSS error: {:?}", e),
+ };
+}
diff --git a/client/src/app.rs b/client/src/app.rs
new file mode 100644
index 0000000..535af93
--- /dev/null
+++ b/client/src/app.rs
@@ -0,0 +1,294 @@
+//#![allow(unused_imports)]
+//#![allow(dead_code)]
+//#![allow(unused_variables)]
+// Suppress leptos_router warnings about reactive signal access outside tracking context
+#![allow(clippy::redundant_closure)]
+//#![allow(unused_assignments)]
+
+//use crate::defs::{NAV_LINK_CLASS, ROUTES};
+
+use crate::auth::AuthProvider;
+use crate::components::NavbarLogo;
+use crate::i18n::{I18nProvider, LanguageSelector, use_i18n};
+use crate::pages::{AboutPage, DaisyUIPage, FeaturesDemoPage, HomePage};
+use crate::state::*;
+use crate::utils::{get_initial_path, make_navigate, make_on_link_click, make_popstate_effect};
+use leptos::children::Children;
+use leptos::prelude::*;
+use leptos_meta::{MetaTags, Title, provide_meta_context};
+// use regex::Regex;
+use shared::{get_bundle, load_menu_toml, t};
+use std::collections::HashMap;
+
+//// Wrapper component for consistent layout.
+#[component]
+fn Wrapper(children: Children) -> impl IntoView {
+ view! { <>{children()}> }
+}
+
+/// NotFoundPage component for 404s.
+#[component]
+fn NotFoundPage() -> impl IntoView {
+ view! {
"Page not found."
}
+}
+
+/// Navigation menu component, maps over ROUTES.
+#[component]
+pub fn NavMenu(path: ReadSignal, set_path: WriteSignal) -> impl IntoView {
+ let navigate = make_navigate(set_path.clone());
+ let on_link_click = make_on_link_click(set_path.clone(), navigate.clone());
+ let i18n = use_i18n();
+ let menu_items = load_menu_toml().unwrap_or_default();
+ println!("NavMenu rendered");
+ view! {
+
+ }
+ // view! {
+ //
+ //}
+}
+
+/// Main app component with SSR path awareness and SPA routing.
+#[component]
+pub fn App() -> impl IntoView {
+ provide_meta_context();
+ let (path, set_path) = signal(get_initial_path());
+ make_popstate_effect(set_path);
+ let (lang, _set_lang) = signal("en".to_string());
+ // --- Unit test placeholder for route matching ---
+ // #[cfg(test)]
+ // mod tests {
+ // use super::*;
+ // #[test]
+ // fn test_user_route() {
+ // let re = Regex::new(r"^/user/(\\d+)$").expect("Valid regex");
+ // assert!(re.is_match("/user/42"));
+ // }
+ // }
+ view! {
+
+
+
+
+
+
+
+
+
+
+
+ { 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::() {
+ 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! {
+
+ {content}
+ {match p.as_str() {
+ "/" => view! {
}.into_any(),
+ "/about" => view! { }.into_any(),
+ "/daisyui" => view! {
}.into_any(),
+ "/features-demo" => view! {
}.into_any(),
+
+ _ => view! { Not found
}.into_any(),
+ }}
+
+ }
+ }}
+
+
+
+
+
+
+
+
+
+ }
+}
+
+/// The SSR shell for Leptos/Axum integration.
+pub fn shell(options: LeptosOptions) -> impl IntoView {
+ view! {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
diff --git a/client/src/auth/context.rs b/client/src/auth/context.rs
new file mode 100644
index 0000000..f6971cb
--- /dev/null
+++ b/client/src/auth/context.rs
@@ -0,0 +1,889 @@
+use crate::i18n::use_i18n;
+use leptos::prelude::*;
+// use leptos_router::use_navigate;
+use shared::auth::{AuthResponse, User};
+use std::sync::Arc;
+use wasm_bindgen_futures::spawn_local;
+
+#[derive(Clone, Debug)]
+pub struct AuthState {
+ pub user: Option,
+ pub is_loading: bool,
+ pub error: Option,
+ pub requires_2fa: bool,
+ pub pending_2fa_email: Option,
+}
+
+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,
+ pub login_with_2fa: Arc,
+ pub logout: Arc,
+ pub register: Arc) + Send + Sync>,
+ pub refresh_token: Arc,
+ pub update_profile: Arc, Option) + Send + Sync>,
+ pub change_password: Arc,
+ pub clear_error: Arc,
+ pub clear_2fa_state: Arc,
+}
+
+#[derive(Clone)]
+pub struct AuthContext {
+ pub state: ReadSignal,
+ 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 {
+ self.state.get().user
+ }
+
+ pub fn error(&self) -> Option {
+ self.state.get().error
+ }
+
+ pub fn requires_2fa(&self) -> bool {
+ self.state.get().requires_2fa
+ }
+
+ pub fn pending_2fa_email(&self) -> Option {
+ 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::(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::