From 3ecfdfadf2da6e04a5e634f708de252bc25ec2ca Mon Sep 17 00:00:00 2001 From: Florian Berthold Date: Sun, 15 Mar 2026 13:01:38 +0100 Subject: [PATCH] Add DE/EN language toggle with bilingual descriptions - Language switcher in sidebar (DE/EN buttons, persists to localStorage) - i18n module with pick_desc/pick_name helpers for language-aware fallback - All detail/list pages use language context for names and descriptions - Species/Cultivar types updated with description_de/description_en - Common Name column added to species/families lists --- herbapi-ui/assets/herbapi.css | 45 ++++++++++++++- herbapi-ui/src/app.rs | 34 ++++++++++++ herbapi-ui/src/i18n.rs | 25 +++++++++ herbapi-ui/src/main.rs | 1 + herbapi-ui/src/pages/cultivars.rs | 16 +++++- herbapi-ui/src/pages/families.rs | 91 +++++++++++++++++++------------ herbapi-ui/src/pages/home.rs | 22 +++++--- herbapi-ui/src/pages/species.rs | 69 +++++++++++++++++------ herbapi-ui/src/types.rs | 4 ++ 9 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 herbapi-ui/src/i18n.rs diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index af1f787..eb2af0f 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -120,8 +120,51 @@ em { text-decoration: none; } -.sidebar-user { +/* Language toggle */ + +.sidebar-lang { margin-top: auto; + padding: 0.75rem 1.25rem; + border-top: 1px solid rgba(255,255,255,0.1); + display: flex; + justify-content: center; +} + +.lang-toggle { + display: flex; + border-radius: 4px; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.2); +} + +.lang-btn { + padding: 0.3rem 0.75rem; + background: transparent; + color: rgba(255,255,255,0.5); + border: none; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.5px; + cursor: pointer; + transition: all 0.15s; +} + +.lang-btn:hover { + color: rgba(255,255,255,0.8); + background: rgba(255,255,255,0.05); +} + +.lang-btn-active { + background: var(--accent); + color: #fff; +} + +.lang-btn-active:hover { + background: var(--accent-hover); + color: #fff; +} + +.sidebar-user { padding: 1rem 1.25rem; border-top: 1px solid rgba(255,255,255,0.1); display: flex; diff --git a/herbapi-ui/src/app.rs b/herbapi-ui/src/app.rs index edd3a49..92c6b0f 100644 --- a/herbapi-ui/src/app.rs +++ b/herbapi-ui/src/app.rs @@ -1,8 +1,13 @@ use dioxus::prelude::*; +use gloo_storage::{LocalStorage, Storage}; use crate::api; use crate::types::MeResponse; +/// Global language signal shared via context. Values: "de" or "en". +#[derive(Clone, Copy)] +pub struct Lang(pub Signal); + #[derive(Routable, Clone, Debug, PartialEq)] #[rustfmt::skip] pub enum Route { @@ -43,6 +48,15 @@ pub fn App() -> Element { #[component] fn Layout() -> Element { + // Language state: load from localStorage, default "de" + let lang_signal = use_signal(|| { + LocalStorage::get::("herbapi_lang").unwrap_or_else(|_| "de".to_string()) + }); + use_context_provider(|| Lang(lang_signal)); + + let mut lang = use_context::().0; + let current_lang = lang.read().clone(); + // Try to get current user (may be None for public access) let auth = use_resource(|| async { api::get_current_user().await.ok() }); let user: Option = auth.read().as_ref().and_then(|r| r.clone()); @@ -66,6 +80,26 @@ fn Layout() -> Element { NavLink { to: Route::SearchPage {}, label: "Search" } NavLink { to: Route::Sources {}, label: "Sources" } } + div { class: "sidebar-lang", + div { class: "lang-toggle", + button { + class: if current_lang == "de" { "lang-btn lang-btn-active" } else { "lang-btn" }, + onclick: move |_| { + lang.set("de".to_string()); + let _ = LocalStorage::set("herbapi_lang", "de".to_string()); + }, + "DE" + } + button { + class: if current_lang == "en" { "lang-btn lang-btn-active" } else { "lang-btn" }, + onclick: move |_| { + lang.set("en".to_string()); + let _ = LocalStorage::set("herbapi_lang", "en".to_string()); + }, + "EN" + } + } + } div { class: "sidebar-user", if let Some(ref u) = user { span { class: "user-name", "{u.nickname.as_deref().or(u.name.as_deref()).unwrap_or(&u.email)}" } diff --git a/herbapi-ui/src/i18n.rs b/herbapi-ui/src/i18n.rs new file mode 100644 index 0000000..deac8b9 --- /dev/null +++ b/herbapi-ui/src/i18n.rs @@ -0,0 +1,25 @@ +/// Pick the right description based on language, falling back to the other. +pub fn pick_desc(lang: &str, de: &Option, en: &Option, fallback: &Option) -> String { + match lang { + "de" => de.as_deref() + .or(fallback.as_deref()) + .or(en.as_deref()) + .unwrap_or("\u{2014}").to_string(), + _ => en.as_deref() + .or(fallback.as_deref()) + .or(de.as_deref()) + .unwrap_or("\u{2014}").to_string(), + } +} + +/// Pick the right name based on language +pub fn pick_name(lang: &str, de: &Option, en: &Option, scientific: &str) -> String { + match lang { + "de" => de.as_deref().filter(|s| !s.is_empty()) + .or(en.as_deref().filter(|s| !s.is_empty())) + .unwrap_or(scientific).to_string(), + _ => en.as_deref().filter(|s| !s.is_empty()) + .or(de.as_deref().filter(|s| !s.is_empty())) + .unwrap_or(scientific).to_string(), + } +} diff --git a/herbapi-ui/src/main.rs b/herbapi-ui/src/main.rs index 1bdf3c5..8ebb10e 100644 --- a/herbapi-ui/src/main.rs +++ b/herbapi-ui/src/main.rs @@ -1,6 +1,7 @@ mod api; mod app; mod components; +mod i18n; mod pages; mod types; diff --git a/herbapi-ui/src/pages/cultivars.rs b/herbapi-ui/src/pages/cultivars.rs index 28f2665..12d66de 100644 --- a/herbapi-ui/src/pages/cultivars.rs +++ b/herbapi-ui/src/pages/cultivars.rs @@ -3,8 +3,9 @@ use std::collections::HashMap; use uuid::Uuid; use crate::api; -use crate::app::Route; +use crate::app::{Lang, Route}; use crate::components::table_controls::*; +use crate::i18n::{pick_desc, pick_name}; /// Convert a month-number array (1=Jan..12=Dec) to a comma-separated string of abbreviations. fn months_display(months: &Option>) -> String { @@ -279,6 +280,7 @@ pub fn CultivarList() -> Element { #[component] pub fn CultivarDetail(slug: String) -> Element { + let lang = use_context::().0; let slug_clone = slug.clone(); let cultivar = use_resource(move || { let s = slug_clone.clone(); @@ -326,11 +328,14 @@ pub fn CultivarDetail(slug: String) -> Element { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, Some(Ok(c)) => { + let current_lang = lang.read().clone(); + // Pre-compute display strings outside of rsx + let common_name = pick_name(¤t_lang, &c.name_de, &c.name_en, &c.name); let name_en = opt_str(&c.name_en); let name_de = opt_str(&c.name_de); let name_sci = opt_str(&c.name_scientific); - let desc = opt_str(&c.description); + let desc = pick_desc(¤t_lang, &c.description_de, &c.description_en, &c.description); let organic = bool_display(c.is_organic); let perennial = bool_display(c.perennial); @@ -369,6 +374,9 @@ pub fn CultivarDetail(slug: String) -> Element { rsx! { h1 { "{c.name}" } + if common_name != c.name { + p { class: "name-common", "{common_name}" } + } div { class: "detail-row", // === LEFT COLUMN === @@ -383,6 +391,10 @@ pub fn CultivarDetail(slug: String) -> Element { th { "Name" } td { "{c.name}" } } + tr { + th { "Common Name" } + td { "{common_name}" } + } tr { th { "Name EN" } td { class: if name_en == "\u{2014}" { "placeholder" } else { "" }, "{name_en}" } diff --git a/herbapi-ui/src/pages/families.rs b/herbapi-ui/src/pages/families.rs index 8d8fc42..f22bb83 100644 --- a/herbapi-ui/src/pages/families.rs +++ b/herbapi-ui/src/pages/families.rs @@ -1,8 +1,9 @@ use dioxus::prelude::*; use crate::api; -use crate::app::Route; +use crate::app::{Lang, Route}; use crate::components::table_controls::*; +use crate::i18n::pick_name; const STORAGE_KEY_COLS: &str = "herbapi_families_cols"; const STORAGE_KEY_PP: &str = "herbapi_families_pp"; @@ -10,14 +11,16 @@ const STORAGE_KEY_PP: &str = "herbapi_families_pp"; fn family_columns() -> Vec { vec![ ColumnDef { key: "name_scientific", label: "Scientific Name", default_visible: true }, - ColumnDef { key: "name_en", label: "English", default_visible: true }, - ColumnDef { key: "name_de", label: "German", default_visible: true }, + ColumnDef { key: "common_name", label: "Common Name", default_visible: true }, + ColumnDef { key: "name_en", label: "English", default_visible: false }, + ColumnDef { key: "name_de", label: "German", default_visible: false }, ColumnDef { key: "description", label: "Description", default_visible: false }, ] } #[component] pub fn FamilyList() -> Element { + let lang = use_context::().0; let columns = family_columns(); let mut page = use_signal(|| 1i64); let per_page = use_signal(|| load_per_page(STORAGE_KEY_PP, 25)); @@ -79,6 +82,9 @@ pub fn FamilyList() -> Element { if is_col_visible(&vis, "name_scientific") { th { "Scientific Name" } } + if is_col_visible(&vis, "common_name") { + th { "Common Name" } + } if is_col_visible(&vis, "name_en") { th { "English" } } @@ -92,23 +98,31 @@ pub fn FamilyList() -> Element { } tbody { for f in data.data.iter() { - tr { - if is_col_visible(&vis, "name_scientific") { - td { - Link { to: Route::FamilyDetail { slug: f.slug.clone() }, - em { "{f.name_scientific}" } + { + let common = pick_name(&lang.read(), &f.name_de, &f.name_en, &f.name_scientific); + rsx! { + tr { + if is_col_visible(&vis, "name_scientific") { + td { + Link { to: Route::FamilyDetail { slug: f.slug.clone() }, + em { "{f.name_scientific}" } + } + } + } + if is_col_visible(&vis, "common_name") { + td { "{common}" } + } + if is_col_visible(&vis, "name_en") { + td { "{f.name_en.as_deref().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "name_de") { + td { "{f.name_de.as_deref().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "description") { + td { class: "cell-truncated", + "{f.description.as_deref().map(|d| truncate(d, 80)).unwrap_or_else(|| \"-\".to_string())}" + } } - } - } - if is_col_visible(&vis, "name_en") { - td { "{f.name_en.as_deref().unwrap_or(\"-\")}" } - } - if is_col_visible(&vis, "name_de") { - td { "{f.name_de.as_deref().unwrap_or(\"-\")}" } - } - if is_col_visible(&vis, "description") { - td { class: "cell-truncated", - "{f.description.as_deref().map(|d| truncate(d, 80)).unwrap_or_else(|| \"-\".to_string())}" } } } @@ -140,6 +154,7 @@ pub fn FamilyList() -> Element { #[component] pub fn FamilyDetail(slug: String) -> Element { + let lang = use_context::().0; let slug_clone = slug.clone(); let family = use_resource(move || { let s = slug_clone.clone(); @@ -157,16 +172,16 @@ pub fn FamilyDetail(slug: String) -> Element { match &*family.read() { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, - Some(Ok(f)) => rsx! { - h1 { em { "{f.name_scientific}" } } - if let Some(ref en) = f.name_en { - p { class: "name-common", "{en}" } - } - if let Some(ref de) = f.name_de { - p { class: "name-common", "{de}" } - } - if let Some(ref desc) = f.description { - p { "{desc}" } + Some(Ok(f)) => { + let common = pick_name(&lang.read(), &f.name_de, &f.name_en, &f.name_scientific); + rsx! { + h1 { em { "{f.name_scientific}" } } + if common != f.name_scientific { + p { class: "name-common", "{common}" } + } + if let Some(ref desc) = f.description { + p { "{desc}" } + } } }, } @@ -178,12 +193,18 @@ pub fn FamilyDetail(slug: String) -> Element { Some(Ok(data)) => rsx! { div { class: "card-grid", for s in data.data.iter() { - div { class: "plant-card", - Link { to: Route::SpeciesDetail { slug: s.slug.clone() }, - em { "{s.name_scientific}" } - } - if let Some(ref en) = s.name_en { - p { class: "card-common", "{en}" } + { + let sp_common = pick_name(&lang.read(), &s.name_de, &s.name_en, &s.name_scientific); + let show_common = if sp_common == s.name_scientific { None } else { Some(sp_common) }; + rsx! { + div { class: "plant-card", + Link { to: Route::SpeciesDetail { slug: s.slug.clone() }, + em { "{s.name_scientific}" } + } + if let Some(ref common) = show_common { + p { class: "card-common", "{common}" } + } + } } } } diff --git a/herbapi-ui/src/pages/home.rs b/herbapi-ui/src/pages/home.rs index f13896e..6a17d68 100644 --- a/herbapi-ui/src/pages/home.rs +++ b/herbapi-ui/src/pages/home.rs @@ -1,11 +1,13 @@ use dioxus::prelude::*; use crate::api; -use crate::app::Route; +use crate::app::{Lang, Route}; use crate::components::plant_card::PlantCard; +use crate::i18n::pick_name; #[component] pub fn Home() -> Element { + let lang = use_context::().0; let mut search_query = use_signal(|| String::new()); let species = use_resource(|| async { api::list_species(1, 12, None, None).await }); @@ -36,12 +38,18 @@ pub fn Home() -> Element { Some(Ok(data)) => rsx! { div { class: "card-grid", for s in data.data.iter().take(12) { - PlantCard { - key: "{s.id}", - slug: s.slug.clone(), - name: s.name_scientific.clone(), - name_common: s.name_en.clone(), - entity_type: "species".to_string(), + { + let card_common = pick_name(&lang.read(), &s.name_de, &s.name_en, &s.name_scientific); + let show_common = if card_common == s.name_scientific { None } else { Some(card_common) }; + rsx! { + PlantCard { + key: "{s.id}", + slug: s.slug.clone(), + name: s.name_scientific.clone(), + name_common: show_common, + entity_type: "species".to_string(), + } + } } } } diff --git a/herbapi-ui/src/pages/species.rs b/herbapi-ui/src/pages/species.rs index b12c2cb..52ee3bf 100644 --- a/herbapi-ui/src/pages/species.rs +++ b/herbapi-ui/src/pages/species.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use uuid::Uuid; use crate::api; -use crate::app::Route; +use crate::app::{Lang, Route}; use crate::components::plant_card::PlantCard; use crate::components::table_controls::*; +use crate::i18n::{pick_desc, pick_name}; const STORAGE_KEY_COLS: &str = "herbapi_species_cols"; const STORAGE_KEY_PP: &str = "herbapi_species_pp"; @@ -15,7 +16,8 @@ const STORAGE_KEY_VIEW: &str = "herbapi_species_view"; fn species_columns() -> Vec { vec![ ColumnDef { key: "name_scientific", label: "Scientific Name", default_visible: true }, - ColumnDef { key: "name_de", label: "German", default_visible: true }, + ColumnDef { key: "common_name", label: "Common Name", default_visible: true }, + ColumnDef { key: "name_de", label: "German", default_visible: false }, ColumnDef { key: "name_en", label: "English", default_visible: false }, ColumnDef { key: "family", label: "Family", default_visible: true }, ColumnDef { key: "plant_layer", label: "Layer", default_visible: true }, @@ -33,6 +35,7 @@ fn species_columns() -> Vec { #[component] pub fn SpeciesList() -> Element { + let lang = use_context::().0; let columns = species_columns(); let mut page = use_signal(|| 1i64); let per_page = use_signal(|| load_per_page(STORAGE_KEY_PP, 25)); @@ -222,6 +225,9 @@ pub fn SpeciesList() -> Element { if is_col_visible(&vis, "name_scientific") { th { "Scientific Name" } } + if is_col_visible(&vis, "common_name") { + th { "Common Name" } + } if is_col_visible(&vis, "name_de") { th { "German" } } @@ -267,6 +273,7 @@ pub fn SpeciesList() -> Element { for s in data.data.iter() { { let family_name: &str = fm.get(&s.family_id).map(String::as_str).unwrap_or("-"); + let common = pick_name(&lang.read(), &s.name_de, &s.name_en, &s.name_scientific); rsx! { tr { if is_col_visible(&vis, "name_scientific") { @@ -276,6 +283,9 @@ pub fn SpeciesList() -> Element { } } } + if is_col_visible(&vis, "common_name") { + td { "{common}" } + } if is_col_visible(&vis, "name_de") { td { "{s.name_de.as_deref().unwrap_or(\"-\")}" } } @@ -369,12 +379,18 @@ pub fn SpeciesList() -> Element { } else { div { class: "card-grid", for s in data.data.iter() { - PlantCard { - key: "{s.id}", - slug: s.slug.clone(), - name: s.name_scientific.clone(), - name_common: s.name_en.clone(), - entity_type: "species".to_string(), + { + let card_common = pick_name(&lang.read(), &s.name_de, &s.name_en, &s.name_scientific); + let show_common = if card_common == s.name_scientific { None } else { Some(card_common) }; + rsx! { + PlantCard { + key: "{s.id}", + slug: s.slug.clone(), + name: s.name_scientific.clone(), + name_common: show_common, + entity_type: "species".to_string(), + } + } } } } @@ -404,6 +420,7 @@ pub fn SpeciesList() -> Element { #[component] pub fn SpeciesDetail(slug: String) -> Element { + let lang = use_context::().0; let slug_clone = slug.clone(); let species = use_resource(move || { let s = slug_clone.clone(); @@ -436,6 +453,7 @@ pub fn SpeciesDetail(slug: String) -> Element { Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, Some(Ok(s)) => { let species_slug = s.slug.clone(); + let current_lang = lang.read().clone(); // Helper closures to format fields let os = |v: &Option| -> String { @@ -460,9 +478,10 @@ pub fn SpeciesDetail(slug: String) -> Element { let em = "\u{2014}"; + let common_name = pick_name(¤t_lang, &s.name_de, &s.name_en, &s.name_scientific); let name_en = os(&s.name_en); let name_de = os(&s.name_de); - let desc = os(&s.description); + let desc = pick_desc(¤t_lang, &s.description_de, &s.description_en, &s.description); // Uses let food = os(&s.food_uses); @@ -511,6 +530,9 @@ pub fn SpeciesDetail(slug: String) -> Element { rsx! { h1 { em { "{s.name_scientific}" } } + if common_name != s.name_scientific { + p { class: "name-common", "{common_name}" } + } div { class: "detail-row", // === LEFT COLUMN === @@ -525,6 +547,10 @@ pub fn SpeciesDetail(slug: String) -> Element { th { "Scientific Name" } td { em { "{s.name_scientific}" } } } + tr { + th { "Common Name" } + td { "{common_name}" } + } tr { th { "Name EN" } td { class: if name_en == em { "placeholder" } else { "" }, "{name_en}" } @@ -894,6 +920,7 @@ pub fn SpeciesDetail(slug: String) -> Element { #[component] fn CultivarListForSpecies(species_slug: String) -> Element { + let lang = use_context::().0; let slug = species_slug.clone(); let cultivars = use_resource(move || { let s = slug.clone(); @@ -911,15 +938,21 @@ fn CultivarListForSpecies(species_slug: String) -> Element { rsx! { div { class: "card-grid", for c in data.data.iter() { - div { class: "plant-card", - Link { to: Route::CultivarDetail { slug: c.slug.clone() }, - strong { "{c.name}" } - } - if let Some(ref en) = c.name_en { - p { class: "card-common", "{en}" } - } - if c.is_organic { - span { class: "badge organic", "Organic" } + { + let cv_common = pick_name(&lang.read(), &c.name_de, &c.name_en, &c.name); + let show_common = if cv_common == c.name { None } else { Some(cv_common) }; + rsx! { + div { class: "plant-card", + Link { to: Route::CultivarDetail { slug: c.slug.clone() }, + strong { "{c.name}" } + } + if let Some(ref common) = show_common { + p { class: "card-common", "{common}" } + } + if c.is_organic { + span { class: "badge organic", "Organic" } + } + } } } } diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index fbafc13..012a0bf 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -32,6 +32,8 @@ pub struct Species { pub name_en: Option, pub name_de: Option, pub description: Option, + pub description_en: Option, + pub description_de: Option, pub soil_moisture: Option, pub ph_min: Option, pub ph_max: Option, @@ -89,6 +91,8 @@ pub struct Cultivar { pub name_de: Option, pub name_scientific: Option, pub description: Option, + pub description_en: Option, + pub description_de: Option, pub is_organic: bool, pub perennial: bool, pub growing_time_days: Option,