From e79e1d173698c0dc05389163bec37e4554ddc118 Mon Sep 17 00:00:00 2001 From: Florian Berthold Date: Mon, 16 Mar 2026 03:15:41 +0100 Subject: [PATCH] Add dedicated Companion Planting page Backend: new GET /api/v1/companions endpoint returning all companion relationships with joined species names, slugs and images. Adds CompanionWithNames model and list_all DB query. Frontend: new /companions route with search bar, beneficial (green) and antagonistic (red) sections, species thumbnails, mechanism text, source links, and species detail links. Full DE/EN i18n support. --- herbapi-api/src/api/companions.rs | 7 ++ herbapi-api/src/api/mod.rs | 6 +- herbapi-api/src/db/companions.rs | 24 +++- herbapi-api/src/db/models.rs | 19 +++ herbapi-ui/assets/herbapi.css | 171 +++++++++++++++++++++++++++ herbapi-ui/src/api.rs | 4 + herbapi-ui/src/app.rs | 4 + herbapi-ui/src/i18n.rs | 14 +++ herbapi-ui/src/pages/companions.rs | 178 +++++++++++++++++++++++++++++ herbapi-ui/src/pages/mod.rs | 1 + herbapi-ui/src/types.rs | 18 +++ 11 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 herbapi-ui/src/pages/companions.rs diff --git a/herbapi-api/src/api/companions.rs b/herbapi-api/src/api/companions.rs index ffc03cb..d50e329 100644 --- a/herbapi-api/src/api/companions.rs +++ b/herbapi-api/src/api/companions.rs @@ -6,6 +6,13 @@ use crate::db::{companions as db, models::*}; use crate::error::{AppError, Result}; use crate::state::AppState; +pub async fn list_all( + State(state): State, +) -> Result>> { + let companions = db::list_all(&state.pool).await?; + Ok(Json(companions)) +} + pub async fn list_for_species( State(state): State, Path(r): Path, diff --git a/herbapi-api/src/api/mod.rs b/herbapi-api/src/api/mod.rs index a8cdb43..7eff56c 100644 --- a/herbapi-api/src/api/mod.rs +++ b/herbapi-api/src/api/mod.rs @@ -1,5 +1,6 @@ mod companions; mod cultivars; +mod docs; mod families; mod health; mod images; @@ -35,7 +36,7 @@ pub fn router(state: AppState) -> Router { .route("/api/v1/suppliers", get(suppliers::list).post(suppliers::create)) .route("/api/v1/suppliers/{ref}", get(suppliers::get_by_slug).put(suppliers::update).delete(suppliers::remove)) // Companions - .route("/api/v1/companions", post(companions::create)) + .route("/api/v1/companions", get(companions::list_all).post(companions::create)) .route("/api/v1/companions/{id}", delete(companions::remove)) // Images .route("/api/v1/images/{entity_type}/{entity_id}", get(images::list_for_entity)) @@ -45,6 +46,9 @@ pub fn router(state: AppState) -> Router { .route("/img/{*path}", get(images::serve_image)) // Search .route("/api/v1/search", get(search::search)) + // API docs + .route("/api/openapi.yaml", get(docs::openapi_yaml)) + .route("/api/docs", get(docs::swagger_ui)) // OIDC auth .route("/auth/oidc/login", get(crate::auth::oidc::login)) .route("/auth/oidc/callback", get(crate::auth::oidc::callback)) diff --git a/herbapi-api/src/db/companions.rs b/herbapi-api/src/db/companions.rs index 7a89b29..688b72b 100644 --- a/herbapi-api/src/db/companions.rs +++ b/herbapi-api/src/db/companions.rs @@ -2,7 +2,29 @@ use sqlx::PgPool; use uuid::Uuid; use crate::error::{AppError, Result}; -use super::models::{CompanionRelationship, CreateCompanion}; +use super::models::{CompanionRelationship, CompanionWithNames, CreateCompanion}; + +pub async fn list_all(pool: &PgPool) -> Result> { + sqlx::query_as::<_, CompanionWithNames>( + "SELECT cr.id, cr.species_a_id, cr.species_b_id, cr.relationship, + cr.mechanism, cr.source_url, cr.created_at, + sa.name_scientific AS species_a_scientific, + sa.name_de AS species_a_de, + sa.slug AS species_a_slug, + sa.primary_image_key AS species_a_image, + sb.name_scientific AS species_b_scientific, + sb.name_de AS species_b_de, + sb.slug AS species_b_slug, + sb.primary_image_key AS species_b_image + FROM companion_relationships cr + JOIN species sa ON cr.species_a_id = sa.id + JOIN species sb ON cr.species_b_id = sb.id + ORDER BY sa.name_scientific, sb.name_scientific" + ) + .fetch_all(pool) + .await + .map_err(Into::into) +} pub async fn list_for_species(pool: &PgPool, species_id: Uuid) -> Result> { sqlx::query_as::<_, CompanionRelationship>( diff --git a/herbapi-api/src/db/models.rs b/herbapi-api/src/db/models.rs index 6e23aa0..6f1ec3b 100644 --- a/herbapi-api/src/db/models.rs +++ b/herbapi-api/src/db/models.rs @@ -438,6 +438,25 @@ pub struct CompanionRelationship { pub created_at: DateTime, } +#[derive(Debug, FromRow, Serialize)] +pub struct CompanionWithNames { + pub id: Uuid, + pub species_a_id: Uuid, + pub species_b_id: Uuid, + pub relationship: String, + pub mechanism: Option, + pub source_url: Option, + pub created_at: DateTime, + pub species_a_scientific: String, + pub species_a_de: Option, + pub species_a_slug: String, + pub species_a_image: Option, + pub species_b_scientific: String, + pub species_b_de: Option, + pub species_b_slug: String, + pub species_b_image: Option, +} + #[derive(Debug, Deserialize)] pub struct CreateCompanion { pub species_a_id: Uuid, diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index 324201e..9df9975 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -1258,3 +1258,174 @@ td.placeholder { min-width: 100px; } } + +/* ── Companion Planting Page ──────────────────────────────── */ + +.companion-section { + margin: 1.5rem 0; +} + +.companion-section-title { + font-size: 1.2rem; + font-weight: 600; + padding: 0.5rem 0.75rem; + border-radius: 4px 4px 0 0; + margin-bottom: 0; +} + +.companion-beneficial .companion-section-title { + background: #e8f5e9; + color: #2e7d32; + border-left: 4px solid #4caf50; +} + +.companion-antagonistic .companion-section-title { + background: #ffebee; + color: #c62828; + border-left: 4px solid #ef5350; +} + +.companion-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 0.75rem; + padding-top: 0.75rem; +} + +.companion-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + transition: box-shadow 0.15s; +} + +.companion-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.companion-card-header { + display: flex; + align-items: center; + padding: 0.4rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.companion-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + font-weight: 700; + font-size: 1rem; + line-height: 1; +} + +.companion-badge-beneficial { + background: #e8f5e9; + color: #2e7d32; +} + +.companion-badge-antagonistic { + background: #ffebee; + color: #c62828; +} + +.companion-card-body { + padding: 0.75rem; +} + +.companion-pair { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.companion-species { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.companion-thumb { + width: 36px; + height: 36px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.companion-name-group { + display: flex; + flex-direction: column; + min-width: 0; +} + +.companion-link { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.companion-link:hover { + text-decoration: underline; +} + +.companion-sci { + font-size: 0.75rem; + color: var(--text-light); + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.companion-arrow { + font-size: 1.1rem; + color: var(--text-light); + flex-shrink: 0; + padding: 0 0.25rem; +} + +.companion-mechanism { + margin: 0.5rem 0 0.25rem; + font-size: 0.82rem; + color: var(--text); + line-height: 1.4; +} + +.mechanism-label { + font-weight: 600; + color: var(--text-light); +} + +.companion-source { + font-size: 0.75rem; + color: var(--accent); + text-decoration: none; +} + +.companion-source:hover { + text-decoration: underline; +} + +@media (max-width: 600px) { + .companion-grid { + grid-template-columns: 1fr; + } + .companion-pair { + flex-direction: column; + align-items: flex-start; + } + .companion-arrow { + transform: rotate(90deg); + align-self: center; + } +} diff --git a/herbapi-ui/src/api.rs b/herbapi-ui/src/api.rs index 613d4f5..35e26df 100644 --- a/herbapi-ui/src/api.rs +++ b/herbapi-ui/src/api.rs @@ -185,6 +185,10 @@ pub async fn get_companions(species_id: Uuid) -> Result Result, String> { + get_json(&format!("{API_BASE}/companions")).await +} + // --- Images --- pub async fn get_images(entity_type: &str, entity_id: Uuid) -> Result, String> { get_json(&format!("{API_BASE}/images/{entity_type}/{entity_id}")).await diff --git a/herbapi-ui/src/app.rs b/herbapi-ui/src/app.rs index 4107e8f..9b444ec 100644 --- a/herbapi-ui/src/app.rs +++ b/herbapi-ui/src/app.rs @@ -31,6 +31,8 @@ pub enum Route { SupplierList {}, #[route("/suppliers/:slug")] SupplierDetail { slug: String }, + #[route("/companions")] + CompanionList {}, #[route("/search")] SearchPage {}, #[route("/sources")] @@ -80,6 +82,7 @@ fn Layout() -> Element { NavLink { to: Route::SpeciesList {}, label: t(l, "nav.species") } NavLink { to: Route::CultivarList {}, label: t(l, "nav.cultivars") } NavLink { to: Route::SupplierList {}, label: t(l, "nav.suppliers") } + NavLink { to: Route::CompanionList {}, label: t(l, "nav.companions") } NavLink { to: Route::SearchPage {}, label: t(l, "nav.search") } NavLink { to: Route::Sources {}, label: t(l, "nav.sources") } } @@ -140,6 +143,7 @@ fn NotFound(segments: Vec) -> Element { } // Re-export page components for the router +pub use crate::pages::companions::CompanionList; pub use crate::pages::cultivars::{CultivarDetail, CultivarList}; pub use crate::pages::families::{FamilyDetail, FamilyList}; pub use crate::pages::home::Home; diff --git a/herbapi-ui/src/i18n.rs b/herbapi-ui/src/i18n.rs index 9379635..d802c4f 100644 --- a/herbapi-ui/src/i18n.rs +++ b/herbapi-ui/src/i18n.rs @@ -36,6 +36,7 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "nav.suppliers") => "Lieferanten", ("de", "nav.search") => "Suche", ("de", "nav.sources") => "Quellen", + ("de", "nav.companions") => "Begleiter", // Sidebar ("de", "brand.sub") => "Pflanzendatenbank", @@ -47,6 +48,7 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "page.suppliers") => "Lieferanten", ("de", "page.search") => "Suche", ("de", "page.sources") => "Datenquellen", + ("de", "page.companions") => "Begleitpflanzung", ("de", "page.recent_species") => "Neueste Arten", ("de", "page.species_in_family") => "Arten in dieser Familie", @@ -66,6 +68,8 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "card.external_links") => "Externe Links", ("de", "card.wildlife") => "Wildtiere & \u{00d6}kologie", ("de", "card.image") => "Bild", + ("de", "card.beneficial") => "F\u{00f6}rdernd", + ("de", "card.antagonistic") => "Hemmend", ("de", "card.species_info") => "Artinformationen", ("de", "card.cultivars") => "Sorten", @@ -156,6 +160,7 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "field.license") => "Lizenz", ("de", "field.view_on_pfaf") => "Auf PFAF ansehen", ("de", "field.source") => "Quelle", + ("de", "field.mechanism") => "Mechanismus", // Planting calendar ("de", "cal.indoor_sowing") => "Voranzucht", @@ -196,6 +201,8 @@ pub fn t(lang: &str, key: &str) -> &'static str { ("de", "no_suppliers_linked") => "Keine Lieferanten verkn\u{00fc}pft", ("de", "no_planting_data") => "Keine Pflanzkalenderdaten.", ("de", "results") => "Ergebnisse", + ("de", "no_companions") => "Keine Begleitpflanzungen vorhanden.", + ("de", "search.placeholder_companions") => "Begleitpflanzungen filtern\u{2026}", ("de", "search.placeholder") => "Pflanzen suchen\u{2026}", ("de", "search.placeholder_species") => "Arten suchen\u{2026}", ("de", "search.placeholder_cultivars") => "Sorten suchen\u{2026}", @@ -218,6 +225,7 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "nav.suppliers") => "Suppliers", (_, "nav.search") => "Search", (_, "nav.sources") => "Sources", + (_, "nav.companions") => "Companions", // Sidebar (_, "brand.sub") => "Plant Database", @@ -229,6 +237,7 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "page.suppliers") => "Suppliers", (_, "page.search") => "Search", (_, "page.sources") => "Data Sources", + (_, "page.companions") => "Companion Planting", (_, "page.recent_species") => "Recent Species", (_, "page.species_in_family") => "Species in this Family", @@ -248,6 +257,8 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "card.external_links") => "External Links", (_, "card.wildlife") => "Wildlife & Ecology", (_, "card.image") => "Image", + (_, "card.beneficial") => "Beneficial", + (_, "card.antagonistic") => "Antagonistic", (_, "card.species_info") => "Species Information", (_, "card.cultivars") => "Cultivars", @@ -338,6 +349,7 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "field.license") => "License", (_, "field.view_on_pfaf") => "View on PFAF", (_, "field.source") => "Source", + (_, "field.mechanism") => "Mechanism", // Planting calendar (_, "cal.indoor_sowing") => "Indoor Sowing", @@ -378,6 +390,8 @@ pub fn t(lang: &str, key: &str) -> &'static str { (_, "no_suppliers_linked") => "No suppliers linked", (_, "no_planting_data") => "No planting calendar data.", (_, "results") => "results", + (_, "no_companions") => "No companion planting relationships yet.", + (_, "search.placeholder_companions") => "Filter companions...", (_, "search.placeholder") => "Search plants...", (_, "search.placeholder_species") => "Search species...", (_, "search.placeholder_cultivars") => "Search cultivars...", diff --git a/herbapi-ui/src/pages/companions.rs b/herbapi-ui/src/pages/companions.rs new file mode 100644 index 0000000..a038a8d --- /dev/null +++ b/herbapi-ui/src/pages/companions.rs @@ -0,0 +1,178 @@ +use dioxus::prelude::*; + +use crate::api; +use crate::app::{Lang, Route}; +use crate::i18n::{pick_name, t}; +use crate::types::CompanionWithNames; + +#[component] +pub fn CompanionList() -> Element { + let lang = use_context::().0; + let mut search = use_signal(|| String::new()); + + let companions = use_resource(|| async { api::list_all_companions().await }); + + let l = lang.read().clone(); + + rsx! { + div { class: "page", + h1 { "{t(&l, \"page.companions\")}" } + + div { class: "search-bar", + input { + r#type: "text", + placeholder: "{t(&l, \"search.placeholder_companions\")}", + value: "{search}", + oninput: move |e| search.set(e.value().clone()), + } + } + + match &*companions.read() { + None => rsx! { p { "{t(&l, \"loading\")}" } }, + Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, + Some(Ok(data)) => { + let q = search.read().to_lowercase(); + let filtered: Vec<&CompanionWithNames> = data.iter().filter(|c| { + if q.is_empty() { + return true; + } + c.species_a_scientific.to_lowercase().contains(&q) + || c.species_b_scientific.to_lowercase().contains(&q) + || c.species_a_de.as_deref().unwrap_or("").to_lowercase().contains(&q) + || c.species_b_de.as_deref().unwrap_or("").to_lowercase().contains(&q) + || c.mechanism.as_deref().unwrap_or("").to_lowercase().contains(&q) + }).collect(); + + let beneficial: Vec<&&CompanionWithNames> = filtered.iter() + .filter(|c| c.relationship == "beneficial") + .collect(); + let antagonistic: Vec<&&CompanionWithNames> = filtered.iter() + .filter(|c| c.relationship == "antagonistic") + .collect(); + + if filtered.is_empty() { + rsx! { p { class: "empty-state", "{t(&l, \"no_companions\")}" } } + } else { + rsx! { + p { class: "result-count", + "{filtered.len()} {t(&l, \"results\")}" + " ({beneficial.len()} {t(&l, \"card.beneficial\").to_lowercase()}, {antagonistic.len()} {t(&l, \"card.antagonistic\").to_lowercase()})" + } + + if !beneficial.is_empty() { + CompanionSection { + title: t(&l, "card.beneficial"), + items: beneficial.iter().map(|c| (**c).clone()).collect(), + css_class: "companion-beneficial", + lang: l.clone(), + } + } + + if !antagonistic.is_empty() { + CompanionSection { + title: t(&l, "card.antagonistic"), + items: antagonistic.iter().map(|c| (**c).clone()).collect(), + css_class: "companion-antagonistic", + lang: l.clone(), + } + } + } + } + }, + } + } + } +} + +#[component] +fn CompanionSection(title: &'static str, items: Vec, css_class: String, lang: String) -> Element { + rsx! { + section { class: "companion-section {css_class}", + h2 { class: "companion-section-title", "{title}" } + div { class: "companion-grid", + for c in items.iter() { + CompanionCard { companion: c.clone(), lang: lang.clone() } + } + } + } + } +} + +#[component] +fn CompanionCard(companion: CompanionWithNames, lang: String) -> Element { + let l = ⟨ + let name_a = pick_name(l, &companion.species_a_de, &None, &companion.species_a_scientific); + let name_b = pick_name(l, &companion.species_b_de, &None, &companion.species_b_scientific); + let sci_a = &companion.species_a_scientific; + let sci_b = &companion.species_b_scientific; + + let badge_class = if companion.relationship == "beneficial" { + "companion-badge companion-badge-beneficial" + } else { + "companion-badge companion-badge-antagonistic" + }; + + rsx! { + div { class: "companion-card", + div { class: "companion-card-header", + span { class: "{badge_class}", + if companion.relationship == "beneficial" { "+" } else { "\u{2212}" } + } + } + div { class: "companion-card-body", + div { class: "companion-pair", + div { class: "companion-species", + if let Some(ref img_key) = companion.species_a_image { + img { + class: "companion-thumb", + src: "/img/{img_key}", + alt: "{sci_a}", + } + } + div { class: "companion-name-group", + Link { + to: Route::SpeciesDetail { slug: companion.species_a_slug.clone() }, + class: "companion-link", + strong { "{name_a}" } + } + if name_a != *sci_a { + span { class: "companion-sci", "{sci_a}" } + } + } + } + span { class: "companion-arrow", "\u{2194}" } + div { class: "companion-species", + if let Some(ref img_key) = companion.species_b_image { + img { + class: "companion-thumb", + src: "/img/{img_key}", + alt: "{sci_b}", + } + } + div { class: "companion-name-group", + Link { + to: Route::SpeciesDetail { slug: companion.species_b_slug.clone() }, + class: "companion-link", + strong { "{name_b}" } + } + if name_b != *sci_b { + span { class: "companion-sci", "{sci_b}" } + } + } + } + } + if let Some(ref mechanism) = companion.mechanism { + p { class: "companion-mechanism", + span { class: "mechanism-label", "{t(l, \"field.mechanism\")}: " } + "{mechanism}" + } + } + if let Some(ref source_url) = companion.source_url { + a { class: "companion-source", href: "{source_url}", target: "_blank", + "{t(l, \"field.source\")}" + } + } + } + } + } +} diff --git a/herbapi-ui/src/pages/mod.rs b/herbapi-ui/src/pages/mod.rs index 21a6a9a..89973c9 100644 --- a/herbapi-ui/src/pages/mod.rs +++ b/herbapi-ui/src/pages/mod.rs @@ -1,3 +1,4 @@ +pub mod companions; pub mod cultivars; pub mod families; pub mod home; diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index 620e062..a556db7 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -176,6 +176,24 @@ pub struct CompanionRelationship { pub source_url: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct CompanionWithNames { + pub id: Uuid, + pub species_a_id: Uuid, + pub species_b_id: Uuid, + pub relationship: String, + pub mechanism: Option, + pub source_url: Option, + pub species_a_scientific: String, + pub species_a_de: Option, + pub species_a_slug: String, + pub species_a_image: Option, + pub species_b_scientific: String, + pub species_b_de: Option, + pub species_b_slug: String, + pub species_b_image: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Image { pub id: Uuid,