1026 lines
61 KiB
Rust
1026 lines
61 KiB
Rust
use dioxus::prelude::*;
|
|
use gloo_storage::{LocalStorage, Storage};
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
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, t, t_val};
|
|
|
|
const STORAGE_KEY_COLS: &str = "herbapi_species_cols";
|
|
const STORAGE_KEY_PP: &str = "herbapi_species_pp";
|
|
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: "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 },
|
|
ColumnDef { key: "nitrogen_fixer", label: "N-Fixer", default_visible: true },
|
|
ColumnDef { key: "dynamic_accumulator", label: "Dyn. Accum.", default_visible: true },
|
|
ColumnDef { key: "food_uses", label: "Food Uses", default_visible: false },
|
|
ColumnDef { key: "edibility_rating", label: "Edibility", default_visible: false },
|
|
ColumnDef { key: "drought_tolerance", label: "Drought Tol.", default_visible: false },
|
|
ColumnDef { key: "hardiness_zone_usda", label: "USDA Zone", default_visible: false },
|
|
ColumnDef { key: "nectar_value", label: "Nectar", default_visible: false },
|
|
ColumnDef { key: "wild_bee_count", label: "Wild Bees", default_visible: false },
|
|
ColumnDef { key: "native_status", label: "Native Status", default_visible: false },
|
|
]
|
|
}
|
|
|
|
#[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));
|
|
let mut search = use_signal(|| String::new());
|
|
let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &species_columns()));
|
|
let mut table_view = use_signal(|| {
|
|
LocalStorage::get::<bool>(STORAGE_KEY_VIEW).unwrap_or(true)
|
|
});
|
|
|
|
// Filter signals
|
|
let mut filter_layer = use_signal(|| String::new());
|
|
let mut filter_nfixer = use_signal(|| false);
|
|
let mut filter_dynacc = use_signal(|| false);
|
|
let mut filter_drought = use_signal(|| String::new());
|
|
|
|
// Fetch family map for resolving family_id -> name
|
|
let family_map = use_resource(move || async move {
|
|
let mut map = HashMap::<Uuid, String>::new();
|
|
if let Ok(resp) = api::list_families(1, 100, None).await {
|
|
for f in resp.data {
|
|
map.insert(f.id, f.name_scientific);
|
|
}
|
|
let total_pages = (resp.total + resp.per_page - 1) / resp.per_page;
|
|
for p in 2..=total_pages {
|
|
if let Ok(r) = api::list_families(p, 100, None).await {
|
|
for f in r.data {
|
|
map.insert(f.id, f.name_scientific);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
map
|
|
});
|
|
|
|
let species = use_resource(move || {
|
|
let s = search.read().clone();
|
|
let p = *page.read();
|
|
let pp = *per_page.read();
|
|
let layer = filter_layer.read().clone();
|
|
let nfixer = *filter_nfixer.read();
|
|
let dynacc = *filter_dynacc.read();
|
|
let drought = filter_drought.read().clone();
|
|
async move {
|
|
let filters = api::SpeciesFilters {
|
|
search: if s.is_empty() { None } else { Some(s) },
|
|
plant_layer: if layer.is_empty() { None } else { Some(layer) },
|
|
nitrogen_fixer: if nfixer { Some(true) } else { None },
|
|
dynamic_accumulator: if dynacc { Some(true) } else { None },
|
|
drought_tolerance: if drought.is_empty() { None } else { Some(drought) },
|
|
..Default::default()
|
|
};
|
|
api::list_species_filtered(p, pp, &filters).await
|
|
}
|
|
});
|
|
|
|
let current_page = *page.read();
|
|
let is_table = *table_view.read();
|
|
let l = lang.read().clone();
|
|
|
|
rsx! {
|
|
div { class: "page",
|
|
h1 { "{t(&l, \"page.species\")}" }
|
|
|
|
div { class: "table-toolbar",
|
|
div { class: "search-bar",
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "{t(&l, \"search.placeholder_species\")}",
|
|
value: "{search}",
|
|
oninput: move |e| {
|
|
search.set(e.value());
|
|
page.set(1);
|
|
},
|
|
}
|
|
}
|
|
div { class: "toolbar-right",
|
|
button {
|
|
class: "view-toggle-btn",
|
|
onclick: move |_| {
|
|
let new_val = !*table_view.read();
|
|
table_view.set(new_val);
|
|
let _ = LocalStorage::set(STORAGE_KEY_VIEW, new_val);
|
|
},
|
|
if is_table { "{t(&l, \"btn.card_view\")}" } else { "{t(&l, \"btn.table_view\")}" }
|
|
}
|
|
PerPageSelector {
|
|
per_page: per_page,
|
|
page: page,
|
|
storage_key: STORAGE_KEY_PP.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter bar
|
|
div { class: "filter-bar",
|
|
div { class: "filter-group",
|
|
label { "{t(&l, \"filter.layer\")}" }
|
|
select {
|
|
value: "{filter_layer}",
|
|
onchange: move |e| {
|
|
filter_layer.set(e.value());
|
|
page.set(1);
|
|
},
|
|
option { value: "", "{t(&l, \"filter.all\")}" }
|
|
option { value: "canopy", "{t_val(&l, \"canopy\")}" }
|
|
option { value: "understory", "{t_val(&l, \"understory\")}" }
|
|
option { value: "shrub", "{t_val(&l, \"shrub\")}" }
|
|
option { value: "herbaceous", "{t_val(&l, \"herbaceous\")}" }
|
|
option { value: "ground_cover", "{t_val(&l, \"ground_cover\")}" }
|
|
option { value: "vine", "{t_val(&l, \"vine\")}" }
|
|
option { value: "root", "{t_val(&l, \"root\")}" }
|
|
}
|
|
}
|
|
div { class: "filter-group",
|
|
label { "{t(&l, \"filter.drought_tol\")}" }
|
|
select {
|
|
value: "{filter_drought}",
|
|
onchange: move |e| {
|
|
filter_drought.set(e.value());
|
|
page.set(1);
|
|
},
|
|
option { value: "", "{t(&l, \"filter.all\")}" }
|
|
option { value: "none", "{t_val(&l, \"none\")}" }
|
|
option { value: "low", "{t_val(&l, \"low\")}" }
|
|
option { value: "moderate", "{t_val(&l, \"moderate\")}" }
|
|
option { value: "high", "{t_val(&l, \"high\")}" }
|
|
option { value: "very_high", "{t_val(&l, \"very_high\")}" }
|
|
}
|
|
}
|
|
div { class: "filter-group filter-checkbox",
|
|
label {
|
|
input {
|
|
r#type: "checkbox",
|
|
checked: *filter_nfixer.read(),
|
|
onchange: move |e| {
|
|
filter_nfixer.set(e.checked());
|
|
page.set(1);
|
|
},
|
|
}
|
|
"{t(&l, \"field.n_fixer\")}"
|
|
}
|
|
}
|
|
div { class: "filter-group filter-checkbox",
|
|
label {
|
|
input {
|
|
r#type: "checkbox",
|
|
checked: *filter_dynacc.read(),
|
|
onchange: move |e| {
|
|
filter_dynacc.set(e.checked());
|
|
page.set(1);
|
|
},
|
|
}
|
|
"{t(&l, \"field.dyn_accum\")}"
|
|
}
|
|
}
|
|
}
|
|
|
|
if is_table {
|
|
ColumnToggle {
|
|
columns: columns.clone(),
|
|
visible: visible_cols,
|
|
storage_key: STORAGE_KEY_COLS.to_string(),
|
|
}
|
|
}
|
|
|
|
match &*species.read() {
|
|
None => rsx! { p { "{t(&l, \"loading\")}" } },
|
|
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
|
Some(Ok(data)) => {
|
|
let fmap_read = family_map.read();
|
|
let empty_map: HashMap<Uuid, String> = HashMap::new();
|
|
let fm = match &*fmap_read {
|
|
Some(m) => m,
|
|
_ => &empty_map,
|
|
};
|
|
|
|
rsx! {
|
|
p { class: "result-count", "{data.total} {t(&l, \"nav.species\").to_lowercase()}" }
|
|
|
|
if is_table {
|
|
{
|
|
let vis = visible_cols.read();
|
|
rsx! {
|
|
div { class: "table-wrap",
|
|
table {
|
|
thead {
|
|
tr {
|
|
if is_col_visible(&vis, "name_scientific") {
|
|
th { "{t(&l, \"field.scientific_name\")}" }
|
|
}
|
|
if is_col_visible(&vis, "common_name") {
|
|
th { "{t(&l, \"field.common_name\")}" }
|
|
}
|
|
if is_col_visible(&vis, "name_de") {
|
|
th { "{t(&l, \"field.name_de\")}" }
|
|
}
|
|
if is_col_visible(&vis, "name_en") {
|
|
th { "{t(&l, \"field.name_en\")}" }
|
|
}
|
|
if is_col_visible(&vis, "family") {
|
|
th { "{t(&l, \"field.family\")}" }
|
|
}
|
|
if is_col_visible(&vis, "plant_layer") {
|
|
th { "{t(&l, \"field.layer\")}" }
|
|
}
|
|
if is_col_visible(&vis, "nitrogen_fixer") {
|
|
th { "{t(&l, \"field.n_fixer\")}" }
|
|
}
|
|
if is_col_visible(&vis, "dynamic_accumulator") {
|
|
th { "{t(&l, \"field.dyn_accum\")}" }
|
|
}
|
|
if is_col_visible(&vis, "food_uses") {
|
|
th { "{t(&l, \"field.food_uses\")}" }
|
|
}
|
|
if is_col_visible(&vis, "edibility_rating") {
|
|
th { "{t(&l, \"field.edibility\")}" }
|
|
}
|
|
if is_col_visible(&vis, "drought_tolerance") {
|
|
th { "{t(&l, \"field.drought_tol\")}" }
|
|
}
|
|
if is_col_visible(&vis, "hardiness_zone_usda") {
|
|
th { "{t(&l, \"field.usda_zone\")}" }
|
|
}
|
|
if is_col_visible(&vis, "nectar_value") {
|
|
th { "{t(&l, \"field.nectar\")}" }
|
|
}
|
|
if is_col_visible(&vis, "wild_bee_count") {
|
|
th { "{t(&l, \"field.wild_bees\")}" }
|
|
}
|
|
if is_col_visible(&vis, "native_status") {
|
|
th { "{t(&l, \"field.native_status\")}" }
|
|
}
|
|
}
|
|
}
|
|
tbody {
|
|
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") {
|
|
td {
|
|
Link { to: Route::SpeciesDetail { slug: s.slug.clone() },
|
|
em { "{s.name_scientific}" }
|
|
}
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "common_name") {
|
|
td { "{common}" }
|
|
}
|
|
if is_col_visible(&vis, "name_de") {
|
|
td { "{s.name_de.as_deref().unwrap_or(\"-\")}" }
|
|
}
|
|
if is_col_visible(&vis, "name_en") {
|
|
td { "{s.name_en.as_deref().unwrap_or(\"-\")}" }
|
|
}
|
|
if is_col_visible(&vis, "family") {
|
|
td { class: "species-name", em { "{family_name}" } }
|
|
}
|
|
if is_col_visible(&vis, "plant_layer") {
|
|
td { "{s.plant_layer.as_deref().map(|v| t_val(&l, v)).unwrap_or(\"-\")}" }
|
|
}
|
|
if is_col_visible(&vis, "nitrogen_fixer") {
|
|
td {
|
|
match s.nitrogen_fixer {
|
|
Some(true) => rsx! { span { class: "badge organic", "{t(&l, \"yes\")}" } },
|
|
Some(false) => rsx! { "-" },
|
|
None => rsx! { "-" },
|
|
}
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "dynamic_accumulator") {
|
|
td {
|
|
match s.dynamic_accumulator {
|
|
Some(true) => rsx! { span { class: "badge organic", "{t(&l, \"yes\")}" } },
|
|
Some(false) => rsx! { "-" },
|
|
None => rsx! { "-" },
|
|
}
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "food_uses") {
|
|
td { class: "cell-truncated",
|
|
"{s.food_uses.as_deref().map(|u| truncate(u, 60)).unwrap_or_else(|| \"-\".to_string())}"
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "edibility_rating") {
|
|
td {
|
|
match s.edibility_rating {
|
|
Some(r) => rsx! { "{r}/5" },
|
|
None => rsx! { "-" },
|
|
}
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "drought_tolerance") {
|
|
td { "{s.drought_tolerance.as_deref().map(|v| t_val(&l, v)).unwrap_or(\"-\")}" }
|
|
}
|
|
if is_col_visible(&vis, "hardiness_zone_usda") {
|
|
td { "{s.hardiness_zone_usda.as_deref().unwrap_or(\"-\")}" }
|
|
}
|
|
if is_col_visible(&vis, "nectar_value") {
|
|
td {
|
|
match s.nectar_value {
|
|
Some(v) => rsx! { "{v}/4" },
|
|
None => rsx! { "-" },
|
|
}
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "wild_bee_count") {
|
|
td {
|
|
match s.wild_bee_count {
|
|
Some(v) => rsx! { "{v}" },
|
|
None => rsx! { "-" },
|
|
}
|
|
}
|
|
}
|
|
if is_col_visible(&vis, "native_status") {
|
|
td {
|
|
match &s.native_status {
|
|
Some(ns) if !ns.is_empty() => {
|
|
let badge_class = match ns.as_str() {
|
|
s if s.contains("Wildform") || s.contains("wildform") || s.contains("heimisch") => "native-badge native-badge-heimisch",
|
|
s if s.contains("rchäophyt") => "native-badge native-badge-archaeophyt",
|
|
s if s.contains("eophyt") => "native-badge native-badge-neophyt",
|
|
_ => "native-badge",
|
|
};
|
|
let translated = t_val(&l, ns);
|
|
rsx! { span { class: "{badge_class}", "{translated}" } }
|
|
},
|
|
_ => rsx! { "-" },
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} 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: show_common,
|
|
entity_type: "species".to_string(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if data.total > data.per_page {
|
|
div { class: "pagination",
|
|
button {
|
|
disabled: current_page <= 1,
|
|
onclick: move |_| page.set(current_page - 1),
|
|
"{t(&l, \"btn.previous\")}"
|
|
}
|
|
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),
|
|
"{t(&l, \"btn.next\")}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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();
|
|
async move { api::get_species(&s).await }
|
|
});
|
|
|
|
// Fetch family for family link
|
|
let family_data = use_resource(move || {
|
|
let sp = species.read().clone();
|
|
async move {
|
|
if let Some(Ok(s)) = sp {
|
|
// look up by iterating families
|
|
let resp = api::list_families(1, 200, None).await.ok();
|
|
if let Some(r) = resp {
|
|
for f in &r.data {
|
|
if f.id == s.family_id {
|
|
return Some(f.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
});
|
|
|
|
// Fetch images for this species
|
|
let species_images = use_resource(move || {
|
|
let sp = species.read().clone();
|
|
async move {
|
|
if let Some(Ok(s)) = sp {
|
|
api::get_images("species", s.id).await.ok().unwrap_or_default()
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
});
|
|
|
|
rsx! {
|
|
div { class: "page species-detail",
|
|
match &*species.read() {
|
|
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 {
|
|
match v {
|
|
Some(x) if !x.is_empty() => x.clone(),
|
|
_ => "\u{2014}".to_string(),
|
|
}
|
|
};
|
|
let ob = |v: Option<bool>| -> String {
|
|
match v {
|
|
Some(true) => t(l, "yes").to_string(),
|
|
Some(false) => t(l, "no").to_string(),
|
|
None => "\u{2014}".to_string(),
|
|
}
|
|
};
|
|
let ovs = |v: &Option<Vec<String>>| -> String {
|
|
match v {
|
|
Some(x) if !x.is_empty() => x.join(", "),
|
|
_ => "\u{2014}".to_string(),
|
|
}
|
|
};
|
|
|
|
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 = pick_desc(¤t_lang, &s.description_de, &s.description_en, &s.description);
|
|
|
|
// Uses
|
|
let food = pick_desc(¤t_lang, &s.food_uses_de, &s.food_uses_en, &s.food_uses);
|
|
let med = pick_desc(¤t_lang, &s.medicinal_uses_de, &s.medicinal_uses_en, &s.medicinal_uses);
|
|
let other = pick_desc(¤t_lang, &s.other_uses_de, &s.other_uses_en, &s.other_uses);
|
|
let edibility = match s.edibility_rating {
|
|
Some(r) => format!("{r}/5"),
|
|
None => em.to_string(),
|
|
};
|
|
|
|
// Ecology
|
|
let layer = s.plant_layer.as_deref().map(|v| t_val(l, v).to_string()).unwrap_or_else(|| em.to_string());
|
|
let succession = s.succession_stage.as_deref().map(|v| t_val(l, v).to_string()).unwrap_or_else(|| em.to_string());
|
|
let wildlife = os(&s.wildlife_value);
|
|
let native = os(&s.native_range);
|
|
let pollination = s.pollination_type.as_deref().map(|v| t_val(l, v).to_string()).unwrap_or_else(|| em.to_string());
|
|
|
|
// Growing requirements
|
|
let soil_moist = os(&s.soil_moisture);
|
|
let ph_range = match (s.ph_min, s.ph_max) {
|
|
(Some(mn), Some(mx)) => format!("{mn} \u{2013} {mx}"),
|
|
(Some(mn), None) => format!("{mn}+"),
|
|
(None, Some(mx)) => format!("< {mx}"),
|
|
_ => em.to_string(),
|
|
};
|
|
let drought = s.drought_tolerance.as_deref().map(|v| t_val(l, v).to_string()).unwrap_or_else(|| em.to_string());
|
|
let salt = os(&s.salt_tolerance);
|
|
let usda = os(&s.hardiness_zone_usda);
|
|
let at_zone = os(&s.hardiness_zone_at);
|
|
|
|
// Permaculture
|
|
let n_fixer = ob(s.nitrogen_fixer);
|
|
let dyn_acc = ob(s.dynamic_accumulator);
|
|
let pollinators = ob(s.attracts_pollinators);
|
|
let beneficial = ob(s.attracts_beneficial_insects);
|
|
let mulch = ob(s.mulch_plant);
|
|
let gc_quality = os(&s.ground_cover_quality);
|
|
let allelo = ob(s.allelopathic);
|
|
let guild = ovs(&s.guild_role);
|
|
|
|
// External links
|
|
let qid = s.wikidata_qid.clone();
|
|
let gbif = s.gbif_id.clone();
|
|
let eppo = os(&s.eppo_code);
|
|
let pfaf = s.pfaf_url.clone();
|
|
|
|
// Primary image: prefer primary from images API, else primary_image_key
|
|
let primary_img: Option<crate::types::Image> = {
|
|
let imgs = species_images.read();
|
|
match &*imgs {
|
|
Some(v) if !v.is_empty() => {
|
|
v.iter().find(|i| i.is_primary).or(v.first()).cloned()
|
|
}
|
|
_ => None,
|
|
}
|
|
};
|
|
let img_key: Option<String> = primary_img.as_ref().map(|i| i.s3_key.clone())
|
|
.or_else(|| s.primary_image_key.clone());
|
|
|
|
rsx! {
|
|
h1 { em { "{s.name_scientific}" } }
|
|
if common_name != s.name_scientific {
|
|
p { class: "name-common", "{common_name}" }
|
|
}
|
|
|
|
div { class: "detail-row",
|
|
// === LEFT COLUMN ===
|
|
div { class: "detail-col",
|
|
|
|
// Card 1: Species Details
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.species_details\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.scientific_name\")}" }
|
|
td { em { "{s.name_scientific}" } }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.common_name\")}" }
|
|
td { "{common_name}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.name_en\")}" }
|
|
td { class: if name_en == em { "placeholder" } else { "" }, "{name_en}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.name_de\")}" }
|
|
td { class: if name_de == em { "placeholder" } else { "" }, "{name_de}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.family\")}" }
|
|
td {
|
|
if let Some(Some(ref fam)) = *family_data.read() {
|
|
Link { to: Route::FamilyDetail { slug: fam.slug.clone() },
|
|
em { "{fam.name_scientific}" }
|
|
}
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.description\")}" }
|
|
td { class: if desc == em { "placeholder" } else { "" }, "{desc}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Card 2: Uses
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.uses\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.food_uses\")}" }
|
|
td { class: if food == em { "placeholder" } else { "" }, "{food}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.medicinal_uses\")}" }
|
|
td { class: if med == em { "placeholder" } else { "" }, "{med}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.other_uses\")}" }
|
|
td { class: if other == em { "placeholder" } else { "" }, "{other}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.edibility\")}" }
|
|
td { class: if edibility == em { "placeholder" } else { "" }, "{edibility}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Card 3: Ecology
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.ecology\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.plant_layer\")}" }
|
|
td { class: if layer == em { "placeholder" } else { "" }, "{layer}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.succession\")}" }
|
|
td { class: if succession == em { "placeholder" } else { "" }, "{succession}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.wildlife_value\")}" }
|
|
td { class: if wildlife == em { "placeholder" } else { "" }, "{wildlife}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.native_range\")}" }
|
|
td { class: if native == em { "placeholder" } else { "" }, "{native}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.pollination\")}" }
|
|
td { class: if pollination == em { "placeholder" } else { "" }, "{pollination}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// === RIGHT COLUMN ===
|
|
div { class: "detail-col",
|
|
|
|
// Image card
|
|
if let Some(ref key) = img_key {
|
|
div { class: "detail-card",
|
|
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 {
|
|
div { class: "image-attribution",
|
|
if let Some(ref caption) = img.caption {
|
|
span { "{caption}" }
|
|
}
|
|
if let Some(ref lic) = img.license {
|
|
if img.caption.is_some() {
|
|
" | "
|
|
}
|
|
span { "{lic}" }
|
|
}
|
|
if let Some(ref url) = img.source_url {
|
|
if img.caption.is_some() || img.license.is_some() {
|
|
" | "
|
|
}
|
|
a { href: "{url}", target: "_blank", class: "attribution-link", "{t(l, \"field.source\")}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Card 4: Growing Requirements
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.growing_requirements\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.soil_moisture\")}" }
|
|
td { class: if soil_moist == em { "placeholder" } else { "" }, "{soil_moist}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.ph_range\")}" }
|
|
td { class: if ph_range == em { "placeholder" } else { "" }, "{ph_range}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.drought_tolerance\")}" }
|
|
td { class: if drought == em { "placeholder" } else { "" }, "{drought}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.salt_tolerance\")}" }
|
|
td { class: if salt == em { "placeholder" } else { "" }, "{salt}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.usda_zone\")}" }
|
|
td { class: if usda == em { "placeholder" } else { "" }, "{usda}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.at_zone\")}" }
|
|
td { class: if at_zone == em { "placeholder" } else { "" }, "{at_zone}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Card 5: Permaculture
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.permaculture\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.nitrogen_fixer\")}" }
|
|
td { class: if n_fixer == em { "placeholder" } else { "" }, "{n_fixer}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.dynamic_acc\")}" }
|
|
td { class: if dyn_acc == em { "placeholder" } else { "" }, "{dyn_acc}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.attracts_pollinators\")}" }
|
|
td { class: if pollinators == em { "placeholder" } else { "" }, "{pollinators}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.attracts_beneficial\")}" }
|
|
td { class: if beneficial == em { "placeholder" } else { "" }, "{beneficial}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.mulch_plant\")}" }
|
|
td { class: if mulch == em { "placeholder" } else { "" }, "{mulch}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.ground_cover_quality\")}" }
|
|
td { class: if gc_quality == em { "placeholder" } else { "" }, "{gc_quality}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.allelopathic\")}" }
|
|
td { class: if allelo == em { "placeholder" } else { "" }, "{allelo}" }
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.guild_role\")}" }
|
|
td { class: if guild == em { "placeholder" } else { "" }, "{guild}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Card 6: Wildlife & Ecology
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.wildlife\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.nectar\")}" }
|
|
td {
|
|
match s.nectar_value {
|
|
Some(v) => rsx! {
|
|
div { class: "wildlife-bar-wrap",
|
|
div {
|
|
class: "wildlife-bar wildlife-bar-{v}",
|
|
style: "width: {v as f64 * 25.0}%",
|
|
}
|
|
}
|
|
span { class: "wildlife-bar-label", "{v}/4" }
|
|
},
|
|
None => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.pollen\")}" }
|
|
td {
|
|
match s.pollen_value {
|
|
Some(v) => rsx! {
|
|
div { class: "wildlife-bar-wrap",
|
|
div {
|
|
class: "wildlife-bar wildlife-bar-{v}",
|
|
style: "width: {v as f64 * 25.0}%",
|
|
}
|
|
}
|
|
span { class: "wildlife-bar-label", "{v}/4" }
|
|
},
|
|
None => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.wild_bees\")}" }
|
|
td {
|
|
match (s.wild_bee_count, s.wild_bee_specialist_count) {
|
|
(Some(total), Some(spec)) => rsx! { "{total} {t(l, \"species_species\")} ({spec} {t(l, \"specialists\")})" },
|
|
(Some(total), None) => rsx! { "{total} {t(l, \"species_species\")}" },
|
|
_ => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.butterflies\")}" }
|
|
td {
|
|
match (s.butterfly_moth_count, s.caterpillar_host_count) {
|
|
(Some(bm), Some(ch)) => rsx! { "{bm} {t(l, \"species_species\")} ({ch} {t(l, \"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 { "{t(l, \"field.caterpillar_specialists\")}" }
|
|
td { "{s.caterpillar_specialist_count.unwrap()}" }
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.hoverflies\")}" }
|
|
td {
|
|
match s.hoverfly_count {
|
|
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
|
|
None => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.beetles\")}" }
|
|
td {
|
|
match s.beetle_count {
|
|
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
|
|
None => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.birds\")}" }
|
|
td {
|
|
match s.bird_count {
|
|
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
|
|
None => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.mammals\")}" }
|
|
td {
|
|
match s.mammal_count {
|
|
Some(v) => rsx! { "{v} {t(l, \"species_species\")}" },
|
|
None => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.native_status\")}" }
|
|
td {
|
|
match &s.native_status {
|
|
Some(ns) if !ns.is_empty() => {
|
|
let badge_class = match ns.as_str() {
|
|
s if s.contains("Wildform") || s.contains("wildform") || s.contains("heimisch") => "native-badge native-badge-heimisch",
|
|
s if s.contains("rchäophyt") => "native-badge native-badge-archaeophyt",
|
|
s if s.contains("eophyt") => "native-badge native-badge-neophyt",
|
|
_ => "native-badge",
|
|
};
|
|
let translated = t_val(l, ns);
|
|
rsx! { span { class: "{badge_class}", "{translated}" } }
|
|
},
|
|
_ => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.naturadb_tags\")}" }
|
|
td {
|
|
match &s.naturadb_tags {
|
|
Some(tags) if !tags.is_empty() => {
|
|
let tag_list: Vec<&str> = tags.split(',').map(|t| t.trim()).filter(|t| !t.is_empty()).collect();
|
|
rsx! {
|
|
div { class: "badges",
|
|
for tag in tag_list {
|
|
span { class: "badge", "{tag}" }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
_ => rsx! { span { class: "placeholder", "\u{2014}" } },
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Card 7: External Links
|
|
div { class: "detail-card",
|
|
div { class: "detail-card-header", "{t(l, \"card.external_links\")}" }
|
|
table { class: "attr-table",
|
|
tbody {
|
|
tr {
|
|
th { "{t(l, \"field.wikidata_qid\")}" }
|
|
td {
|
|
if let Some(ref q) = qid {
|
|
if !q.is_empty() {
|
|
a { href: "https://www.wikidata.org/wiki/{q}", target: "_blank", class: "external-link", "{q}" }
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.gbif_id\")}" }
|
|
td {
|
|
if let Some(ref g) = gbif {
|
|
if !g.is_empty() {
|
|
a { href: "https://www.gbif.org/species/{g}", target: "_blank", class: "external-link", "{g}" }
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
}
|
|
}
|
|
tr {
|
|
th { "{t(l, \"field.eppo_code\")}" }
|
|
td { class: if eppo == em { "placeholder" } else { "" }, "{eppo}" }
|
|
}
|
|
tr {
|
|
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", "{t(l, \"field.view_on_pfaf\")}" }
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
} else {
|
|
span { class: "placeholder", "\u{2014}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cultivars for this species (below the two-column layout)
|
|
h2 { "{t(l, \"card.cultivars\")}" }
|
|
CultivarListForSpecies { species_slug: species_slug }
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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();
|
|
async move { api::list_cultivars(1, 100, Some(&s), None).await }
|
|
});
|
|
|
|
let l = lang.read().clone();
|
|
|
|
rsx! {
|
|
match &*cultivars.read() {
|
|
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", "{t(&l, \"no_cultivars\")}" } }
|
|
} else {
|
|
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 common) = show_common {
|
|
p { class: "card-common", "{common}" }
|
|
}
|
|
if c.is_organic {
|
|
span { class: "badge organic", "{t(&l, \"field.organic\")}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|