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:
2026-03-16 11:18:10 +01:00
parent 0ef902cc91
commit 00e26b3a84
9 changed files with 518 additions and 10 deletions
+1 -1
View File
@@ -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"
+206
View File
@@ -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;
}
+9
View File
@@ -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
View File
@@ -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}",
+66 -5
View File
@@ -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}" }
}
}
}
+139 -2
View File
@@ -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 ===
+10
View File
@@ -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,