diff --git a/herbapi-api/src/api/mod.rs b/herbapi-api/src/api/mod.rs index 7eff56c..f73884b 100644 --- a/herbapi-api/src/api/mod.rs +++ b/herbapi-api/src/api/mod.rs @@ -6,6 +6,7 @@ mod health; mod images; mod search; mod species; +mod stats; mod suppliers; use axum::http::{header, HeaderValue, StatusCode}; @@ -44,6 +45,8 @@ pub fn router(state: AppState) -> Router { .route("/api/v1/images/{id}", delete(images::remove)) // Image file serving (S3 proxy) .route("/img/{*path}", get(images::serve_image)) + // Stats + .route("/api/v1/stats", get(stats::get_stats)) // Search .route("/api/v1/search", get(search::search)) // API docs diff --git a/herbapi-api/src/api/stats.rs b/herbapi-api/src/api/stats.rs new file mode 100644 index 0000000..bacb950 --- /dev/null +++ b/herbapi-api/src/api/stats.rs @@ -0,0 +1,52 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; + +use crate::error::Result; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct DbStats { + pub families: i64, + pub species: i64, + pub cultivars: i64, + pub suppliers: i64, + pub companions: i64, + pub images: i64, +} + +pub async fn get_stats(State(state): State) -> Result> { + let (families,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM families") + .fetch_one(&state.pool) + .await?; + let (species,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM species") + .fetch_one(&state.pool) + .await?; + let (cultivars,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM cultivars") + .fetch_one(&state.pool) + .await?; + let (suppliers,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM suppliers") + .fetch_one(&state.pool) + .await?; + let (companions,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM companion_relationships") + .fetch_one(&state.pool) + .await?; + let (images,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM images") + .fetch_one(&state.pool) + .await?; + + Ok(Json(DbStats { + families, + species, + cultivars, + suppliers, + companions, + images, + })) +} diff --git a/herbapi-ui/Cargo.toml b/herbapi-ui/Cargo.toml index b515498..58677a2 100644 --- a/herbapi-ui/Cargo.toml +++ b/herbapi-ui/Cargo.toml @@ -11,7 +11,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["serde", "v4", "js"] } chrono = { version = "0.4", features = ["serde"] } -web-sys = { version = "0.3", features = ["Document", "Element", "RequestCredentials", "Window", "HtmlInputElement", "FormData"] } +web-sys = { version = "0.3", features = ["Document", "Element", "RequestCredentials", "Window", "HtmlInputElement", "FormData", "Location", "UrlSearchParams"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index 9df9975..93654f2 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -904,6 +904,148 @@ tr:hover td { font-weight: 500; } +/* Hero section */ + +.hero { + text-align: center; + padding: 2rem 0 1.5rem; +} + +.hero-title { + font-size: 2.5rem; + font-weight: 700; + color: var(--accent); + margin-bottom: 0.25rem; +} + +.hero-subtitle { + font-size: 1.1rem; + color: var(--text-muted); + margin-bottom: 0; +} + +/* Stats cards */ + +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + text-align: center; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + transition: box-shadow 0.15s, transform 0.15s; +} + +.stat-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + transform: translateY(-1px); +} + +.stat-card-loading { + opacity: 0.4; +} + +.stat-icon { + font-size: 1.5rem; + line-height: 1; +} + +.stat-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--accent); + line-height: 1.2; +} + +.stat-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + font-weight: 500; +} + +/* Quick filters */ + +.quick-filters { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.quick-filter-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + box-shadow: var(--shadow); + transition: all 0.15s; + cursor: pointer; +} + +.quick-filter-btn:hover { + border-color: var(--accent); + background: var(--accent-light); + color: var(--accent); + text-decoration: none; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.qf-icon { + font-size: 1.1rem; + line-height: 1; +} + +.qf-nitrogen { border-left: 3px solid #4caf50; } +.qf-bee { border-left: 3px solid #ffc107; } +.qf-ground { border-left: 3px solid #8bc34a; } +.qf-tree { border-left: 3px solid #795548; } + +@media (max-width: 768px) { + .hero-title { + font-size: 1.8rem; + } + + .stats-row { + grid-template-columns: repeat(3, 1fr); + } + + .quick-filters { + gap: 0.5rem; + } + + .quick-filter-btn { + padding: 0.5rem 0.8rem; + font-size: 0.8rem; + } +} + +@media (max-width: 480px) { + .stats-row { + grid-template-columns: repeat(2, 1fr); + } +} + /* 404 */ .not-found { @@ -1429,3 +1571,67 @@ td.placeholder { align-self: center; } } + +/* Companion planting on species detail page */ +.companion-detail-body { + padding: 0.75rem 1rem 1rem; +} + +.companion-sub { + font-size: 0.95rem; + font-weight: 600; + margin: 0.75rem 0 0.35rem; + padding-bottom: 0.25rem; + border-bottom: 2px solid; +} + +.companion-sub:first-child { + margin-top: 0; +} + +.companion-sub.beneficial { + color: var(--green, #2e7d32); + border-color: var(--green, #2e7d32); +} + +.companion-sub.antagonistic { + color: var(--red, #c62828); + border-color: var(--red, #c62828); +} + +.companion-list { + list-style: none; + padding: 0; + margin: 0 0 0.5rem; +} + +.companion-list li { + padding: 0.3rem 0; + line-height: 1.45; +} + +.companion-icon { + font-weight: 700; +} + +.companion-icon.beneficial { + color: var(--green, #2e7d32); +} + +.companion-icon.antagonistic { + color: var(--red, #c62828); +} + +.companion-detail-link { + color: var(--link, #1565c0); + text-decoration: none; +} + +.companion-detail-link:hover { + text-decoration: underline; +} + +.companion-mechanism-inline { + color: var(--text-muted, #666); + font-size: 0.9em; +} diff --git a/herbapi-ui/src/api.rs b/herbapi-ui/src/api.rs index 35e26df..4b579c3 100644 --- a/herbapi-ui/src/api.rs +++ b/herbapi-ui/src/api.rs @@ -80,6 +80,11 @@ async fn delete_req(path: &str) -> Result<(), String> { Ok(()) } +// --- Stats --- +pub async fn get_stats() -> Result { + get_json(&format!("{API_BASE}/stats")).await +} + // --- Auth --- pub async fn get_current_user() -> Result { get_json("/auth/me").await @@ -108,6 +113,7 @@ pub struct SpeciesFilters { pub nitrogen_fixer: Option, pub dynamic_accumulator: Option, pub drought_tolerance: Option, + pub min_nectar: Option, } pub async fn list_species(page: i64, per_page: i64, family: Option<&str>, search: Option<&str>) -> Result, String> { @@ -144,6 +150,9 @@ pub async fn list_species_filtered(page: i64, per_page: i64, filters: &SpeciesFi url.push_str(&format!("&drought_tolerance={v}")); } } + if let Some(v) = filters.min_nectar { + url.push_str(&format!("&min_nectar={v}")); + } get_json(&url).await } diff --git a/herbapi-ui/src/i18n.rs b/herbapi-ui/src/i18n.rs index d802c4f..ceac093 100644 --- a/herbapi-ui/src/i18n.rs +++ b/herbapi-ui/src/i18n.rs @@ -72,6 +72,9 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "card.antagonistic") => "Hemmend", ("de", "card.species_info") => "Artinformationen", ("de", "card.cultivars") => "Sorten", + ("de", "card.companion_plants") => "Begleitpflanzen", + ("de", "companions.beneficial_for") => "Gute Nachbarn", + ("de", "companions.antagonistic_for") => "Schlechte Nachbarn", // Field labels ("de", "field.scientific_name") => "Wissenschaftlicher Name", @@ -208,7 +211,19 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "search.placeholder_cultivars") => "Sorten suchen\u{2026}", ("de", "search.placeholder_families") => "Familien suchen\u{2026}", ("de", "search.placeholder_full") => "Pflanzen, Familien, Sorten suchen\u{2026}", - ("de", "subtitle") => "Dreisprachige Pflanzen-Referenzdatenbank", + ("de", "subtitle") => "Dreisprachige Pflanzendatenbank f\u{00fc}r Permakultur", + ("de", "stat.families") => "Familien", + ("de", "stat.species") => "Arten", + ("de", "stat.cultivars") => "Sorten", + ("de", "stat.suppliers") => "Lieferanten", + ("de", "stat.companions") => "Begleitpflanzungen", + ("de", "stat.images") => "Bilder", + ("de", "home.quick_filters") => "Schnellfilter", + ("de", "filter.nitrogen_fixers") => "Stickstoffbinder", + ("de", "filter.bee_plants") => "Bienenpflanzen", + ("de", "filter.ground_cover") => "Bodendecker", + ("de", "filter.fruit_trees") => "Obstb\u{00e4}ume", + ("de", "home.featured_species") => "Ausgew\u{00e4}hlte Arten", ("de", "sources.intro") => "HerbAPI aggregiert Pflanzendaten aus mehreren offenen Quellen. Wir danken diesen Projekten, dass sie botanisches Wissen frei zug\u{00e4}nglich machen.", ("de", "species_species") => "Arten", ("de", "loading_species_data") => "Lade Artdaten\u{2026}", @@ -261,6 +276,9 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "card.antagonistic") => "Antagonistic", (_, "card.species_info") => "Species Information", (_, "card.cultivars") => "Cultivars", + (_, "card.companion_plants") => "Companion Plants", + (_, "companions.beneficial_for") => "Good companions", + (_, "companions.antagonistic_for") => "Bad companions", // Field labels (_, "field.scientific_name") => "Scientific Name", @@ -397,7 +415,19 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "search.placeholder_cultivars") => "Search cultivars...", (_, "search.placeholder_families") => "Search families...", (_, "search.placeholder_full") => "Search plants, families, cultivars...", - (_, "subtitle") => "Trilingual plant reference database", + (_, "subtitle") => "Trilingual Plant Database for Permaculture", + (_, "stat.families") => "Families", + (_, "stat.species") => "Species", + (_, "stat.cultivars") => "Cultivars", + (_, "stat.suppliers") => "Suppliers", + (_, "stat.companions") => "Companions", + (_, "stat.images") => "Images", + (_, "home.quick_filters") => "Quick Filters", + (_, "filter.nitrogen_fixers") => "Nitrogen Fixers", + (_, "filter.bee_plants") => "Bee Plants", + (_, "filter.ground_cover") => "Ground Cover", + (_, "filter.fruit_trees") => "Fruit Trees", + (_, "home.featured_species") => "Featured Species", (_, "sources.intro") => "HerbAPI aggregates plant data from multiple open sources. We are grateful to these projects for making botanical knowledge freely available.", (_, "species_species") => "species", (_, "loading_species_data") => "Loading species data\u{2026}", diff --git a/herbapi-ui/src/pages/home.rs b/herbapi-ui/src/pages/home.rs index eb55d39..f26464f 100644 --- a/herbapi-ui/src/pages/home.rs +++ b/herbapi-ui/src/pages/home.rs @@ -9,14 +9,42 @@ use crate::i18n::{pick_name, t}; pub fn Home() -> Element { let lang = use_context::().0; let mut search_query = use_signal(|| String::new()); - let species = use_resource(|| async { api::list_species(1, 12, None, None).await }); + let species = use_resource(|| async { api::list_species(1, 8, None, None).await }); + let stats = use_resource(|| async { api::get_stats().await }); let l = lang.read().clone(); rsx! { div { class: "page home-page", - h1 { "HerbAPI" } - p { class: "subtitle", "{t(&l, \"subtitle\")}" } + // Hero section + div { class: "hero", + h1 { class: "hero-title", "HerbAPI" } + p { class: "hero-subtitle", "{t(&l, \"subtitle\")}" } + } + // Stats cards + div { class: "stats-row", + match &*stats.read() { + None => rsx! { + for _ in 0..6 { + div { class: "stat-card stat-card-loading", + span { class: "stat-value", "\u{2014}" } + span { class: "stat-label", "\u{2026}" } + } + } + }, + Some(Err(_)) => rsx! {}, + Some(Ok(s)) => rsx! { + StatCard { value: s.families, label: t(&l, "stat.families"), icon: "\u{1fab4}" } + StatCard { value: s.species, label: t(&l, "stat.species"), icon: "\u{1f33f}" } + StatCard { value: s.cultivars, label: t(&l, "stat.cultivars"), icon: "\u{1f331}" } + StatCard { value: s.suppliers, label: t(&l, "stat.suppliers"), icon: "\u{1f6d2}" } + StatCard { value: s.companions, label: t(&l, "stat.companions"), icon: "\u{1f91d}" } + StatCard { value: s.images, label: t(&l, "stat.images"), icon: "\u{1f5bc}\u{fe0f}" } + }, + } + } + + // Search bar div { class: "search-bar", input { r#type: "text", @@ -32,13 +60,35 @@ pub fn Home() -> Element { } } - h2 { "{t(&l, \"page.recent_species\")}" } + // Quick filters + h2 { "{t(&l, \"home.quick_filters\")}" } + div { class: "quick-filters", + a { href: "/species?nitrogen_fixer=true", class: "quick-filter-btn qf-nitrogen", + span { class: "qf-icon", "\u{2699}\u{fe0f}" } + span { "{t(&l, \"filter.nitrogen_fixers\")}" } + } + a { href: "/species?min_nectar=3", class: "quick-filter-btn qf-bee", + span { class: "qf-icon", "\u{1f41d}" } + span { "{t(&l, \"filter.bee_plants\")}" } + } + a { href: "/species?plant_layer=ground_cover", class: "quick-filter-btn qf-ground", + span { class: "qf-icon", "\u{1f343}" } + span { "{t(&l, \"filter.ground_cover\")}" } + } + a { href: "/species?plant_layer=canopy", class: "quick-filter-btn qf-tree", + span { class: "qf-icon", "\u{1f333}" } + span { "{t(&l, \"filter.fruit_trees\")}" } + } + } + + // Featured species + h2 { "{t(&l, \"home.featured_species\")}" } match &*species.read() { None => rsx! { p { "{t(&l, \"loading\")}" } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, Some(Ok(data)) => rsx! { div { class: "card-grid", - for s in data.data.iter().take(12) { + for s in data.data.iter().take(8) { { 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) }; @@ -59,3 +109,14 @@ pub fn Home() -> Element { } } } + +#[component] +fn StatCard(value: i64, label: &'static str, icon: &'static str) -> Element { + rsx! { + div { class: "stat-card", + span { class: "stat-icon", "{icon}" } + span { class: "stat-value", "{value}" } + span { class: "stat-label", "{label}" } + } + } +} diff --git a/herbapi-ui/src/pages/species.rs b/herbapi-ui/src/pages/species.rs index 361fee4..5c5378a 100644 --- a/herbapi-ui/src/pages/species.rs +++ b/herbapi-ui/src/pages/species.rs @@ -45,11 +45,27 @@ pub fn SpeciesList() -> Element { LocalStorage::get::(STORAGE_KEY_VIEW).unwrap_or(true) }); + // Read initial filter state from URL query params (for quick-filter links) + let url_params = web_sys::window() + .and_then(|w| w.location().search().ok()) + .and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()); + let init_layer = url_params.as_ref() + .and_then(|p| p.get("plant_layer")) + .unwrap_or_default(); + let init_nfixer = url_params.as_ref() + .and_then(|p| p.get("nitrogen_fixer")) + .map(|v| v == "true") + .unwrap_or(false); + let init_nectar = url_params.as_ref() + .and_then(|p| p.get("min_nectar")) + .and_then(|v| v.parse::().ok()); + // Filter signals - let mut filter_layer = use_signal(|| String::new()); - let mut filter_nfixer = use_signal(|| false); + let mut filter_layer = use_signal(move || init_layer.clone()); + let mut filter_nfixer = use_signal(move || init_nfixer); let mut filter_dynacc = use_signal(|| false); let mut filter_drought = use_signal(|| String::new()); + let filter_nectar = use_signal(move || init_nectar); // Fetch family map for resolving family_id -> name let family_map = use_resource(move || async move { @@ -78,6 +94,7 @@ pub fn SpeciesList() -> Element { let nfixer = *filter_nfixer.read(); let dynacc = *filter_dynacc.read(); let drought = filter_drought.read().clone(); + let nectar = *filter_nectar.read(); async move { let filters = api::SpeciesFilters { search: if s.is_empty() { None } else { Some(s) }, @@ -85,6 +102,7 @@ pub fn SpeciesList() -> Element { 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) }, + min_nectar: nectar, ..Default::default() }; api::list_species_filtered(p, pp, &filters).await @@ -448,6 +466,9 @@ pub fn SpeciesDetail(slug: String) -> Element { } }); + // Fetch all companion relationships (for companion planting section) + let all_companions = use_resource(move || async { api::list_all_companions().await }); + // Fetch images for this species let species_images = use_resource(move || { let sp = species.read().clone(); @@ -660,6 +681,122 @@ pub fn SpeciesDetail(slug: String) -> Element { } } } + + // Card: Companion Plants + { + let species_id = s.id; + let comp_lang = current_lang.clone(); + let cl = &*comp_lang; + match &*all_companions.read() { + Some(Ok(data)) => { + let my_companions: Vec<_> = data.iter() + .filter(|c| c.species_a_id == species_id || c.species_b_id == species_id) + .collect(); + + if my_companions.is_empty() { + rsx! {} + } else { + let beneficial: Vec<_> = my_companions.iter() + .filter(|c| c.relationship == "beneficial") + .collect(); + let antagonistic: Vec<_> = my_companions.iter() + .filter(|c| c.relationship == "antagonistic") + .collect(); + + rsx! { + div { class: "detail-card", + div { class: "detail-card-header", "{t(cl, \"card.companion_plants\")}" } + div { class: "companion-detail-body", + if !beneficial.is_empty() { + h4 { class: "companion-sub beneficial", "{t(cl, \"companions.beneficial_for\")}" } + ul { class: "companion-list companion-list-beneficial", + for c in beneficial.iter() { + { + let (other_name, other_sci, other_slug) = if c.species_a_id == species_id { + ( + pick_name(cl, &c.species_b_de, &None, &c.species_b_scientific), + c.species_b_scientific.clone(), + c.species_b_slug.clone(), + ) + } else { + ( + pick_name(cl, &c.species_a_de, &None, &c.species_a_scientific), + c.species_a_scientific.clone(), + c.species_a_slug.clone(), + ) + }; + let show_common = other_name != other_sci; + rsx! { + li { + span { class: "companion-icon beneficial", "\u{2713} " } + Link { + to: Route::SpeciesDetail { slug: other_slug }, + class: "companion-detail-link", + em { "{other_sci}" } + if show_common { + " ({other_name})" + } + } + if let Some(ref mechanism) = c.mechanism { + span { class: "companion-mechanism-inline", + " \u{2014} {mechanism}" + } + } + } + } + } + } + } + } + if !antagonistic.is_empty() { + h4 { class: "companion-sub antagonistic", "{t(cl, \"companions.antagonistic_for\")}" } + ul { class: "companion-list companion-list-antagonistic", + for c in antagonistic.iter() { + { + let (other_name, other_sci, other_slug) = if c.species_a_id == species_id { + ( + pick_name(cl, &c.species_b_de, &None, &c.species_b_scientific), + c.species_b_scientific.clone(), + c.species_b_slug.clone(), + ) + } else { + ( + pick_name(cl, &c.species_a_de, &None, &c.species_a_scientific), + c.species_a_scientific.clone(), + c.species_a_slug.clone(), + ) + }; + let show_common = other_name != other_sci; + rsx! { + li { + span { class: "companion-icon antagonistic", "\u{2717} " } + Link { + to: Route::SpeciesDetail { slug: other_slug }, + class: "companion-detail-link", + em { "{other_sci}" } + if show_common { + " ({other_name})" + } + } + if let Some(ref mechanism) = c.mechanism { + span { class: "companion-mechanism-inline", + " \u{2014} {mechanism}" + } + } + } + } + } + } + } + } + } + } + } + } + }, + _ => rsx! {}, + } + } } // === RIGHT COLUMN === diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index a556db7..09c048d 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -216,6 +216,16 @@ pub struct SearchResult { pub rank: f32, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DbStats { + pub families: i64, + pub species: i64, + pub cultivars: i64, + pub suppliers: i64, + pub companions: i64, + pub images: i64, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MeResponse { pub id: Uuid,