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
This commit is contained in:
2026-03-15 13:01:38 +01:00
parent efa05b2d44
commit 3ecfdfadf2
9 changed files with 244 additions and 63 deletions
+44 -1
View File
@@ -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;
+34
View File
@@ -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<String>);
#[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::<String>("herbapi_lang").unwrap_or_else(|_| "de".to_string())
});
use_context_provider(|| Lang(lang_signal));
let mut lang = use_context::<Lang>().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<MeResponse> = 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)}" }
+25
View File
@@ -0,0 +1,25 @@
/// Pick the right description based on language, falling back to the other.
pub fn pick_desc(lang: &str, de: &Option<String>, en: &Option<String>, fallback: &Option<String>) -> 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<String>, en: &Option<String>, 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(),
}
}
+1
View File
@@ -1,6 +1,7 @@
mod api;
mod app;
mod components;
mod i18n;
mod pages;
mod types;
+14 -2
View File
@@ -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<Vec<i32>>) -> String {
@@ -279,6 +280,7 @@ pub fn CultivarList() -> Element {
#[component]
pub fn CultivarDetail(slug: String) -> Element {
let lang = use_context::<Lang>().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(&current_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(&current_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}" }
+32 -11
View File
@@ -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<ColumnDef> {
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::<Lang>().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,6 +98,9 @@ pub fn FamilyList() -> Element {
}
tbody {
for f in data.data.iter() {
{
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 {
@@ -100,6 +109,9 @@ pub fn FamilyList() -> Element {
}
}
}
if is_col_visible(&vis, "common_name") {
td { "{common}" }
}
if is_col_visible(&vis, "name_en") {
td { "{f.name_en.as_deref().unwrap_or(\"-\")}" }
}
@@ -116,6 +128,8 @@ pub fn FamilyList() -> Element {
}
}
}
}
}
if data.total > data.per_page {
div { class: "pagination",
button {
@@ -140,6 +154,7 @@ pub fn FamilyList() -> Element {
#[component]
pub fn FamilyDetail(slug: String) -> Element {
let lang = use_context::<Lang>().0;
let slug_clone = slug.clone();
let family = use_resource(move || {
let s = slug_clone.clone();
@@ -157,17 +172,17 @@ 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! {
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 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 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() {
{
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 en) = s.name_en {
p { class: "card-common", "{en}" }
if let Some(ref common) = show_common {
p { class: "card-common", "{common}" }
}
}
}
}
}
+10 -2
View File
@@ -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::<Lang>().0;
let mut search_query = use_signal(|| String::new());
let species = use_resource(|| async { api::list_species(1, 12, None, None).await });
@@ -36,15 +38,21 @@ pub fn Home() -> Element {
Some(Ok(data)) => rsx! {
div { class: "card-grid",
for s in data.data.iter().take(12) {
{
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: s.name_en.clone(),
name_common: show_common,
entity_type: "species".to_string(),
}
}
}
}
}
},
}
}
+39 -6
View File
@@ -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<ColumnDef> {
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<ColumnDef> {
#[component]
pub fn SpeciesList() -> Element {
let lang = use_context::<Lang>().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,16 +379,22 @@ pub fn SpeciesList() -> Element {
} else {
div { class: "card-grid",
for s in data.data.iter() {
{
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: s.name_en.clone(),
name_common: show_common,
entity_type: "species".to_string(),
}
}
}
}
}
}
if data.total > data.per_page {
div { class: "pagination",
@@ -404,6 +420,7 @@ pub fn SpeciesList() -> Element {
#[component]
pub fn SpeciesDetail(slug: String) -> Element {
let lang = use_context::<Lang>().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>| -> String {
@@ -460,9 +478,10 @@ pub fn SpeciesDetail(slug: String) -> Element {
let em = "\u{2014}";
let common_name = pick_name(&current_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(&current_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::<Lang>().0;
let slug = species_slug.clone();
let cultivars = use_resource(move || {
let s = slug.clone();
@@ -911,12 +938,16 @@ fn CultivarListForSpecies(species_slug: String) -> Element {
rsx! {
div { class: "card-grid",
for c in data.data.iter() {
{
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 en) = c.name_en {
p { class: "card-common", "{en}" }
if let Some(ref common) = show_common {
p { class: "card-common", "{common}" }
}
if c.is_organic {
span { class: "badge organic", "Organic" }
@@ -926,6 +957,8 @@ fn CultivarListForSpecies(species_slug: String) -> Element {
}
}
}
}
}
},
}
}
+4
View File
@@ -32,6 +32,8 @@ pub struct Species {
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub description_en: Option<String>,
pub description_de: Option<String>,
pub soil_moisture: Option<String>,
pub ph_min: Option<f64>,
pub ph_max: Option<f64>,
@@ -89,6 +91,8 @@ pub struct Cultivar {
pub name_de: Option<String>,
pub name_scientific: Option<String>,
pub description: Option<String>,
pub description_en: Option<String>,
pub description_de: Option<String>,
pub is_organic: bool,
pub perennial: bool,
pub growing_time_days: Option<i32>,