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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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 = ⟨
|
||||
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,3 +1,4 @@
|
||||
pub mod companions;
|
||||
pub mod cultivars;
|
||||
pub mod families;
|
||||
pub mod home;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user