2025-07-07 23:05:46 +01:00

292 lines
10 KiB
Rust

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");
}
}