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:
@@ -6,6 +6,13 @@ use crate::db::{companions as db, models::*};
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::state::AppState;
|
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(
|
pub async fn list_for_species(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(r): Path<String>,
|
Path(r): Path<String>,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod companions;
|
mod companions;
|
||||||
mod cultivars;
|
mod cultivars;
|
||||||
|
mod docs;
|
||||||
mod families;
|
mod families;
|
||||||
mod health;
|
mod health;
|
||||||
mod images;
|
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", get(suppliers::list).post(suppliers::create))
|
||||||
.route("/api/v1/suppliers/{ref}", get(suppliers::get_by_slug).put(suppliers::update).delete(suppliers::remove))
|
.route("/api/v1/suppliers/{ref}", get(suppliers::get_by_slug).put(suppliers::update).delete(suppliers::remove))
|
||||||
// Companions
|
// 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))
|
.route("/api/v1/companions/{id}", delete(companions::remove))
|
||||||
// Images
|
// Images
|
||||||
.route("/api/v1/images/{entity_type}/{entity_id}", get(images::list_for_entity))
|
.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))
|
.route("/img/{*path}", get(images::serve_image))
|
||||||
// Search
|
// Search
|
||||||
.route("/api/v1/search", get(search::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
|
// OIDC auth
|
||||||
.route("/auth/oidc/login", get(crate::auth::oidc::login))
|
.route("/auth/oidc/login", get(crate::auth::oidc::login))
|
||||||
.route("/auth/oidc/callback", get(crate::auth::oidc::callback))
|
.route("/auth/oidc/callback", get(crate::auth::oidc::callback))
|
||||||
|
|||||||
@@ -2,7 +2,29 @@ use sqlx::PgPool;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
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>> {
|
pub async fn list_for_species(pool: &PgPool, species_id: Uuid) -> Result<Vec<CompanionRelationship>> {
|
||||||
sqlx::query_as::<_, CompanionRelationship>(
|
sqlx::query_as::<_, CompanionRelationship>(
|
||||||
|
|||||||
@@ -438,6 +438,25 @@ pub struct CompanionRelationship {
|
|||||||
pub created_at: DateTime<Utc>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateCompanion {
|
pub struct CreateCompanion {
|
||||||
pub species_a_id: Uuid,
|
pub species_a_id: Uuid,
|
||||||
|
|||||||
@@ -1258,3 +1258,174 @@ td.placeholder {
|
|||||||
min-width: 100px;
|
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
|
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 ---
|
// --- Images ---
|
||||||
pub async fn get_images(entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>, String> {
|
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
|
get_json(&format!("{API_BASE}/images/{entity_type}/{entity_id}")).await
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ pub enum Route {
|
|||||||
SupplierList {},
|
SupplierList {},
|
||||||
#[route("/suppliers/:slug")]
|
#[route("/suppliers/:slug")]
|
||||||
SupplierDetail { slug: String },
|
SupplierDetail { slug: String },
|
||||||
|
#[route("/companions")]
|
||||||
|
CompanionList {},
|
||||||
#[route("/search")]
|
#[route("/search")]
|
||||||
SearchPage {},
|
SearchPage {},
|
||||||
#[route("/sources")]
|
#[route("/sources")]
|
||||||
@@ -80,6 +82,7 @@ fn Layout() -> Element {
|
|||||||
NavLink { to: Route::SpeciesList {}, label: t(l, "nav.species") }
|
NavLink { to: Route::SpeciesList {}, label: t(l, "nav.species") }
|
||||||
NavLink { to: Route::CultivarList {}, label: t(l, "nav.cultivars") }
|
NavLink { to: Route::CultivarList {}, label: t(l, "nav.cultivars") }
|
||||||
NavLink { to: Route::SupplierList {}, label: t(l, "nav.suppliers") }
|
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::SearchPage {}, label: t(l, "nav.search") }
|
||||||
NavLink { to: Route::Sources {}, label: t(l, "nav.sources") }
|
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
|
// Re-export page components for the router
|
||||||
|
pub use crate::pages::companions::CompanionList;
|
||||||
pub use crate::pages::cultivars::{CultivarDetail, CultivarList};
|
pub use crate::pages::cultivars::{CultivarDetail, CultivarList};
|
||||||
pub use crate::pages::families::{FamilyDetail, FamilyList};
|
pub use crate::pages::families::{FamilyDetail, FamilyList};
|
||||||
pub use crate::pages::home::Home;
|
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.suppliers") => "Lieferanten",
|
||||||
("de", "nav.search") => "Suche",
|
("de", "nav.search") => "Suche",
|
||||||
("de", "nav.sources") => "Quellen",
|
("de", "nav.sources") => "Quellen",
|
||||||
|
("de", "nav.companions") => "Begleiter",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
("de", "brand.sub") => "Pflanzendatenbank",
|
("de", "brand.sub") => "Pflanzendatenbank",
|
||||||
@@ -47,6 +48,7 @@ pub fn t(lang: &str, key: &str) -> &'static str {
|
|||||||
("de", "page.suppliers") => "Lieferanten",
|
("de", "page.suppliers") => "Lieferanten",
|
||||||
("de", "page.search") => "Suche",
|
("de", "page.search") => "Suche",
|
||||||
("de", "page.sources") => "Datenquellen",
|
("de", "page.sources") => "Datenquellen",
|
||||||
|
("de", "page.companions") => "Begleitpflanzung",
|
||||||
("de", "page.recent_species") => "Neueste Arten",
|
("de", "page.recent_species") => "Neueste Arten",
|
||||||
("de", "page.species_in_family") => "Arten in dieser Familie",
|
("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.external_links") => "Externe Links",
|
||||||
("de", "card.wildlife") => "Wildtiere & \u{00d6}kologie",
|
("de", "card.wildlife") => "Wildtiere & \u{00d6}kologie",
|
||||||
("de", "card.image") => "Bild",
|
("de", "card.image") => "Bild",
|
||||||
|
("de", "card.beneficial") => "F\u{00f6}rdernd",
|
||||||
|
("de", "card.antagonistic") => "Hemmend",
|
||||||
("de", "card.species_info") => "Artinformationen",
|
("de", "card.species_info") => "Artinformationen",
|
||||||
("de", "card.cultivars") => "Sorten",
|
("de", "card.cultivars") => "Sorten",
|
||||||
|
|
||||||
@@ -156,6 +160,7 @@ pub fn t(lang: &str, key: &str) -> &'static str {
|
|||||||
("de", "field.license") => "Lizenz",
|
("de", "field.license") => "Lizenz",
|
||||||
("de", "field.view_on_pfaf") => "Auf PFAF ansehen",
|
("de", "field.view_on_pfaf") => "Auf PFAF ansehen",
|
||||||
("de", "field.source") => "Quelle",
|
("de", "field.source") => "Quelle",
|
||||||
|
("de", "field.mechanism") => "Mechanismus",
|
||||||
|
|
||||||
// Planting calendar
|
// Planting calendar
|
||||||
("de", "cal.indoor_sowing") => "Voranzucht",
|
("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_suppliers_linked") => "Keine Lieferanten verkn\u{00fc}pft",
|
||||||
("de", "no_planting_data") => "Keine Pflanzkalenderdaten.",
|
("de", "no_planting_data") => "Keine Pflanzkalenderdaten.",
|
||||||
("de", "results") => "Ergebnisse",
|
("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") => "Pflanzen suchen\u{2026}",
|
||||||
("de", "search.placeholder_species") => "Arten suchen\u{2026}",
|
("de", "search.placeholder_species") => "Arten suchen\u{2026}",
|
||||||
("de", "search.placeholder_cultivars") => "Sorten 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.suppliers") => "Suppliers",
|
||||||
(_, "nav.search") => "Search",
|
(_, "nav.search") => "Search",
|
||||||
(_, "nav.sources") => "Sources",
|
(_, "nav.sources") => "Sources",
|
||||||
|
(_, "nav.companions") => "Companions",
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
(_, "brand.sub") => "Plant Database",
|
(_, "brand.sub") => "Plant Database",
|
||||||
@@ -229,6 +237,7 @@ pub fn t(lang: &str, key: &str) -> &'static str {
|
|||||||
(_, "page.suppliers") => "Suppliers",
|
(_, "page.suppliers") => "Suppliers",
|
||||||
(_, "page.search") => "Search",
|
(_, "page.search") => "Search",
|
||||||
(_, "page.sources") => "Data Sources",
|
(_, "page.sources") => "Data Sources",
|
||||||
|
(_, "page.companions") => "Companion Planting",
|
||||||
(_, "page.recent_species") => "Recent Species",
|
(_, "page.recent_species") => "Recent Species",
|
||||||
(_, "page.species_in_family") => "Species in this Family",
|
(_, "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.external_links") => "External Links",
|
||||||
(_, "card.wildlife") => "Wildlife & Ecology",
|
(_, "card.wildlife") => "Wildlife & Ecology",
|
||||||
(_, "card.image") => "Image",
|
(_, "card.image") => "Image",
|
||||||
|
(_, "card.beneficial") => "Beneficial",
|
||||||
|
(_, "card.antagonistic") => "Antagonistic",
|
||||||
(_, "card.species_info") => "Species Information",
|
(_, "card.species_info") => "Species Information",
|
||||||
(_, "card.cultivars") => "Cultivars",
|
(_, "card.cultivars") => "Cultivars",
|
||||||
|
|
||||||
@@ -338,6 +349,7 @@ pub fn t(lang: &str, key: &str) -> &'static str {
|
|||||||
(_, "field.license") => "License",
|
(_, "field.license") => "License",
|
||||||
(_, "field.view_on_pfaf") => "View on PFAF",
|
(_, "field.view_on_pfaf") => "View on PFAF",
|
||||||
(_, "field.source") => "Source",
|
(_, "field.source") => "Source",
|
||||||
|
(_, "field.mechanism") => "Mechanism",
|
||||||
|
|
||||||
// Planting calendar
|
// Planting calendar
|
||||||
(_, "cal.indoor_sowing") => "Indoor Sowing",
|
(_, "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_suppliers_linked") => "No suppliers linked",
|
||||||
(_, "no_planting_data") => "No planting calendar data.",
|
(_, "no_planting_data") => "No planting calendar data.",
|
||||||
(_, "results") => "results",
|
(_, "results") => "results",
|
||||||
|
(_, "no_companions") => "No companion planting relationships yet.",
|
||||||
|
(_, "search.placeholder_companions") => "Filter companions...",
|
||||||
(_, "search.placeholder") => "Search plants...",
|
(_, "search.placeholder") => "Search plants...",
|
||||||
(_, "search.placeholder_species") => "Search species...",
|
(_, "search.placeholder_species") => "Search species...",
|
||||||
(_, "search.placeholder_cultivars") => "Search cultivars...",
|
(_, "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 cultivars;
|
||||||
pub mod families;
|
pub mod families;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
|
|||||||
@@ -176,6 +176,24 @@ pub struct CompanionRelationship {
|
|||||||
pub source_url: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct Image {
|
pub struct Image {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user