diff --git a/herbapi-api/src/api/search.rs b/herbapi-api/src/api/search.rs index fa7d95d..5eadf54 100644 --- a/herbapi-api/src/api/search.rs +++ b/herbapi-api/src/api/search.rs @@ -12,81 +12,201 @@ pub struct SearchParams { pub limit: Option, } +/// Build a tsquery string with prefix matching from user input. +/// "tomato red" -> "tomato:* & red:*" +fn build_prefix_tsquery(input: &str) -> String { + input + .split_whitespace() + .filter(|w| !w.is_empty()) + .map(|w| format!("{}:*", w.replace('\'', "").replace('\\', ""))) + .collect::>() + .join(" & ") +} + +/// Truncate a string to roughly `max_chars`, breaking at word boundary. +fn truncate_snippet(s: &str, max_chars: usize) -> String { + if s.len() <= max_chars { + return s.to_string(); + } + match s[..max_chars].rfind(' ') { + Some(pos) => format!("{}...", &s[..pos]), + None => format!("{}...", &s[..max_chars]), + } +} + pub async fn search( State(state): State, Query(params): Query, ) -> Result>> { let limit = params.limit.unwrap_or(20).min(100); - let tsquery = params.q.split_whitespace().collect::>().join(" & "); + let tsquery = build_prefix_tsquery(¶ms.q); + + if tsquery.is_empty() { + return Ok(Json(Vec::new())); + } - // Search across families, species, cultivars let mut results = Vec::new(); - // Families - let families: Vec<(uuid::Uuid, String, String, Option, f32)> = sqlx::query_as( - "SELECT id, slug, name_scientific, description, - ts_rank(to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')), - to_tsquery('english', $1)) AS rank + // --- Families --- + // Weighted: A = names, D = description + let families: Vec<( + uuid::Uuid, String, String, Option, Option, Option, f32, + )> = sqlx::query_as( + "SELECT id, slug, name_scientific, name_en, name_de, description, + ts_rank_cd( + setweight(to_tsvector('simple', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')), 'A') + || setweight(to_tsvector('simple', coalesce(description,'')), 'D'), + to_tsquery('simple', $1) + ) AS rank FROM families - WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')) - @@ to_tsquery('english', $1) - ORDER BY rank DESC LIMIT $2" + WHERE ( + setweight(to_tsvector('simple', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')), 'A') + || setweight(to_tsvector('simple', coalesce(description,'')), 'D') + ) @@ to_tsquery('simple', $1) + ORDER BY rank DESC LIMIT $2", ) - .bind(&tsquery).bind(limit) + .bind(&tsquery) + .bind(limit) .fetch_all(&state.pool) .await?; - for (id, slug, name, desc, rank) in families { + for (id, slug, name, name_en, name_de, desc, rank) in families { + let snippet = desc.as_deref().map(|d| truncate_snippet(d, 160)); results.push(SearchResult { entity_type: "family".to_string(), - id, slug, name, + id, + slug, + name, + name_de, + name_en, description: desc, + snippet, + plant_layer: None, + food_uses: None, + species_name: None, + species_slug: None, + is_organic: None, rank, }); } - // Species - let species: Vec<(uuid::Uuid, String, String, Option, f32)> = sqlx::query_as( - "SELECT id, slug, name_scientific, description, - ts_rank(to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,'')), - to_tsquery('english', $1)) AS rank + // --- Species --- + // Weighted: A = names, D = descriptions + food_uses + let species: Vec<( + uuid::Uuid, + String, + String, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + f32, + )> = sqlx::query_as( + "SELECT id, slug, name_scientific, name_en, name_de, + description, description_de, description_en, + plant_layer, food_uses, + ts_rank_cd( + setweight(to_tsvector('simple', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')), 'A') + || setweight(to_tsvector('simple', coalesce(description,'') || ' ' || coalesce(description_de,'') || ' ' || coalesce(description_en,'') || ' ' || coalesce(food_uses,'') || ' ' || coalesce(food_uses_de,'') || ' ' || coalesce(food_uses_en,'')), 'D'), + to_tsquery('simple', $1) + ) AS rank FROM species - WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,'')) - @@ to_tsquery('english', $1) - ORDER BY rank DESC LIMIT $2" + WHERE ( + setweight(to_tsvector('simple', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')), 'A') + || setweight(to_tsvector('simple', coalesce(description,'') || ' ' || coalesce(description_de,'') || ' ' || coalesce(description_en,'') || ' ' || coalesce(food_uses,'') || ' ' || coalesce(food_uses_de,'') || ' ' || coalesce(food_uses_en,'')), 'D') + ) @@ to_tsquery('simple', $1) + ORDER BY rank DESC LIMIT $2", ) - .bind(&tsquery).bind(limit) + .bind(&tsquery) + .bind(limit) .fetch_all(&state.pool) .await?; - for (id, slug, name, desc, rank) in species { + for (id, slug, name, name_en, name_de, desc, desc_de, desc_en, plant_layer, food_uses, rank) in + species + { + // Build snippet from whichever description is available + let snippet_text = desc + .as_deref() + .or(desc_en.as_deref()) + .or(desc_de.as_deref()); + let snippet = snippet_text.map(|d| truncate_snippet(d, 160)); results.push(SearchResult { entity_type: "species".to_string(), - id, slug, name, + id, + slug, + name, + name_de, + name_en, description: desc, + snippet, + plant_layer, + food_uses, + species_name: None, + species_slug: None, + is_organic: None, rank, }); } - // Cultivars - let cultivars: Vec<(uuid::Uuid, String, String, Option, f32)> = sqlx::query_as( - "SELECT id, slug, name, description, - ts_rank(to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,'')), - to_tsquery('english', $1)) AS rank - FROM cultivars - WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,'')) - @@ to_tsquery('english', $1) - ORDER BY rank DESC LIMIT $2" + // --- Cultivars --- + // Join species so that searching "tomato" finds tomato cultivars. + // Weighted: A = cultivar names + species names, D = descriptions + let cultivars: Vec<( + uuid::Uuid, + String, + String, + Option, + Option, + Option, + bool, + String, + String, + f32, + )> = sqlx::query_as( + "SELECT c.id, c.slug, c.name, c.name_en, c.name_de, c.description, c.is_organic, + s.name_scientific AS species_name, s.slug AS species_slug, + ts_rank_cd( + setweight(to_tsvector('simple', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') + || ' ' || coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'')), 'A') + || setweight(to_tsvector('simple', coalesce(c.description,'') || ' ' || coalesce(c.description_de,'') || ' ' || coalesce(c.description_en,'')), 'D'), + to_tsquery('simple', $1) + ) AS rank + FROM cultivars c + JOIN species s ON c.species_id = s.id + WHERE ( + setweight(to_tsvector('simple', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') + || ' ' || coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'')), 'A') + || setweight(to_tsvector('simple', coalesce(c.description,'') || ' ' || coalesce(c.description_de,'') || ' ' || coalesce(c.description_en,'')), 'D') + ) @@ to_tsquery('simple', $1) + ORDER BY rank DESC LIMIT $2", ) - .bind(&tsquery).bind(limit) + .bind(&tsquery) + .bind(limit) .fetch_all(&state.pool) .await?; - for (id, slug, name, desc, rank) in cultivars { + for (id, slug, name, name_en, name_de, desc, is_organic, species_name, species_slug, rank) in + cultivars + { + let snippet = desc.as_deref().map(|d| truncate_snippet(d, 160)); results.push(SearchResult { entity_type: "cultivar".to_string(), - id, slug, name, + id, + slug, + name, + name_de, + name_en, description: desc, + snippet, + plant_layer: None, + food_uses: None, + species_name: Some(species_name), + species_slug: Some(species_slug), + is_organic: Some(is_organic), rank, }); } diff --git a/herbapi-api/src/db/models.rs b/herbapi-api/src/db/models.rs index 6f1ec3b..c04a705 100644 --- a/herbapi-api/src/db/models.rs +++ b/herbapi-api/src/db/models.rs @@ -505,6 +505,14 @@ pub struct SearchResult { pub id: Uuid, pub slug: String, pub name: String, + pub name_de: Option, + pub name_en: Option, pub description: Option, + pub snippet: Option, + pub plant_layer: Option, + pub food_uses: Option, + pub species_name: Option, + pub species_slug: Option, + pub is_organic: Option, pub rank: f32, } diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index 93654f2..92569cb 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -601,23 +601,80 @@ tr:hover td { border-radius: var(--radius); padding: 0.75rem 1rem; display: flex; - align-items: baseline; - gap: 0.75rem; - flex-wrap: wrap; + flex-direction: column; + gap: 0.4rem; box-shadow: var(--shadow); } -.result-type { - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.5px; - flex-shrink: 0; +.search-result-header { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} + +.search-result-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + font-size: 0.85rem; +} + +.badge-family { + background: #e8eaf6; + color: #283593; +} + +.badge-species { + background: #e8f5e9; + color: #2e7d32; +} + +.badge-cultivar { + background: #fff3e0; + color: #e65100; +} + +.badge-meta { + background: var(--accent-light); + color: var(--text-muted); + font-size: 0.72rem; +} + +.result-scientific { + font-style: italic; +} + +.result-cultivar-name { + font-weight: 600; +} + +.result-common-name { + color: var(--text-muted); + font-size: 0.9rem; +} + +.result-species-link { + font-size: 0.85rem; + color: var(--text-muted); +} + +.result-species-link em { + font-style: italic; +} + +.result-uses { + font-size: 0.82rem; + color: var(--text-muted); } .result-desc { width: 100%; color: var(--text-muted); font-size: 0.85rem; + margin: 0; + line-height: 1.4; } /* Table toolbar (search + per-page on same row) */ @@ -1027,7 +1084,11 @@ tr:hover td { } .stats-row { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); + } + + .stat-value { + font-size: 1.4rem; } .quick-filters { @@ -1040,12 +1101,6 @@ tr:hover td { } } -@media (max-width: 480px) { - .stats-row { - grid-template-columns: repeat(2, 1fr); - } -} - /* 404 */ .not-found { @@ -1361,43 +1416,204 @@ td.placeholder { opacity: 0.7; } +/* ── Bottom navigation (mobile only) ──────────────────────── */ + +.bottom-nav { + display: none; +} + /* Responsive */ @media (max-width: 768px) { + /* Hide sidebar completely on mobile */ .sidebar { - width: 60px; - overflow: hidden; - } - - .sidebar-brand, - .sidebar-user, - .brand-text-group { display: none; } - .nav-label { - display: none; + /* Show bottom nav */ + .bottom-nav { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + background: var(--bg-sidebar); + border-top: 1px solid rgba(255,255,255,0.1); + justify-content: space-around; + align-items: center; + padding: 0.35rem 0; + padding-bottom: calc(0.35rem + env(safe-area-inset-bottom, 0px)); } + .bottom-nav-link { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + color: rgba(255,255,255,0.55); + text-decoration: none; + font-size: 0.6rem; + padding: 0.25rem 0.4rem; + border-radius: 4px; + transition: color 0.15s; + min-width: 44px; + text-align: center; + } + + .bottom-nav-link:hover, + .bottom-nav-link:active { + color: #fff; + text-decoration: none; + } + + .bottom-nav-icon { + font-size: 1.25rem; + line-height: 1; + } + + .bottom-nav-label { + font-weight: 500; + letter-spacing: 0.3px; + } + + /* Language toggle in bottom nav */ + .bottom-nav .lang-toggle { + display: flex; + flex-direction: column; + gap: 2px; + } + + .bottom-nav .lang-btn { + padding: 0.2rem 0.45rem; + font-size: 0.65rem; + } + + /* Content adjustments */ .content { - padding: 1.5rem; + padding: 1rem; + margin-left: 0; + padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px)); } + /* Cards */ .card-grid { grid-template-columns: 1fr; } + /* Info grid */ .info-grid { grid-template-columns: 1fr 1fr; } + /* Detail page two-column → single column */ .detail-row { grid-template-columns: 1fr; } + /* Tables: scrollable + smaller text */ + .table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + table { + font-size: 0.82rem; + } + + th, td { + padding: 0.45rem 0.6rem; + } + .attr-table th { width: auto; - min-width: 100px; + min-width: 90px; + font-size: 0.8rem; + } + + .attr-table td { + font-size: 0.85rem; + } + + .cell-truncated { + max-width: 150px; + } + + /* Filter bar: stack vertically */ + .filter-bar { + flex-direction: column; + align-items: stretch; + } + + .filter-group { + width: 100%; + justify-content: space-between; + } + + .filter-group select { + flex: 1; + min-width: 0; + } + + /* Table toolbar: stack */ + .table-toolbar { + flex-direction: column; + align-items: stretch; + } + + .table-toolbar .search-bar { + min-width: 0; + } + + .toolbar-right { + justify-content: space-between; + } + + /* Column toggle: scroll horizontally */ + .column-toggle { + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; + } + + /* 52-week calendar: horizontal scroll */ + .planting-calendar { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .wcal-month-row, + .wcal-row { + min-width: 500px; + } + + .wcal-legend { + padding-left: 0; + flex-wrap: wrap; + } + + /* Hero */ + h1 { + font-size: 1.4rem; + } + + h2 { + font-size: 1.1rem; + } + + /* Pagination */ + .pagination { + gap: 0.5rem; + } + + .pagination button { + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + } + + /* Search results */ + .search-result { + padding: 0.6rem 0.75rem; } } diff --git a/herbapi-ui/src/i18n.rs b/herbapi-ui/src/i18n.rs index ceac093..49e4ec0 100644 --- a/herbapi-ui/src/i18n.rs +++ b/herbapi-ui/src/i18n.rs @@ -211,6 +211,9 @@ 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", "search.type.family") => "Familie", + ("de", "search.type.species") => "Art", + ("de", "search.type.cultivar") => "Sorte", ("de", "subtitle") => "Dreisprachige Pflanzendatenbank f\u{00fc}r Permakultur", ("de", "stat.families") => "Familien", ("de", "stat.species") => "Arten", @@ -415,6 +418,9 @@ 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...", + (_, "search.type.family") => "Family", + (_, "search.type.species") => "Species", + (_, "search.type.cultivar") => "Cultivar", (_, "subtitle") => "Trilingual Plant Database for Permaculture", (_, "stat.families") => "Families", (_, "stat.species") => "Species", diff --git a/herbapi-ui/src/pages/search.rs b/herbapi-ui/src/pages/search.rs index 94791e7..df4ff4b 100644 --- a/herbapi-ui/src/pages/search.rs +++ b/herbapi-ui/src/pages/search.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use crate::api; use crate::app::{Lang, Route}; -use crate::i18n::t; +use crate::i18n::{pick_name, t, t_val}; #[component] pub fn SearchPage() -> Element { @@ -48,30 +48,7 @@ pub fn SearchPage() -> Element { p { class: "result-count", "{data.len()} {t(&l, \"results\")}" } div { class: "search-results", for r in data.iter() { - div { class: "search-result", - span { class: "result-type badge", "{r.entity_type}" } - match r.entity_type.as_str() { - "family" => rsx! { - Link { to: Route::FamilyDetail { slug: r.slug.clone() }, - em { "{r.name}" } - } - }, - "species" => rsx! { - Link { to: Route::SpeciesDetail { slug: r.slug.clone() }, - em { "{r.name}" } - } - }, - "cultivar" => rsx! { - Link { to: Route::CultivarDetail { slug: r.slug.clone() }, - strong { "{r.name}" } - } - }, - _ => rsx! { span { "{r.name}" } }, - } - if let Some(ref desc) = r.description { - p { class: "result-desc", "{desc}" } - } - } + {render_search_result(r, &l)} } } }, @@ -79,3 +56,97 @@ pub fn SearchPage() -> Element { } } } + +fn render_search_result(r: &crate::types::SearchResult, lang: &str) -> Element { + let badge_class = match r.entity_type.as_str() { + "family" => "badge badge-family", + "species" => "badge badge-species", + "cultivar" => "badge badge-cultivar", + _ => "badge", + }; + let badge_label = match r.entity_type.as_str() { + "family" => t(lang, "search.type.family"), + "species" => t(lang, "search.type.species"), + "cultivar" => t(lang, "search.type.cultivar"), + _ => "???", + }; + + // Pick the display name: prefer localized common name, fall back to primary name + let common_name = pick_name(lang, &r.name_de, &r.name_en, &r.name); + let show_common = common_name != r.name; + + rsx! { + div { class: "search-result", + div { class: "search-result-header", + span { class: "{badge_class}", "{badge_label}" } + match r.entity_type.as_str() { + "family" => rsx! { + Link { to: Route::FamilyDetail { slug: r.slug.clone() }, + em { class: "result-scientific", "{r.name}" } + } + }, + "species" => rsx! { + Link { to: Route::SpeciesDetail { slug: r.slug.clone() }, + em { class: "result-scientific", "{r.name}" } + } + }, + "cultivar" => rsx! { + Link { to: Route::CultivarDetail { slug: r.slug.clone() }, + strong { class: "result-cultivar-name", "{r.name}" } + } + }, + _ => rsx! { span { "{r.name}" } }, + } + if show_common { + span { class: "result-common-name", " ({common_name})" } + } + } + + div { class: "search-result-meta", + // Species: show plant layer + food uses + if r.entity_type == "species" { + if let Some(ref layer) = r.plant_layer { + span { class: "badge badge-meta", "{t_val(lang, layer)}" } + } + if let Some(ref uses) = r.food_uses { + span { class: "result-uses", + strong { "{t(lang, \"field.food_uses\")}: " } + "{truncate_display(uses, 80)}" + } + } + } + + // Cultivar: show species name + organic badge + if r.entity_type == "cultivar" { + if let Some(ref sname) = r.species_name { + if let Some(ref sslug) = r.species_slug { + span { class: "result-species-link", + Link { to: Route::SpeciesDetail { slug: sslug.clone() }, + em { "{sname}" } + } + } + } + } + if r.is_organic == Some(true) { + span { class: "badge organic", "{t(lang, \"field.organic\")}" } + } + } + } + + if let Some(ref snippet) = r.snippet { + p { class: "result-desc", "{snippet}" } + } + } + } +} + +fn truncate_display(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + match s[..max].rfind(' ') { + Some(pos) => format!("{}...", &s[..pos]), + None => format!("{}...", &s[..max]), + } + } +} diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index 09c048d..005bf3a 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -212,7 +212,15 @@ pub struct SearchResult { pub id: Uuid, pub slug: String, pub name: String, + pub name_de: Option, + pub name_en: Option, pub description: Option, + pub snippet: Option, + pub plant_layer: Option, + pub food_uses: Option, + pub species_name: Option, + pub species_slug: Option, + pub is_organic: Option, pub rank: f32, }