use leptos::prelude::*; use serde::{Deserialize, Serialize}; use shared::{Texts, load_texts_toml}; use std::collections::HashMap; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local; #[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 { vec![Language::English, Language::Spanish] } } impl Default for Language { fn default() -> Self { Language::English } } #[derive(Clone)] pub struct I18nContext { pub language: ReadSignal, pub set_language: WriteSignal, pub texts: Memo, } impl I18nContext { /// Get translated text (non-reactive version) pub fn t(&self, key: &str, _args: Option<&HashMap<&str, &str>>) -> String { // Use get_untracked to avoid reactivity tracking in non-reactive contexts let texts = self.texts.get_untracked(); let lang_code = self.language.get_untracked().code(); let translations = match lang_code { "es" => &texts.es, _ => &texts.en, }; translations .get(key) .cloned() .unwrap_or_else(|| key.to_string()) } /// Get translated text (reactive version) - returns a reactive closure pub fn t_reactive(&self, key: &'static str) -> impl Fn() -> String + Clone { let texts = self.texts; let language = self.language; move || { let texts = texts.get(); let lang_code = 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_untracked().code().to_string() } /// Check if current language is specific language pub fn is_language(&self, lang: Language) -> bool { self.language.get_untracked() == 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::()) } /// 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)) } /// Get translated text (reactive version) - returns a reactive closure pub fn t_reactive(&self, key: &'static str) -> impl Fn() -> String + Clone { self.0.t_reactive(key) } /// 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_untracked() } /// 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) -> impl IntoView { let i18n = use_i18n(); let (is_open, set_is_open) = signal(false); view! {
// Click outside to close
} } /// Compact language toggle component #[component] pub fn LanguageToggle(#[prop(optional)] class: Option) -> impl IntoView { let i18n = use_i18n(); view! { } } // Dark Mode Context and Components #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Theme { Light, Dark, } impl Theme { pub fn to_class(&self) -> &'static str { match self { Theme::Light => "light", Theme::Dark => "dark", } } pub fn is_dark(&self) -> bool { matches!(self, Theme::Dark) } } impl Default for Theme { fn default() -> Self { Theme::Light } } #[derive(Clone)] pub struct ThemeContext { pub theme: ReadSignal, pub set_theme: WriteSignal, } impl ThemeContext { pub fn new() -> Self { // Default to light theme on server-side let initial_theme = Theme::Light; let (theme, set_theme) = signal(initial_theme); // Only run client-side code after hydration #[cfg(target_arch = "wasm32")] { // Initialize theme from localStorage on client spawn_local(async move { if let Some(window) = web_sys::window() { if let Ok(Some(storage)) = window.local_storage() { if let Ok(Some(stored_theme)) = storage.get_item("theme") { let saved_theme = match stored_theme.as_str() { "dark" => Theme::Dark, _ => Theme::Light, }; set_theme.set(saved_theme); } } } }); // Save theme to localStorage and update document class when it changes // Only create effect if window exists (client-side) if web_sys::window().is_some() { Effect::new(move |_| { let current_theme = theme.get(); if let Some(window) = web_sys::window() { if let Ok(Some(storage)) = window.local_storage() { let theme_str = match current_theme { Theme::Light => "light", Theme::Dark => "dark", }; let _ = storage.set_item("theme", theme_str); } // Update document class for dark mode if let Some(document) = window.document() { if let Some(html) = document.document_element() { match current_theme { Theme::Dark => { let _ = html.class_list().add_1("dark"); } Theme::Light => { let _ = html.class_list().remove_1("dark"); } } } } } }); } } Self { theme, set_theme } } pub fn toggle_theme(&self) { let new_theme = match self.theme.get_untracked() { Theme::Light => Theme::Dark, Theme::Dark => Theme::Light, }; self.set_theme.set(new_theme); } pub fn is_dark(&self) -> bool { self.theme.get_untracked().is_dark() } } // Theme context provider #[component] pub fn ThemeProvider(children: Children) -> impl IntoView { // Only create theme context on client-side to avoid SSR issues #[cfg(target_arch = "wasm32")] { let theme_context = ThemeContext::new(); provide_context(theme_context); } // On server-side, provide a minimal theme context #[cfg(not(target_arch = "wasm32"))] { let (theme, set_theme) = signal(Theme::Light); let theme_context = ThemeContext { theme, set_theme }; provide_context(theme_context); } children() } // Theme hook pub fn use_theme() -> ThemeContext { expect_context::() } // Dark mode toggle component #[component] pub fn DarkModeToggle(#[prop(optional)] class: Option) -> impl IntoView { let theme_context = use_theme(); let theme_context_click = theme_context.clone(); let theme_context_title = theme_context.clone(); let theme_context_sun = theme_context.clone(); let theme_context_moon = theme_context.clone(); view! { } } #[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"); } }