2025-07-11 21:20:26 +01:00

491 lines
18 KiB
Rust

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<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 (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::<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))
}
/// 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<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 px-2 py-1 text-sm font-medium bg-white dark:bg-gray-800 text-stone-800 dark:text-gray-200 border border-stone-200 dark:border-gray-600 rounded-lg hover:bg-stone-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200"
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.0.language.get_untracked().code().to_uppercase()
}
<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 top-full z-[9999] w-40 mt-1 origin-top-right bg-white dark:bg-gray-800 border border-stone-200 dark:border-gray-600 rounded-lg shadow-xl ring-1 ring-stone-950 dark:ring-gray-700 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 lang_for_click = lang.clone();
let i18n_for_click = i18n_item.clone();
let lang_for_reactive = lang.clone();
let i18n_for_reactive = i18n_item.clone();
let lang_for_show1 = lang.clone();
let i18n_for_show1 = i18n_item.clone();
let lang_for_show2 = lang.clone();
let i18n_for_show2 = i18n_item.clone();
let lang_for_display = lang.clone();
view! {
<button
type="button"
class=move || format!(
"flex items-center w-full px-4 py-2 text-sm text-left hover:bg-stone-50 dark:hover:bg-gray-700 focus:outline-none focus:bg-stone-50 dark:focus:bg-gray-700 transition-colors duration-200 {}",
if i18n_for_reactive.is_language(lang_for_reactive.clone()) { "bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 font-medium" } else { "text-stone-700 dark:text-gray-300 hover:text-stone-900 dark:hover:text-gray-100" }
)
role="menuitem"
on:click=move |_| {
i18n_for_click.set_language(lang_for_click.clone());
set_is_open.set(false);
}
>
<Show when=move || i18n_for_show1.is_language(lang_for_show1.clone())>
<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 || !i18n_for_show2.is_language(lang_for_show2.clone())>
<div class="w-4 h-4 mr-2"></div>
</Show>
{lang_for_display.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.0.language.get();
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.0.language.get().code().to_uppercase()
}
</button>
}
}
// 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<Theme>,
pub set_theme: WriteSignal<Theme>,
}
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::<ThemeContext>()
}
// Dark mode toggle component
#[component]
pub fn DarkModeToggle(#[prop(optional)] class: Option<String>) -> 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! {
<button
type="button"
class=move || format!(
"inline-flex items-center justify-center p-2 text-sm font-medium bg-white dark:bg-stone-800 text-stone-800 dark:text-stone-200 border border-stone-200 dark:border-stone-700 rounded-lg hover:bg-stone-50 dark:hover:bg-stone-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 {}",
class.as_deref().unwrap_or("")
)
on:click=move |_| theme_context_click.toggle_theme()
title=move || if theme_context_title.theme.get_untracked().is_dark() { "Switch to light mode" } else { "Switch to dark mode" }
>
<Show when=move || theme_context_sun.theme.get_untracked().is_dark()>
// Sun icon for light mode
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</Show>
<Show when=move || !theme_context_moon.theme.get_untracked().is_dark()>
// Moon icon for dark mode
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</Show>
</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");
}
}