diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index c2d916e..af1f787 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -519,6 +519,65 @@ tr:hover td { gap: 0.75rem; } +/* Filter bar */ + +.filter-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.6rem 0.85rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.filter-group { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--text-muted); + white-space: nowrap; +} + +.filter-group label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} + +.filter-group select { + padding: 0.3rem 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + font-size: 0.85rem; + cursor: pointer; +} + +.filter-group select:focus { + outline: none; + border-color: var(--accent); +} + +.filter-checkbox label { + display: flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + user-select: none; +} + +.filter-checkbox input[type="checkbox"] { + accent-color: var(--accent); + width: 1rem; + height: 1rem; + cursor: pointer; +} + /* Column toggle bar */ .column-toggle { @@ -927,6 +986,62 @@ td.placeholder { opacity: 0.5; } +/* Wildlife bars (nectar/pollen 1-4 scale) */ + +.wildlife-bar-wrap { + display: inline-block; + width: 80px; + height: 10px; + background: #e0e0e0; + border-radius: 5px; + overflow: hidden; + vertical-align: middle; +} + +.wildlife-bar { + height: 100%; + border-radius: 5px; + transition: width 0.3s ease; +} + +.wildlife-bar-1 { background: #c8e6c9; } +.wildlife-bar-2 { background: #81c784; } +.wildlife-bar-3 { background: #4caf50; } +.wildlife-bar-4 { background: #2e7d32; } + +.wildlife-bar-label { + margin-left: 0.5rem; + font-size: 0.85rem; + font-weight: 500; +} + +/* Native status badges */ + +.native-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 3px; + font-size: 0.8rem; + font-weight: 500; + background: var(--accent-light); + color: var(--text); +} + +.native-badge-heimisch { + background: #e8f5e9; + color: #2e7d32; +} + +.native-badge-archaeophyt { + background: #e3f2fd; + color: #1565c0; +} + +.native-badge-neophyt { + background: #fff3e0; + color: #e65100; +} + /* Responsive */ @media (max-width: 768px) { diff --git a/herbapi-ui/src/api.rs b/herbapi-ui/src/api.rs index 7b3c9cd..613d4f5 100644 --- a/herbapi-ui/src/api.rs +++ b/herbapi-ui/src/api.rs @@ -99,13 +99,50 @@ pub async fn get_family(slug: &str) -> Result { } // --- Species --- + +#[derive(Debug, Clone, Default)] +pub struct SpeciesFilters { + pub family: Option, + pub search: Option, + pub plant_layer: Option, + pub nitrogen_fixer: Option, + pub dynamic_accumulator: Option, + pub drought_tolerance: Option, +} + pub async fn list_species(page: i64, per_page: i64, family: Option<&str>, search: Option<&str>) -> Result, String> { + list_species_filtered(page, per_page, &SpeciesFilters { + family: family.map(String::from), + search: search.map(String::from), + ..Default::default() + }).await +} + +pub async fn list_species_filtered(page: i64, per_page: i64, filters: &SpeciesFilters) -> Result, String> { let mut url = format!("{API_BASE}/species?page={page}&per_page={per_page}"); - if let Some(f) = family { + if let Some(ref f) = filters.family { url.push_str(&format!("&family={f}")); } - if let Some(q) = search { - url.push_str(&format!("&search={q}")); + if let Some(ref q) = filters.search { + if !q.is_empty() { + url.push_str(&format!("&search={q}")); + } + } + if let Some(ref v) = filters.plant_layer { + if !v.is_empty() { + url.push_str(&format!("&plant_layer={v}")); + } + } + if let Some(v) = filters.nitrogen_fixer { + url.push_str(&format!("&nitrogen_fixer={v}")); + } + if let Some(v) = filters.dynamic_accumulator { + url.push_str(&format!("&dynamic_accumulator={v}")); + } + if let Some(ref v) = filters.drought_tolerance { + if !v.is_empty() { + url.push_str(&format!("&drought_tolerance={v}")); + } } get_json(&url).await } diff --git a/herbapi-ui/src/pages/sources.rs b/herbapi-ui/src/pages/sources.rs index 128bf4a..232fe88 100644 --- a/herbapi-ui/src/pages/sources.rs +++ b/herbapi-ui/src/pages/sources.rs @@ -58,6 +58,27 @@ const SOURCES: &[DataSource] = &[ data_used: "Structured data: USDA zones, taxonomy links.", license: "CC0", }, + DataSource { + name: "NaturaDB", + url: "https://naturadb.de", + description: "German native plant database with extensive wildlife interaction data.", + data_used: "Wildlife interaction data (pollinators, wild bees, butterflies, birds), native status classification, nectar/pollen values.", + license: "Content scraped for research", + }, + DataSource { + name: "Dreschflegel", + url: "https://dreschflegel-saatgut.de", + description: "German organic seed cooperative offering open-pollinated and heritage varieties.", + data_used: "Cultivar data. Scraped from catalog.", + license: "Proprietary", + }, + DataSource { + name: "Bingenheimer Saatgut", + url: "https://bingenheimersaatgut.de", + description: "Demeter-certified biodynamic seed company from Germany.", + data_used: "Cultivar data. Scraped from catalog.", + license: "Proprietary", + }, ]; #[component] diff --git a/herbapi-ui/src/pages/species.rs b/herbapi-ui/src/pages/species.rs index 41accdb..b12c2cb 100644 --- a/herbapi-ui/src/pages/species.rs +++ b/herbapi-ui/src/pages/species.rs @@ -25,6 +25,9 @@ fn species_columns() -> Vec { 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 }, ] } @@ -39,6 +42,12 @@ pub fn SpeciesList() -> Element { LocalStorage::get::(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::::new(); @@ -62,9 +71,20 @@ pub fn SpeciesList() -> Element { 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 q = if s.is_empty() { None } else { Some(s.as_str()) }; - api::list_species(p, pp, None, q).await + 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 } }); @@ -105,6 +125,70 @@ pub fn SpeciesList() -> Element { } } + // Filter bar + div { class: "filter-bar", + div { class: "filter-group", + label { "Layer" } + select { + value: "{filter_layer}", + onchange: move |e| { + filter_layer.set(e.value()); + page.set(1); + }, + option { value: "", "All" } + option { value: "canopy", "Canopy" } + option { value: "understory", "Understory" } + option { value: "shrub", "Shrub" } + option { value: "herbaceous", "Herbaceous" } + option { value: "ground_cover", "Ground Cover" } + option { value: "vine", "Vine" } + option { value: "root", "Root" } + } + } + div { class: "filter-group", + label { "Drought Tol." } + select { + value: "{filter_drought}", + onchange: move |e| { + filter_drought.set(e.value()); + page.set(1); + }, + option { value: "", "All" } + option { value: "none", "None" } + option { value: "low", "Low" } + option { value: "moderate", "Moderate" } + option { value: "high", "High" } + option { value: "very_high", "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); + }, + } + "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); + }, + } + "Dyn. Accumulator" + } + } + } + if is_table { ColumnToggle { columns: columns.clone(), @@ -168,6 +252,15 @@ pub fn SpeciesList() -> Element { if is_col_visible(&vis, "hardiness_zone_usda") { th { "USDA Zone" } } + if is_col_visible(&vis, "nectar_value") { + th { "Nectar" } + } + if is_col_visible(&vis, "wild_bee_count") { + th { "Wild Bees" } + } + if is_col_visible(&vis, "native_status") { + th { "Native Status" } + } } } tbody { @@ -232,6 +325,38 @@ pub fn SpeciesList() -> Element { 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", + }; + rsx! { span { class: "{badge_class}", "{ns}" } } + }, + _ => rsx! { "-" }, + } + } + } } } } @@ -560,7 +685,147 @@ pub fn SpeciesDetail(slug: String) -> Element { } } - // Card 6: External Links + // Card 6: Wildlife & Ecology + div { class: "detail-card", + div { class: "detail-card-header", "Wildlife & Ecology" } + table { class: "attr-table", + tbody { + tr { + th { "Nectar Value" } + 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 { "Pollen Value" } + 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 { "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" }, + _ => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + tr { + th { "Butterflies & Moths" } + 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" }, + _ => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + if s.caterpillar_specialist_count.is_some() { + tr { + th { "Caterpillar Specialists" } + td { "{s.caterpillar_specialist_count.unwrap()}" } + } + } + tr { + th { "Hoverflies" } + td { + match s.hoverfly_count { + Some(v) => rsx! { "{v} species" }, + None => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + tr { + th { "Beetles" } + td { + match s.beetle_count { + Some(v) => rsx! { "{v} species" }, + None => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + tr { + th { "Birds" } + td { + match s.bird_count { + Some(v) => rsx! { "{v} species" }, + None => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + tr { + th { "Mammals" } + td { + match s.mammal_count { + Some(v) => rsx! { "{v} species" }, + None => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + tr { + th { "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", + }; + rsx! { span { class: "{badge_class}", "{ns}" } } + }, + _ => rsx! { span { class: "placeholder", "\u{2014}" } }, + } + } + } + tr { + th { "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", "External Links" } table { class: "attr-table", diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index 285d36c..fbafc13 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -60,6 +60,19 @@ pub struct Species { pub gbif_id: Option, pub eppo_code: Option, pub pfaf_url: Option, + pub nectar_value: Option, + pub pollen_value: Option, + pub wild_bee_count: Option, + pub wild_bee_specialist_count: Option, + pub butterfly_moth_count: Option, + pub caterpillar_host_count: Option, + pub caterpillar_specialist_count: Option, + pub hoverfly_count: Option, + pub beetle_count: Option, + pub bird_count: Option, + pub mammal_count: Option, + pub native_status: Option, + pub naturadb_tags: Option, pub primary_image_key: Option, pub created_at: Option>, pub updated_at: Option>,