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.
This commit is contained in:
2026-03-16 03:15:41 +01:00
parent e3b1d5ff6d
commit e79e1d1736
11 changed files with 444 additions and 2 deletions
+7
View File
@@ -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<AppState>,
) -> Result<Json<Vec<CompanionWithNames>>> {
let companions = db::list_all(&state.pool).await?;
Ok(Json(companions))
}
pub async fn list_for_species(
State(state): State<AppState>,
Path(r): Path<String>,
+5 -1
View File
@@ -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))
+23 -1
View File
@@ -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<Vec<CompanionWithNames>> {
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<Vec<CompanionRelationship>> {
sqlx::query_as::<_, CompanionRelationship>(
+19
View File
@@ -438,6 +438,25 @@ pub struct CompanionRelationship {
pub created_at: DateTime<Utc>,
}
#[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<String>,
pub source_url: Option<String>,
pub created_at: DateTime<Utc>,
pub species_a_scientific: String,
pub species_a_de: Option<String>,
pub species_a_slug: String,
pub species_a_image: Option<String>,
pub species_b_scientific: String,
pub species_b_de: Option<String>,
pub species_b_slug: String,
pub species_b_image: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCompanion {
pub species_a_id: Uuid,
+171
View File
@@ -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;
}
}
+4
View File
@@ -185,6 +185,10 @@ pub async fn get_companions(species_id: Uuid) -> Result<Vec<CompanionRelationshi
get_json(&format!("{API_BASE}/species/{species_id}/companions")).await
}
pub async fn list_all_companions() -> Result<Vec<CompanionWithNames>, String> {
get_json(&format!("{API_BASE}/companions")).await
}
// --- Images ---
pub async fn get_images(entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>, String> {
get_json(&format!("{API_BASE}/images/{entity_type}/{entity_id}")).await
+4
View File
@@ -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<String>) -> 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;
+14
View File
@@ -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...",
+178
View File
@@ -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::<Lang>().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<CompanionWithNames>, 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 = &lang;
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\")}"
}
}
}
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod companions;
pub mod cultivars;
pub mod families;
pub mod home;
+18
View File
@@ -176,6 +176,24 @@ pub struct CompanionRelationship {
pub source_url: Option<String>,
}
#[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<String>,
pub source_url: Option<String>,
pub species_a_scientific: String,
pub species_a_de: Option<String>,
pub species_a_slug: String,
pub species_a_image: Option<String>,
pub species_b_scientific: String,
pub species_b_de: Option<String>,
pub species_b_slug: String,
pub species_b_image: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Image {
pub id: Uuid,