292 lines
10 KiB
Rust
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");
|
|
}
|
|
}
|