Add companion planting section to species detail page and redesign home page
Species detail now shows a "Companion Plants" card (left column, after Ecology) with beneficial/antagonistic sub-lists. Each entry links to the companion species and shows the mechanism. Home page gains stats cards, quick-filter buttons, and a hero section. Species list supports URL query params for quick-filter links. New /api/v1/stats endpoint provides database counts. i18n keys added for DE/EN: card.companion_plants, companions.beneficial_for, companions.antagonistic_for, stat.*, filter.*, home.*.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<AppState>) -> Result<Json<DbStats>> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@ async fn delete_req(path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Stats ---
|
||||
pub async fn get_stats() -> Result<DbStats, String> {
|
||||
get_json(&format!("{API_BASE}/stats")).await
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
pub async fn get_current_user() -> Result<MeResponse, String> {
|
||||
get_json("/auth/me").await
|
||||
@@ -108,6 +113,7 @@ pub struct SpeciesFilters {
|
||||
pub nitrogen_fixer: Option<bool>,
|
||||
pub dynamic_accumulator: Option<bool>,
|
||||
pub drought_tolerance: Option<String>,
|
||||
pub min_nectar: Option<i16>,
|
||||
}
|
||||
|
||||
pub async fn list_species(page: i64, per_page: i64, family: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Species>, 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
|
||||
}
|
||||
|
||||
|
||||
+32
-2
@@ -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}",
|
||||
|
||||
@@ -9,14 +9,42 @@ use crate::i18n::{pick_name, t};
|
||||
pub fn Home() -> Element {
|
||||
let lang = use_context::<Lang>().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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,27 @@ pub fn SpeciesList() -> Element {
|
||||
LocalStorage::get::<bool>(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::<i16>().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 ===
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user