diff --git a/herbapi-api/migrations/002_create_species.sql b/herbapi-api/migrations/002_create_species.sql index b5f1f8d..36c27b2 100644 --- a/herbapi-api/migrations/002_create_species.sql +++ b/herbapi-api/migrations/002_create_species.sql @@ -14,28 +14,28 @@ CREATE TABLE species ( description TEXT, soil_moisture TEXT, drainage_requirement TEXT, - organic_matter_pct NUMERIC(5,2), + organic_matter_pct DOUBLE PRECISION, nitrogen_ppm INTEGER, phosphorus_ppm INTEGER, potassium_ppm INTEGER, - boron_ppm NUMERIC(8,2), + boron_ppm DOUBLE PRECISION, calcium_ppm INTEGER, - copper_ppm NUMERIC(8,2), - iron_ppm NUMERIC(8,2), + copper_ppm DOUBLE PRECISION, + iron_ppm DOUBLE PRECISION, magnesium_ppm INTEGER, - manganese_ppm NUMERIC(8,2), - molybdenum_ppm NUMERIC(8,2), + manganese_ppm DOUBLE PRECISION, + molybdenum_ppm DOUBLE PRECISION, sulfur_ppm INTEGER, - zinc_ppm NUMERIC(8,2), - ph_min NUMERIC(4,2), - ph_max NUMERIC(4,2), + zinc_ppm DOUBLE PRECISION, + ph_min DOUBLE PRECISION, + ph_max DOUBLE PRECISION, soil_texture_preference TEXT[], hardiness_zone_usda TEXT, hardiness_zone_at TEXT, - min_temp NUMERIC(5,2), - max_temp NUMERIC(5,2), + min_temp DOUBLE PRECISION, + max_temp DOUBLE PRECISION, drought_tolerance drought_tolerance, - water_requirement_mm_week NUMERIC(5,2), + water_requirement_mm_week DOUBLE PRECISION, waterlogging_tolerance BOOLEAN, salt_tolerance salt_tolerance, edibility_rating SMALLINT, diff --git a/herbapi-api/migrations/003_create_cultivars.sql b/herbapi-api/migrations/003_create_cultivars.sql index 9f1edca..3c61774 100644 --- a/herbapi-api/migrations/003_create_cultivars.sql +++ b/herbapi-api/migrations/003_create_cultivars.sql @@ -12,27 +12,27 @@ CREATE TABLE cultivars ( is_organic BOOLEAN NOT NULL DEFAULT FALSE, perennial BOOLEAN NOT NULL DEFAULT FALSE, growing_time_days INTEGER, - planting_depth_cm NUMERIC(5,2), - row_spacing_cm NUMERIC(5,2), - plant_spacing_cm NUMERIC(5,2), + planting_depth_cm DOUBLE PRECISION, + row_spacing_cm DOUBLE PRECISION, + plant_spacing_cm DOUBLE PRECISION, days_to_germination INTEGER, - germination_temp_c NUMERIC(5,2), + germination_temp_c DOUBLE PRECISION, light_requirement TEXT, stratification_required BOOLEAN, stratification_days INTEGER, scarification_required BOOLEAN, seed_viability_years INTEGER, - storage_temp_c NUMERIC(5,2), + storage_temp_c DOUBLE PRECISION, storage_humidity TEXT, storage_notes TEXT, - min_temp NUMERIC(5,2), - max_temp NUMERIC(5,2), + min_temp DOUBLE PRECISION, + max_temp DOUBLE PRECISION, humidity TEXT, light TEXT, frost_tolerance frost_tolerance, - min_light_hours_day NUMERIC(4,1), - optimal_light_hours_day NUMERIC(4,1), - greenhouse_min_temp_c NUMERIC(5,2), + min_light_hours_day DOUBLE PRECISION, + optimal_light_hours_day DOUBLE PRECISION, + greenhouse_min_temp_c DOUBLE PRECISION, indoor_season_extension_weeks INTEGER, ventilation_requirement TEXT, heating_required BOOLEAN, @@ -48,9 +48,9 @@ CREATE TABLE cultivars ( rootstock_species_id UUID REFERENCES species(id), years_to_first_harvest INTEGER, productive_lifespan_years INTEGER, - expected_yield_kg_per_m2 NUMERIC(8,2), + expected_yield_kg_per_m2 DOUBLE PRECISION, yield_unit TEXT, - expected_yield_value NUMERIC(8,2), + expected_yield_value DOUBLE PRECISION, harvest_window_days INTEGER, storage_method TEXT[], shelf_life_days INTEGER, diff --git a/herbapi-api/migrations/006_create_suppliers.sql b/herbapi-api/migrations/006_create_suppliers.sql index aa0088b..b28b953 100644 --- a/herbapi-api/migrations/006_create_suppliers.sql +++ b/herbapi-api/migrations/006_create_suppliers.sql @@ -17,8 +17,8 @@ CREATE TABLE cultivar_suppliers ( supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE, article_number TEXT, product_url TEXT, - price_eur NUMERIC(8,2), - pack_size NUMERIC(8,2), + price_eur DOUBLE PRECISION, + pack_size DOUBLE PRECISION, pack_unit TEXT, last_checked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/herbapi-api/src/db/species.rs b/herbapi-api/src/db/species.rs index 0ea269b..48b65c7 100644 --- a/herbapi-api/src/db/species.rs +++ b/herbapi-api/src/db/species.rs @@ -121,9 +121,9 @@ pub async fn create(pool: &PgPool, req: &CreateSpecies) -> Result { ground_cover_quality, allelopathic, guild_role, succession_stage, heavy_metal_tolerance, wikidata_qid, gbif_id, eppo_code, pfaf_url, source_urls) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, - $17::drought_tolerance,$18::salt_tolerance,$19,$20,$21,$22,$23, - $24::invasiveness_level,$25,$26::plant_layer,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36, - $37::succession_stage,$38,$39,$40,$41,$42,$43) + $17,$18,$19,$20,$21,$22,$23, + $24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36, + $37,$38,$39,$40,$41,$42,$43) RETURNING *" ) .bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific) @@ -160,13 +160,13 @@ pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSpecies) -> Result Result { } // --- Families --- -pub async fn list_families(page: i64, search: Option<&str>) -> Result, String> { - let mut url = format!("{API_BASE}/families?page={page}&per_page=25"); +pub async fn list_families(page: i64, per_page: i64, search: Option<&str>) -> Result, String> { + let mut url = format!("{API_BASE}/families?page={page}&per_page={per_page}"); if let Some(q) = search { url.push_str(&format!("&search={q}")); } @@ -99,8 +99,8 @@ pub async fn get_family(slug: &str) -> Result { } // --- Species --- -pub async fn list_species(page: i64, family: Option<&str>, search: Option<&str>) -> Result, String> { - let mut url = format!("{API_BASE}/species?page={page}&per_page=25"); +pub async fn list_species(page: i64, per_page: i64, family: Option<&str>, search: Option<&str>) -> Result, String> { + let mut url = format!("{API_BASE}/species?page={page}&per_page={per_page}"); if let Some(f) = family { url.push_str(&format!("&family={f}")); } @@ -115,8 +115,8 @@ pub async fn get_species(slug: &str) -> Result { } // --- Cultivars --- -pub async fn list_cultivars(page: i64, species: Option<&str>, search: Option<&str>) -> Result, String> { - let mut url = format!("{API_BASE}/cultivars?page={page}&per_page=25"); +pub async fn list_cultivars(page: i64, per_page: i64, species: Option<&str>, search: Option<&str>) -> Result, String> { + let mut url = format!("{API_BASE}/cultivars?page={page}&per_page={per_page}"); if let Some(s) = species { url.push_str(&format!("&species={s}")); } diff --git a/herbapi-ui/src/app.rs b/herbapi-ui/src/app.rs index 0903330..edd3a49 100644 --- a/herbapi-ui/src/app.rs +++ b/herbapi-ui/src/app.rs @@ -27,6 +27,8 @@ pub enum Route { SupplierDetail { slug: String }, #[route("/search")] SearchPage {}, + #[route("/sources")] + Sources {}, #[end_layout] #[route("/:..segments")] NotFound { segments: Vec }, @@ -62,6 +64,7 @@ fn Layout() -> Element { NavLink { to: Route::CultivarList {}, label: "Cultivars" } NavLink { to: Route::SupplierList {}, label: "Suppliers" } NavLink { to: Route::SearchPage {}, label: "Search" } + NavLink { to: Route::Sources {}, label: "Sources" } } div { class: "sidebar-user", if let Some(ref u) = user { @@ -104,5 +107,6 @@ pub use crate::pages::cultivars::{CultivarDetail, CultivarList}; pub use crate::pages::families::{FamilyDetail, FamilyList}; pub use crate::pages::home::Home; pub use crate::pages::search::SearchPage; +pub use crate::pages::sources::Sources; pub use crate::pages::species::{SpeciesDetail, SpeciesList}; pub use crate::pages::suppliers::{SupplierDetail, SupplierList}; diff --git a/herbapi-ui/src/components/mod.rs b/herbapi-ui/src/components/mod.rs index f6c0d45..35f3362 100644 --- a/herbapi-ui/src/components/mod.rs +++ b/herbapi-ui/src/components/mod.rs @@ -1,2 +1,3 @@ pub mod plant_card; pub mod planting_calendar; +pub mod table_controls; diff --git a/herbapi-ui/src/components/table_controls.rs b/herbapi-ui/src/components/table_controls.rs new file mode 100644 index 0000000..649f936 --- /dev/null +++ b/herbapi-ui/src/components/table_controls.rs @@ -0,0 +1,118 @@ +use dioxus::prelude::*; +use gloo_storage::{LocalStorage, Storage}; +use std::collections::HashSet; + +/// Column definition for configurable tables +#[derive(Clone, PartialEq)] +pub struct ColumnDef { + pub key: &'static str, + pub label: &'static str, + pub default_visible: bool, +} + +/// Load visible columns from localStorage, falling back to defaults +pub fn load_visible_columns(storage_key: &str, columns: &[ColumnDef]) -> HashSet { + if let Ok(stored) = LocalStorage::get::>(storage_key) { + stored.into_iter().collect() + } else { + columns + .iter() + .filter(|c| c.default_visible) + .map(|c| c.key.to_string()) + .collect() + } +} + +/// Save visible columns to localStorage +pub fn save_visible_columns(storage_key: &str, visible: &HashSet) { + let vec: Vec = visible.iter().cloned().collect(); + let _ = LocalStorage::set(storage_key, vec); +} + +/// Column visibility toggle bar +#[component] +pub fn ColumnToggle( + columns: Vec, + visible: Signal>, + storage_key: String, +) -> Element { + rsx! { + div { class: "column-toggle", + span { class: "column-toggle-label", "Columns:" } + for col in columns.iter() { + { + let key = col.key.to_string(); + let is_visible = visible.read().contains(&key); + let key_toggle = key.clone(); + let sk = storage_key.clone(); + rsx! { + button { + class: if is_visible { "col-btn col-btn-active" } else { "col-btn" }, + onclick: move |_| { + let mut v = visible.write(); + let k = key_toggle.clone(); + if v.contains(&k) { + v.remove(&k); + } else { + v.insert(k); + } + save_visible_columns(&sk, &v); + }, + "{col.label}" + } + } + } + } + } + } +} + +/// Per-page selector dropdown +#[component] +pub fn PerPageSelector( + per_page: Signal, + page: Signal, + storage_key: String, +) -> Element { + let current = *per_page.read(); + + rsx! { + div { class: "per-page-selector", + label { "Show " } + select { + value: "{current}", + onchange: move |e| { + if let Ok(v) = e.value().parse::() { + per_page.set(v); + page.set(1); + let _ = LocalStorage::set(&storage_key, v); + } + }, + option { value: "10", "10" } + option { value: "25", "25" } + option { value: "50", "50" } + option { value: "100", "100" } + } + label { " per page" } + } + } +} + +/// Load per-page value from localStorage +pub fn load_per_page(storage_key: &str, default: i64) -> i64 { + LocalStorage::get::(storage_key).unwrap_or(default) +} + +/// Helper to check if a column is visible +pub fn is_col_visible(visible: &HashSet, key: &str) -> bool { + visible.contains(key) +} + +/// Truncate a string to max_len chars, adding "..." if truncated +pub fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len]) + } +} diff --git a/herbapi-ui/src/pages/cultivars.rs b/herbapi-ui/src/pages/cultivars.rs index b886aa2..28f2665 100644 --- a/herbapi-ui/src/pages/cultivars.rs +++ b/herbapi-ui/src/pages/cultivars.rs @@ -1,82 +1,273 @@ use dioxus::prelude::*; +use std::collections::HashMap; +use uuid::Uuid; use crate::api; use crate::app::Route; -use crate::components::planting_calendar::PlantingCalendar; +use crate::components::table_controls::*; + +/// Convert a month-number array (1=Jan..12=Dec) to a comma-separated string of abbreviations. +fn months_display(months: &Option>) -> String { + const NAMES: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + match months { + Some(v) if !v.is_empty() => v + .iter() + .filter_map(|&m| NAMES.get((m - 1) as usize).copied()) + .collect::>() + .join(", "), + _ => "\u{2014}".to_string(), // em dash + } +} + +/// Format an Option for display: value or em dash placeholder. +fn opt_str(val: &Option) -> String { + match val { + Some(s) if !s.is_empty() => s.clone(), + _ => "\u{2014}".to_string(), + } +} + +/// Format an Option with a suffix. +fn opt_f64_suffix(val: Option, suffix: &str) -> String { + match val { + Some(v) => format!("{v}{suffix}"), + None => "\u{2014}".to_string(), + } +} + +/// Format an Option with a suffix. +fn opt_i32_suffix(val: Option, suffix: &str) -> String { + match val { + Some(v) => format!("{v}{suffix}"), + None => "\u{2014}".to_string(), + } +} + +/// Format an Option as Yes / No / em dash. +fn opt_bool(val: Option) -> String { + match val { + Some(true) => "Yes".to_string(), + Some(false) => "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" } +} + +/// Format an Option> as comma-separated or em dash. +fn opt_vec_str(val: &Option>) -> String { + match val { + Some(v) if !v.is_empty() => v.join(", "), + _ => "\u{2014}".to_string(), + } +} + +const STORAGE_KEY_COLS: &str = "herbapi_cultivars_cols"; +const STORAGE_KEY_PP: &str = "herbapi_cultivars_pp"; + +fn cultivar_columns() -> Vec { + vec![ + ColumnDef { key: "name", label: "Name", default_visible: true }, + ColumnDef { key: "species", label: "Species", default_visible: true }, + ColumnDef { key: "organic", label: "Organic", default_visible: true }, + ColumnDef { key: "perennial", label: "Perennial", default_visible: true }, + ColumnDef { key: "description", label: "Description", default_visible: false }, + ColumnDef { key: "frost_tolerance", label: "Frost Tol.", default_visible: false }, + ColumnDef { key: "growing_time", label: "Growing Time", default_visible: false }, + ColumnDef { key: "days_germ", label: "Days to Germ.", default_visible: false }, + ] +} #[component] pub fn CultivarList() -> Element { + let columns = cultivar_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 current_page = *page.read(); - let search_str = search.read().clone(); + let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &cultivar_columns())); + + // Fetch species list once to map species_id -> name + let species_map = use_resource(move || async move { + let mut map = HashMap::::new(); + if let Ok(resp) = api::list_species(1, 100, None, None).await { + for s in resp.data { + map.insert(s.id, s.name_scientific); + } + // Fetch remaining pages if needed + let total_pages = (resp.total + resp.per_page - 1) / resp.per_page; + for p in 2..=total_pages { + if let Ok(r) = api::list_species(p, 100, None, None).await { + for s in r.data { + map.insert(s.id, s.name_scientific); + } + } + } + } + map + }); let cultivars = use_resource(move || { - let s = search_str.clone(); + let p = *page.read(); + let pp = *per_page.read(); + let s = search.read().clone(); async move { let q = if s.is_empty() { None } else { Some(s.as_str()) }; - api::list_cultivars(current_page, None, q).await + api::list_cultivars(p, pp, None, q).await } }); + let current_page = *page.read(); + rsx! { div { class: "page", h1 { "Cultivars" } - div { class: "search-bar", - input { - r#type: "text", - placeholder: "Search cultivars...", - value: "{search}", - oninput: move |e| { - search.set(e.value()); - page.set(1); - }, + div { class: "table-toolbar", + div { class: "search-bar", + input { + r#type: "text", + placeholder: "Search cultivars...", + value: "{search}", + oninput: move |e| { + search.set(e.value()); + page.set(1); + }, + } } + PerPageSelector { + per_page: per_page, + page: page, + storage_key: STORAGE_KEY_PP.to_string(), + } + } + + ColumnToggle { + columns: columns.clone(), + visible: visible_cols, + storage_key: STORAGE_KEY_COLS.to_string(), } match &*cultivars.read() { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, - Some(Ok(data)) => rsx! { - div { class: "table-wrap", - table { - thead { - tr { - th { "Name" } - th { "Organic" } - th { "Perennial" } - th { "Frost Tolerance" } - } - } - tbody { - for c in data.data.iter() { + Some(Ok(data)) => { + let smap = species_map.read(); + let empty_map: HashMap = HashMap::new(); + let sm = match &*smap { + Some(m) => m, + _ => &empty_map, + }; + let vis = visible_cols.read(); + rsx! { + p { class: "result-count", "{data.total} cultivars" } + div { class: "table-wrap", + table { + thead { tr { - td { - Link { to: Route::CultivarDetail { slug: c.slug.clone() }, - strong { "{c.name}" } + if is_col_visible(&vis, "name") { + th { "Name" } + } + if is_col_visible(&vis, "species") { + th { "Species" } + } + if is_col_visible(&vis, "organic") { + th { "Organic" } + } + if is_col_visible(&vis, "perennial") { + th { "Perennial" } + } + if is_col_visible(&vis, "description") { + th { "Description" } + } + if is_col_visible(&vis, "frost_tolerance") { + th { "Frost Tol." } + } + if is_col_visible(&vis, "growing_time") { + th { "Growing Time" } + } + if is_col_visible(&vis, "days_germ") { + th { "Days to Germ." } + } + } + } + tbody { + for c in data.data.iter() { + { + let species_name: &str = sm.get(&c.species_id).map(String::as_str).unwrap_or("-"); + rsx! { + tr { + if is_col_visible(&vis, "name") { + td { + Link { to: Route::CultivarDetail { slug: c.slug.clone() }, + strong { "{c.name}" } + } + } + } + if is_col_visible(&vis, "species") { + td { class: "species-name", em { "{species_name}" } } + } + if is_col_visible(&vis, "organic") { + td { + if c.is_organic { + span { class: "badge organic", "Yes" } + } else { + "-" + } + } + } + if is_col_visible(&vis, "perennial") { + td { if c.perennial { "Yes" } else { "Annual" } } + } + if is_col_visible(&vis, "description") { + td { class: "cell-truncated", + "{c.description.as_deref().map(|d| truncate(d, 60)).unwrap_or_else(|| \"-\".to_string())}" + } + } + if is_col_visible(&vis, "frost_tolerance") { + td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "growing_time") { + td { + match c.growing_time_days { + Some(d) => rsx! { "{d} days" }, + None => rsx! { "-" }, + } + } + } + if is_col_visible(&vis, "days_germ") { + td { + match c.days_to_germination { + Some(d) => rsx! { "{d}" }, + None => rsx! { "-" }, + } + } + } + } } } - td { if c.is_organic { "Yes" } else { "-" } } - td { if c.perennial { "Yes" } else { "Annual" } } - td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" } } } } } - } - if data.total > data.per_page { - div { class: "pagination", - button { - disabled: current_page <= 1, - onclick: move |_| page.set(current_page - 1), - "Previous" - } - span { "Page {current_page}" } - button { - disabled: current_page * data.per_page >= data.total, - onclick: move |_| page.set(current_page + 1), - "Next" + if data.total > data.per_page { + div { class: "pagination", + button { + disabled: current_page <= 1, + onclick: move |_| page.set(current_page - 1), + "Previous" + } + span { "Page {current_page} 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" + } } } } @@ -94,57 +285,442 @@ pub fn CultivarDetail(slug: String) -> Element { async move { api::get_cultivar(&s).await } }); + // Fetch species info once we have the cultivar + let species_data = use_resource(move || { + let cv = cultivar.read().clone(); + async move { + if let Some(Ok(c)) = cv { + let resp = api::list_species(1, 200, None, None).await.ok(); + if let Some(r) = resp { + for s in &r.data { + if s.id == c.species_id { + return Some(s.clone()); + } + } + } + } + None + } + }); + + // Fetch supplier links once we have the cultivar + let suppliers_data = use_resource(move || { + let cv = cultivar.read().clone(); + async move { + if let Some(Ok(c)) = cv { + api::get_cultivar_suppliers(c.id).await.ok() + } else { + None + } + } + }); + + // Fetch all suppliers for name lookup + let all_suppliers = use_resource(move || async move { + api::list_suppliers().await.ok().unwrap_or_default() + }); + rsx! { div { class: "page cultivar-detail", match &*cultivar.read() { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, - Some(Ok(c)) => rsx! { - h1 { "{c.name}" } - if let Some(ref en) = c.name_en { - p { class: "name-common", "{en}" } - } - if let Some(ref desc) = c.description { - div { class: "description", "{desc}" } - } + Some(Ok(c)) => { + // Pre-compute display strings outside of rsx + let name_en = opt_str(&c.name_en); + let name_de = opt_str(&c.name_de); + let name_sci = opt_str(&c.name_scientific); + let desc = opt_str(&c.description); + let organic = bool_display(c.is_organic); + let perennial = bool_display(c.perennial); - div { class: "badges", - if c.is_organic { - span { class: "badge organic", "Organic" } - } - if c.perennial { - span { class: "badge", "Perennial" } - } - if let Some(ref ft) = c.frost_tolerance { - span { class: "badge", "Frost: {ft}" } - } - } + // Planting schedule + let indoor = months_display(&c.indoor_sowing_months); + let direct = months_display(&c.direct_sowing_months); + let transplant = months_display(&c.transplanting_months); + let glasshouse = months_display(&c.glasshouse_months); + let harvest = months_display(&c.harvesting_months); + let succession = opt_i32_suffix(c.succession_planting_days, " days"); + let planting_notes = opt_str(&c.planting_notes); - if let Some(ref dtg) = c.days_to_germination { - p { "Days to germination: {dtg}" } - } - if let Some(ref gtd) = c.growing_time_days { - p { "Growing time: {gtd} days" } - } + // Growing information + let growing_time = opt_i32_suffix(c.growing_time_days, " days"); + let planting_depth = opt_f64_suffix(c.planting_depth_cm, " cm"); + let row_spacing = opt_f64_suffix(c.row_spacing_cm, " cm"); + let plant_spacing = opt_f64_suffix(c.plant_spacing_cm, " cm"); + let propagation = opt_vec_str(&c.propagation_methods); - // Planting calendar - h2 { "Planting Calendar" } - PlantingCalendar { - indoor_sowing: c.indoor_sowing_months.clone(), - direct_sowing: c.direct_sowing_months.clone(), - transplanting: c.transplanting_months.clone(), - glasshouse: c.glasshouse_months.clone(), - harvesting: c.harvesting_months.clone(), - } + // Germination + 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_days = opt_i32_suffix(c.stratification_days, ""); + let scar_req = opt_bool(c.scarification_required); - if let Some(ref pg) = c.pollination_group { - p { "Pollination group: {pg}" } - } - if let Some(sf) = c.self_fertile { - if sf { - p { "Self-fertile: Yes" } - } else { - p { "Self-fertile: No" } + // Climate + let min_temp = opt_f64_suffix(c.min_temp, " \u{00b0}C"); + let max_temp = opt_f64_suffix(c.max_temp, " \u{00b0}C"); + let humidity = opt_str(&c.humidity); + let light = opt_str(&c.light); + let frost_tol = opt_str(&c.frost_tolerance); + let min_light_h = opt_f64_suffix(c.min_light_hours_day, " h"); + let opt_light_h = opt_f64_suffix(c.optimal_light_hours_day, " h"); + + rsx! { + h1 { "{c.name}" } + + div { class: "detail-row", + // === LEFT COLUMN === + div { class: "detail-col", + + // Card 1: Cultivar Details + div { class: "detail-card", + div { class: "detail-card-header", "Cultivar Details" } + table { class: "attr-table", + tbody { + tr { + th { "Name" } + td { "{c.name}" } + } + tr { + th { "Name EN" } + td { class: if name_en == "\u{2014}" { "placeholder" } else { "" }, "{name_en}" } + } + tr { + th { "Name DE" } + td { class: if name_de == "\u{2014}" { "placeholder" } else { "" }, "{name_de}" } + } + tr { + th { "Scientific Name" } + td { class: if name_sci == "\u{2014}" { "placeholder" } else { "" }, + if name_sci != "\u{2014}" { + em { "{name_sci}" } + } else { + span { "\u{2014}" } + } + } + } + tr { + th { "Species" } + td { + if let Some(Some(ref sp)) = *species_data.read() { + Link { to: Route::SpeciesDetail { slug: sp.slug.clone() }, + em { "{sp.name_scientific}" } + } + if let Some(ref de) = sp.name_de { + if !de.is_empty() { + " ({de})" + } + } + } else { + span { class: "placeholder", "\u{2014}" } + } + } + } + tr { + th { "Description" } + td { class: if desc == "\u{2014}" { "placeholder" } else { "" }, "{desc}" } + } + tr { + th { "Organic" } + td { "{organic}" } + } + tr { + th { "Perennial" } + td { "{perennial}" } + } + } + } + } + + // Card 2: Planting Schedule + div { class: "detail-card", + div { class: "detail-card-header", "Planting Schedule" } + table { class: "attr-table", + tbody { + tr { + th { "Indoor Sowing" } + td { class: if indoor == "\u{2014}" { "placeholder" } else { "" }, "{indoor}" } + } + tr { + th { "Direct Sowing" } + td { class: if direct == "\u{2014}" { "placeholder" } else { "" }, "{direct}" } + } + tr { + th { "Transplanting" } + td { class: if transplant == "\u{2014}" { "placeholder" } else { "" }, "{transplant}" } + } + tr { + th { "Glasshouse" } + td { class: if glasshouse == "\u{2014}" { "placeholder" } else { "" }, "{glasshouse}" } + } + tr { + th { "Harvesting" } + td { class: if harvest == "\u{2014}" { "placeholder" } else { "" }, "{harvest}" } + } + tr { + th { "Succession Planting" } + td { class: if succession == "\u{2014}" { "placeholder" } else { "" }, "{succession}" } + } + tr { + th { "Planting Notes" } + td { class: if planting_notes == "\u{2014}" { "placeholder" } else { "" }, "{planting_notes}" } + } + } + } + } + + // Card 3: Where to Buy + div { class: "detail-card", + div { class: "detail-card-header", "Where to Buy" } + { + let sup_links = suppliers_data.read(); + let sups = all_suppliers.read(); + let sup_list: Vec = match &*sups { + Some(v) => v.clone(), + None => vec![], + }; + match &*sup_links { + Some(Some(links)) if !links.is_empty() => { + rsx! { + table { class: "attr-table", + thead { + tr { + th { "Supplier" } + th { "SKU" } + th { "Price" } + th { "Link" } + } + } + tbody { + for link in links.iter() { + { + let sup_name = sup_list.iter() + .find(|s| s.id == link.supplier_id) + .map(|s| s.name.clone()) + .unwrap_or_else(|| "\u{2014}".to_string()); + let sku = link.article_number.as_deref().unwrap_or("\u{2014}").to_string(); + let price = match link.price_eur { + Some(p) if p > 0.0 => format!("EUR {p:.2}"), + _ => "\u{2014}".to_string(), + }; + let url = link.product_url.clone(); + rsx! { + tr { + td { "{sup_name}" } + td { class: if sku == "\u{2014}" { "placeholder" } else { "" }, "{sku}" } + td { class: if price == "\u{2014}" { "placeholder" } else { "" }, "{price}" } + td { + if let Some(ref u) = url { + if !u.is_empty() { + a { href: "{u}", target: "_blank", class: "external-link", "View" } + } else { + span { class: "placeholder", "\u{2014}" } + } + } else { + span { class: "placeholder", "\u{2014}" } + } + } + } + } + } + } + } + } + } + }, + _ => rsx! { + p { class: "placeholder detail-card-empty", "\u{2014} No suppliers linked" } + }, + } + } + } + } + + // === RIGHT COLUMN === + div { class: "detail-col", + + // Card 4: Growing Information + div { class: "detail-card", + div { class: "detail-card-header", "Growing Information" } + table { class: "attr-table", + tbody { + tr { + th { "Growing Time" } + td { class: if growing_time == "\u{2014}" { "placeholder" } else { "" }, "{growing_time}" } + } + tr { + th { "Planting Depth" } + td { class: if planting_depth == "\u{2014}" { "placeholder" } else { "" }, "{planting_depth}" } + } + tr { + th { "Row Spacing" } + td { class: if row_spacing == "\u{2014}" { "placeholder" } else { "" }, "{row_spacing}" } + } + tr { + th { "Plant Spacing" } + td { class: if plant_spacing == "\u{2014}" { "placeholder" } else { "" }, "{plant_spacing}" } + } + tr { + th { "Propagation Methods" } + td { class: if propagation == "\u{2014}" { "placeholder" } else { "" }, "{propagation}" } + } + } + } + } + + // Card 5: Germination + div { class: "detail-card", + div { class: "detail-card-header", "Germination" } + table { class: "attr-table", + tbody { + tr { + th { "Days to Germination" } + td { class: if days_germ == "\u{2014}" { "placeholder" } else { "" }, "{days_germ}" } + } + tr { + th { "Germination Temp" } + td { class: if germ_temp == "\u{2014}" { "placeholder" } else { "" }, "{germ_temp}" } + } + tr { + th { "Light Requirement" } + td { class: if light_req == "\u{2014}" { "placeholder" } else { "" }, "{light_req}" } + } + tr { + th { "Stratification Required" } + td { class: if strat_req == "\u{2014}" { "placeholder" } else { "" }, "{strat_req}" } + } + tr { + th { "Stratification Days" } + td { class: if strat_days == "\u{2014}" { "placeholder" } else { "" }, "{strat_days}" } + } + tr { + th { "Scarification Required" } + td { class: if scar_req == "\u{2014}" { "placeholder" } else { "" }, "{scar_req}" } + } + } + } + } + + // Card 6: Climate & Environment + div { class: "detail-card", + div { class: "detail-card-header", "Climate & Environment" } + table { class: "attr-table", + tbody { + tr { + th { "Min Temp" } + td { class: if min_temp == "\u{2014}" { "placeholder" } else { "" }, "{min_temp}" } + } + tr { + th { "Max Temp" } + td { class: if max_temp == "\u{2014}" { "placeholder" } else { "" }, "{max_temp}" } + } + tr { + th { "Humidity" } + td { class: if humidity == "\u{2014}" { "placeholder" } else { "" }, "{humidity}" } + } + tr { + th { "Light" } + td { class: if light == "\u{2014}" { "placeholder" } else { "" }, "{light}" } + } + tr { + th { "Frost Tolerance" } + td { class: if frost_tol == "\u{2014}" { "placeholder" } else { "" }, "{frost_tol}" } + } + tr { + th { "Min Light Hours/Day" } + td { class: if min_light_h == "\u{2014}" { "placeholder" } else { "" }, "{min_light_h}" } + } + tr { + th { "Optimal Light Hours/Day" } + td { class: if opt_light_h == "\u{2014}" { "placeholder" } else { "" }, "{opt_light_h}" } + } + } + } + } + + // Card 7: Species Information + div { class: "detail-card", + div { class: "detail-card-header", "Species Information" } + { + let sp_read = species_data.read(); + match &*sp_read { + Some(Some(sp)) => { + let ph_range = match (sp.ph_min, sp.ph_max) { + (Some(mn), Some(mx)) => format!("{mn} \u{2013} {mx}"), + (Some(mn), None) => format!("{mn}+"), + (None, Some(mx)) => format!("< {mx}"), + _ => "\u{2014}".to_string(), + }; + 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_food = opt_str(&sp.food_uses); + let sp_med = opt_str(&sp.medicinal_uses); + let sp_other = opt_str(&sp.other_uses); + let sp_wildlife = opt_str(&sp.wildlife_value); + let sp_native = opt_str(&sp.native_range); + rsx! { + table { class: "attr-table", + tbody { + tr { + th { "Plant Layer" } + td { class: if sp_layer == "\u{2014}" { "placeholder" } else { "" }, "{sp_layer}" } + } + tr { + th { "Drought Tolerance" } + td { class: if sp_drought == "\u{2014}" { "placeholder" } else { "" }, "{sp_drought}" } + } + tr { + th { "USDA Zone" } + td { class: if sp_usda == "\u{2014}" { "placeholder" } else { "" }, "{sp_usda}" } + } + tr { + th { "pH Range" } + td { class: if ph_range == "\u{2014}" { "placeholder" } else { "" }, "{ph_range}" } + } + tr { + th { "Nitrogen Fixer" } + td { class: if sp_nfix == "\u{2014}" { "placeholder" } else { "" }, "{sp_nfix}" } + } + tr { + th { "Dynamic Accumulator" } + td { class: if sp_dynacc == "\u{2014}" { "placeholder" } else { "" }, "{sp_dynacc}" } + } + tr { + th { "Food Uses" } + td { class: if sp_food == "\u{2014}" { "placeholder" } else { "" }, "{sp_food}" } + } + tr { + th { "Medicinal Uses" } + td { class: if sp_med == "\u{2014}" { "placeholder" } else { "" }, "{sp_med}" } + } + tr { + th { "Other Uses" } + td { class: if sp_other == "\u{2014}" { "placeholder" } else { "" }, "{sp_other}" } + } + tr { + th { "Wildlife Value" } + td { class: if sp_wildlife == "\u{2014}" { "placeholder" } else { "" }, "{sp_wildlife}" } + } + tr { + th { "Native Range" } + td { class: if sp_native == "\u{2014}" { "placeholder" } else { "" }, "{sp_native}" } + } + } + } + } + }, + _ => rsx! { + p { class: "placeholder detail-card-empty", "Loading species data\u{2026}" } + }, + } + } + } + } } } }, diff --git a/herbapi-ui/src/pages/families.rs b/herbapi-ui/src/pages/families.rs index 8add23a..8d8fc42 100644 --- a/herbapi-ui/src/pages/families.rs +++ b/herbapi-ui/src/pages/families.rs @@ -2,78 +2,133 @@ use dioxus::prelude::*; use crate::api; use crate::app::Route; +use crate::components::table_controls::*; + +const STORAGE_KEY_COLS: &str = "herbapi_families_cols"; +const STORAGE_KEY_PP: &str = "herbapi_families_pp"; + +fn family_columns() -> Vec { + vec![ + ColumnDef { key: "name_scientific", label: "Scientific Name", default_visible: true }, + ColumnDef { key: "name_en", label: "English", default_visible: true }, + ColumnDef { key: "name_de", label: "German", default_visible: true }, + ColumnDef { key: "description", label: "Description", default_visible: false }, + ] +} #[component] pub fn FamilyList() -> Element { + let columns = family_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 current_page = *page.read(); - let search_str = search.read().clone(); + let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &family_columns())); let families = use_resource(move || { - let s = search_str.clone(); + let s = search.read().clone(); + let p = *page.read(); + let pp = *per_page.read(); async move { let q = if s.is_empty() { None } else { Some(s.as_str()) }; - api::list_families(current_page, q).await + api::list_families(p, pp, q).await } }); + let current_page = *page.read(); + rsx! { div { class: "page", h1 { "Plant Families" } - div { class: "search-bar", - input { - r#type: "text", - placeholder: "Search families...", - value: "{search}", - oninput: move |e| { - search.set(e.value()); - page.set(1); - }, + div { class: "table-toolbar", + div { class: "search-bar", + input { + r#type: "text", + placeholder: "Search families...", + value: "{search}", + oninput: move |e| { + search.set(e.value()); + page.set(1); + }, + } } + PerPageSelector { + per_page: per_page, + page: page, + storage_key: STORAGE_KEY_PP.to_string(), + } + } + + ColumnToggle { + columns: columns.clone(), + visible: visible_cols, + storage_key: STORAGE_KEY_COLS.to_string(), } match &*families.read() { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, - Some(Ok(data)) => rsx! { - div { class: "table-wrap", - table { - thead { - tr { - th { "Scientific Name" } - th { "English" } - th { "German" } - } - } - tbody { - for f in data.data.iter() { + Some(Ok(data)) => { + let vis = visible_cols.read(); + rsx! { + p { class: "result-count", "{data.total} families" } + div { class: "table-wrap", + table { + thead { tr { - td { - Link { to: Route::FamilyDetail { slug: f.slug.clone() }, - em { "{f.name_scientific}" } + if is_col_visible(&vis, "name_scientific") { + th { "Scientific Name" } + } + if is_col_visible(&vis, "name_en") { + th { "English" } + } + if is_col_visible(&vis, "name_de") { + th { "German" } + } + if is_col_visible(&vis, "description") { + th { "Description" } + } + } + } + tbody { + for f in data.data.iter() { + tr { + if is_col_visible(&vis, "name_scientific") { + td { + Link { to: Route::FamilyDetail { slug: f.slug.clone() }, + em { "{f.name_scientific}" } + } + } + } + if is_col_visible(&vis, "name_en") { + td { "{f.name_en.as_deref().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "name_de") { + td { "{f.name_de.as_deref().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "description") { + td { class: "cell-truncated", + "{f.description.as_deref().map(|d| truncate(d, 80)).unwrap_or_else(|| \"-\".to_string())}" + } } } - td { "{f.name_en.as_deref().unwrap_or(\"-\")}" } - td { "{f.name_de.as_deref().unwrap_or(\"-\")}" } } } } } - } - if data.total > data.per_page { - div { class: "pagination", - button { - disabled: current_page <= 1, - onclick: move |_| page.set(current_page - 1), - "Previous" - } - span { "Page {current_page} 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" + if data.total > data.per_page { + div { class: "pagination", + button { + disabled: current_page <= 1, + onclick: move |_| page.set(current_page - 1), + "Previous" + } + span { "Page {current_page} 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" + } } } } @@ -94,7 +149,7 @@ pub fn FamilyDetail(slug: String) -> Element { let slug_for_species = slug.clone(); let species = use_resource(move || { let s = slug_for_species.clone(); - async move { api::list_species(1, Some(&s), None).await } + async move { api::list_species(1, 100, Some(&s), None).await } }); rsx! { diff --git a/herbapi-ui/src/pages/home.rs b/herbapi-ui/src/pages/home.rs index 8982776..f13896e 100644 --- a/herbapi-ui/src/pages/home.rs +++ b/herbapi-ui/src/pages/home.rs @@ -7,7 +7,7 @@ use crate::components::plant_card::PlantCard; #[component] pub fn Home() -> Element { let mut search_query = use_signal(|| String::new()); - let species = use_resource(|| async { api::list_species(1, None, None).await }); + let species = use_resource(|| async { api::list_species(1, 12, None, None).await }); rsx! { div { class: "page home-page", diff --git a/herbapi-ui/src/pages/mod.rs b/herbapi-ui/src/pages/mod.rs index 9ae842d..21a6a9a 100644 --- a/herbapi-ui/src/pages/mod.rs +++ b/herbapi-ui/src/pages/mod.rs @@ -2,5 +2,6 @@ pub mod cultivars; pub mod families; pub mod home; pub mod search; +pub mod sources; pub mod species; pub mod suppliers; diff --git a/herbapi-ui/src/pages/sources.rs b/herbapi-ui/src/pages/sources.rs new file mode 100644 index 0000000..128bf4a --- /dev/null +++ b/herbapi-ui/src/pages/sources.rs @@ -0,0 +1,100 @@ +use dioxus::prelude::*; + +struct DataSource { + name: &'static str, + url: &'static str, + description: &'static str, + data_used: &'static str, + license: &'static str, +} + +const SOURCES: &[DataSource] = &[ + DataSource { + name: "GBIF", + url: "https://gbif.org", + description: "Global Biodiversity Information Facility — the world's largest open biodiversity data network.", + data_used: "Taxonomy and German common names. Used for species name lookups and vernacular name enrichment.", + license: "CC0", + }, + DataSource { + name: "Reinsaat", + url: "https://reinsaat.at", + description: "Austrian biodynamic seed producer.", + data_used: "Cultivar data, sowing calendars, spacing info. Scraped from product catalog.", + license: "Proprietary", + }, + DataSource { + name: "Magic Garden Seeds", + url: "https://magicgardenseeds.com", + description: "Specialist seed shop with a wide range of rare and heritage varieties.", + data_used: "Cultivar data, growing info. Scraped from product catalog.", + license: "Proprietary", + }, + DataSource { + name: "PFAF (Plants for a Future)", + url: "https://pfaf.org", + description: "Permaculture plant database with extensive information on useful plants.", + data_used: "Species data: uses, tolerances, hardiness zones, height/spread, bloom periods. Data from community SQLite export.", + license: "Open data", + }, + DataSource { + name: "FloraWeb / BIOLFLOR", + url: "https://floraweb.de", + description: "German Federal Agency for Nature Conservation plant information system.", + data_used: "Ellenberg indicator values for soil pH, moisture, light requirements.", + license: "Public data", + }, + DataSource { + name: "Arche Noah", + url: "https://arche-noah.at", + description: "Austrian heritage seed library dedicated to preserving crop diversity.", + data_used: "Cultivar data and heritage varieties.", + license: "Proprietary", + }, + DataSource { + name: "Wikidata", + url: "https://wikidata.org", + description: "Free and open knowledge base providing structured data to Wikimedia projects and beyond.", + data_used: "Structured data: USDA zones, taxonomy links.", + license: "CC0", + }, +]; + +#[component] +pub fn Sources() -> Element { + rsx! { + div { class: "page sources-page", + h1 { "Data 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." + } + div { class: "sources-grid", + for source in SOURCES.iter() { + div { class: "source-card", + div { class: "source-header", + h3 { class: "source-name", "{source.name}" } + a { + class: "source-url", + href: "{source.url}", + target: "_blank", + rel: "noopener", + "{source.url}" + } + } + 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-value", "{source.data_used}" } + } + div { class: "source-detail", + span { class: "source-detail-label", "License" } + span { class: "source-detail-value source-license", "{source.license}" } + } + } + } + } + } + } + } +} diff --git a/herbapi-ui/src/pages/species.rs b/herbapi-ui/src/pages/species.rs index 5370053..41accdb 100644 --- a/herbapi-ui/src/pages/species.rs +++ b/herbapi-ui/src/pages/species.rs @@ -1,67 +1,273 @@ use dioxus::prelude::*; +use gloo_storage::{LocalStorage, Storage}; +use std::collections::HashMap; +use uuid::Uuid; use crate::api; use crate::app::Route; use crate::components::plant_card::PlantCard; +use crate::components::table_controls::*; + +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 { + vec![ + ColumnDef { key: "name_scientific", label: "Scientific Name", default_visible: true }, + ColumnDef { key: "name_de", label: "German", default_visible: true }, + 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 }, + ] +} #[component] pub fn SpeciesList() -> Element { + 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 current_page = *page.read(); - let search_str = search.read().clone(); + let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &species_columns())); + let mut table_view = use_signal(|| { + LocalStorage::get::(STORAGE_KEY_VIEW).unwrap_or(true) + }); + + // Fetch family map for resolving family_id -> name + let family_map = use_resource(move || async move { + let mut map = HashMap::::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_str.clone(); + let s = search.read().clone(); + let p = *page.read(); + let pp = *per_page.read(); async move { let q = if s.is_empty() { None } else { Some(s.as_str()) }; - api::list_species(current_page, None, q).await + api::list_species(p, pp, None, q).await } }); + let current_page = *page.read(); + let is_table = *table_view.read(); + rsx! { div { class: "page", h1 { "Species" } - div { class: "search-bar", - input { - r#type: "text", - placeholder: "Search species...", - value: "{search}", - oninput: move |e| { - search.set(e.value()); - page.set(1); - }, + div { class: "table-toolbar", + div { class: "search-bar", + input { + r#type: "text", + placeholder: "Search 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 { "Card View" } else { "Table View" } + } + PerPageSelector { + per_page: per_page, + page: page, + storage_key: STORAGE_KEY_PP.to_string(), + } + } + } + + if is_table { + ColumnToggle { + columns: columns.clone(), + visible: visible_cols, + storage_key: STORAGE_KEY_COLS.to_string(), } } match &*species.read() { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, - Some(Ok(data)) => rsx! { - div { class: "card-grid", - for s in data.data.iter() { - PlantCard { - key: "{s.id}", - slug: s.slug.clone(), - name: s.name_scientific.clone(), - name_common: s.name_en.clone(), - entity_type: "species".to_string(), + Some(Ok(data)) => { + let fmap_read = family_map.read(); + let empty_map: HashMap = HashMap::new(); + let fm = match &*fmap_read { + Some(m) => m, + _ => &empty_map, + }; + + rsx! { + p { class: "result-count", "{data.total} species" } + + if is_table { + { + let vis = visible_cols.read(); + rsx! { + div { class: "table-wrap", + table { + thead { + tr { + if is_col_visible(&vis, "name_scientific") { + th { "Scientific Name" } + } + if is_col_visible(&vis, "name_de") { + th { "German" } + } + if is_col_visible(&vis, "name_en") { + th { "English" } + } + if is_col_visible(&vis, "family") { + th { "Family" } + } + if is_col_visible(&vis, "plant_layer") { + th { "Layer" } + } + if is_col_visible(&vis, "nitrogen_fixer") { + th { "N-Fixer" } + } + if is_col_visible(&vis, "dynamic_accumulator") { + th { "Dyn. Accum." } + } + if is_col_visible(&vis, "food_uses") { + th { "Food Uses" } + } + if is_col_visible(&vis, "edibility_rating") { + th { "Edibility" } + } + if is_col_visible(&vis, "drought_tolerance") { + th { "Drought Tol." } + } + if is_col_visible(&vis, "hardiness_zone_usda") { + th { "USDA Zone" } + } + } + } + tbody { + for s in data.data.iter() { + { + let family_name: &str = fm.get(&s.family_id).map(String::as_str).unwrap_or("-"); + 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, "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().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "nitrogen_fixer") { + td { + match s.nitrogen_fixer { + Some(true) => rsx! { span { class: "badge organic", "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", "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().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "hardiness_zone_usda") { + td { "{s.hardiness_zone_usda.as_deref().unwrap_or(\"-\")}" } + } + } + } + } + } + } + } + } + } + } + } else { + div { class: "card-grid", + for s in data.data.iter() { + PlantCard { + key: "{s.id}", + slug: s.slug.clone(), + name: s.name_scientific.clone(), + name_common: s.name_en.clone(), + entity_type: "species".to_string(), + } + } } } - } - if data.total > data.per_page { - div { class: "pagination", - button { - disabled: current_page <= 1, - onclick: move |_| page.set(current_page - 1), - "Previous" - } - span { "Page {current_page} 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" + + if data.total > data.per_page { + div { class: "pagination", + button { + disabled: current_page <= 1, + onclick: move |_| page.set(current_page - 1), + "Previous" + } + span { "Page {current_page} 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" + } } } } @@ -79,6 +285,25 @@ pub fn SpeciesDetail(slug: String) -> Element { 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 + } + }); + rsx! { div { class: "page species-detail", match &*species.read() { @@ -86,61 +311,313 @@ pub fn SpeciesDetail(slug: String) -> Element { Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, Some(Ok(s)) => { let species_slug = s.slug.clone(); + + // Helper closures to format fields + let os = |v: &Option| -> String { + match v { + Some(x) if !x.is_empty() => x.clone(), + _ => "\u{2014}".to_string(), + } + }; + let ob = |v: Option| -> String { + match v { + Some(true) => "Yes".to_string(), + Some(false) => "No".to_string(), + None => "\u{2014}".to_string(), + } + }; + let ovs = |v: &Option>| -> String { + match v { + Some(x) if !x.is_empty() => x.join(", "), + _ => "\u{2014}".to_string(), + } + }; + + let em = "\u{2014}"; + + let name_en = os(&s.name_en); + let name_de = os(&s.name_de); + let desc = os(&s.description); + + // Uses + let food = os(&s.food_uses); + let med = os(&s.medicinal_uses); + let other = os(&s.other_uses); + let edibility = match s.edibility_rating { + Some(r) => format!("{r}/5"), + None => em.to_string(), + }; + + // Ecology + let layer = os(&s.plant_layer); + let succession = os(&s.succession_stage); + let wildlife = os(&s.wildlife_value); + let native = os(&s.native_range); + let pollination = os(&s.pollination_type); + + // 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 = os(&s.drought_tolerance); + 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(); + rsx! { h1 { em { "{s.name_scientific}" } } - if let Some(ref en) = s.name_en { - p { class: "name-common", "{en}" } - } - if let Some(ref de) = s.name_de { - p { class: "name-common", "{de}" } - } - if let Some(ref desc) = s.description { - div { class: "description", "{desc}" } - } - // Info grid - div { class: "info-grid", - if let Some(ref layer) = s.plant_layer { - div { class: "info-item", - span { class: "info-label", "Layer" } - span { class: "info-value", "{layer}" } + div { class: "detail-row", + // === LEFT COLUMN === + div { class: "detail-col", + + // Card 1: Species Details + div { class: "detail-card", + div { class: "detail-card-header", "Species Details" } + table { class: "attr-table", + tbody { + tr { + th { "Scientific Name" } + td { em { "{s.name_scientific}" } } + } + tr { + th { "Name EN" } + td { class: if name_en == em { "placeholder" } else { "" }, "{name_en}" } + } + tr { + th { "Name DE" } + td { class: if name_de == em { "placeholder" } else { "" }, "{name_de}" } + } + tr { + th { "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 { "Description" } + td { class: if desc == em { "placeholder" } else { "" }, "{desc}" } + } + } + } } - } - if let Some(ref dt) = s.drought_tolerance { - div { class: "info-item", - span { class: "info-label", "Drought Tolerance" } - span { class: "info-value", "{dt}" } + + // Card 2: Uses + div { class: "detail-card", + div { class: "detail-card-header", "Uses" } + table { class: "attr-table", + tbody { + tr { + th { "Food Uses" } + td { class: if food == em { "placeholder" } else { "" }, "{food}" } + } + tr { + th { "Medicinal Uses" } + td { class: if med == em { "placeholder" } else { "" }, "{med}" } + } + tr { + th { "Other Uses" } + td { class: if other == em { "placeholder" } else { "" }, "{other}" } + } + tr { + th { "Edibility Rating" } + td { class: if edibility == em { "placeholder" } else { "" }, "{edibility}" } + } + } + } } - } - if let Some(ref hz) = s.hardiness_zone_usda { - div { class: "info-item", - span { class: "info-label", "USDA Zone" } - span { class: "info-value", "{hz}" } - } - } - if let Some(rating) = s.edibility_rating { - div { class: "info-item", - span { class: "info-label", "Edibility" } - span { class: "info-value", "{rating}/5" } - } - } - if let Some(nf) = s.nitrogen_fixer { - if nf { - div { class: "info-item badge", - "Nitrogen Fixer" + + // Card 3: Ecology + div { class: "detail-card", + div { class: "detail-card-header", "Ecology" } + table { class: "attr-table", + tbody { + tr { + th { "Plant Layer" } + td { class: if layer == em { "placeholder" } else { "" }, "{layer}" } + } + tr { + th { "Succession Stage" } + td { class: if succession == em { "placeholder" } else { "" }, "{succession}" } + } + tr { + th { "Wildlife Value" } + td { class: if wildlife == em { "placeholder" } else { "" }, "{wildlife}" } + } + tr { + th { "Native Range" } + td { class: if native == em { "placeholder" } else { "" }, "{native}" } + } + tr { + th { "Pollination Type" } + td { class: if pollination == em { "placeholder" } else { "" }, "{pollination}" } + } + } } } } - if let Some(da) = s.dynamic_accumulator { - if da { - div { class: "info-item badge", - "Dynamic Accumulator" + + // === RIGHT COLUMN === + div { class: "detail-col", + + // Card 4: Growing Requirements + div { class: "detail-card", + div { class: "detail-card-header", "Growing Requirements" } + table { class: "attr-table", + tbody { + tr { + th { "Soil Moisture" } + td { class: if soil_moist == em { "placeholder" } else { "" }, "{soil_moist}" } + } + tr { + th { "pH Range" } + td { class: if ph_range == em { "placeholder" } else { "" }, "{ph_range}" } + } + tr { + th { "Drought Tolerance" } + td { class: if drought == em { "placeholder" } else { "" }, "{drought}" } + } + tr { + th { "Salt Tolerance" } + td { class: if salt == em { "placeholder" } else { "" }, "{salt}" } + } + tr { + th { "USDA Zone" } + td { class: if usda == em { "placeholder" } else { "" }, "{usda}" } + } + tr { + th { "AT Zone" } + td { class: if at_zone == em { "placeholder" } else { "" }, "{at_zone}" } + } + } + } + } + + // Card 5: Permaculture + div { class: "detail-card", + div { class: "detail-card-header", "Permaculture" } + table { class: "attr-table", + tbody { + tr { + th { "Nitrogen Fixer" } + td { class: if n_fixer == em { "placeholder" } else { "" }, "{n_fixer}" } + } + tr { + th { "Dynamic Accumulator" } + td { class: if dyn_acc == em { "placeholder" } else { "" }, "{dyn_acc}" } + } + tr { + th { "Attracts Pollinators" } + td { class: if pollinators == em { "placeholder" } else { "" }, "{pollinators}" } + } + tr { + th { "Attracts Beneficial Insects" } + td { class: if beneficial == em { "placeholder" } else { "" }, "{beneficial}" } + } + tr { + th { "Mulch Plant" } + td { class: if mulch == em { "placeholder" } else { "" }, "{mulch}" } + } + tr { + th { "Ground Cover Quality" } + td { class: if gc_quality == em { "placeholder" } else { "" }, "{gc_quality}" } + } + tr { + th { "Allelopathic" } + td { class: if allelo == em { "placeholder" } else { "" }, "{allelo}" } + } + tr { + th { "Guild Role" } + td { class: if guild == em { "placeholder" } else { "" }, "{guild}" } + } + } + } + } + + // Card 6: External Links + div { class: "detail-card", + div { class: "detail-card-header", "External Links" } + table { class: "attr-table", + tbody { + tr { + th { "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 { "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 { "EPPO Code" } + td { class: if eppo == em { "placeholder" } else { "" }, "{eppo}" } + } + tr { + th { "PFAF URL" } + td { + if let Some(ref u) = pfaf { + if !u.is_empty() { + a { href: "{u}", target: "_blank", class: "external-link", "View on PFAF" } + } else { + span { class: "placeholder", "\u{2014}" } + } + } else { + span { class: "placeholder", "\u{2014}" } + } + } + } + } } } } } - // Cultivars for this species + // Cultivars for this species (below the two-column layout) h2 { "Cultivars" } CultivarListForSpecies { species_slug: species_slug } } @@ -155,7 +632,7 @@ fn CultivarListForSpecies(species_slug: String) -> Element { let slug = species_slug.clone(); let cultivars = use_resource(move || { let s = slug.clone(); - async move { api::list_cultivars(1, Some(&s), None).await } + async move { api::list_cultivars(1, 100, Some(&s), None).await } }); rsx! { diff --git a/herbapi-ui/src/pages/suppliers.rs b/herbapi-ui/src/pages/suppliers.rs index 03b675c..befbc81 100644 --- a/herbapi-ui/src/pages/suppliers.rs +++ b/herbapi-ui/src/pages/suppliers.rs @@ -2,40 +2,118 @@ use dioxus::prelude::*; use crate::api; use crate::app::Route; +use crate::components::table_controls::*; + +const STORAGE_KEY_COLS: &str = "herbapi_suppliers_cols"; + +fn supplier_columns() -> Vec { + vec![ + ColumnDef { key: "name", label: "Name", default_visible: true }, + ColumnDef { key: "country", label: "Country", default_visible: true }, + ColumnDef { key: "organic", label: "Organic", default_visible: true }, + ColumnDef { key: "demeter", label: "Demeter", default_visible: true }, + ColumnDef { key: "website", label: "Website", default_visible: true }, + ColumnDef { key: "notes", label: "Notes", default_visible: false }, + ] +} #[component] pub fn SupplierList() -> Element { + 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 }); rsx! { div { class: "page", h1 { "Suppliers" } + ColumnToggle { + columns: columns.clone(), + visible: visible_cols, + storage_key: STORAGE_KEY_COLS.to_string(), + } + match &*suppliers.read() { None => rsx! { p { "Loading..." } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, - Some(Ok(data)) => rsx! { - div { class: "table-wrap", - table { - thead { - tr { - th { "Name" } - th { "Country" } - th { "Organic" } - th { "Demeter" } - } - } - tbody { - for s in data.iter() { + Some(Ok(data)) => { + let vis = visible_cols.read(); + rsx! { + p { class: "result-count", "{data.len()} suppliers" } + div { class: "table-wrap", + table { + thead { tr { - td { - Link { to: Route::SupplierDetail { slug: s.slug.clone() }, - strong { "{s.name}" } + if is_col_visible(&vis, "name") { + th { "Name" } + } + if is_col_visible(&vis, "country") { + th { "Country" } + } + if is_col_visible(&vis, "organic") { + th { "Organic" } + } + if is_col_visible(&vis, "demeter") { + th { "Demeter" } + } + if is_col_visible(&vis, "website") { + th { "Website" } + } + if is_col_visible(&vis, "notes") { + th { "Notes" } + } + } + } + tbody { + for s in data.iter() { + tr { + if is_col_visible(&vis, "name") { + td { + Link { to: Route::SupplierDetail { slug: s.slug.clone() }, + strong { "{s.name}" } + } + } + } + if is_col_visible(&vis, "country") { + td { "{s.country.as_deref().unwrap_or(\"-\")}" } + } + if is_col_visible(&vis, "organic") { + td { + if s.is_organic { + span { class: "badge organic", "Organic" } + } else { + "-" + } + } + } + if is_col_visible(&vis, "demeter") { + td { + if s.is_demeter { + span { class: "badge demeter", "Demeter" } + } else { + "-" + } + } + } + if is_col_visible(&vis, "website") { + td { + match &s.url { + Some(url) => rsx! { + a { href: "{url}", target: "_blank", class: "external-link", + "{truncate(url, 40)}" + } + }, + None => rsx! { "-" }, + } + } + } + if is_col_visible(&vis, "notes") { + td { class: "cell-truncated", + "{s.notes.as_deref().map(|n| truncate(n, 60)).unwrap_or_else(|| \"-\".to_string())}" + } } } - td { "{s.country.as_deref().unwrap_or(\"-\")}" } - td { if s.is_organic { "Yes" } else { "-" } } - td { if s.is_demeter { "Yes" } else { "-" } } } } } diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index badd50b..285d36c 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -22,7 +22,8 @@ pub struct Family { pub updated_at: DateTime, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] pub struct Species { pub id: Uuid, pub slug: String, @@ -37,6 +38,7 @@ pub struct Species { pub hardiness_zone_usda: Option, pub hardiness_zone_at: Option, pub drought_tolerance: Option, + pub salt_tolerance: Option, pub edibility_rating: Option, pub food_uses: Option, pub medicinal_uses: Option, @@ -45,13 +47,26 @@ pub struct Species { pub plant_layer: Option, pub nitrogen_fixer: Option, pub dynamic_accumulator: Option, + pub attracts_pollinators: Option, + pub attracts_beneficial_insects: Option, + pub wildlife_value: Option, + pub mulch_plant: Option, + pub ground_cover_quality: Option, + pub allelopathic: Option, + pub guild_role: Option>, + pub succession_stage: Option, + pub pollination_type: Option, pub wikidata_qid: Option, + pub gbif_id: Option, + pub eppo_code: Option, + pub pfaf_url: Option, pub primary_image_key: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: Option>, + pub updated_at: Option>, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] pub struct Cultivar { pub id: Uuid, pub slug: String, @@ -59,22 +74,40 @@ pub struct Cultivar { pub name: String, pub name_en: Option, pub name_de: Option, + pub name_scientific: Option, pub description: Option, pub is_organic: bool, pub perennial: bool, pub growing_time_days: Option, + pub planting_depth_cm: Option, + pub row_spacing_cm: Option, + pub plant_spacing_cm: Option, pub days_to_germination: Option, + pub germination_temp_c: Option, + pub light_requirement: Option, + pub stratification_required: Option, + pub stratification_days: Option, + pub scarification_required: Option, + pub min_temp: Option, + pub max_temp: Option, + pub humidity: Option, + pub light: Option, pub frost_tolerance: Option, + pub min_light_hours_day: Option, + pub optimal_light_hours_day: Option, pub indoor_sowing_months: Option>, pub direct_sowing_months: Option>, pub transplanting_months: Option>, pub glasshouse_months: Option>, pub harvesting_months: Option>, + pub succession_planting_days: Option, + pub planting_notes: Option, + pub propagation_methods: Option>, pub pollination_group: Option, pub self_fertile: Option, pub primary_image_key: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: Option>, + pub updated_at: Option>, } #[derive(Debug, Clone, Deserialize, Serialize)]