Add full DE/EN UI label translations across all pages

Add t() translation function to i18n.rs with ~200 key/value pairs
covering navigation, card headers, field labels, buttons, filters,
planting calendar rows, and general UI text in both German and English.

Updated all 11 source files to use t(&lang, "key") instead of
hardcoded English strings: app.rs (sidebar nav), all 7 page files
(species, cultivars, families, suppliers, home, search, sources),
and all 3 component files (planting_calendar, table_controls,
plant_card). Boolean values (Yes/No/Annual) are also translated.
This commit is contained in:
2026-03-15 17:20:51 +01:00
parent 896b364b09
commit aa36c846d7
11 changed files with 679 additions and 270 deletions
+13 -10
View File
@@ -2,6 +2,7 @@ use dioxus::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use crate::api;
use crate::i18n::t;
use crate::types::MeResponse;
/// Global language signal shared via context. Values: "de" or "en".
@@ -61,6 +62,8 @@ fn Layout() -> Element {
let auth = use_resource(|| async { api::get_current_user().await.ok() });
let user: Option<MeResponse> = auth.read().as_ref().and_then(|r| r.clone());
let l = &*current_lang;
rsx! {
div { class: "app-layout",
nav { class: "sidebar",
@@ -68,17 +71,17 @@ fn Layout() -> Element {
span { class: "brand-icon", "\u{1F33F}" }
div { class: "brand-text-group",
span { class: "brand-text", "HerbAPI" }
span { class: "brand-sub", "Plant Database" }
span { class: "brand-sub", "{t(l, \"brand.sub\")}" }
}
}
div { class: "sidebar-nav",
NavLink { to: Route::Home {}, label: "Home" }
NavLink { to: Route::FamilyList {}, label: "Families" }
NavLink { to: Route::SpeciesList {}, label: "Species" }
NavLink { to: Route::CultivarList {}, label: "Cultivars" }
NavLink { to: Route::SupplierList {}, label: "Suppliers" }
NavLink { to: Route::SearchPage {}, label: "Search" }
NavLink { to: Route::Sources {}, label: "Sources" }
NavLink { to: Route::Home {}, label: t(l, "nav.home") }
NavLink { to: Route::FamilyList {}, label: t(l, "nav.families") }
NavLink { to: Route::SpeciesList {}, label: t(l, "nav.species") }
NavLink { to: Route::CultivarList {}, label: t(l, "nav.cultivars") }
NavLink { to: Route::SupplierList {}, label: t(l, "nav.suppliers") }
NavLink { to: Route::SearchPage {}, label: t(l, "nav.search") }
NavLink { to: Route::Sources {}, label: t(l, "nav.sources") }
}
div { class: "sidebar-lang",
div { class: "lang-toggle",
@@ -103,9 +106,9 @@ fn Layout() -> Element {
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)}" }
a { class: "logout-link", href: "/auth/oidc/logout", "Logout" }
a { class: "logout-link", href: "/auth/oidc/logout", "{t(l, \"btn.logout\")}" }
} else {
a { class: "login-link", href: "/auth/oidc/login", "Login" }
a { class: "login-link", href: "/auth/oidc/login", "{t(l, \"btn.login\")}" }
}
}
}
+20 -12
View File
@@ -1,5 +1,8 @@
use dioxus::prelude::*;
use crate::app::Lang;
use crate::i18n::t;
/// Month boundaries as week ranges (approximate ISO weeks).
/// Jan=1-4, Feb=5-8, Mar=9-13, Apr=14-17, May=18-22, Jun=23-26,
/// Jul=27-30, Aug=31-35, Sep=36-39, Oct=40-44, Nov=45-48, Dec=49-52
@@ -54,17 +57,22 @@ pub fn PlantingCalendar(
glasshouse_weeks: Option<Vec<i32>>,
harvesting_weeks: Option<Vec<i32>>,
) -> Element {
let rows: Vec<(&str, &str, Option<Vec<i32>>)> = vec![
("Indoor Sowing", "cal-indoor", resolve_weeks(&indoor_sowing_weeks, &indoor_sowing_months)),
("Direct Sowing", "cal-direct", resolve_weeks(&direct_sowing_weeks, &direct_sowing_months)),
("Transplanting", "cal-transplant", resolve_weeks(&transplanting_weeks, &transplanting_months)),
("Glasshouse", "cal-glass", resolve_weeks(&glasshouse_weeks, &glasshouse_months)),
("Harvesting", "cal-harvest", resolve_weeks(&harvesting_weeks, &harvesting_months)),
let lang = use_context::<Lang>().0;
let l = lang.read().clone();
let row_indoor = (t(&l, "cal.indoor_sowing").to_string(), "cal-indoor".to_string(), resolve_weeks(&indoor_sowing_weeks, &indoor_sowing_months));
let row_direct = (t(&l, "cal.direct_sowing").to_string(), "cal-direct".to_string(), resolve_weeks(&direct_sowing_weeks, &direct_sowing_months));
let row_transplant = (t(&l, "cal.transplanting").to_string(), "cal-transplant".to_string(), resolve_weeks(&transplanting_weeks, &transplanting_months));
let row_glass = (t(&l, "cal.glasshouse").to_string(), "cal-glass".to_string(), resolve_weeks(&glasshouse_weeks, &glasshouse_months));
let row_harvest = (t(&l, "cal.harvesting").to_string(), "cal-harvest".to_string(), resolve_weeks(&harvesting_weeks, &harvesting_months));
let rows: Vec<(String, String, Option<Vec<i32>>)> = vec![
row_indoor, row_direct, row_transplant, row_glass, row_harvest,
];
let has_data = rows.iter().any(|(_, _, w)| w.is_some());
if !has_data {
return rsx! { p { class: "empty", "No planting calendar data." } };
return rsx! { p { class: "empty", "{t(&l, \"no_planting_data\")}" } };
}
rsx! {
@@ -111,23 +119,23 @@ pub fn PlantingCalendar(
div { class: "wcal-legend",
div { class: "wcal-legend-item",
div { class: "wcal-legend-swatch cal-indoor" }
span { "Indoor Sowing" }
span { "{t(&l, \"cal.indoor_sowing\")}" }
}
div { class: "wcal-legend-item",
div { class: "wcal-legend-swatch cal-direct" }
span { "Direct Sowing" }
span { "{t(&l, \"cal.direct_sowing\")}" }
}
div { class: "wcal-legend-item",
div { class: "wcal-legend-swatch cal-transplant" }
span { "Transplanting" }
span { "{t(&l, \"cal.transplanting\")}" }
}
div { class: "wcal-legend-item",
div { class: "wcal-legend-swatch cal-glass" }
span { "Glasshouse" }
span { "{t(&l, \"cal.glasshouse\")}" }
}
div { class: "wcal-legend-item",
div { class: "wcal-legend-swatch cal-harvest" }
span { "Harvesting" }
span { "{t(&l, \"cal.harvesting\")}" }
}
}
}
+11 -3
View File
@@ -2,6 +2,9 @@ use dioxus::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashSet;
use crate::app::Lang;
use crate::i18n::t;
/// Column definition for configurable tables
#[derive(Clone, PartialEq)]
pub struct ColumnDef {
@@ -36,9 +39,12 @@ pub fn ColumnToggle(
visible: Signal<HashSet<String>>,
storage_key: String,
) -> Element {
let lang = use_context::<Lang>().0;
let l = lang.read().clone();
rsx! {
div { class: "column-toggle",
span { class: "column-toggle-label", "Columns:" }
span { class: "column-toggle-label", "{t(&l, \"table.columns\")}" }
for col in columns.iter() {
{
let key = col.key.to_string();
@@ -74,11 +80,13 @@ pub fn PerPageSelector(
page: Signal<i64>,
storage_key: String,
) -> Element {
let lang = use_context::<Lang>().0;
let l = lang.read().clone();
let current = *per_page.read();
rsx! {
div { class: "per-page-selector",
label { "Show " }
label { "{t(&l, \"table.show\")} " }
select {
value: "{current}",
onchange: move |e| {
@@ -93,7 +101,7 @@ pub fn PerPageSelector(
option { value: "50", "50" }
option { value: "100", "100" }
}
label { " per page" }
label { " {t(&l, \"table.per_page\")}" }
}
}
}
+366
View File
@@ -23,3 +23,369 @@ pub fn pick_name(lang: &str, de: &Option<String>, en: &Option<String>, scientifi
.unwrap_or(scientific).to_string(),
}
}
/// Translate a UI label key into the appropriate language.
/// All keys should be covered; unknown keys return "???".
pub fn t(lang: &str, key: &str) -> &'static str {
match (lang, key) {
// Navigation
("de", "nav.home") => "Startseite",
("de", "nav.families") => "Familien",
("de", "nav.species") => "Arten",
("de", "nav.cultivars") => "Sorten",
("de", "nav.suppliers") => "Lieferanten",
("de", "nav.search") => "Suche",
("de", "nav.sources") => "Quellen",
// Sidebar
("de", "brand.sub") => "Pflanzendatenbank",
// Page headings
("de", "page.species") => "Arten",
("de", "page.cultivars") => "Sorten",
("de", "page.families") => "Pflanzenfamilien",
("de", "page.suppliers") => "Lieferanten",
("de", "page.search") => "Suche",
("de", "page.sources") => "Datenquellen",
("de", "page.recent_species") => "Neueste Arten",
("de", "page.species_in_family") => "Arten in dieser Familie",
// Card headers
("de", "card.species_details") => "Artdetails",
("de", "card.cultivar_details") => "Sortendetails",
("de", "card.growing_info") => "Anbauinformationen",
("de", "card.growing_requirements") => "Anbauanforderungen",
("de", "card.planting_schedule") => "Pflanzkalender",
("de", "card.planting_calendar") => "Pflanzkalender",
("de", "card.germination") => "Keimung",
("de", "card.climate") => "Klima & Umgebung",
("de", "card.where_to_buy") => "Bezugsquellen",
("de", "card.uses") => "Nutzung",
("de", "card.ecology") => "\u{00d6}kologie",
("de", "card.permaculture") => "Permakultur",
("de", "card.external_links") => "Externe Links",
("de", "card.wildlife") => "Wildtiere & \u{00d6}kologie",
("de", "card.image") => "Bild",
("de", "card.species_info") => "Artinformationen",
("de", "card.cultivars") => "Sorten",
// Field labels
("de", "field.scientific_name") => "Wissenschaftlicher Name",
("de", "field.common_name") => "Allgemeiner Name",
("de", "field.name") => "Name",
("de", "field.name_en") => "Name EN",
("de", "field.name_de") => "Name DE",
("de", "field.family") => "Familie",
("de", "field.description") => "Beschreibung",
("de", "field.organic") => "Bio",
("de", "field.perennial") => "Mehrj\u{00e4}hrig",
("de", "field.growing_time") => "Kulturzeit",
("de", "field.planting_depth") => "Pflanztiefe",
("de", "field.row_spacing") => "Reihenabstand",
("de", "field.plant_spacing") => "Pflanzabstand",
("de", "field.propagation") => "Vermehrung",
("de", "field.days_to_germ") => "Tage bis Keimung",
("de", "field.germ_temp") => "Keimtemperatur",
("de", "field.light_req") => "Lichtbedarf",
("de", "field.stratification") => "Stratifikation erforderlich",
("de", "field.stratification_days") => "Stratifikationstage",
("de", "field.scarification") => "Skarifizierung erforderlich",
("de", "field.min_temp") => "Min. Temperatur",
("de", "field.max_temp") => "Max. Temperatur",
("de", "field.humidity") => "Luftfeuchtigkeit",
("de", "field.light") => "Licht",
("de", "field.frost_tolerance") => "Frosttoleranz",
("de", "field.drought_tolerance") => "Trockenheitstoleranz",
("de", "field.usda_zone") => "USDA-Zone",
("de", "field.at_zone") => "AT-Zone",
("de", "field.ph_range") => "pH-Bereich",
("de", "field.soil_moisture") => "Bodenfeuchte",
("de", "field.salt_tolerance") => "Salztoleranz",
("de", "field.plant_layer") => "Pflanzenschicht",
("de", "field.nitrogen_fixer") => "Stickstoffbinder",
("de", "field.dynamic_acc") => "Dynamischer N\u{00e4}hrstoffsammler",
("de", "field.food_uses") => "Nahrungsnutzung",
("de", "field.medicinal_uses") => "Medizinische Nutzung",
("de", "field.other_uses") => "Sonstige Nutzung",
("de", "field.wildlife_value") => "Wildtierwert",
("de", "field.native_range") => "Herkunftsgebiet",
("de", "field.edibility") => "Essbarkeit",
("de", "field.pollination") => "Best\u{00e4}ubung",
("de", "field.species") => "Art",
("de", "field.supplier") => "Lieferant",
("de", "field.sku") => "Art.-Nr.",
("de", "field.price") => "Preis",
("de", "field.link") => "Link",
("de", "field.nectar") => "Nektar",
("de", "field.pollen") => "Pollen",
("de", "field.wild_bees") => "Wildbienen",
("de", "field.butterflies") => "Schmetterlinge & Falter",
("de", "field.native_status") => "Heimatstatus",
("de", "field.succession") => "Sukzessionsstufe",
("de", "field.country") => "Land",
("de", "field.website") => "Website",
("de", "field.notes") => "Anmerkungen",
("de", "field.attracts_pollinators") => "Zieht Best\u{00e4}uber an",
("de", "field.attracts_beneficial") => "Zieht N\u{00fc}tzlinge an",
("de", "field.mulch_plant") => "Mulchpflanze",
("de", "field.ground_cover_quality") => "Bodendeckerqualit\u{00e4}t",
("de", "field.allelopathic") => "Allelopathisch",
("de", "field.guild_role") => "Gildenrolle",
("de", "field.wikidata_qid") => "Wikidata QID",
("de", "field.gbif_id") => "GBIF ID",
("de", "field.eppo_code") => "EPPO-Code",
("de", "field.pfaf_url") => "PFAF URL",
("de", "field.hoverflies") => "Schwebfliegen",
("de", "field.beetles") => "K\u{00e4}fer",
("de", "field.birds") => "V\u{00f6}gel",
("de", "field.mammals") => "S\u{00e4}ugetiere",
("de", "field.naturadb_tags") => "NaturaDB Tags",
("de", "field.caterpillar_specialists") => "Raupen-Spezialisten",
("de", "field.succession_planting") => "Staffelpflanzung",
("de", "field.planting_notes") => "Pflanzhinweise",
("de", "field.min_light_hours") => "Min. Lichtstunden/Tag",
("de", "field.opt_light_hours") => "Opt. Lichtstunden/Tag",
("de", "field.usda_hardiness_zone") => "USDA-Winterh\u{00e4}rtezone",
("de", "field.n_fixer") => "N-Binder",
("de", "field.dyn_accum") => "Dyn. Sammler",
("de", "field.layer") => "Schicht",
("de", "field.drought_tol") => "Trockentol.",
("de", "field.frost_tol") => "Frosttol.",
("de", "field.days_germ") => "Tage Keimung",
("de", "field.data_used") => "Verwendete Daten",
("de", "field.license") => "Lizenz",
("de", "field.view_on_pfaf") => "Auf PFAF ansehen",
("de", "field.source") => "Quelle",
// Planting calendar
("de", "cal.indoor_sowing") => "Voranzucht",
("de", "cal.direct_sowing") => "Direktsaat",
("de", "cal.transplanting") => "Auspflanzen",
("de", "cal.glasshouse") => "Gew\u{00e4}chshaus",
("de", "cal.harvesting") => "Ernte",
// Buttons
("de", "btn.previous") => "Zur\u{00fc}ck",
("de", "btn.next") => "Weiter",
("de", "btn.view") => "Ansehen",
("de", "btn.search") => "Suchen",
("de", "btn.table_view") => "Tabellenansicht",
("de", "btn.card_view") => "Kartenansicht",
("de", "btn.logout") => "Abmelden",
("de", "btn.login") => "Anmelden",
// Table controls
("de", "table.columns") => "Spalten:",
("de", "table.show") => "Zeige",
("de", "table.per_page") => "pro Seite",
// Filters
("de", "filter.layer") => "Schicht",
("de", "filter.drought_tol") => "Trockentol.",
("de", "filter.all") => "Alle",
// General
("de", "yes") => "Ja",
("de", "no") => "Nein",
("de", "annual") => "Einj\u{00e4}hrig",
("de", "loading") => "Laden\u{2026}",
("de", "estimated_note") => "Mit ~ markierte Werte sind von der Art gesch\u{00e4}tzt",
("de", "page") => "Seite",
("de", "of") => "von",
("de", "no_cultivars") => "Noch keine Sorten.",
("de", "no_suppliers_linked") => "Keine Lieferanten verkn\u{00fc}pft",
("de", "no_planting_data") => "Keine Pflanzkalenderdaten.",
("de", "results") => "Ergebnisse",
("de", "search.placeholder") => "Pflanzen suchen\u{2026}",
("de", "search.placeholder_species") => "Arten suchen\u{2026}",
("de", "search.placeholder_cultivars") => "Sorten suchen\u{2026}",
("de", "search.placeholder_families") => "Familien suchen\u{2026}",
("de", "search.placeholder_full") => "Pflanzen, Familien, Sorten suchen\u{2026}",
("de", "subtitle") => "Dreisprachige Pflanzen-Referenzdatenbank",
("de", "sources.intro") => "HerbAPI aggregiert Pflanzendaten aus mehreren offenen Quellen. Wir danken diesen Projekten, dass sie botanisches Wissen frei zug\u{00e4}nglich machen.",
("de", "species_species") => "Arten",
("de", "loading_species_data") => "Lade Artdaten\u{2026}",
// ── English defaults ──────────────────────────────────────
// Navigation
(_, "nav.home") => "Home",
(_, "nav.families") => "Families",
(_, "nav.species") => "Species",
(_, "nav.cultivars") => "Cultivars",
(_, "nav.suppliers") => "Suppliers",
(_, "nav.search") => "Search",
(_, "nav.sources") => "Sources",
// Sidebar
(_, "brand.sub") => "Plant Database",
// Page headings
(_, "page.species") => "Species",
(_, "page.cultivars") => "Cultivars",
(_, "page.families") => "Plant Families",
(_, "page.suppliers") => "Suppliers",
(_, "page.search") => "Search",
(_, "page.sources") => "Data Sources",
(_, "page.recent_species") => "Recent Species",
(_, "page.species_in_family") => "Species in this Family",
// Card headers
(_, "card.species_details") => "Species Details",
(_, "card.cultivar_details") => "Cultivar Details",
(_, "card.growing_info") => "Growing Information",
(_, "card.growing_requirements") => "Growing Requirements",
(_, "card.planting_schedule") => "Planting Schedule",
(_, "card.planting_calendar") => "Planting Calendar",
(_, "card.germination") => "Germination",
(_, "card.climate") => "Climate & Environment",
(_, "card.where_to_buy") => "Where to Buy",
(_, "card.uses") => "Uses",
(_, "card.ecology") => "Ecology",
(_, "card.permaculture") => "Permaculture",
(_, "card.external_links") => "External Links",
(_, "card.wildlife") => "Wildlife & Ecology",
(_, "card.image") => "Image",
(_, "card.species_info") => "Species Information",
(_, "card.cultivars") => "Cultivars",
// Field labels
(_, "field.scientific_name") => "Scientific Name",
(_, "field.common_name") => "Common Name",
(_, "field.name") => "Name",
(_, "field.name_en") => "Name EN",
(_, "field.name_de") => "Name DE",
(_, "field.family") => "Family",
(_, "field.description") => "Description",
(_, "field.organic") => "Organic",
(_, "field.perennial") => "Perennial",
(_, "field.growing_time") => "Growing Time",
(_, "field.planting_depth") => "Planting Depth",
(_, "field.row_spacing") => "Row Spacing",
(_, "field.plant_spacing") => "Plant Spacing",
(_, "field.propagation") => "Propagation Methods",
(_, "field.days_to_germ") => "Days to Germination",
(_, "field.germ_temp") => "Germination Temp",
(_, "field.light_req") => "Light Requirement",
(_, "field.stratification") => "Stratification Required",
(_, "field.stratification_days") => "Stratification Days",
(_, "field.scarification") => "Scarification Required",
(_, "field.min_temp") => "Min Temp",
(_, "field.max_temp") => "Max Temp",
(_, "field.humidity") => "Humidity",
(_, "field.light") => "Light",
(_, "field.frost_tolerance") => "Frost Tolerance",
(_, "field.drought_tolerance") => "Drought Tolerance",
(_, "field.usda_zone") => "USDA Zone",
(_, "field.at_zone") => "AT Zone",
(_, "field.ph_range") => "pH Range",
(_, "field.soil_moisture") => "Soil Moisture",
(_, "field.salt_tolerance") => "Salt Tolerance",
(_, "field.plant_layer") => "Plant Layer",
(_, "field.nitrogen_fixer") => "Nitrogen Fixer",
(_, "field.dynamic_acc") => "Dynamic Accumulator",
(_, "field.food_uses") => "Food Uses",
(_, "field.medicinal_uses") => "Medicinal Uses",
(_, "field.other_uses") => "Other Uses",
(_, "field.wildlife_value") => "Wildlife Value",
(_, "field.native_range") => "Native Range",
(_, "field.edibility") => "Edibility Rating",
(_, "field.pollination") => "Pollination Type",
(_, "field.species") => "Species",
(_, "field.supplier") => "Supplier",
(_, "field.sku") => "SKU",
(_, "field.price") => "Price",
(_, "field.link") => "Link",
(_, "field.nectar") => "Nectar Value",
(_, "field.pollen") => "Pollen Value",
(_, "field.wild_bees") => "Wild Bees",
(_, "field.butterflies") => "Butterflies & Moths",
(_, "field.native_status") => "Native Status",
(_, "field.succession") => "Succession Stage",
(_, "field.country") => "Country",
(_, "field.website") => "Website",
(_, "field.notes") => "Notes",
(_, "field.attracts_pollinators") => "Attracts Pollinators",
(_, "field.attracts_beneficial") => "Attracts Beneficial Insects",
(_, "field.mulch_plant") => "Mulch Plant",
(_, "field.ground_cover_quality") => "Ground Cover Quality",
(_, "field.allelopathic") => "Allelopathic",
(_, "field.guild_role") => "Guild Role",
(_, "field.wikidata_qid") => "Wikidata QID",
(_, "field.gbif_id") => "GBIF ID",
(_, "field.eppo_code") => "EPPO Code",
(_, "field.pfaf_url") => "PFAF URL",
(_, "field.hoverflies") => "Hoverflies",
(_, "field.beetles") => "Beetles",
(_, "field.birds") => "Birds",
(_, "field.mammals") => "Mammals",
(_, "field.naturadb_tags") => "NaturaDB Tags",
(_, "field.caterpillar_specialists") => "Caterpillar Specialists",
(_, "field.succession_planting") => "Succession Planting",
(_, "field.planting_notes") => "Planting Notes",
(_, "field.min_light_hours") => "Min Light Hours/Day",
(_, "field.opt_light_hours") => "Optimal Light Hours/Day",
(_, "field.usda_hardiness_zone") => "USDA Hardiness Zone",
(_, "field.n_fixer") => "N-Fixer",
(_, "field.dyn_accum") => "Dyn. Accum.",
(_, "field.layer") => "Layer",
(_, "field.drought_tol") => "Drought Tol.",
(_, "field.frost_tol") => "Frost Tol.",
(_, "field.days_germ") => "Days to Germ.",
(_, "field.data_used") => "Data used",
(_, "field.license") => "License",
(_, "field.view_on_pfaf") => "View on PFAF",
(_, "field.source") => "Source",
// Planting calendar
(_, "cal.indoor_sowing") => "Indoor Sowing",
(_, "cal.direct_sowing") => "Direct Sowing",
(_, "cal.transplanting") => "Transplanting",
(_, "cal.glasshouse") => "Glasshouse",
(_, "cal.harvesting") => "Harvesting",
// Buttons
(_, "btn.previous") => "Previous",
(_, "btn.next") => "Next",
(_, "btn.view") => "View",
(_, "btn.search") => "Search",
(_, "btn.table_view") => "Table View",
(_, "btn.card_view") => "Card View",
(_, "btn.logout") => "Logout",
(_, "btn.login") => "Login",
// Table controls
(_, "table.columns") => "Columns:",
(_, "table.show") => "Show",
(_, "table.per_page") => "per page",
// Filters
(_, "filter.layer") => "Layer",
(_, "filter.drought_tol") => "Drought Tol.",
(_, "filter.all") => "All",
// General
(_, "yes") => "Yes",
(_, "no") => "No",
(_, "annual") => "Annual",
(_, "loading") => "Loading...",
(_, "estimated_note") => "Values marked ~ species are estimated from species-level data",
(_, "page") => "Page",
(_, "of") => "of",
(_, "no_cultivars") => "No cultivars yet.",
(_, "no_suppliers_linked") => "No suppliers linked",
(_, "no_planting_data") => "No planting calendar data.",
(_, "results") => "results",
(_, "search.placeholder") => "Search plants...",
(_, "search.placeholder_species") => "Search species...",
(_, "search.placeholder_cultivars") => "Search cultivars...",
(_, "search.placeholder_families") => "Search families...",
(_, "search.placeholder_full") => "Search plants, families, cultivars...",
(_, "subtitle") => "Trilingual plant reference database",
(_, "sources.intro") => "HerbAPI aggregates plant data from multiple open sources. We are grateful to these projects for making botanical knowledge freely available.",
(_, "species_species") => "species",
(_, "loading_species_data") => "Loading species data\u{2026}",
_ => "???",
}
}
+101 -102
View File
@@ -6,7 +6,7 @@ use crate::api;
use crate::app::{Lang, Route};
use crate::components::planting_calendar::PlantingCalendar;
use crate::components::table_controls::*;
use crate::i18n::{pick_desc, pick_name};
use crate::i18n::{pick_desc, pick_name, t};
/// Convert a month-number array (1=Jan..12=Dec) to a comma-separated string of abbreviations.
fn months_display(months: &Option<Vec<i32>>) -> String {
@@ -48,18 +48,18 @@ fn opt_i32_suffix(val: Option<i32>, suffix: &str) -> String {
}
}
/// Format an Option<bool> as Yes / No / em dash.
fn opt_bool(val: Option<bool>) -> String {
/// Format an Option<bool> as Yes / No / em dash (translated).
fn opt_bool_t(val: Option<bool>, lang: &str) -> String {
match val {
Some(true) => "Yes".to_string(),
Some(false) => "No".to_string(),
Some(true) => t(lang, "yes").to_string(),
Some(false) => t(lang, "no").to_string(),
None => "\u{2014}".to_string(),
}
}
/// Bool field: always present (non-Option).
fn bool_display(val: bool) -> &'static str {
if val { "Yes" } else { "No" }
/// Bool field: always present (non-Option), translated.
fn bool_display_t(val: bool, lang: &str) -> String {
if val { t(lang, "yes").to_string() } else { t(lang, "no").to_string() }
}
/// Format an Option<Vec<String>> as comma-separated or em dash.
@@ -109,6 +109,7 @@ fn cultivar_columns() -> Vec<ColumnDef> {
#[component]
pub fn CultivarList() -> Element {
let lang = use_context::<Lang>().0;
let columns = cultivar_columns();
let mut page = use_signal(|| 1i64);
let per_page = use_signal(|| load_per_page(STORAGE_KEY_PP, 25));
@@ -146,16 +147,17 @@ pub fn CultivarList() -> Element {
});
let current_page = *page.read();
let l = lang.read().clone();
rsx! {
div { class: "page",
h1 { "Cultivars" }
h1 { "{t(&l, \"page.cultivars\")}" }
div { class: "table-toolbar",
div { class: "search-bar",
input {
r#type: "text",
placeholder: "Search cultivars...",
placeholder: "{t(&l, \"search.placeholder_cultivars\")}",
value: "{search}",
oninput: move |e| {
search.set(e.value());
@@ -177,7 +179,7 @@ pub fn CultivarList() -> Element {
}
match &*cultivars.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => {
let smap = species_map.read();
@@ -188,34 +190,34 @@ pub fn CultivarList() -> Element {
};
let vis = visible_cols.read();
rsx! {
p { class: "result-count", "{data.total} cultivars" }
p { class: "result-count", "{data.total} {t(&l, \"nav.cultivars\").to_lowercase()}" }
div { class: "table-wrap",
table {
thead {
tr {
if is_col_visible(&vis, "name") {
th { "Name" }
th { "{t(&l, \"field.name\")}" }
}
if is_col_visible(&vis, "species") {
th { "Species" }
th { "{t(&l, \"field.species\")}" }
}
if is_col_visible(&vis, "organic") {
th { "Organic" }
th { "{t(&l, \"field.organic\")}" }
}
if is_col_visible(&vis, "perennial") {
th { "Perennial" }
th { "{t(&l, \"field.perennial\")}" }
}
if is_col_visible(&vis, "description") {
th { "Description" }
th { "{t(&l, \"field.description\")}" }
}
if is_col_visible(&vis, "frost_tolerance") {
th { "Frost Tol." }
th { "{t(&l, \"field.frost_tol\")}" }
}
if is_col_visible(&vis, "growing_time") {
th { "Growing Time" }
th { "{t(&l, \"field.growing_time\")}" }
}
if is_col_visible(&vis, "days_germ") {
th { "Days to Germ." }
th { "{t(&l, \"field.days_germ\")}" }
}
}
}
@@ -238,14 +240,14 @@ pub fn CultivarList() -> Element {
if is_col_visible(&vis, "organic") {
td {
if c.is_organic {
span { class: "badge organic", "Yes" }
span { class: "badge organic", "{t(&l, \"yes\")}" }
} else {
"-"
}
}
}
if is_col_visible(&vis, "perennial") {
td { if c.perennial { "Yes" } else { "Annual" } }
td { if c.perennial { "{t(&l, \"yes\")}" } else { "{t(&l, \"annual\")}" } }
}
if is_col_visible(&vis, "description") {
td { class: "cell-truncated",
@@ -283,13 +285,13 @@ pub fn CultivarList() -> Element {
button {
disabled: current_page <= 1,
onclick: move |_| page.set(current_page - 1),
"Previous"
"{t(&l, \"btn.previous\")}"
}
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
span { "{t(&l, \"page\")} {current_page} {t(&l, \"of\")} {(data.total + data.per_page - 1) / data.per_page}" }
button {
disabled: current_page * data.per_page >= data.total,
onclick: move |_| page.set(current_page + 1),
"Next"
"{t(&l, \"btn.next\")}"
}
}
}
@@ -359,10 +361,11 @@ pub fn CultivarDetail(slug: String) -> Element {
rsx! {
div { class: "page cultivar-detail",
match &*cultivar.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&lang.read(), \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(c)) => {
let current_lang = lang.read().clone();
let l = &*current_lang;
// Pre-compute display strings outside of rsx
let common_name = pick_name(&current_lang, &c.name_de, &c.name_en, &c.name);
@@ -370,8 +373,8 @@ pub fn CultivarDetail(slug: String) -> Element {
let name_de = opt_str(&c.name_de);
let name_sci = opt_str(&c.name_scientific);
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);
let organic = bool_display_t(c.is_organic, l);
let perennial = bool_display_t(c.perennial, l);
// Planting schedule
let indoor = months_display(&c.indoor_sowing_months);
@@ -393,9 +396,9 @@ pub fn CultivarDetail(slug: String) -> Element {
let days_germ = opt_i32_suffix(c.days_to_germination, "");
let germ_temp = opt_f64_suffix(c.germination_temp_c, " \u{00b0}C");
let light_req = opt_str(&c.light_requirement);
let strat_req = opt_bool(c.stratification_required);
let strat_req = opt_bool_t(c.stratification_required, l);
let strat_days = opt_i32_suffix(c.stratification_days, "");
let scar_req = opt_bool(c.scarification_required);
let scar_req = opt_bool_t(c.scarification_required, l);
// Climate
let min_temp = opt_f64_suffix(c.min_temp, " \u{00b0}C");
@@ -407,8 +410,6 @@ pub fn CultivarDetail(slug: String) -> Element {
let opt_light_h = opt_f64_suffix(c.optimal_light_hours_day, " h");
// --- Species fallback values ---
// When a cultivar field is empty, we show species-level
// data styled as an estimation.
let (sp_frost_fb, sp_light_fb, sp_drought_fb, sp_usda_fb) = {
let sp_read = species_data.read();
match &*sp_read {
@@ -433,7 +434,7 @@ pub fn CultivarDetail(slug: String) -> Element {
|| sp_drought_fb.is_some()
|| sp_usda_fb.is_some();
// Species image: prefer primary from images API, else primary_image_key
// Species image
let sp_primary_img: Option<crate::types::Image> = {
let imgs = species_images.read();
match &*imgs {
@@ -460,7 +461,7 @@ pub fn CultivarDetail(slug: String) -> Element {
// Week-based planting calendar (full width)
div { class: "detail-card", style: "margin-top: 1.25rem;",
div { class: "detail-card-header", "Planting Calendar" }
div { class: "detail-card-header", "{t(l, \"card.planting_calendar\")}" }
div { style: "padding: 0.75rem;",
PlantingCalendar {
indoor_sowing_months: c.indoor_sowing_months.clone(),
@@ -483,27 +484,27 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 1: Cultivar Details
div { class: "detail-card",
div { class: "detail-card-header", "Cultivar Details" }
div { class: "detail-card-header", "{t(l, \"card.cultivar_details\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Name" }
th { "{t(l, \"field.name\")}" }
td { "{c.name}" }
}
tr {
th { "Common Name" }
th { "{t(l, \"field.common_name\")}" }
td { "{common_name}" }
}
tr {
th { "Name EN" }
th { "{t(l, \"field.name_en\")}" }
td { class: if name_en == "\u{2014}" { "placeholder" } else { "" }, "{name_en}" }
}
tr {
th { "Name DE" }
th { "{t(l, \"field.name_de\")}" }
td { class: if name_de == "\u{2014}" { "placeholder" } else { "" }, "{name_de}" }
}
tr {
th { "Scientific Name" }
th { "{t(l, \"field.scientific_name\")}" }
td { class: if name_sci == "\u{2014}" { "placeholder" } else { "" },
if name_sci != "\u{2014}" {
em { "{name_sci}" }
@@ -513,7 +514,7 @@ pub fn CultivarDetail(slug: String) -> Element {
}
}
tr {
th { "Species" }
th { "{t(l, \"field.species\")}" }
td {
if let Some(Some(ref sp)) = *species_data.read() {
Link { to: Route::SpeciesDetail { slug: sp.slug.clone() },
@@ -530,15 +531,15 @@ pub fn CultivarDetail(slug: String) -> Element {
}
}
tr {
th { "Description" }
th { "{t(l, \"field.description\")}" }
td { class: if desc == "\u{2014}" { "placeholder" } else { "" }, "{desc}" }
}
tr {
th { "Organic" }
th { "{t(l, \"field.organic\")}" }
td { "{organic}" }
}
tr {
th { "Perennial" }
th { "{t(l, \"field.perennial\")}" }
td { "{perennial}" }
}
}
@@ -547,35 +548,35 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 2: Planting Schedule
div { class: "detail-card",
div { class: "detail-card-header", "Planting Schedule" }
div { class: "detail-card-header", "{t(l, \"card.planting_schedule\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Indoor Sowing" }
th { "{t(l, \"cal.indoor_sowing\")}" }
td { class: if indoor == "\u{2014}" { "placeholder" } else { "" }, "{indoor}" }
}
tr {
th { "Direct Sowing" }
th { "{t(l, \"cal.direct_sowing\")}" }
td { class: if direct == "\u{2014}" { "placeholder" } else { "" }, "{direct}" }
}
tr {
th { "Transplanting" }
th { "{t(l, \"cal.transplanting\")}" }
td { class: if transplant == "\u{2014}" { "placeholder" } else { "" }, "{transplant}" }
}
tr {
th { "Glasshouse" }
th { "{t(l, \"cal.glasshouse\")}" }
td { class: if glasshouse == "\u{2014}" { "placeholder" } else { "" }, "{glasshouse}" }
}
tr {
th { "Harvesting" }
th { "{t(l, \"cal.harvesting\")}" }
td { class: if harvest == "\u{2014}" { "placeholder" } else { "" }, "{harvest}" }
}
tr {
th { "Succession Planting" }
th { "{t(l, \"field.succession_planting\")}" }
td { class: if succession == "\u{2014}" { "placeholder" } else { "" }, "{succession}" }
}
tr {
th { "Planting Notes" }
th { "{t(l, \"field.planting_notes\")}" }
td { class: if planting_notes == "\u{2014}" { "placeholder" } else { "" }, "{planting_notes}" }
}
}
@@ -584,7 +585,7 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 3: Where to Buy
div { class: "detail-card",
div { class: "detail-card-header", "Where to Buy" }
div { class: "detail-card-header", "{t(l, \"card.where_to_buy\")}" }
{
let sup_links = suppliers_data.read();
let sups = all_suppliers.read();
@@ -598,10 +599,10 @@ pub fn CultivarDetail(slug: String) -> Element {
table { class: "attr-table",
thead {
tr {
th { "Supplier" }
th { "SKU" }
th { "Price" }
th { "Link" }
th { "{t(l, \"field.supplier\")}" }
th { "{t(l, \"field.sku\")}" }
th { "{t(l, \"field.price\")}" }
th { "{t(l, \"field.link\")}" }
}
}
tbody {
@@ -625,7 +626,7 @@ pub fn CultivarDetail(slug: String) -> Element {
td {
if let Some(ref u) = url {
if !u.is_empty() {
a { href: "{u}", target: "_blank", class: "external-link", "View" }
a { href: "{u}", target: "_blank", class: "external-link", "{t(l, \"btn.view\")}" }
} else {
span { class: "placeholder", "\u{2014}" }
}
@@ -642,7 +643,7 @@ pub fn CultivarDetail(slug: String) -> Element {
}
},
_ => rsx! {
p { class: "placeholder detail-card-empty", "\u{2014} No suppliers linked" }
p { class: "placeholder detail-card-empty", "\u{2014} {t(l, \"no_suppliers_linked\")}" }
},
}
}
@@ -655,7 +656,7 @@ pub fn CultivarDetail(slug: String) -> Element {
// Image card
if let Some(ref key) = sp_img_key {
div { class: "detail-card",
div { class: "detail-card-header", "Image" }
div { class: "detail-card-header", "{t(l, \"card.image\")}" }
div { class: "image-card-body",
img { class: "species-image", src: "/img/{key}", alt: "{c.name}" }
if let Some(ref img) = sp_primary_img {
@@ -673,7 +674,7 @@ pub fn CultivarDetail(slug: String) -> Element {
if img.caption.is_some() || img.license.is_some() {
" | "
}
a { href: "{url}", target: "_blank", class: "attribution-link", "Source" }
a { href: "{url}", target: "_blank", class: "attribution-link", "{t(l, \"field.source\")}" }
}
}
}
@@ -683,27 +684,27 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 4: Growing Information
div { class: "detail-card",
div { class: "detail-card-header", "Growing Information" }
div { class: "detail-card-header", "{t(l, \"card.growing_info\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Growing Time" }
th { "{t(l, \"field.growing_time\")}" }
td { class: if growing_time == "\u{2014}" { "placeholder" } else { "" }, "{growing_time}" }
}
tr {
th { "Planting Depth" }
th { "{t(l, \"field.planting_depth\")}" }
td { class: if planting_depth == "\u{2014}" { "placeholder" } else { "" }, "{planting_depth}" }
}
tr {
th { "Row Spacing" }
th { "{t(l, \"field.row_spacing\")}" }
td { class: if row_spacing == "\u{2014}" { "placeholder" } else { "" }, "{row_spacing}" }
}
tr {
th { "Plant Spacing" }
th { "{t(l, \"field.plant_spacing\")}" }
td { class: if plant_spacing == "\u{2014}" { "placeholder" } else { "" }, "{plant_spacing}" }
}
tr {
th { "Propagation Methods" }
th { "{t(l, \"field.propagation\")}" }
td { class: if propagation == "\u{2014}" { "placeholder" } else { "" }, "{propagation}" }
}
}
@@ -712,19 +713,19 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 5: Germination
div { class: "detail-card",
div { class: "detail-card-header", "Germination" }
div { class: "detail-card-header", "{t(l, \"card.germination\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Days to Germination" }
th { "{t(l, \"field.days_to_germ\")}" }
td { class: if days_germ == "\u{2014}" { "placeholder" } else { "" }, "{days_germ}" }
}
tr {
th { "Germination Temp" }
th { "{t(l, \"field.germ_temp\")}" }
td { class: if germ_temp == "\u{2014}" { "placeholder" } else { "" }, "{germ_temp}" }
}
tr {
th { "Light Requirement" }
th { "{t(l, \"field.light_req\")}" }
if light_req != "\u{2014}" {
td { "{light_req}" }
} else if let Some(ref fb) = sp_light_fb {
@@ -737,15 +738,15 @@ pub fn CultivarDetail(slug: String) -> Element {
}
}
tr {
th { "Stratification Required" }
th { "{t(l, \"field.stratification\")}" }
td { class: if strat_req == "\u{2014}" { "placeholder" } else { "" }, "{strat_req}" }
}
tr {
th { "Stratification Days" }
th { "{t(l, \"field.stratification_days\")}" }
td { class: if strat_days == "\u{2014}" { "placeholder" } else { "" }, "{strat_days}" }
}
tr {
th { "Scarification Required" }
th { "{t(l, \"field.scarification\")}" }
td { class: if scar_req == "\u{2014}" { "placeholder" } else { "" }, "{scar_req}" }
}
}
@@ -754,23 +755,23 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 6: Climate & Environment
div { class: "detail-card",
div { class: "detail-card-header", "Climate & Environment" }
div { class: "detail-card-header", "{t(l, \"card.climate\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Min Temp" }
th { "{t(l, \"field.min_temp\")}" }
td { class: if min_temp == "\u{2014}" { "placeholder" } else { "" }, "{min_temp}" }
}
tr {
th { "Max Temp" }
th { "{t(l, \"field.max_temp\")}" }
td { class: if max_temp == "\u{2014}" { "placeholder" } else { "" }, "{max_temp}" }
}
tr {
th { "Humidity" }
th { "{t(l, \"field.humidity\")}" }
td { class: if humidity == "\u{2014}" { "placeholder" } else { "" }, "{humidity}" }
}
tr {
th { "Light" }
th { "{t(l, \"field.light\")}" }
if light != "\u{2014}" {
td { "{light}" }
} else if let Some(ref fb) = sp_light_fb {
@@ -783,7 +784,7 @@ pub fn CultivarDetail(slug: String) -> Element {
}
}
tr {
th { "Frost Tolerance" }
th { "{t(l, \"field.frost_tolerance\")}" }
if frost_tol != "\u{2014}" {
td { "{frost_tol}" }
} else if let Some(ref fb) = sp_frost_fb {
@@ -796,16 +797,16 @@ pub fn CultivarDetail(slug: String) -> Element {
}
}
tr {
th { "Min Light Hours/Day" }
th { "{t(l, \"field.min_light_hours\")}" }
td { class: if min_light_h == "\u{2014}" { "placeholder" } else { "" }, "{min_light_h}" }
}
tr {
th { "Optimal Light Hours/Day" }
th { "{t(l, \"field.opt_light_hours\")}" }
td { class: if opt_light_h == "\u{2014}" { "placeholder" } else { "" }, "{opt_light_h}" }
}
if let Some(ref fb) = sp_drought_fb {
tr {
th { "Drought Tolerance" }
th { "{t(l, \"field.drought_tolerance\")}" }
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
@@ -814,7 +815,7 @@ pub fn CultivarDetail(slug: String) -> Element {
}
if let Some(ref fb) = sp_usda_fb {
tr {
th { "USDA Hardiness Zone" }
th { "{t(l, \"field.usda_hardiness_zone\")}" }
td {
span { class: "estimated-value", "{fb}" }
span { class: "estimated-tag", "~ species" }
@@ -827,7 +828,7 @@ pub fn CultivarDetail(slug: String) -> Element {
// Card 7: Species Information
div { class: "detail-card",
div { class: "detail-card-header", "Species Information" }
div { class: "detail-card-header", "{t(l, \"card.species_info\")}" }
{
let sp_read = species_data.read();
match &*sp_read {
@@ -841,8 +842,8 @@ pub fn CultivarDetail(slug: String) -> Element {
let sp_layer = opt_str(&sp.plant_layer);
let sp_drought = opt_str(&sp.drought_tolerance);
let sp_usda = opt_str(&sp.hardiness_zone_usda);
let sp_nfix = opt_bool(sp.nitrogen_fixer);
let sp_dynacc = opt_bool(sp.dynamic_accumulator);
let sp_nfix = opt_bool_t(sp.nitrogen_fixer, l);
let sp_dynacc = opt_bool_t(sp.dynamic_accumulator, l);
let sp_food = pick_desc(&current_lang, &sp.food_uses_de, &sp.food_uses_en, &sp.food_uses);
let sp_med = pick_desc(&current_lang, &sp.medicinal_uses_de, &sp.medicinal_uses_en, &sp.medicinal_uses);
let sp_other = pick_desc(&current_lang, &sp.other_uses_de, &sp.other_uses_en, &sp.other_uses);
@@ -852,47 +853,47 @@ pub fn CultivarDetail(slug: String) -> Element {
table { class: "attr-table",
tbody {
tr {
th { "Plant Layer" }
th { "{t(l, \"field.plant_layer\")}" }
td { class: if sp_layer == "\u{2014}" { "placeholder" } else { "" }, "{sp_layer}" }
}
tr {
th { "Drought Tolerance" }
th { "{t(l, \"field.drought_tolerance\")}" }
td { class: if sp_drought == "\u{2014}" { "placeholder" } else { "" }, "{sp_drought}" }
}
tr {
th { "USDA Zone" }
th { "{t(l, \"field.usda_zone\")}" }
td { class: if sp_usda == "\u{2014}" { "placeholder" } else { "" }, "{sp_usda}" }
}
tr {
th { "pH Range" }
th { "{t(l, \"field.ph_range\")}" }
td { class: if ph_range == "\u{2014}" { "placeholder" } else { "" }, "{ph_range}" }
}
tr {
th { "Nitrogen Fixer" }
th { "{t(l, \"field.nitrogen_fixer\")}" }
td { class: if sp_nfix == "\u{2014}" { "placeholder" } else { "" }, "{sp_nfix}" }
}
tr {
th { "Dynamic Accumulator" }
th { "{t(l, \"field.dynamic_acc\")}" }
td { class: if sp_dynacc == "\u{2014}" { "placeholder" } else { "" }, "{sp_dynacc}" }
}
tr {
th { "Food Uses" }
th { "{t(l, \"field.food_uses\")}" }
td { class: if sp_food == "\u{2014}" { "placeholder" } else { "" }, "{sp_food}" }
}
tr {
th { "Medicinal Uses" }
th { "{t(l, \"field.medicinal_uses\")}" }
td { class: if sp_med == "\u{2014}" { "placeholder" } else { "" }, "{sp_med}" }
}
tr {
th { "Other Uses" }
th { "{t(l, \"field.other_uses\")}" }
td { class: if sp_other == "\u{2014}" { "placeholder" } else { "" }, "{sp_other}" }
}
tr {
th { "Wildlife Value" }
th { "{t(l, \"field.wildlife_value\")}" }
td { class: if sp_wildlife == "\u{2014}" { "placeholder" } else { "" }, "{sp_wildlife}" }
}
tr {
th { "Native Range" }
th { "{t(l, \"field.native_range\")}" }
td { class: if sp_native == "\u{2014}" { "placeholder" } else { "" }, "{sp_native}" }
}
}
@@ -900,7 +901,7 @@ pub fn CultivarDetail(slug: String) -> Element {
}
},
_ => rsx! {
p { class: "placeholder detail-card-empty", "Loading species data\u{2026}" }
p { class: "placeholder detail-card-empty", "{t(l, \"loading_species_data\")}" }
},
}
}
@@ -910,9 +911,7 @@ pub fn CultivarDetail(slug: String) -> Element {
if has_any_fallback {
p { class: "species-fallback-note",
"Values marked "
span { class: "estimated-tag", "~ species" }
" are estimated from species-level data."
"{t(l, \"estimated_note\")}"
}
}
}
+19 -16
View File
@@ -3,7 +3,7 @@ use dioxus::prelude::*;
use crate::api;
use crate::app::{Lang, Route};
use crate::components::table_controls::*;
use crate::i18n::pick_name;
use crate::i18n::{pick_name, t};
const STORAGE_KEY_COLS: &str = "herbapi_families_cols";
const STORAGE_KEY_PP: &str = "herbapi_families_pp";
@@ -38,16 +38,17 @@ pub fn FamilyList() -> Element {
});
let current_page = *page.read();
let l = lang.read().clone();
rsx! {
div { class: "page",
h1 { "Plant Families" }
h1 { "{t(&l, \"page.families\")}" }
div { class: "table-toolbar",
div { class: "search-bar",
input {
r#type: "text",
placeholder: "Search families...",
placeholder: "{t(&l, \"search.placeholder_families\")}",
value: "{search}",
oninput: move |e| {
search.set(e.value());
@@ -69,30 +70,30 @@ pub fn FamilyList() -> Element {
}
match &*families.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => {
let vis = visible_cols.read();
rsx! {
p { class: "result-count", "{data.total} families" }
p { class: "result-count", "{data.total} {t(&l, \"nav.families\").to_lowercase()}" }
div { class: "table-wrap",
table {
thead {
tr {
if is_col_visible(&vis, "name_scientific") {
th { "Scientific Name" }
th { "{t(&l, \"field.scientific_name\")}" }
}
if is_col_visible(&vis, "common_name") {
th { "Common Name" }
th { "{t(&l, \"field.common_name\")}" }
}
if is_col_visible(&vis, "name_en") {
th { "English" }
th { "{t(&l, \"field.name_en\")}" }
}
if is_col_visible(&vis, "name_de") {
th { "German" }
th { "{t(&l, \"field.name_de\")}" }
}
if is_col_visible(&vis, "description") {
th { "Description" }
th { "{t(&l, \"field.description\")}" }
}
}
}
@@ -135,13 +136,13 @@ pub fn FamilyList() -> Element {
button {
disabled: current_page <= 1,
onclick: move |_| page.set(current_page - 1),
"Previous"
"{t(&l, \"btn.previous\")}"
}
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
span { "{t(&l, \"page\")} {current_page} {t(&l, \"of\")} {(data.total + data.per_page - 1) / data.per_page}" }
button {
disabled: current_page * data.per_page >= data.total,
onclick: move |_| page.set(current_page + 1),
"Next"
"{t(&l, \"btn.next\")}"
}
}
}
@@ -167,10 +168,12 @@ pub fn FamilyDetail(slug: String) -> Element {
async move { api::list_species(1, 100, Some(&s), None).await }
});
let l = lang.read().clone();
rsx! {
div { class: "page",
match &*family.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(f)) => {
let common = pick_name(&lang.read(), &f.name_de, &f.name_en, &f.name_scientific);
@@ -186,9 +189,9 @@ pub fn FamilyDetail(slug: String) -> Element {
},
}
h2 { "Species in this Family" }
h2 { "{t(&l, \"page.species_in_family\")}" }
match &*species.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => rsx! {
div { class: "card-grid",
+6 -5
View File
@@ -3,23 +3,24 @@ use dioxus::prelude::*;
use crate::api;
use crate::app::{Lang, Route};
use crate::components::plant_card::PlantCard;
use crate::i18n::pick_name;
use crate::i18n::{pick_name, t};
#[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 });
let l = lang.read().clone();
rsx! {
div { class: "page home-page",
h1 { "HerbAPI" }
p { class: "subtitle", "Trilingual plant reference database" }
p { class: "subtitle", "{t(&l, \"subtitle\")}" }
div { class: "search-bar",
input {
r#type: "text",
placeholder: "Search plants...",
placeholder: "{t(&l, \"search.placeholder\")}",
value: "{search_query}",
oninput: move |e| search_query.set(e.value()),
onkeydown: move |e| {
@@ -31,9 +32,9 @@ pub fn Home() -> Element {
}
}
h2 { "Recent Species" }
h2 { "{t(&l, \"page.recent_species\")}" }
match &*species.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => rsx! {
div { class: "card-grid",
+9 -5
View File
@@ -1,10 +1,12 @@
use dioxus::prelude::*;
use crate::api;
use crate::app::Route;
use crate::app::{Lang, Route};
use crate::i18n::t;
#[component]
pub fn SearchPage() -> Element {
let lang = use_context::<Lang>().0;
let mut query = use_signal(|| String::new());
let mut results = use_signal(|| None::<Result<Vec<crate::types::SearchResult>, String>>);
@@ -18,14 +20,16 @@ pub fn SearchPage() -> Element {
}
};
let l = lang.read().clone();
rsx! {
div { class: "page search-page",
h1 { "Search" }
h1 { "{t(&l, \"page.search\")}" }
div { class: "search-bar",
input {
r#type: "text",
placeholder: "Search plants, families, cultivars...",
placeholder: "{t(&l, \"search.placeholder_full\")}",
value: "{query}",
oninput: move |e| query.set(e.value()),
onkeydown: move |e| {
@@ -34,14 +38,14 @@ pub fn SearchPage() -> Element {
}
},
}
button { onclick: move |_| trigger_search(), "Search" }
button { onclick: move |_| trigger_search(), "{t(&l, \"btn.search\")}" }
}
match &*results.read() {
None => rsx! {},
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => rsx! {
p { class: "result-count", "{data.len()} results" }
p { class: "result-count", "{data.len()} {t(&l, \"results\")}" }
div { class: "search-results",
for r in data.iter() {
div { class: "search-result",
+10 -4
View File
@@ -1,5 +1,8 @@
use dioxus::prelude::*;
use crate::app::Lang;
use crate::i18n::t;
struct DataSource {
name: &'static str,
url: &'static str,
@@ -83,11 +86,14 @@ const SOURCES: &[DataSource] = &[
#[component]
pub fn Sources() -> Element {
let lang = use_context::<Lang>().0;
let l = lang.read().clone();
rsx! {
div { class: "page sources-page",
h1 { "Data Sources" }
h1 { "{t(&l, \"page.sources\")}" }
p { class: "sources-intro",
"HerbAPI aggregates plant data from multiple open sources. We are grateful to these projects for making botanical knowledge freely available."
"{t(&l, \"sources.intro\")}"
}
div { class: "sources-grid",
for source in SOURCES.iter() {
@@ -105,11 +111,11 @@ pub fn Sources() -> Element {
p { class: "source-description", "{source.description}" }
div { class: "source-details",
div { class: "source-detail",
span { class: "source-detail-label", "Data used" }
span { class: "source-detail-label", "{t(&l, \"field.data_used\")}" }
span { class: "source-detail-value", "{source.data_used}" }
}
div { class: "source-detail",
span { class: "source-detail-label", "License" }
span { class: "source-detail-label", "{t(&l, \"field.license\")}" }
span { class: "source-detail-value source-license", "{source.license}" }
}
}
+105 -101
View File
@@ -7,7 +7,7 @@ use crate::api;
use crate::app::{Lang, Route};
use crate::components::plant_card::PlantCard;
use crate::components::table_controls::*;
use crate::i18n::{pick_desc, pick_name};
use crate::i18n::{pick_desc, pick_name, t};
const STORAGE_KEY_COLS: &str = "herbapi_species_cols";
const STORAGE_KEY_PP: &str = "herbapi_species_pp";
@@ -93,16 +93,17 @@ pub fn SpeciesList() -> Element {
let current_page = *page.read();
let is_table = *table_view.read();
let l = lang.read().clone();
rsx! {
div { class: "page",
h1 { "Species" }
h1 { "{t(&l, \"page.species\")}" }
div { class: "table-toolbar",
div { class: "search-bar",
input {
r#type: "text",
placeholder: "Search species...",
placeholder: "{t(&l, \"search.placeholder_species\")}",
value: "{search}",
oninput: move |e| {
search.set(e.value());
@@ -118,7 +119,7 @@ pub fn SpeciesList() -> Element {
table_view.set(new_val);
let _ = LocalStorage::set(STORAGE_KEY_VIEW, new_val);
},
if is_table { "Card View" } else { "Table View" }
if is_table { "{t(&l, \"btn.card_view\")}" } else { "{t(&l, \"btn.table_view\")}" }
}
PerPageSelector {
per_page: per_page,
@@ -131,14 +132,14 @@ pub fn SpeciesList() -> Element {
// Filter bar
div { class: "filter-bar",
div { class: "filter-group",
label { "Layer" }
label { "{t(&l, \"filter.layer\")}" }
select {
value: "{filter_layer}",
onchange: move |e| {
filter_layer.set(e.value());
page.set(1);
},
option { value: "", "All" }
option { value: "", "{t(&l, \"filter.all\")}" }
option { value: "canopy", "Canopy" }
option { value: "understory", "Understory" }
option { value: "shrub", "Shrub" }
@@ -149,14 +150,14 @@ pub fn SpeciesList() -> Element {
}
}
div { class: "filter-group",
label { "Drought Tol." }
label { "{t(&l, \"filter.drought_tol\")}" }
select {
value: "{filter_drought}",
onchange: move |e| {
filter_drought.set(e.value());
page.set(1);
},
option { value: "", "All" }
option { value: "", "{t(&l, \"filter.all\")}" }
option { value: "none", "None" }
option { value: "low", "Low" }
option { value: "moderate", "Moderate" }
@@ -174,7 +175,7 @@ pub fn SpeciesList() -> Element {
page.set(1);
},
}
"N-Fixer"
"{t(&l, \"field.n_fixer\")}"
}
}
div { class: "filter-group filter-checkbox",
@@ -187,7 +188,7 @@ pub fn SpeciesList() -> Element {
page.set(1);
},
}
"Dyn. Accumulator"
"{t(&l, \"field.dyn_accum\")}"
}
}
}
@@ -201,7 +202,7 @@ pub fn SpeciesList() -> Element {
}
match &*species.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => {
let fmap_read = family_map.read();
@@ -212,7 +213,7 @@ pub fn SpeciesList() -> Element {
};
rsx! {
p { class: "result-count", "{data.total} species" }
p { class: "result-count", "{data.total} {t(&l, \"nav.species\").to_lowercase()}" }
if is_table {
{
@@ -223,49 +224,49 @@ pub fn SpeciesList() -> Element {
thead {
tr {
if is_col_visible(&vis, "name_scientific") {
th { "Scientific Name" }
th { "{t(&l, \"field.scientific_name\")}" }
}
if is_col_visible(&vis, "common_name") {
th { "Common Name" }
th { "{t(&l, \"field.common_name\")}" }
}
if is_col_visible(&vis, "name_de") {
th { "German" }
th { "{t(&l, \"field.name_de\")}" }
}
if is_col_visible(&vis, "name_en") {
th { "English" }
th { "{t(&l, \"field.name_en\")}" }
}
if is_col_visible(&vis, "family") {
th { "Family" }
th { "{t(&l, \"field.family\")}" }
}
if is_col_visible(&vis, "plant_layer") {
th { "Layer" }
th { "{t(&l, \"field.layer\")}" }
}
if is_col_visible(&vis, "nitrogen_fixer") {
th { "N-Fixer" }
th { "{t(&l, \"field.n_fixer\")}" }
}
if is_col_visible(&vis, "dynamic_accumulator") {
th { "Dyn. Accum." }
th { "{t(&l, \"field.dyn_accum\")}" }
}
if is_col_visible(&vis, "food_uses") {
th { "Food Uses" }
th { "{t(&l, \"field.food_uses\")}" }
}
if is_col_visible(&vis, "edibility_rating") {
th { "Edibility" }
th { "{t(&l, \"field.edibility\")}" }
}
if is_col_visible(&vis, "drought_tolerance") {
th { "Drought Tol." }
th { "{t(&l, \"field.drought_tol\")}" }
}
if is_col_visible(&vis, "hardiness_zone_usda") {
th { "USDA Zone" }
th { "{t(&l, \"field.usda_zone\")}" }
}
if is_col_visible(&vis, "nectar_value") {
th { "Nectar" }
th { "{t(&l, \"field.nectar\")}" }
}
if is_col_visible(&vis, "wild_bee_count") {
th { "Wild Bees" }
th { "{t(&l, \"field.wild_bees\")}" }
}
if is_col_visible(&vis, "native_status") {
th { "Native Status" }
th { "{t(&l, \"field.native_status\")}" }
}
}
}
@@ -301,7 +302,7 @@ pub fn SpeciesList() -> Element {
if is_col_visible(&vis, "nitrogen_fixer") {
td {
match s.nitrogen_fixer {
Some(true) => rsx! { span { class: "badge organic", "Yes" } },
Some(true) => rsx! { span { class: "badge organic", "{t(&l, \"yes\")}" } },
Some(false) => rsx! { "-" },
None => rsx! { "-" },
}
@@ -310,7 +311,7 @@ pub fn SpeciesList() -> Element {
if is_col_visible(&vis, "dynamic_accumulator") {
td {
match s.dynamic_accumulator {
Some(true) => rsx! { span { class: "badge organic", "Yes" } },
Some(true) => rsx! { span { class: "badge organic", "{t(&l, \"yes\")}" } },
Some(false) => rsx! { "-" },
None => rsx! { "-" },
}
@@ -401,13 +402,13 @@ pub fn SpeciesList() -> Element {
button {
disabled: current_page <= 1,
onclick: move |_| page.set(current_page - 1),
"Previous"
"{t(&l, \"btn.previous\")}"
}
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
span { "{t(&l, \"page\")} {current_page} {t(&l, \"of\")} {(data.total + data.per_page - 1) / data.per_page}" }
button {
disabled: current_page * data.per_page >= data.total,
onclick: move |_| page.set(current_page + 1),
"Next"
"{t(&l, \"btn.next\")}"
}
}
}
@@ -461,11 +462,12 @@ pub fn SpeciesDetail(slug: String) -> Element {
rsx! {
div { class: "page species-detail",
match &*species.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&lang.read(), \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(s)) => {
let species_slug = s.slug.clone();
let current_lang = lang.read().clone();
let l = &*current_lang;
// Helper closures to format fields
let os = |v: &Option<String>| -> String {
@@ -476,8 +478,8 @@ pub fn SpeciesDetail(slug: String) -> Element {
};
let ob = |v: Option<bool>| -> String {
match v {
Some(true) => "Yes".to_string(),
Some(false) => "No".to_string(),
Some(true) => t(l, "yes").to_string(),
Some(false) => t(l, "no").to_string(),
None => "\u{2014}".to_string(),
}
};
@@ -565,27 +567,27 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 1: Species Details
div { class: "detail-card",
div { class: "detail-card-header", "Species Details" }
div { class: "detail-card-header", "{t(l, \"card.species_details\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Scientific Name" }
th { "{t(l, \"field.scientific_name\")}" }
td { em { "{s.name_scientific}" } }
}
tr {
th { "Common Name" }
th { "{t(l, \"field.common_name\")}" }
td { "{common_name}" }
}
tr {
th { "Name EN" }
th { "{t(l, \"field.name_en\")}" }
td { class: if name_en == em { "placeholder" } else { "" }, "{name_en}" }
}
tr {
th { "Name DE" }
th { "{t(l, \"field.name_de\")}" }
td { class: if name_de == em { "placeholder" } else { "" }, "{name_de}" }
}
tr {
th { "Family" }
th { "{t(l, \"field.family\")}" }
td {
if let Some(Some(ref fam)) = *family_data.read() {
Link { to: Route::FamilyDetail { slug: fam.slug.clone() },
@@ -597,7 +599,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
}
tr {
th { "Description" }
th { "{t(l, \"field.description\")}" }
td { class: if desc == em { "placeholder" } else { "" }, "{desc}" }
}
}
@@ -606,23 +608,23 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 2: Uses
div { class: "detail-card",
div { class: "detail-card-header", "Uses" }
div { class: "detail-card-header", "{t(l, \"card.uses\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Food Uses" }
th { "{t(l, \"field.food_uses\")}" }
td { class: if food == em { "placeholder" } else { "" }, "{food}" }
}
tr {
th { "Medicinal Uses" }
th { "{t(l, \"field.medicinal_uses\")}" }
td { class: if med == em { "placeholder" } else { "" }, "{med}" }
}
tr {
th { "Other Uses" }
th { "{t(l, \"field.other_uses\")}" }
td { class: if other == em { "placeholder" } else { "" }, "{other}" }
}
tr {
th { "Edibility Rating" }
th { "{t(l, \"field.edibility\")}" }
td { class: if edibility == em { "placeholder" } else { "" }, "{edibility}" }
}
}
@@ -631,27 +633,27 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 3: Ecology
div { class: "detail-card",
div { class: "detail-card-header", "Ecology" }
div { class: "detail-card-header", "{t(l, \"card.ecology\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Plant Layer" }
th { "{t(l, \"field.plant_layer\")}" }
td { class: if layer == em { "placeholder" } else { "" }, "{layer}" }
}
tr {
th { "Succession Stage" }
th { "{t(l, \"field.succession\")}" }
td { class: if succession == em { "placeholder" } else { "" }, "{succession}" }
}
tr {
th { "Wildlife Value" }
th { "{t(l, \"field.wildlife_value\")}" }
td { class: if wildlife == em { "placeholder" } else { "" }, "{wildlife}" }
}
tr {
th { "Native Range" }
th { "{t(l, \"field.native_range\")}" }
td { class: if native == em { "placeholder" } else { "" }, "{native}" }
}
tr {
th { "Pollination Type" }
th { "{t(l, \"field.pollination\")}" }
td { class: if pollination == em { "placeholder" } else { "" }, "{pollination}" }
}
}
@@ -665,7 +667,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Image card
if let Some(ref key) = img_key {
div { class: "detail-card",
div { class: "detail-card-header", "Image" }
div { class: "detail-card-header", "{t(l, \"card.image\")}" }
div { class: "image-card-body",
img { class: "species-image", src: "/img/{key}", alt: "{s.name_scientific}" }
if let Some(ref img) = primary_img {
@@ -683,7 +685,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
if img.caption.is_some() || img.license.is_some() {
" | "
}
a { href: "{url}", target: "_blank", class: "attribution-link", "Source" }
a { href: "{url}", target: "_blank", class: "attribution-link", "{t(l, \"field.source\")}" }
}
}
}
@@ -693,31 +695,31 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 4: Growing Requirements
div { class: "detail-card",
div { class: "detail-card-header", "Growing Requirements" }
div { class: "detail-card-header", "{t(l, \"card.growing_requirements\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Soil Moisture" }
th { "{t(l, \"field.soil_moisture\")}" }
td { class: if soil_moist == em { "placeholder" } else { "" }, "{soil_moist}" }
}
tr {
th { "pH Range" }
th { "{t(l, \"field.ph_range\")}" }
td { class: if ph_range == em { "placeholder" } else { "" }, "{ph_range}" }
}
tr {
th { "Drought Tolerance" }
th { "{t(l, \"field.drought_tolerance\")}" }
td { class: if drought == em { "placeholder" } else { "" }, "{drought}" }
}
tr {
th { "Salt Tolerance" }
th { "{t(l, \"field.salt_tolerance\")}" }
td { class: if salt == em { "placeholder" } else { "" }, "{salt}" }
}
tr {
th { "USDA Zone" }
th { "{t(l, \"field.usda_zone\")}" }
td { class: if usda == em { "placeholder" } else { "" }, "{usda}" }
}
tr {
th { "AT Zone" }
th { "{t(l, \"field.at_zone\")}" }
td { class: if at_zone == em { "placeholder" } else { "" }, "{at_zone}" }
}
}
@@ -726,39 +728,39 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 5: Permaculture
div { class: "detail-card",
div { class: "detail-card-header", "Permaculture" }
div { class: "detail-card-header", "{t(l, \"card.permaculture\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Nitrogen Fixer" }
th { "{t(l, \"field.nitrogen_fixer\")}" }
td { class: if n_fixer == em { "placeholder" } else { "" }, "{n_fixer}" }
}
tr {
th { "Dynamic Accumulator" }
th { "{t(l, \"field.dynamic_acc\")}" }
td { class: if dyn_acc == em { "placeholder" } else { "" }, "{dyn_acc}" }
}
tr {
th { "Attracts Pollinators" }
th { "{t(l, \"field.attracts_pollinators\")}" }
td { class: if pollinators == em { "placeholder" } else { "" }, "{pollinators}" }
}
tr {
th { "Attracts Beneficial Insects" }
th { "{t(l, \"field.attracts_beneficial\")}" }
td { class: if beneficial == em { "placeholder" } else { "" }, "{beneficial}" }
}
tr {
th { "Mulch Plant" }
th { "{t(l, \"field.mulch_plant\")}" }
td { class: if mulch == em { "placeholder" } else { "" }, "{mulch}" }
}
tr {
th { "Ground Cover Quality" }
th { "{t(l, \"field.ground_cover_quality\")}" }
td { class: if gc_quality == em { "placeholder" } else { "" }, "{gc_quality}" }
}
tr {
th { "Allelopathic" }
th { "{t(l, \"field.allelopathic\")}" }
td { class: if allelo == em { "placeholder" } else { "" }, "{allelo}" }
}
tr {
th { "Guild Role" }
th { "{t(l, \"field.guild_role\")}" }
td { class: if guild == em { "placeholder" } else { "" }, "{guild}" }
}
}
@@ -767,11 +769,11 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 6: Wildlife & Ecology
div { class: "detail-card",
div { class: "detail-card-header", "Wildlife & Ecology" }
div { class: "detail-card-header", "{t(l, \"card.wildlife\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Nectar Value" }
th { "{t(l, \"field.nectar\")}" }
td {
match s.nectar_value {
Some(v) => rsx! {
@@ -788,7 +790,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
}
tr {
th { "Pollen Value" }
th { "{t(l, \"field.pollen\")}" }
td {
match s.pollen_value {
Some(v) => rsx! {
@@ -805,69 +807,69 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
}
tr {
th { "Wild Bees" }
th { "{t(l, \"field.wild_bees\")}" }
td {
match (s.wild_bee_count, s.wild_bee_specialist_count) {
(Some(total), Some(spec)) => rsx! { "{total} species ({spec} specialists)" },
(Some(total), None) => rsx! { "{total} species" },
(Some(total), Some(spec)) => rsx! { "{total} {t(l, \"species_species\")} ({spec} specialists)" },
(Some(total), None) => rsx! { "{total} {t(l, \"species_species\")}" },
_ => rsx! { span { class: "placeholder", "\u{2014}" } },
}
}
}
tr {
th { "Butterflies & Moths" }
th { "{t(l, \"field.butterflies\")}" }
td {
match (s.butterfly_moth_count, s.caterpillar_host_count) {
(Some(bm), Some(ch)) => rsx! { "{bm} species ({ch} caterpillar hosts)" },
(Some(bm), None) => rsx! { "{bm} species" },
(Some(bm), Some(ch)) => rsx! { "{bm} {t(l, \"species_species\")} ({ch} caterpillar hosts)" },
(Some(bm), None) => rsx! { "{bm} {t(l, \"species_species\")}" },
_ => rsx! { span { class: "placeholder", "\u{2014}" } },
}
}
}
if s.caterpillar_specialist_count.is_some() {
tr {
th { "Caterpillar Specialists" }
th { "{t(l, \"field.caterpillar_specialists\")}" }
td { "{s.caterpillar_specialist_count.unwrap()}" }
}
}
tr {
th { "Hoverflies" }
th { "{t(l, \"field.hoverflies\")}" }
td {
match s.hoverfly_count {
Some(v) => rsx! { "{v} species" },
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
None => rsx! { span { class: "placeholder", "\u{2014}" } },
}
}
}
tr {
th { "Beetles" }
th { "{t(l, \"field.beetles\")}" }
td {
match s.beetle_count {
Some(v) => rsx! { "{v} species" },
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
None => rsx! { span { class: "placeholder", "\u{2014}" } },
}
}
}
tr {
th { "Birds" }
th { "{t(l, \"field.birds\")}" }
td {
match s.bird_count {
Some(v) => rsx! { "{v} species" },
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
None => rsx! { span { class: "placeholder", "\u{2014}" } },
}
}
}
tr {
th { "Mammals" }
th { "{t(l, \"field.mammals\")}" }
td {
match s.mammal_count {
Some(v) => rsx! { "{v} species" },
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
None => rsx! { span { class: "placeholder", "\u{2014}" } },
}
}
}
tr {
th { "Native Status" }
th { "{t(l, \"field.native_status\")}" }
td {
match &s.native_status {
Some(ns) if !ns.is_empty() => {
@@ -884,7 +886,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
}
tr {
th { "NaturaDB Tags" }
th { "{t(l, \"field.naturadb_tags\")}" }
td {
match &s.naturadb_tags {
Some(tags) if !tags.is_empty() => {
@@ -907,11 +909,11 @@ pub fn SpeciesDetail(slug: String) -> Element {
// Card 7: External Links
div { class: "detail-card",
div { class: "detail-card-header", "External Links" }
div { class: "detail-card-header", "{t(l, \"card.external_links\")}" }
table { class: "attr-table",
tbody {
tr {
th { "Wikidata QID" }
th { "{t(l, \"field.wikidata_qid\")}" }
td {
if let Some(ref q) = qid {
if !q.is_empty() {
@@ -925,7 +927,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
}
tr {
th { "GBIF ID" }
th { "{t(l, \"field.gbif_id\")}" }
td {
if let Some(ref g) = gbif {
if !g.is_empty() {
@@ -939,15 +941,15 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
}
tr {
th { "EPPO Code" }
th { "{t(l, \"field.eppo_code\")}" }
td { class: if eppo == em { "placeholder" } else { "" }, "{eppo}" }
}
tr {
th { "PFAF URL" }
th { "{t(l, \"field.pfaf_url\")}" }
td {
if let Some(ref u) = pfaf {
if !u.is_empty() {
a { href: "{u}", target: "_blank", class: "external-link", "View on PFAF" }
a { href: "{u}", target: "_blank", class: "external-link", "{t(l, \"field.view_on_pfaf\")}" }
} else {
span { class: "placeholder", "\u{2014}" }
}
@@ -963,7 +965,7 @@ pub fn SpeciesDetail(slug: String) -> Element {
}
// Cultivars for this species (below the two-column layout)
h2 { "Cultivars" }
h2 { "{t(l, \"card.cultivars\")}" }
CultivarListForSpecies { species_slug: species_slug }
}
},
@@ -981,13 +983,15 @@ fn CultivarListForSpecies(species_slug: String) -> Element {
async move { api::list_cultivars(1, 100, Some(&s), None).await }
});
let l = lang.read().clone();
rsx! {
match &*cultivars.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => {
if data.data.is_empty() {
rsx! { p { class: "empty", "No cultivars yet." } }
rsx! { p { class: "empty", "{t(&l, \"no_cultivars\")}" } }
} else {
rsx! {
div { class: "card-grid",
@@ -1004,7 +1008,7 @@ fn CultivarListForSpecies(species_slug: String) -> Element {
p { class: "card-common", "{common}" }
}
if c.is_organic {
span { class: "badge organic", "Organic" }
span { class: "badge organic", "{t(&l, \"field.organic\")}" }
}
}
}
+19 -12
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::t;
const STORAGE_KEY_COLS: &str = "herbapi_suppliers_cols";
@@ -19,14 +20,17 @@ fn supplier_columns() -> Vec<ColumnDef> {
#[component]
pub fn SupplierList() -> Element {
let lang = use_context::<Lang>().0;
let columns = supplier_columns();
let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &supplier_columns()));
let suppliers = use_resource(|| async { api::list_suppliers().await });
let l = lang.read().clone();
rsx! {
div { class: "page",
h1 { "Suppliers" }
h1 { "{t(&l, \"page.suppliers\")}" }
ColumnToggle {
columns: columns.clone(),
@@ -35,33 +39,33 @@ pub fn SupplierList() -> Element {
}
match &*suppliers.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(data)) => {
let vis = visible_cols.read();
rsx! {
p { class: "result-count", "{data.len()} suppliers" }
p { class: "result-count", "{data.len()} {t(&l, \"nav.suppliers\").to_lowercase()}" }
div { class: "table-wrap",
table {
thead {
tr {
if is_col_visible(&vis, "name") {
th { "Name" }
th { "{t(&l, \"field.name\")}" }
}
if is_col_visible(&vis, "country") {
th { "Country" }
th { "{t(&l, \"field.country\")}" }
}
if is_col_visible(&vis, "organic") {
th { "Organic" }
th { "{t(&l, \"field.organic\")}" }
}
if is_col_visible(&vis, "demeter") {
th { "Demeter" }
}
if is_col_visible(&vis, "website") {
th { "Website" }
th { "{t(&l, \"field.website\")}" }
}
if is_col_visible(&vis, "notes") {
th { "Notes" }
th { "{t(&l, \"field.notes\")}" }
}
}
}
@@ -81,7 +85,7 @@ pub fn SupplierList() -> Element {
if is_col_visible(&vis, "organic") {
td {
if s.is_organic {
span { class: "badge organic", "Organic" }
span { class: "badge organic", "{t(&l, \"field.organic\")}" }
} else {
"-"
}
@@ -127,16 +131,19 @@ pub fn SupplierList() -> Element {
#[component]
pub fn SupplierDetail(slug: String) -> Element {
let lang = use_context::<Lang>().0;
let slug_clone = slug.clone();
let supplier = use_resource(move || {
let s = slug_clone.clone();
async move { api::get_supplier(&s).await }
});
let l = lang.read().clone();
rsx! {
div { class: "page",
match &*supplier.read() {
None => rsx! { p { "Loading..." } },
None => rsx! { p { "{t(&l, \"loading\")}" } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(s)) => rsx! {
h1 { "{s.name}" }
@@ -145,7 +152,7 @@ pub fn SupplierDetail(slug: String) -> Element {
}
div { class: "badges",
if s.is_organic {
span { class: "badge organic", "Organic" }
span { class: "badge organic", "{t(&l, \"field.organic\")}" }
}
if s.is_demeter {
span { class: "badge demeter", "Demeter" }