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 images;
|
||||||
mod search;
|
mod search;
|
||||||
mod species;
|
mod species;
|
||||||
|
mod stats;
|
||||||
mod suppliers;
|
mod suppliers;
|
||||||
|
|
||||||
use axum::http::{header, HeaderValue, StatusCode};
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
@@ -44,6 +45,8 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/v1/images/{id}", delete(images::remove))
|
.route("/api/v1/images/{id}", delete(images::remove))
|
||||||
// Image file serving (S3 proxy)
|
// Image file serving (S3 proxy)
|
||||||
.route("/img/{*path}", get(images::serve_image))
|
.route("/img/{*path}", get(images::serve_image))
|
||||||
|
// Stats
|
||||||
|
.route("/api/v1/stats", get(stats::get_stats))
|
||||||
// Search
|
// Search
|
||||||
.route("/api/v1/search", get(search::search))
|
.route("/api/v1/search", get(search::search))
|
||||||
// API docs
|
// 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"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["serde", "v4", "js"] }
|
uuid = { version = "1", features = ["serde", "v4", "js"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
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 = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
|
|||||||
@@ -904,6 +904,148 @@ tr:hover td {
|
|||||||
font-weight: 500;
|
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 */
|
/* 404 */
|
||||||
|
|
||||||
.not-found {
|
.not-found {
|
||||||
@@ -1429,3 +1571,67 @@ td.placeholder {
|
|||||||
align-self: center;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Stats ---
|
||||||
|
pub async fn get_stats() -> Result<DbStats, String> {
|
||||||
|
get_json(&format!("{API_BASE}/stats")).await
|
||||||
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
pub async fn get_current_user() -> Result<MeResponse, String> {
|
pub async fn get_current_user() -> Result<MeResponse, String> {
|
||||||
get_json("/auth/me").await
|
get_json("/auth/me").await
|
||||||
@@ -108,6 +113,7 @@ pub struct SpeciesFilters {
|
|||||||
pub nitrogen_fixer: Option<bool>,
|
pub nitrogen_fixer: Option<bool>,
|
||||||
pub dynamic_accumulator: Option<bool>,
|
pub dynamic_accumulator: Option<bool>,
|
||||||
pub drought_tolerance: Option<String>,
|
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> {
|
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}"));
|
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
|
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.antagonistic") => "Hemmend",
|
||||||
("de", "card.species_info") => "Artinformationen",
|
("de", "card.species_info") => "Artinformationen",
|
||||||
("de", "card.cultivars") => "Sorten",
|
("de", "card.cultivars") => "Sorten",
|
||||||
|
("de", "card.companion_plants") => "Begleitpflanzen",
|
||||||
|
("de", "companions.beneficial_for") => "Gute Nachbarn",
|
||||||
|
("de", "companions.antagonistic_for") => "Schlechte Nachbarn",
|
||||||
|
|
||||||
// Field labels
|
// Field labels
|
||||||
("de", "field.scientific_name") => "Wissenschaftlicher Name",
|
("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_cultivars") => "Sorten suchen\u{2026}",
|
||||||
("de", "search.placeholder_families") => "Familien suchen\u{2026}",
|
("de", "search.placeholder_families") => "Familien suchen\u{2026}",
|
||||||
("de", "search.placeholder_full") => "Pflanzen, Familien, Sorten 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", "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", "species_species") => "Arten",
|
||||||
("de", "loading_species_data") => "Lade Artdaten\u{2026}",
|
("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.antagonistic") => "Antagonistic",
|
||||||
(_, "card.species_info") => "Species Information",
|
(_, "card.species_info") => "Species Information",
|
||||||
(_, "card.cultivars") => "Cultivars",
|
(_, "card.cultivars") => "Cultivars",
|
||||||
|
(_, "card.companion_plants") => "Companion Plants",
|
||||||
|
(_, "companions.beneficial_for") => "Good companions",
|
||||||
|
(_, "companions.antagonistic_for") => "Bad companions",
|
||||||
|
|
||||||
// Field labels
|
// Field labels
|
||||||
(_, "field.scientific_name") => "Scientific Name",
|
(_, "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_cultivars") => "Search cultivars...",
|
||||||
(_, "search.placeholder_families") => "Search families...",
|
(_, "search.placeholder_families") => "Search families...",
|
||||||
(_, "search.placeholder_full") => "Search plants, families, cultivars...",
|
(_, "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.",
|
(_, "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",
|
(_, "species_species") => "species",
|
||||||
(_, "loading_species_data") => "Loading species data\u{2026}",
|
(_, "loading_species_data") => "Loading species data\u{2026}",
|
||||||
|
|||||||
@@ -9,14 +9,42 @@ use crate::i18n::{pick_name, t};
|
|||||||
pub fn Home() -> Element {
|
pub fn Home() -> Element {
|
||||||
let lang = use_context::<Lang>().0;
|
let lang = use_context::<Lang>().0;
|
||||||
let mut search_query = use_signal(|| String::new());
|
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();
|
let l = lang.read().clone();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "page home-page",
|
div { class: "page home-page",
|
||||||
h1 { "HerbAPI" }
|
// Hero section
|
||||||
p { class: "subtitle", "{t(&l, \"subtitle\")}" }
|
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",
|
div { class: "search-bar",
|
||||||
input {
|
input {
|
||||||
r#type: "text",
|
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() {
|
match &*species.read() {
|
||||||
None => rsx! { p { "{t(&l, \"loading\")}" } },
|
None => rsx! { p { "{t(&l, \"loading\")}" } },
|
||||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||||
Some(Ok(data)) => rsx! {
|
Some(Ok(data)) => rsx! {
|
||||||
div { class: "card-grid",
|
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 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) };
|
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)
|
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
|
// Filter signals
|
||||||
let mut filter_layer = use_signal(|| String::new());
|
let mut filter_layer = use_signal(move || init_layer.clone());
|
||||||
let mut filter_nfixer = use_signal(|| false);
|
let mut filter_nfixer = use_signal(move || init_nfixer);
|
||||||
let mut filter_dynacc = use_signal(|| false);
|
let mut filter_dynacc = use_signal(|| false);
|
||||||
let mut filter_drought = use_signal(|| String::new());
|
let mut filter_drought = use_signal(|| String::new());
|
||||||
|
let filter_nectar = use_signal(move || init_nectar);
|
||||||
|
|
||||||
// Fetch family map for resolving family_id -> name
|
// Fetch family map for resolving family_id -> name
|
||||||
let family_map = use_resource(move || async move {
|
let family_map = use_resource(move || async move {
|
||||||
@@ -78,6 +94,7 @@ pub fn SpeciesList() -> Element {
|
|||||||
let nfixer = *filter_nfixer.read();
|
let nfixer = *filter_nfixer.read();
|
||||||
let dynacc = *filter_dynacc.read();
|
let dynacc = *filter_dynacc.read();
|
||||||
let drought = filter_drought.read().clone();
|
let drought = filter_drought.read().clone();
|
||||||
|
let nectar = *filter_nectar.read();
|
||||||
async move {
|
async move {
|
||||||
let filters = api::SpeciesFilters {
|
let filters = api::SpeciesFilters {
|
||||||
search: if s.is_empty() { None } else { Some(s) },
|
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 },
|
nitrogen_fixer: if nfixer { Some(true) } else { None },
|
||||||
dynamic_accumulator: if dynacc { Some(true) } else { None },
|
dynamic_accumulator: if dynacc { Some(true) } else { None },
|
||||||
drought_tolerance: if drought.is_empty() { None } else { Some(drought) },
|
drought_tolerance: if drought.is_empty() { None } else { Some(drought) },
|
||||||
|
min_nectar: nectar,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
api::list_species_filtered(p, pp, &filters).await
|
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
|
// Fetch images for this species
|
||||||
let species_images = use_resource(move || {
|
let species_images = use_resource(move || {
|
||||||
let sp = species.read().clone();
|
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 ===
|
// === RIGHT COLUMN ===
|
||||||
|
|||||||
@@ -216,6 +216,16 @@ pub struct SearchResult {
|
|||||||
pub rank: f32,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct MeResponse {
|
pub struct MeResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
Reference in New Issue
Block a user