491 lines
18 KiB
Rust
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");
|
|
}
|
|
}
|