Frontend overhaul: NetBox-style detail pages, selectable columns, data sources

- Cultivar/species detail pages rewritten with two-column card layout, attribute tables, em-dash placeholders
- Column toggle + per-page selector on all list pages (families, species, cultivars, suppliers)
- Species list: table/card view toggle with family, layer, N-fixer, uses columns
- Cultivar detail: supplier links with SKU/price/product URL, species info section
- Data sources page (/sources) with attribution for all 10 data sources
- Fixed Cultivar/Species structs with #[serde(default)] for API compatibility
- Added table_controls component (reusable column toggle + per-page selector)
- Removed max-width constraint on content area for full-width tables
- Fixed route conflicts: merged {slug}/{id} into single {ref} routes
- Removed PostgreSQL enum types (plant_layer, drought_tolerance, etc.) in favor of TEXT
- Fixed API per_page parameter support across all list endpoints
This commit is contained in:
2026-03-15 00:53:06 +01:00
parent 484979ad53
commit 42906efd90
17 changed files with 2130 additions and 276 deletions
+12 -12
View File
@@ -14,28 +14,28 @@ CREATE TABLE species (
description TEXT, description TEXT,
soil_moisture TEXT, soil_moisture TEXT,
drainage_requirement TEXT, drainage_requirement TEXT,
organic_matter_pct NUMERIC(5,2), organic_matter_pct DOUBLE PRECISION,
nitrogen_ppm INTEGER, nitrogen_ppm INTEGER,
phosphorus_ppm INTEGER, phosphorus_ppm INTEGER,
potassium_ppm INTEGER, potassium_ppm INTEGER,
boron_ppm NUMERIC(8,2), boron_ppm DOUBLE PRECISION,
calcium_ppm INTEGER, calcium_ppm INTEGER,
copper_ppm NUMERIC(8,2), copper_ppm DOUBLE PRECISION,
iron_ppm NUMERIC(8,2), iron_ppm DOUBLE PRECISION,
magnesium_ppm INTEGER, magnesium_ppm INTEGER,
manganese_ppm NUMERIC(8,2), manganese_ppm DOUBLE PRECISION,
molybdenum_ppm NUMERIC(8,2), molybdenum_ppm DOUBLE PRECISION,
sulfur_ppm INTEGER, sulfur_ppm INTEGER,
zinc_ppm NUMERIC(8,2), zinc_ppm DOUBLE PRECISION,
ph_min NUMERIC(4,2), ph_min DOUBLE PRECISION,
ph_max NUMERIC(4,2), ph_max DOUBLE PRECISION,
soil_texture_preference TEXT[], soil_texture_preference TEXT[],
hardiness_zone_usda TEXT, hardiness_zone_usda TEXT,
hardiness_zone_at TEXT, hardiness_zone_at TEXT,
min_temp NUMERIC(5,2), min_temp DOUBLE PRECISION,
max_temp NUMERIC(5,2), max_temp DOUBLE PRECISION,
drought_tolerance drought_tolerance, drought_tolerance drought_tolerance,
water_requirement_mm_week NUMERIC(5,2), water_requirement_mm_week DOUBLE PRECISION,
waterlogging_tolerance BOOLEAN, waterlogging_tolerance BOOLEAN,
salt_tolerance salt_tolerance, salt_tolerance salt_tolerance,
edibility_rating SMALLINT, edibility_rating SMALLINT,
+12 -12
View File
@@ -12,27 +12,27 @@ CREATE TABLE cultivars (
is_organic BOOLEAN NOT NULL DEFAULT FALSE, is_organic BOOLEAN NOT NULL DEFAULT FALSE,
perennial BOOLEAN NOT NULL DEFAULT FALSE, perennial BOOLEAN NOT NULL DEFAULT FALSE,
growing_time_days INTEGER, growing_time_days INTEGER,
planting_depth_cm NUMERIC(5,2), planting_depth_cm DOUBLE PRECISION,
row_spacing_cm NUMERIC(5,2), row_spacing_cm DOUBLE PRECISION,
plant_spacing_cm NUMERIC(5,2), plant_spacing_cm DOUBLE PRECISION,
days_to_germination INTEGER, days_to_germination INTEGER,
germination_temp_c NUMERIC(5,2), germination_temp_c DOUBLE PRECISION,
light_requirement TEXT, light_requirement TEXT,
stratification_required BOOLEAN, stratification_required BOOLEAN,
stratification_days INTEGER, stratification_days INTEGER,
scarification_required BOOLEAN, scarification_required BOOLEAN,
seed_viability_years INTEGER, seed_viability_years INTEGER,
storage_temp_c NUMERIC(5,2), storage_temp_c DOUBLE PRECISION,
storage_humidity TEXT, storage_humidity TEXT,
storage_notes TEXT, storage_notes TEXT,
min_temp NUMERIC(5,2), min_temp DOUBLE PRECISION,
max_temp NUMERIC(5,2), max_temp DOUBLE PRECISION,
humidity TEXT, humidity TEXT,
light TEXT, light TEXT,
frost_tolerance frost_tolerance, frost_tolerance frost_tolerance,
min_light_hours_day NUMERIC(4,1), min_light_hours_day DOUBLE PRECISION,
optimal_light_hours_day NUMERIC(4,1), optimal_light_hours_day DOUBLE PRECISION,
greenhouse_min_temp_c NUMERIC(5,2), greenhouse_min_temp_c DOUBLE PRECISION,
indoor_season_extension_weeks INTEGER, indoor_season_extension_weeks INTEGER,
ventilation_requirement TEXT, ventilation_requirement TEXT,
heating_required BOOLEAN, heating_required BOOLEAN,
@@ -48,9 +48,9 @@ CREATE TABLE cultivars (
rootstock_species_id UUID REFERENCES species(id), rootstock_species_id UUID REFERENCES species(id),
years_to_first_harvest INTEGER, years_to_first_harvest INTEGER,
productive_lifespan_years INTEGER, productive_lifespan_years INTEGER,
expected_yield_kg_per_m2 NUMERIC(8,2), expected_yield_kg_per_m2 DOUBLE PRECISION,
yield_unit TEXT, yield_unit TEXT,
expected_yield_value NUMERIC(8,2), expected_yield_value DOUBLE PRECISION,
harvest_window_days INTEGER, harvest_window_days INTEGER,
storage_method TEXT[], storage_method TEXT[],
shelf_life_days INTEGER, shelf_life_days INTEGER,
@@ -17,8 +17,8 @@ CREATE TABLE cultivar_suppliers (
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE, supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
article_number TEXT, article_number TEXT,
product_url TEXT, product_url TEXT,
price_eur NUMERIC(8,2), price_eur DOUBLE PRECISION,
pack_size NUMERIC(8,2), pack_size DOUBLE PRECISION,
pack_unit TEXT, pack_unit TEXT,
last_checked_at TIMESTAMPTZ, last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+7 -7
View File
@@ -121,9 +121,9 @@ pub async fn create(pool: &PgPool, req: &CreateSpecies) -> Result<Species> {
ground_cover_quality, allelopathic, guild_role, succession_stage, heavy_metal_tolerance, ground_cover_quality, allelopathic, guild_role, succession_stage, heavy_metal_tolerance,
wikidata_qid, gbif_id, eppo_code, pfaf_url, source_urls) wikidata_qid, gbif_id, eppo_code, pfaf_url, source_urls)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
$17::drought_tolerance,$18::salt_tolerance,$19,$20,$21,$22,$23, $17,$18,$19,$20,$21,$22,$23,
$24::invasiveness_level,$25,$26::plant_layer,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36, $24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,
$37::succession_stage,$38,$39,$40,$41,$42,$43) $37,$38,$39,$40,$41,$42,$43)
RETURNING *" RETURNING *"
) )
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific) .bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
@@ -160,13 +160,13 @@ pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSpecies) -> Result<Spec
description=$7, soil_moisture=$8, drainage_requirement=$9, ph_min=$10, ph_max=$11, description=$7, soil_moisture=$8, drainage_requirement=$9, ph_min=$10, ph_max=$11,
soil_texture_preference=$12, hardiness_zone_usda=$13, hardiness_zone_at=$14, soil_texture_preference=$12, hardiness_zone_usda=$13, hardiness_zone_at=$14,
min_temp=$15, max_temp=$16, min_temp=$15, max_temp=$16,
drought_tolerance=$17::drought_tolerance, salt_tolerance=$18::salt_tolerance, drought_tolerance=$17, salt_tolerance=$18,
edibility_rating=$19, food_uses=$20, medicinal_uses=$21, other_uses=$22, edibility_rating=$19, food_uses=$20, medicinal_uses=$21, other_uses=$22,
native_range=$23, invasiveness=$24::invasiveness_level, pollination_type=$25, native_range=$23, invasiveness=$24, pollination_type=$25,
plant_layer=$26::plant_layer, nitrogen_fixer=$27, dynamic_accumulator=$28, plant_layer=$26, nitrogen_fixer=$27, dynamic_accumulator=$28,
dynamic_accumulator_nutrients=$29, attracts_pollinators=$30, attracts_beneficial_insects=$31, dynamic_accumulator_nutrients=$29, attracts_pollinators=$30, attracts_beneficial_insects=$31,
wildlife_value=$32, mulch_plant=$33, ground_cover_quality=$34, allelopathic=$35, wildlife_value=$32, mulch_plant=$33, ground_cover_quality=$34, allelopathic=$35,
guild_role=$36, succession_stage=$37::succession_stage, heavy_metal_tolerance=$38, guild_role=$36, succession_stage=$37, heavy_metal_tolerance=$38,
wikidata_qid=$39, gbif_id=$40, eppo_code=$41, pfaf_url=$42, source_urls=$43, wikidata_qid=$39, gbif_id=$40, eppo_code=$41, pfaf_url=$42, source_urls=$43,
updated_at=NOW() updated_at=NOW()
WHERE id=$1 RETURNING *" WHERE id=$1 RETURNING *"
+412 -1
View File
@@ -150,7 +150,6 @@ em {
.content { .content {
flex: 1; flex: 1;
padding: 2rem 3rem; padding: 2rem 3rem;
max-width: 1200px;
} }
.page { .page {
@@ -498,6 +497,231 @@ tr:hover td {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Table toolbar (search + per-page on same row) */
.table-toolbar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.table-toolbar .search-bar {
flex: 1;
margin-bottom: 0;
min-width: 200px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Column toggle bar */
.column-toggle {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.column-toggle-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-right: 0.25rem;
font-weight: 600;
}
.col-btn {
padding: 0.2rem 0.5rem;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.col-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.col-btn-active {
background: var(--accent-light);
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
/* Per-page selector */
.per-page-selector {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-muted);
white-space: nowrap;
}
.per-page-selector select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
font-size: 0.85rem;
cursor: pointer;
}
.per-page-selector select:focus {
outline: none;
border-color: var(--accent);
}
/* View toggle button */
.view-toggle-btn {
padding: 0.35rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
font-size: 0.8rem;
cursor: pointer;
color: var(--text);
transition: all 0.15s;
white-space: nowrap;
}
.view-toggle-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
}
/* Truncated cell */
.cell-truncated {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* External link styling in tables */
.external-link {
color: var(--accent);
font-size: 0.85rem;
}
.external-link:hover {
text-decoration: underline;
}
/* Species name in tables */
.species-name {
white-space: nowrap;
}
/* Sources page */
.sources-intro {
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 700px;
line-height: 1.7;
}
.sources-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.source-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
box-shadow: var(--shadow);
}
.source-header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.source-name {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
color: var(--text);
}
.source-url {
font-size: 0.85rem;
color: var(--accent);
}
.source-description {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.source-details {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.source-detail {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
align-items: baseline;
}
.source-detail-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
font-weight: 600;
flex-shrink: 0;
min-width: 70px;
}
.source-detail-value {
color: var(--text);
}
.source-license {
display: inline-block;
padding: 0.1rem 0.5rem;
background: var(--accent-light);
color: var(--accent);
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
/* 404 */ /* 404 */
.not-found { .not-found {
@@ -525,6 +749,184 @@ tr:hover td {
border-radius: var(--radius); border-radius: var(--radius);
} }
/* Cultivar detail page */
.cultivar-detail .species-link {
font-size: 1.1rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.cultivar-detail .description {
margin: 1.5rem 0;
line-height: 1.7;
max-width: 800px;
}
.cultivar-detail .info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin: 1rem 0 1.5rem;
}
.cultivar-detail .info-item {
background: var(--accent-light);
padding: 0.75rem 1rem;
border-radius: var(--radius);
}
.cultivar-detail .info-label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.25rem;
}
.cultivar-detail .info-value {
font-weight: 600;
font-size: 1rem;
}
.supplier-links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
}
.supplier-link-card {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 1rem 1.25rem;
border-radius: var(--radius);
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.supplier-link-card .sku {
color: var(--text-muted);
font-size: 0.85rem;
}
.supplier-link-card .price {
font-weight: 600;
color: var(--accent);
}
.supplier-link-card .external-link {
background: var(--accent);
color: white;
padding: 0.3rem 0.8rem;
border-radius: var(--radius);
text-decoration: none;
font-size: 0.85rem;
}
.supplier-link-card .external-link:hover {
background: var(--accent-hover);
}
/* ========================================
Detail pages — two-column card layout
======================================== */
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
margin-top: 1.25rem;
}
.detail-col {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.detail-card-header {
background: var(--accent-light);
color: var(--accent);
font-weight: 600;
font-size: 0.9rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-card-empty {
padding: 0.75rem 1rem;
font-size: 0.9rem;
}
/* Attribute table inside detail cards */
.attr-table {
width: 100%;
border-collapse: collapse;
box-shadow: none;
border-radius: 0;
}
.attr-table thead th {
text-align: left;
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
}
.attr-table tbody tr:nth-child(even) {
background: var(--bg);
}
.attr-table tbody tr:nth-child(odd) {
background: var(--bg-card);
}
.attr-table th {
width: 200px;
min-width: 140px;
text-align: right;
padding: 0.5rem 0.75rem;
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 500;
vertical-align: top;
white-space: nowrap;
text-transform: none;
letter-spacing: 0;
background: transparent;
border-top: none;
}
.attr-table td {
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
border-top: none;
word-break: break-word;
}
/* Placeholder (em dash) — muted style */
.placeholder,
td.placeholder {
color: var(--text-muted);
opacity: 0.5;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -554,4 +956,13 @@ tr:hover td {
.info-grid { .info-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.detail-row {
grid-template-columns: 1fr;
}
.attr-table th {
width: auto;
min-width: 100px;
}
} }
+6 -6
View File
@@ -86,8 +86,8 @@ pub async fn get_current_user() -> Result<MeResponse, String> {
} }
// --- Families --- // --- Families ---
pub async fn list_families(page: i64, search: Option<&str>) -> Result<PaginatedResponse<Family>, String> { pub async fn list_families(page: i64, per_page: i64, search: Option<&str>) -> Result<PaginatedResponse<Family>, String> {
let mut url = format!("{API_BASE}/families?page={page}&per_page=25"); let mut url = format!("{API_BASE}/families?page={page}&per_page={per_page}");
if let Some(q) = search { if let Some(q) = search {
url.push_str(&format!("&search={q}")); url.push_str(&format!("&search={q}"));
} }
@@ -99,8 +99,8 @@ pub async fn get_family(slug: &str) -> Result<Family, String> {
} }
// --- Species --- // --- Species ---
pub async fn list_species(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> {
let mut url = format!("{API_BASE}/species?page={page}&per_page=25"); let mut url = format!("{API_BASE}/species?page={page}&per_page={per_page}");
if let Some(f) = family { if let Some(f) = family {
url.push_str(&format!("&family={f}")); url.push_str(&format!("&family={f}"));
} }
@@ -115,8 +115,8 @@ pub async fn get_species(slug: &str) -> Result<Species, String> {
} }
// --- Cultivars --- // --- Cultivars ---
pub async fn list_cultivars(page: i64, species: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Cultivar>, String> { pub async fn list_cultivars(page: i64, per_page: i64, species: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Cultivar>, String> {
let mut url = format!("{API_BASE}/cultivars?page={page}&per_page=25"); let mut url = format!("{API_BASE}/cultivars?page={page}&per_page={per_page}");
if let Some(s) = species { if let Some(s) = species {
url.push_str(&format!("&species={s}")); url.push_str(&format!("&species={s}"));
} }
+4
View File
@@ -27,6 +27,8 @@ pub enum Route {
SupplierDetail { slug: String }, SupplierDetail { slug: String },
#[route("/search")] #[route("/search")]
SearchPage {}, SearchPage {},
#[route("/sources")]
Sources {},
#[end_layout] #[end_layout]
#[route("/:..segments")] #[route("/:..segments")]
NotFound { segments: Vec<String> }, NotFound { segments: Vec<String> },
@@ -62,6 +64,7 @@ fn Layout() -> Element {
NavLink { to: Route::CultivarList {}, label: "Cultivars" } NavLink { to: Route::CultivarList {}, label: "Cultivars" }
NavLink { to: Route::SupplierList {}, label: "Suppliers" } NavLink { to: Route::SupplierList {}, label: "Suppliers" }
NavLink { to: Route::SearchPage {}, label: "Search" } NavLink { to: Route::SearchPage {}, label: "Search" }
NavLink { to: Route::Sources {}, label: "Sources" }
} }
div { class: "sidebar-user", div { class: "sidebar-user",
if let Some(ref u) = user { if let Some(ref u) = user {
@@ -104,5 +107,6 @@ 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;
pub use crate::pages::search::SearchPage; pub use crate::pages::search::SearchPage;
pub use crate::pages::sources::Sources;
pub use crate::pages::species::{SpeciesDetail, SpeciesList}; pub use crate::pages::species::{SpeciesDetail, SpeciesList};
pub use crate::pages::suppliers::{SupplierDetail, SupplierList}; pub use crate::pages::suppliers::{SupplierDetail, SupplierList};
+1
View File
@@ -1,2 +1,3 @@
pub mod plant_card; pub mod plant_card;
pub mod planting_calendar; pub mod planting_calendar;
pub mod table_controls;
+118
View File
@@ -0,0 +1,118 @@
use dioxus::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashSet;
/// Column definition for configurable tables
#[derive(Clone, PartialEq)]
pub struct ColumnDef {
pub key: &'static str,
pub label: &'static str,
pub default_visible: bool,
}
/// Load visible columns from localStorage, falling back to defaults
pub fn load_visible_columns(storage_key: &str, columns: &[ColumnDef]) -> HashSet<String> {
if let Ok(stored) = LocalStorage::get::<Vec<String>>(storage_key) {
stored.into_iter().collect()
} else {
columns
.iter()
.filter(|c| c.default_visible)
.map(|c| c.key.to_string())
.collect()
}
}
/// Save visible columns to localStorage
pub fn save_visible_columns(storage_key: &str, visible: &HashSet<String>) {
let vec: Vec<String> = visible.iter().cloned().collect();
let _ = LocalStorage::set(storage_key, vec);
}
/// Column visibility toggle bar
#[component]
pub fn ColumnToggle(
columns: Vec<ColumnDef>,
visible: Signal<HashSet<String>>,
storage_key: String,
) -> Element {
rsx! {
div { class: "column-toggle",
span { class: "column-toggle-label", "Columns:" }
for col in columns.iter() {
{
let key = col.key.to_string();
let is_visible = visible.read().contains(&key);
let key_toggle = key.clone();
let sk = storage_key.clone();
rsx! {
button {
class: if is_visible { "col-btn col-btn-active" } else { "col-btn" },
onclick: move |_| {
let mut v = visible.write();
let k = key_toggle.clone();
if v.contains(&k) {
v.remove(&k);
} else {
v.insert(k);
}
save_visible_columns(&sk, &v);
},
"{col.label}"
}
}
}
}
}
}
}
/// Per-page selector dropdown
#[component]
pub fn PerPageSelector(
per_page: Signal<i64>,
page: Signal<i64>,
storage_key: String,
) -> Element {
let current = *per_page.read();
rsx! {
div { class: "per-page-selector",
label { "Show " }
select {
value: "{current}",
onchange: move |e| {
if let Ok(v) = e.value().parse::<i64>() {
per_page.set(v);
page.set(1);
let _ = LocalStorage::set(&storage_key, v);
}
},
option { value: "10", "10" }
option { value: "25", "25" }
option { value: "50", "50" }
option { value: "100", "100" }
}
label { " per page" }
}
}
}
/// Load per-page value from localStorage
pub fn load_per_page(storage_key: &str, default: i64) -> i64 {
LocalStorage::get::<i64>(storage_key).unwrap_or(default)
}
/// Helper to check if a column is visible
pub fn is_col_visible(visible: &HashSet<String>, key: &str) -> bool {
visible.contains(key)
}
/// Truncate a string to max_len chars, adding "..." if truncated
pub fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len])
}
}
+624 -48
View File
@@ -1,28 +1,134 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use std::collections::HashMap;
use uuid::Uuid;
use crate::api; use crate::api;
use crate::app::Route; use crate::app::Route;
use crate::components::planting_calendar::PlantingCalendar; use crate::components::table_controls::*;
/// Convert a month-number array (1=Jan..12=Dec) to a comma-separated string of abbreviations.
fn months_display(months: &Option<Vec<i32>>) -> String {
const NAMES: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
match months {
Some(v) if !v.is_empty() => v
.iter()
.filter_map(|&m| NAMES.get((m - 1) as usize).copied())
.collect::<Vec<_>>()
.join(", "),
_ => "\u{2014}".to_string(), // em dash
}
}
/// Format an Option<String> for display: value or em dash placeholder.
fn opt_str(val: &Option<String>) -> String {
match val {
Some(s) if !s.is_empty() => s.clone(),
_ => "\u{2014}".to_string(),
}
}
/// Format an Option<f64> with a suffix.
fn opt_f64_suffix(val: Option<f64>, suffix: &str) -> String {
match val {
Some(v) => format!("{v}{suffix}"),
None => "\u{2014}".to_string(),
}
}
/// Format an Option<i32> with a suffix.
fn opt_i32_suffix(val: Option<i32>, suffix: &str) -> String {
match val {
Some(v) => format!("{v}{suffix}"),
None => "\u{2014}".to_string(),
}
}
/// Format an Option<bool> as Yes / No / em dash.
fn opt_bool(val: Option<bool>) -> String {
match val {
Some(true) => "Yes".to_string(),
Some(false) => "No".to_string(),
None => "\u{2014}".to_string(),
}
}
/// Bool field: always present (non-Option).
fn bool_display(val: bool) -> &'static str {
if val { "Yes" } else { "No" }
}
/// Format an Option<Vec<String>> as comma-separated or em dash.
fn opt_vec_str(val: &Option<Vec<String>>) -> String {
match val {
Some(v) if !v.is_empty() => v.join(", "),
_ => "\u{2014}".to_string(),
}
}
const STORAGE_KEY_COLS: &str = "herbapi_cultivars_cols";
const STORAGE_KEY_PP: &str = "herbapi_cultivars_pp";
fn cultivar_columns() -> Vec<ColumnDef> {
vec![
ColumnDef { key: "name", label: "Name", default_visible: true },
ColumnDef { key: "species", label: "Species", default_visible: true },
ColumnDef { key: "organic", label: "Organic", default_visible: true },
ColumnDef { key: "perennial", label: "Perennial", default_visible: true },
ColumnDef { key: "description", label: "Description", default_visible: false },
ColumnDef { key: "frost_tolerance", label: "Frost Tol.", default_visible: false },
ColumnDef { key: "growing_time", label: "Growing Time", default_visible: false },
ColumnDef { key: "days_germ", label: "Days to Germ.", default_visible: false },
]
}
#[component] #[component]
pub fn CultivarList() -> Element { pub fn CultivarList() -> Element {
let columns = cultivar_columns();
let mut page = use_signal(|| 1i64); let mut page = use_signal(|| 1i64);
let per_page = use_signal(|| load_per_page(STORAGE_KEY_PP, 25));
let mut search = use_signal(|| String::new()); let mut search = use_signal(|| String::new());
let current_page = *page.read(); let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &cultivar_columns()));
let search_str = search.read().clone();
// Fetch species list once to map species_id -> name
let species_map = use_resource(move || async move {
let mut map = HashMap::<Uuid, String>::new();
if let Ok(resp) = api::list_species(1, 100, None, None).await {
for s in resp.data {
map.insert(s.id, s.name_scientific);
}
// Fetch remaining pages if needed
let total_pages = (resp.total + resp.per_page - 1) / resp.per_page;
for p in 2..=total_pages {
if let Ok(r) = api::list_species(p, 100, None, None).await {
for s in r.data {
map.insert(s.id, s.name_scientific);
}
}
}
}
map
});
let cultivars = use_resource(move || { let cultivars = use_resource(move || {
let s = search_str.clone(); let p = *page.read();
let pp = *per_page.read();
let s = search.read().clone();
async move { async move {
let q = if s.is_empty() { None } else { Some(s.as_str()) }; let q = if s.is_empty() { None } else { Some(s.as_str()) };
api::list_cultivars(current_page, None, q).await api::list_cultivars(p, pp, None, q).await
} }
}); });
let current_page = *page.read();
rsx! { rsx! {
div { class: "page", div { class: "page",
h1 { "Cultivars" } h1 { "Cultivars" }
div { class: "table-toolbar",
div { class: "search-bar", div { class: "search-bar",
input { input {
r#type: "text", r#type: "text",
@@ -34,33 +140,117 @@ pub fn CultivarList() -> Element {
}, },
} }
} }
PerPageSelector {
per_page: per_page,
page: page,
storage_key: STORAGE_KEY_PP.to_string(),
}
}
ColumnToggle {
columns: columns.clone(),
visible: visible_cols,
storage_key: STORAGE_KEY_COLS.to_string(),
}
match &*cultivars.read() { match &*cultivars.read() {
None => rsx! { p { "Loading..." } }, None => rsx! { p { "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)) => {
let smap = species_map.read();
let empty_map: HashMap<Uuid, String> = HashMap::new();
let sm = match &*smap {
Some(m) => m,
_ => &empty_map,
};
let vis = visible_cols.read();
rsx! {
p { class: "result-count", "{data.total} cultivars" }
div { class: "table-wrap", div { class: "table-wrap",
table { table {
thead { thead {
tr { tr {
if is_col_visible(&vis, "name") {
th { "Name" } th { "Name" }
}
if is_col_visible(&vis, "species") {
th { "Species" }
}
if is_col_visible(&vis, "organic") {
th { "Organic" } th { "Organic" }
}
if is_col_visible(&vis, "perennial") {
th { "Perennial" } th { "Perennial" }
th { "Frost Tolerance" } }
if is_col_visible(&vis, "description") {
th { "Description" }
}
if is_col_visible(&vis, "frost_tolerance") {
th { "Frost Tol." }
}
if is_col_visible(&vis, "growing_time") {
th { "Growing Time" }
}
if is_col_visible(&vis, "days_germ") {
th { "Days to Germ." }
}
} }
} }
tbody { tbody {
for c in data.data.iter() { for c in data.data.iter() {
{
let species_name: &str = sm.get(&c.species_id).map(String::as_str).unwrap_or("-");
rsx! {
tr { tr {
if is_col_visible(&vis, "name") {
td { td {
Link { to: Route::CultivarDetail { slug: c.slug.clone() }, Link { to: Route::CultivarDetail { slug: c.slug.clone() },
strong { "{c.name}" } strong { "{c.name}" }
} }
} }
td { if c.is_organic { "Yes" } else { "-" } } }
if is_col_visible(&vis, "species") {
td { class: "species-name", em { "{species_name}" } }
}
if is_col_visible(&vis, "organic") {
td {
if c.is_organic {
span { class: "badge organic", "Yes" }
} else {
"-"
}
}
}
if is_col_visible(&vis, "perennial") {
td { if c.perennial { "Yes" } else { "Annual" } } td { if c.perennial { "Yes" } else { "Annual" } }
}
if is_col_visible(&vis, "description") {
td { class: "cell-truncated",
"{c.description.as_deref().map(|d| truncate(d, 60)).unwrap_or_else(|| \"-\".to_string())}"
}
}
if is_col_visible(&vis, "frost_tolerance") {
td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" } td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" }
} }
if is_col_visible(&vis, "growing_time") {
td {
match c.growing_time_days {
Some(d) => rsx! { "{d} days" },
None => rsx! { "-" },
}
}
}
if is_col_visible(&vis, "days_germ") {
td {
match c.days_to_germination {
Some(d) => rsx! { "{d}" },
None => rsx! { "-" },
}
}
}
}
}
}
} }
} }
} }
@@ -72,7 +262,7 @@ pub fn CultivarList() -> Element {
onclick: move |_| page.set(current_page - 1), onclick: move |_| page.set(current_page - 1),
"Previous" "Previous"
} }
span { "Page {current_page}" } span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
button { button {
disabled: current_page * data.per_page >= data.total, disabled: current_page * data.per_page >= data.total,
onclick: move |_| page.set(current_page + 1), onclick: move |_| page.set(current_page + 1),
@@ -80,6 +270,7 @@ pub fn CultivarList() -> Element {
} }
} }
} }
}
}, },
} }
} }
@@ -94,57 +285,442 @@ pub fn CultivarDetail(slug: String) -> Element {
async move { api::get_cultivar(&s).await } async move { api::get_cultivar(&s).await }
}); });
// Fetch species info once we have the cultivar
let species_data = use_resource(move || {
let cv = cultivar.read().clone();
async move {
if let Some(Ok(c)) = cv {
let resp = api::list_species(1, 200, None, None).await.ok();
if let Some(r) = resp {
for s in &r.data {
if s.id == c.species_id {
return Some(s.clone());
}
}
}
}
None
}
});
// Fetch supplier links once we have the cultivar
let suppliers_data = use_resource(move || {
let cv = cultivar.read().clone();
async move {
if let Some(Ok(c)) = cv {
api::get_cultivar_suppliers(c.id).await.ok()
} else {
None
}
}
});
// Fetch all suppliers for name lookup
let all_suppliers = use_resource(move || async move {
api::list_suppliers().await.ok().unwrap_or_default()
});
rsx! { rsx! {
div { class: "page cultivar-detail", div { class: "page cultivar-detail",
match &*cultivar.read() { match &*cultivar.read() {
None => rsx! { p { "Loading..." } }, None => rsx! { p { "Loading..." } },
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(c)) => rsx! { Some(Ok(c)) => {
// Pre-compute display strings outside of rsx
let name_en = opt_str(&c.name_en);
let name_de = opt_str(&c.name_de);
let name_sci = opt_str(&c.name_scientific);
let desc = opt_str(&c.description);
let organic = bool_display(c.is_organic);
let perennial = bool_display(c.perennial);
// Planting schedule
let indoor = months_display(&c.indoor_sowing_months);
let direct = months_display(&c.direct_sowing_months);
let transplant = months_display(&c.transplanting_months);
let glasshouse = months_display(&c.glasshouse_months);
let harvest = months_display(&c.harvesting_months);
let succession = opt_i32_suffix(c.succession_planting_days, " days");
let planting_notes = opt_str(&c.planting_notes);
// Growing information
let growing_time = opt_i32_suffix(c.growing_time_days, " days");
let planting_depth = opt_f64_suffix(c.planting_depth_cm, " cm");
let row_spacing = opt_f64_suffix(c.row_spacing_cm, " cm");
let plant_spacing = opt_f64_suffix(c.plant_spacing_cm, " cm");
let propagation = opt_vec_str(&c.propagation_methods);
// Germination
let days_germ = opt_i32_suffix(c.days_to_germination, "");
let germ_temp = opt_f64_suffix(c.germination_temp_c, " \u{00b0}C");
let light_req = opt_str(&c.light_requirement);
let strat_req = opt_bool(c.stratification_required);
let strat_days = opt_i32_suffix(c.stratification_days, "");
let scar_req = opt_bool(c.scarification_required);
// Climate
let min_temp = opt_f64_suffix(c.min_temp, " \u{00b0}C");
let max_temp = opt_f64_suffix(c.max_temp, " \u{00b0}C");
let humidity = opt_str(&c.humidity);
let light = opt_str(&c.light);
let frost_tol = opt_str(&c.frost_tolerance);
let min_light_h = opt_f64_suffix(c.min_light_hours_day, " h");
let opt_light_h = opt_f64_suffix(c.optimal_light_hours_day, " h");
rsx! {
h1 { "{c.name}" } h1 { "{c.name}" }
if let Some(ref en) = c.name_en {
p { class: "name-common", "{en}" }
}
if let Some(ref desc) = c.description {
div { class: "description", "{desc}" }
}
div { class: "badges", div { class: "detail-row",
if c.is_organic { // === LEFT COLUMN ===
span { class: "badge organic", "Organic" } div { class: "detail-col",
}
if c.perennial {
span { class: "badge", "Perennial" }
}
if let Some(ref ft) = c.frost_tolerance {
span { class: "badge", "Frost: {ft}" }
}
}
if let Some(ref dtg) = c.days_to_germination { // Card 1: Cultivar Details
p { "Days to germination: {dtg}" } div { class: "detail-card",
div { class: "detail-card-header", "Cultivar Details" }
table { class: "attr-table",
tbody {
tr {
th { "Name" }
td { "{c.name}" }
} }
if let Some(ref gtd) = c.growing_time_days { tr {
p { "Growing time: {gtd} days" } th { "Name EN" }
td { class: if name_en == "\u{2014}" { "placeholder" } else { "" }, "{name_en}" }
} }
tr {
// Planting calendar th { "Name DE" }
h2 { "Planting Calendar" } td { class: if name_de == "\u{2014}" { "placeholder" } else { "" }, "{name_de}" }
PlantingCalendar {
indoor_sowing: c.indoor_sowing_months.clone(),
direct_sowing: c.direct_sowing_months.clone(),
transplanting: c.transplanting_months.clone(),
glasshouse: c.glasshouse_months.clone(),
harvesting: c.harvesting_months.clone(),
} }
tr {
if let Some(ref pg) = c.pollination_group { th { "Scientific Name" }
p { "Pollination group: {pg}" } td { class: if name_sci == "\u{2014}" { "placeholder" } else { "" },
} if name_sci != "\u{2014}" {
if let Some(sf) = c.self_fertile { em { "{name_sci}" }
if sf {
p { "Self-fertile: Yes" }
} else { } else {
p { "Self-fertile: No" } span { "\u{2014}" }
}
}
}
tr {
th { "Species" }
td {
if let Some(Some(ref sp)) = *species_data.read() {
Link { to: Route::SpeciesDetail { slug: sp.slug.clone() },
em { "{sp.name_scientific}" }
}
if let Some(ref de) = sp.name_de {
if !de.is_empty() {
" ({de})"
}
}
} else {
span { class: "placeholder", "\u{2014}" }
}
}
}
tr {
th { "Description" }
td { class: if desc == "\u{2014}" { "placeholder" } else { "" }, "{desc}" }
}
tr {
th { "Organic" }
td { "{organic}" }
}
tr {
th { "Perennial" }
td { "{perennial}" }
}
}
}
}
// Card 2: Planting Schedule
div { class: "detail-card",
div { class: "detail-card-header", "Planting Schedule" }
table { class: "attr-table",
tbody {
tr {
th { "Indoor Sowing" }
td { class: if indoor == "\u{2014}" { "placeholder" } else { "" }, "{indoor}" }
}
tr {
th { "Direct Sowing" }
td { class: if direct == "\u{2014}" { "placeholder" } else { "" }, "{direct}" }
}
tr {
th { "Transplanting" }
td { class: if transplant == "\u{2014}" { "placeholder" } else { "" }, "{transplant}" }
}
tr {
th { "Glasshouse" }
td { class: if glasshouse == "\u{2014}" { "placeholder" } else { "" }, "{glasshouse}" }
}
tr {
th { "Harvesting" }
td { class: if harvest == "\u{2014}" { "placeholder" } else { "" }, "{harvest}" }
}
tr {
th { "Succession Planting" }
td { class: if succession == "\u{2014}" { "placeholder" } else { "" }, "{succession}" }
}
tr {
th { "Planting Notes" }
td { class: if planting_notes == "\u{2014}" { "placeholder" } else { "" }, "{planting_notes}" }
}
}
}
}
// Card 3: Where to Buy
div { class: "detail-card",
div { class: "detail-card-header", "Where to Buy" }
{
let sup_links = suppliers_data.read();
let sups = all_suppliers.read();
let sup_list: Vec<crate::types::Supplier> = match &*sups {
Some(v) => v.clone(),
None => vec![],
};
match &*sup_links {
Some(Some(links)) if !links.is_empty() => {
rsx! {
table { class: "attr-table",
thead {
tr {
th { "Supplier" }
th { "SKU" }
th { "Price" }
th { "Link" }
}
}
tbody {
for link in links.iter() {
{
let sup_name = sup_list.iter()
.find(|s| s.id == link.supplier_id)
.map(|s| s.name.clone())
.unwrap_or_else(|| "\u{2014}".to_string());
let sku = link.article_number.as_deref().unwrap_or("\u{2014}").to_string();
let price = match link.price_eur {
Some(p) if p > 0.0 => format!("EUR {p:.2}"),
_ => "\u{2014}".to_string(),
};
let url = link.product_url.clone();
rsx! {
tr {
td { "{sup_name}" }
td { class: if sku == "\u{2014}" { "placeholder" } else { "" }, "{sku}" }
td { class: if price == "\u{2014}" { "placeholder" } else { "" }, "{price}" }
td {
if let Some(ref u) = url {
if !u.is_empty() {
a { href: "{u}", target: "_blank", class: "external-link", "View" }
} else {
span { class: "placeholder", "\u{2014}" }
}
} else {
span { class: "placeholder", "\u{2014}" }
}
}
}
}
}
}
}
}
}
},
_ => rsx! {
p { class: "placeholder detail-card-empty", "\u{2014} No suppliers linked" }
},
}
}
}
}
// === RIGHT COLUMN ===
div { class: "detail-col",
// Card 4: Growing Information
div { class: "detail-card",
div { class: "detail-card-header", "Growing Information" }
table { class: "attr-table",
tbody {
tr {
th { "Growing Time" }
td { class: if growing_time == "\u{2014}" { "placeholder" } else { "" }, "{growing_time}" }
}
tr {
th { "Planting Depth" }
td { class: if planting_depth == "\u{2014}" { "placeholder" } else { "" }, "{planting_depth}" }
}
tr {
th { "Row Spacing" }
td { class: if row_spacing == "\u{2014}" { "placeholder" } else { "" }, "{row_spacing}" }
}
tr {
th { "Plant Spacing" }
td { class: if plant_spacing == "\u{2014}" { "placeholder" } else { "" }, "{plant_spacing}" }
}
tr {
th { "Propagation Methods" }
td { class: if propagation == "\u{2014}" { "placeholder" } else { "" }, "{propagation}" }
}
}
}
}
// Card 5: Germination
div { class: "detail-card",
div { class: "detail-card-header", "Germination" }
table { class: "attr-table",
tbody {
tr {
th { "Days to Germination" }
td { class: if days_germ == "\u{2014}" { "placeholder" } else { "" }, "{days_germ}" }
}
tr {
th { "Germination Temp" }
td { class: if germ_temp == "\u{2014}" { "placeholder" } else { "" }, "{germ_temp}" }
}
tr {
th { "Light Requirement" }
td { class: if light_req == "\u{2014}" { "placeholder" } else { "" }, "{light_req}" }
}
tr {
th { "Stratification Required" }
td { class: if strat_req == "\u{2014}" { "placeholder" } else { "" }, "{strat_req}" }
}
tr {
th { "Stratification Days" }
td { class: if strat_days == "\u{2014}" { "placeholder" } else { "" }, "{strat_days}" }
}
tr {
th { "Scarification Required" }
td { class: if scar_req == "\u{2014}" { "placeholder" } else { "" }, "{scar_req}" }
}
}
}
}
// Card 6: Climate & Environment
div { class: "detail-card",
div { class: "detail-card-header", "Climate & Environment" }
table { class: "attr-table",
tbody {
tr {
th { "Min Temp" }
td { class: if min_temp == "\u{2014}" { "placeholder" } else { "" }, "{min_temp}" }
}
tr {
th { "Max Temp" }
td { class: if max_temp == "\u{2014}" { "placeholder" } else { "" }, "{max_temp}" }
}
tr {
th { "Humidity" }
td { class: if humidity == "\u{2014}" { "placeholder" } else { "" }, "{humidity}" }
}
tr {
th { "Light" }
td { class: if light == "\u{2014}" { "placeholder" } else { "" }, "{light}" }
}
tr {
th { "Frost Tolerance" }
td { class: if frost_tol == "\u{2014}" { "placeholder" } else { "" }, "{frost_tol}" }
}
tr {
th { "Min Light Hours/Day" }
td { class: if min_light_h == "\u{2014}" { "placeholder" } else { "" }, "{min_light_h}" }
}
tr {
th { "Optimal Light Hours/Day" }
td { class: if opt_light_h == "\u{2014}" { "placeholder" } else { "" }, "{opt_light_h}" }
}
}
}
}
// Card 7: Species Information
div { class: "detail-card",
div { class: "detail-card-header", "Species Information" }
{
let sp_read = species_data.read();
match &*sp_read {
Some(Some(sp)) => {
let ph_range = match (sp.ph_min, sp.ph_max) {
(Some(mn), Some(mx)) => format!("{mn} \u{2013} {mx}"),
(Some(mn), None) => format!("{mn}+"),
(None, Some(mx)) => format!("< {mx}"),
_ => "\u{2014}".to_string(),
};
let sp_layer = opt_str(&sp.plant_layer);
let sp_drought = opt_str(&sp.drought_tolerance);
let sp_usda = opt_str(&sp.hardiness_zone_usda);
let sp_nfix = opt_bool(sp.nitrogen_fixer);
let sp_dynacc = opt_bool(sp.dynamic_accumulator);
let sp_food = opt_str(&sp.food_uses);
let sp_med = opt_str(&sp.medicinal_uses);
let sp_other = opt_str(&sp.other_uses);
let sp_wildlife = opt_str(&sp.wildlife_value);
let sp_native = opt_str(&sp.native_range);
rsx! {
table { class: "attr-table",
tbody {
tr {
th { "Plant Layer" }
td { class: if sp_layer == "\u{2014}" { "placeholder" } else { "" }, "{sp_layer}" }
}
tr {
th { "Drought Tolerance" }
td { class: if sp_drought == "\u{2014}" { "placeholder" } else { "" }, "{sp_drought}" }
}
tr {
th { "USDA Zone" }
td { class: if sp_usda == "\u{2014}" { "placeholder" } else { "" }, "{sp_usda}" }
}
tr {
th { "pH Range" }
td { class: if ph_range == "\u{2014}" { "placeholder" } else { "" }, "{ph_range}" }
}
tr {
th { "Nitrogen Fixer" }
td { class: if sp_nfix == "\u{2014}" { "placeholder" } else { "" }, "{sp_nfix}" }
}
tr {
th { "Dynamic Accumulator" }
td { class: if sp_dynacc == "\u{2014}" { "placeholder" } else { "" }, "{sp_dynacc}" }
}
tr {
th { "Food Uses" }
td { class: if sp_food == "\u{2014}" { "placeholder" } else { "" }, "{sp_food}" }
}
tr {
th { "Medicinal Uses" }
td { class: if sp_med == "\u{2014}" { "placeholder" } else { "" }, "{sp_med}" }
}
tr {
th { "Other Uses" }
td { class: if sp_other == "\u{2014}" { "placeholder" } else { "" }, "{sp_other}" }
}
tr {
th { "Wildlife Value" }
td { class: if sp_wildlife == "\u{2014}" { "placeholder" } else { "" }, "{sp_wildlife}" }
}
tr {
th { "Native Range" }
td { class: if sp_native == "\u{2014}" { "placeholder" } else { "" }, "{sp_native}" }
}
}
}
}
},
_ => rsx! {
p { class: "placeholder detail-card-empty", "Loading species data\u{2026}" }
},
}
}
}
}
} }
} }
}, },
+61 -6
View File
@@ -2,26 +2,45 @@ use dioxus::prelude::*;
use crate::api; use crate::api;
use crate::app::Route; use crate::app::Route;
use crate::components::table_controls::*;
const STORAGE_KEY_COLS: &str = "herbapi_families_cols";
const STORAGE_KEY_PP: &str = "herbapi_families_pp";
fn family_columns() -> Vec<ColumnDef> {
vec![
ColumnDef { key: "name_scientific", label: "Scientific Name", default_visible: true },
ColumnDef { key: "name_en", label: "English", default_visible: true },
ColumnDef { key: "name_de", label: "German", default_visible: true },
ColumnDef { key: "description", label: "Description", default_visible: false },
]
}
#[component] #[component]
pub fn FamilyList() -> Element { pub fn FamilyList() -> Element {
let columns = family_columns();
let mut page = use_signal(|| 1i64); let mut page = use_signal(|| 1i64);
let per_page = use_signal(|| load_per_page(STORAGE_KEY_PP, 25));
let mut search = use_signal(|| String::new()); let mut search = use_signal(|| String::new());
let current_page = *page.read(); let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &family_columns()));
let search_str = search.read().clone();
let families = use_resource(move || { let families = use_resource(move || {
let s = search_str.clone(); let s = search.read().clone();
let p = *page.read();
let pp = *per_page.read();
async move { async move {
let q = if s.is_empty() { None } else { Some(s.as_str()) }; let q = if s.is_empty() { None } else { Some(s.as_str()) };
api::list_families(current_page, q).await api::list_families(p, pp, q).await
} }
}); });
let current_page = *page.read();
rsx! { rsx! {
div { class: "page", div { class: "page",
h1 { "Plant Families" } h1 { "Plant Families" }
div { class: "table-toolbar",
div { class: "search-bar", div { class: "search-bar",
input { input {
r#type: "text", r#type: "text",
@@ -33,31 +52,66 @@ pub fn FamilyList() -> Element {
}, },
} }
} }
PerPageSelector {
per_page: per_page,
page: page,
storage_key: STORAGE_KEY_PP.to_string(),
}
}
ColumnToggle {
columns: columns.clone(),
visible: visible_cols,
storage_key: STORAGE_KEY_COLS.to_string(),
}
match &*families.read() { match &*families.read() {
None => rsx! { p { "Loading..." } }, None => rsx! { p { "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)) => {
let vis = visible_cols.read();
rsx! {
p { class: "result-count", "{data.total} families" }
div { class: "table-wrap", div { class: "table-wrap",
table { table {
thead { thead {
tr { tr {
if is_col_visible(&vis, "name_scientific") {
th { "Scientific Name" } th { "Scientific Name" }
}
if is_col_visible(&vis, "name_en") {
th { "English" } th { "English" }
}
if is_col_visible(&vis, "name_de") {
th { "German" } th { "German" }
} }
if is_col_visible(&vis, "description") {
th { "Description" }
}
}
} }
tbody { tbody {
for f in data.data.iter() { for f in data.data.iter() {
tr { tr {
if is_col_visible(&vis, "name_scientific") {
td { td {
Link { to: Route::FamilyDetail { slug: f.slug.clone() }, Link { to: Route::FamilyDetail { slug: f.slug.clone() },
em { "{f.name_scientific}" } em { "{f.name_scientific}" }
} }
} }
}
if is_col_visible(&vis, "name_en") {
td { "{f.name_en.as_deref().unwrap_or(\"-\")}" } td { "{f.name_en.as_deref().unwrap_or(\"-\")}" }
}
if is_col_visible(&vis, "name_de") {
td { "{f.name_de.as_deref().unwrap_or(\"-\")}" } td { "{f.name_de.as_deref().unwrap_or(\"-\")}" }
} }
if is_col_visible(&vis, "description") {
td { class: "cell-truncated",
"{f.description.as_deref().map(|d| truncate(d, 80)).unwrap_or_else(|| \"-\".to_string())}"
}
}
}
} }
} }
} }
@@ -77,6 +131,7 @@ pub fn FamilyList() -> Element {
} }
} }
} }
}
}, },
} }
} }
@@ -94,7 +149,7 @@ pub fn FamilyDetail(slug: String) -> Element {
let slug_for_species = slug.clone(); let slug_for_species = slug.clone();
let species = use_resource(move || { let species = use_resource(move || {
let s = slug_for_species.clone(); let s = slug_for_species.clone();
async move { api::list_species(1, Some(&s), None).await } async move { api::list_species(1, 100, Some(&s), None).await }
}); });
rsx! { rsx! {
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::components::plant_card::PlantCard;
#[component] #[component]
pub fn Home() -> Element { pub fn Home() -> Element {
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, None, None).await }); let species = use_resource(|| async { api::list_species(1, 12, None, None).await });
rsx! { rsx! {
div { class: "page home-page", div { class: "page home-page",
+1
View File
@@ -2,5 +2,6 @@ pub mod cultivars;
pub mod families; pub mod families;
pub mod home; pub mod home;
pub mod search; pub mod search;
pub mod sources;
pub mod species; pub mod species;
pub mod suppliers; pub mod suppliers;
+100
View File
@@ -0,0 +1,100 @@
use dioxus::prelude::*;
struct DataSource {
name: &'static str,
url: &'static str,
description: &'static str,
data_used: &'static str,
license: &'static str,
}
const SOURCES: &[DataSource] = &[
DataSource {
name: "GBIF",
url: "https://gbif.org",
description: "Global Biodiversity Information Facility — the world's largest open biodiversity data network.",
data_used: "Taxonomy and German common names. Used for species name lookups and vernacular name enrichment.",
license: "CC0",
},
DataSource {
name: "Reinsaat",
url: "https://reinsaat.at",
description: "Austrian biodynamic seed producer.",
data_used: "Cultivar data, sowing calendars, spacing info. Scraped from product catalog.",
license: "Proprietary",
},
DataSource {
name: "Magic Garden Seeds",
url: "https://magicgardenseeds.com",
description: "Specialist seed shop with a wide range of rare and heritage varieties.",
data_used: "Cultivar data, growing info. Scraped from product catalog.",
license: "Proprietary",
},
DataSource {
name: "PFAF (Plants for a Future)",
url: "https://pfaf.org",
description: "Permaculture plant database with extensive information on useful plants.",
data_used: "Species data: uses, tolerances, hardiness zones, height/spread, bloom periods. Data from community SQLite export.",
license: "Open data",
},
DataSource {
name: "FloraWeb / BIOLFLOR",
url: "https://floraweb.de",
description: "German Federal Agency for Nature Conservation plant information system.",
data_used: "Ellenberg indicator values for soil pH, moisture, light requirements.",
license: "Public data",
},
DataSource {
name: "Arche Noah",
url: "https://arche-noah.at",
description: "Austrian heritage seed library dedicated to preserving crop diversity.",
data_used: "Cultivar data and heritage varieties.",
license: "Proprietary",
},
DataSource {
name: "Wikidata",
url: "https://wikidata.org",
description: "Free and open knowledge base providing structured data to Wikimedia projects and beyond.",
data_used: "Structured data: USDA zones, taxonomy links.",
license: "CC0",
},
];
#[component]
pub fn Sources() -> Element {
rsx! {
div { class: "page sources-page",
h1 { "Data Sources" }
p { class: "sources-intro",
"HerbAPI aggregates plant data from multiple open sources. We are grateful to these projects for making botanical knowledge freely available."
}
div { class: "sources-grid",
for source in SOURCES.iter() {
div { class: "source-card",
div { class: "source-header",
h3 { class: "source-name", "{source.name}" }
a {
class: "source-url",
href: "{source.url}",
target: "_blank",
rel: "noopener",
"{source.url}"
}
}
p { class: "source-description", "{source.description}" }
div { class: "source-details",
div { class: "source-detail",
span { class: "source-detail-label", "Data used" }
span { class: "source-detail-value", "{source.data_used}" }
}
div { class: "source-detail",
span { class: "source-detail-label", "License" }
span { class: "source-detail-value source-license", "{source.license}" }
}
}
}
}
}
}
}
}
+523 -46
View File
@@ -1,28 +1,81 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
use uuid::Uuid;
use crate::api; use crate::api;
use crate::app::Route; use crate::app::Route;
use crate::components::plant_card::PlantCard; use crate::components::plant_card::PlantCard;
use crate::components::table_controls::*;
const STORAGE_KEY_COLS: &str = "herbapi_species_cols";
const STORAGE_KEY_PP: &str = "herbapi_species_pp";
const STORAGE_KEY_VIEW: &str = "herbapi_species_view";
fn species_columns() -> Vec<ColumnDef> {
vec![
ColumnDef { key: "name_scientific", label: "Scientific Name", default_visible: true },
ColumnDef { key: "name_de", label: "German", default_visible: true },
ColumnDef { key: "name_en", label: "English", default_visible: false },
ColumnDef { key: "family", label: "Family", default_visible: true },
ColumnDef { key: "plant_layer", label: "Layer", default_visible: true },
ColumnDef { key: "nitrogen_fixer", label: "N-Fixer", default_visible: true },
ColumnDef { key: "dynamic_accumulator", label: "Dyn. Accum.", default_visible: true },
ColumnDef { key: "food_uses", label: "Food Uses", default_visible: false },
ColumnDef { key: "edibility_rating", label: "Edibility", default_visible: false },
ColumnDef { key: "drought_tolerance", label: "Drought Tol.", default_visible: false },
ColumnDef { key: "hardiness_zone_usda", label: "USDA Zone", default_visible: false },
]
}
#[component] #[component]
pub fn SpeciesList() -> Element { pub fn SpeciesList() -> Element {
let columns = species_columns();
let mut page = use_signal(|| 1i64); let mut page = use_signal(|| 1i64);
let per_page = use_signal(|| load_per_page(STORAGE_KEY_PP, 25));
let mut search = use_signal(|| String::new()); let mut search = use_signal(|| String::new());
let current_page = *page.read(); let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &species_columns()));
let search_str = search.read().clone(); let mut table_view = use_signal(|| {
LocalStorage::get::<bool>(STORAGE_KEY_VIEW).unwrap_or(true)
});
// Fetch family map for resolving family_id -> name
let family_map = use_resource(move || async move {
let mut map = HashMap::<Uuid, String>::new();
if let Ok(resp) = api::list_families(1, 100, None).await {
for f in resp.data {
map.insert(f.id, f.name_scientific);
}
let total_pages = (resp.total + resp.per_page - 1) / resp.per_page;
for p in 2..=total_pages {
if let Ok(r) = api::list_families(p, 100, None).await {
for f in r.data {
map.insert(f.id, f.name_scientific);
}
}
}
}
map
});
let species = use_resource(move || { let species = use_resource(move || {
let s = search_str.clone(); let s = search.read().clone();
let p = *page.read();
let pp = *per_page.read();
async move { async move {
let q = if s.is_empty() { None } else { Some(s.as_str()) }; let q = if s.is_empty() { None } else { Some(s.as_str()) };
api::list_species(current_page, None, q).await api::list_species(p, pp, None, q).await
} }
}); });
let current_page = *page.read();
let is_table = *table_view.read();
rsx! { rsx! {
div { class: "page", div { class: "page",
h1 { "Species" } h1 { "Species" }
div { class: "table-toolbar",
div { class: "search-bar", div { class: "search-bar",
input { input {
r#type: "text", r#type: "text",
@@ -34,11 +87,161 @@ pub fn SpeciesList() -> Element {
}, },
} }
} }
div { class: "toolbar-right",
button {
class: "view-toggle-btn",
onclick: move |_| {
let new_val = !*table_view.read();
table_view.set(new_val);
let _ = LocalStorage::set(STORAGE_KEY_VIEW, new_val);
},
if is_table { "Card View" } else { "Table View" }
}
PerPageSelector {
per_page: per_page,
page: page,
storage_key: STORAGE_KEY_PP.to_string(),
}
}
}
if is_table {
ColumnToggle {
columns: columns.clone(),
visible: visible_cols,
storage_key: STORAGE_KEY_COLS.to_string(),
}
}
match &*species.read() { match &*species.read() {
None => rsx! { p { "Loading..." } }, None => rsx! { p { "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)) => {
let fmap_read = family_map.read();
let empty_map: HashMap<Uuid, String> = HashMap::new();
let fm = match &*fmap_read {
Some(m) => m,
_ => &empty_map,
};
rsx! {
p { class: "result-count", "{data.total} species" }
if is_table {
{
let vis = visible_cols.read();
rsx! {
div { class: "table-wrap",
table {
thead {
tr {
if is_col_visible(&vis, "name_scientific") {
th { "Scientific Name" }
}
if is_col_visible(&vis, "name_de") {
th { "German" }
}
if is_col_visible(&vis, "name_en") {
th { "English" }
}
if is_col_visible(&vis, "family") {
th { "Family" }
}
if is_col_visible(&vis, "plant_layer") {
th { "Layer" }
}
if is_col_visible(&vis, "nitrogen_fixer") {
th { "N-Fixer" }
}
if is_col_visible(&vis, "dynamic_accumulator") {
th { "Dyn. Accum." }
}
if is_col_visible(&vis, "food_uses") {
th { "Food Uses" }
}
if is_col_visible(&vis, "edibility_rating") {
th { "Edibility" }
}
if is_col_visible(&vis, "drought_tolerance") {
th { "Drought Tol." }
}
if is_col_visible(&vis, "hardiness_zone_usda") {
th { "USDA Zone" }
}
}
}
tbody {
for s in data.data.iter() {
{
let family_name: &str = fm.get(&s.family_id).map(String::as_str).unwrap_or("-");
rsx! {
tr {
if is_col_visible(&vis, "name_scientific") {
td {
Link { to: Route::SpeciesDetail { slug: s.slug.clone() },
em { "{s.name_scientific}" }
}
}
}
if is_col_visible(&vis, "name_de") {
td { "{s.name_de.as_deref().unwrap_or(\"-\")}" }
}
if is_col_visible(&vis, "name_en") {
td { "{s.name_en.as_deref().unwrap_or(\"-\")}" }
}
if is_col_visible(&vis, "family") {
td { class: "species-name", em { "{family_name}" } }
}
if is_col_visible(&vis, "plant_layer") {
td { "{s.plant_layer.as_deref().unwrap_or(\"-\")}" }
}
if is_col_visible(&vis, "nitrogen_fixer") {
td {
match s.nitrogen_fixer {
Some(true) => rsx! { span { class: "badge organic", "Yes" } },
Some(false) => rsx! { "-" },
None => rsx! { "-" },
}
}
}
if is_col_visible(&vis, "dynamic_accumulator") {
td {
match s.dynamic_accumulator {
Some(true) => rsx! { span { class: "badge organic", "Yes" } },
Some(false) => rsx! { "-" },
None => rsx! { "-" },
}
}
}
if is_col_visible(&vis, "food_uses") {
td { class: "cell-truncated",
"{s.food_uses.as_deref().map(|u| truncate(u, 60)).unwrap_or_else(|| \"-\".to_string())}"
}
}
if is_col_visible(&vis, "edibility_rating") {
td {
match s.edibility_rating {
Some(r) => rsx! { "{r}/5" },
None => rsx! { "-" },
}
}
}
if is_col_visible(&vis, "drought_tolerance") {
td { "{s.drought_tolerance.as_deref().unwrap_or(\"-\")}" }
}
if is_col_visible(&vis, "hardiness_zone_usda") {
td { "{s.hardiness_zone_usda.as_deref().unwrap_or(\"-\")}" }
}
}
}
}
}
}
}
}
}
}
} else {
div { class: "card-grid", div { class: "card-grid",
for s in data.data.iter() { for s in data.data.iter() {
PlantCard { PlantCard {
@@ -50,6 +253,8 @@ pub fn SpeciesList() -> Element {
} }
} }
} }
}
if data.total > data.per_page { if data.total > data.per_page {
div { class: "pagination", div { class: "pagination",
button { button {
@@ -65,6 +270,7 @@ pub fn SpeciesList() -> Element {
} }
} }
} }
}
}, },
} }
} }
@@ -79,6 +285,25 @@ pub fn SpeciesDetail(slug: String) -> Element {
async move { api::get_species(&s).await } async move { api::get_species(&s).await }
}); });
// Fetch family for family link
let family_data = use_resource(move || {
let sp = species.read().clone();
async move {
if let Some(Ok(s)) = sp {
// look up by iterating families
let resp = api::list_families(1, 200, None).await.ok();
if let Some(r) = resp {
for f in &r.data {
if f.id == s.family_id {
return Some(f.clone());
}
}
}
}
None
}
});
rsx! { rsx! {
div { class: "page species-detail", div { class: "page species-detail",
match &*species.read() { match &*species.read() {
@@ -86,61 +311,313 @@ pub fn SpeciesDetail(slug: String) -> Element {
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } }, Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
Some(Ok(s)) => { Some(Ok(s)) => {
let species_slug = s.slug.clone(); let species_slug = s.slug.clone();
// Helper closures to format fields
let os = |v: &Option<String>| -> String {
match v {
Some(x) if !x.is_empty() => x.clone(),
_ => "\u{2014}".to_string(),
}
};
let ob = |v: Option<bool>| -> String {
match v {
Some(true) => "Yes".to_string(),
Some(false) => "No".to_string(),
None => "\u{2014}".to_string(),
}
};
let ovs = |v: &Option<Vec<String>>| -> String {
match v {
Some(x) if !x.is_empty() => x.join(", "),
_ => "\u{2014}".to_string(),
}
};
let em = "\u{2014}";
let name_en = os(&s.name_en);
let name_de = os(&s.name_de);
let desc = os(&s.description);
// Uses
let food = os(&s.food_uses);
let med = os(&s.medicinal_uses);
let other = os(&s.other_uses);
let edibility = match s.edibility_rating {
Some(r) => format!("{r}/5"),
None => em.to_string(),
};
// Ecology
let layer = os(&s.plant_layer);
let succession = os(&s.succession_stage);
let wildlife = os(&s.wildlife_value);
let native = os(&s.native_range);
let pollination = os(&s.pollination_type);
// Growing requirements
let soil_moist = os(&s.soil_moisture);
let ph_range = match (s.ph_min, s.ph_max) {
(Some(mn), Some(mx)) => format!("{mn} \u{2013} {mx}"),
(Some(mn), None) => format!("{mn}+"),
(None, Some(mx)) => format!("< {mx}"),
_ => em.to_string(),
};
let drought = os(&s.drought_tolerance);
let salt = os(&s.salt_tolerance);
let usda = os(&s.hardiness_zone_usda);
let at_zone = os(&s.hardiness_zone_at);
// Permaculture
let n_fixer = ob(s.nitrogen_fixer);
let dyn_acc = ob(s.dynamic_accumulator);
let pollinators = ob(s.attracts_pollinators);
let beneficial = ob(s.attracts_beneficial_insects);
let mulch = ob(s.mulch_plant);
let gc_quality = os(&s.ground_cover_quality);
let allelo = ob(s.allelopathic);
let guild = ovs(&s.guild_role);
// External links
let qid = s.wikidata_qid.clone();
let gbif = s.gbif_id.clone();
let eppo = os(&s.eppo_code);
let pfaf = s.pfaf_url.clone();
rsx! { rsx! {
h1 { em { "{s.name_scientific}" } } h1 { em { "{s.name_scientific}" } }
if let Some(ref en) = s.name_en {
p { class: "name-common", "{en}" }
}
if let Some(ref de) = s.name_de {
p { class: "name-common", "{de}" }
}
if let Some(ref desc) = s.description {
div { class: "description", "{desc}" }
}
// Info grid div { class: "detail-row",
div { class: "info-grid", // === LEFT COLUMN ===
if let Some(ref layer) = s.plant_layer { div { class: "detail-col",
div { class: "info-item",
span { class: "info-label", "Layer" } // Card 1: Species Details
span { class: "info-value", "{layer}" } div { class: "detail-card",
div { class: "detail-card-header", "Species Details" }
table { class: "attr-table",
tbody {
tr {
th { "Scientific Name" }
td { em { "{s.name_scientific}" } }
} }
tr {
th { "Name EN" }
td { class: if name_en == em { "placeholder" } else { "" }, "{name_en}" }
} }
if let Some(ref dt) = s.drought_tolerance { tr {
div { class: "info-item", th { "Name DE" }
span { class: "info-label", "Drought Tolerance" } td { class: if name_de == em { "placeholder" } else { "" }, "{name_de}" }
span { class: "info-value", "{dt}" }
} }
tr {
th { "Family" }
td {
if let Some(Some(ref fam)) = *family_data.read() {
Link { to: Route::FamilyDetail { slug: fam.slug.clone() },
em { "{fam.name_scientific}" }
} }
if let Some(ref hz) = s.hardiness_zone_usda { } else {
div { class: "info-item", span { class: "placeholder", "\u{2014}" }
span { class: "info-label", "USDA Zone" }
span { class: "info-value", "{hz}" }
}
}
if let Some(rating) = s.edibility_rating {
div { class: "info-item",
span { class: "info-label", "Edibility" }
span { class: "info-value", "{rating}/5" }
}
}
if let Some(nf) = s.nitrogen_fixer {
if nf {
div { class: "info-item badge",
"Nitrogen Fixer"
} }
} }
} }
if let Some(da) = s.dynamic_accumulator { tr {
if da { th { "Description" }
div { class: "info-item badge", td { class: if desc == em { "placeholder" } else { "" }, "{desc}" }
"Dynamic Accumulator"
} }
} }
} }
} }
// Cultivars for this species // Card 2: Uses
div { class: "detail-card",
div { class: "detail-card-header", "Uses" }
table { class: "attr-table",
tbody {
tr {
th { "Food Uses" }
td { class: if food == em { "placeholder" } else { "" }, "{food}" }
}
tr {
th { "Medicinal Uses" }
td { class: if med == em { "placeholder" } else { "" }, "{med}" }
}
tr {
th { "Other Uses" }
td { class: if other == em { "placeholder" } else { "" }, "{other}" }
}
tr {
th { "Edibility Rating" }
td { class: if edibility == em { "placeholder" } else { "" }, "{edibility}" }
}
}
}
}
// Card 3: Ecology
div { class: "detail-card",
div { class: "detail-card-header", "Ecology" }
table { class: "attr-table",
tbody {
tr {
th { "Plant Layer" }
td { class: if layer == em { "placeholder" } else { "" }, "{layer}" }
}
tr {
th { "Succession Stage" }
td { class: if succession == em { "placeholder" } else { "" }, "{succession}" }
}
tr {
th { "Wildlife Value" }
td { class: if wildlife == em { "placeholder" } else { "" }, "{wildlife}" }
}
tr {
th { "Native Range" }
td { class: if native == em { "placeholder" } else { "" }, "{native}" }
}
tr {
th { "Pollination Type" }
td { class: if pollination == em { "placeholder" } else { "" }, "{pollination}" }
}
}
}
}
}
// === RIGHT COLUMN ===
div { class: "detail-col",
// Card 4: Growing Requirements
div { class: "detail-card",
div { class: "detail-card-header", "Growing Requirements" }
table { class: "attr-table",
tbody {
tr {
th { "Soil Moisture" }
td { class: if soil_moist == em { "placeholder" } else { "" }, "{soil_moist}" }
}
tr {
th { "pH Range" }
td { class: if ph_range == em { "placeholder" } else { "" }, "{ph_range}" }
}
tr {
th { "Drought Tolerance" }
td { class: if drought == em { "placeholder" } else { "" }, "{drought}" }
}
tr {
th { "Salt Tolerance" }
td { class: if salt == em { "placeholder" } else { "" }, "{salt}" }
}
tr {
th { "USDA Zone" }
td { class: if usda == em { "placeholder" } else { "" }, "{usda}" }
}
tr {
th { "AT Zone" }
td { class: if at_zone == em { "placeholder" } else { "" }, "{at_zone}" }
}
}
}
}
// Card 5: Permaculture
div { class: "detail-card",
div { class: "detail-card-header", "Permaculture" }
table { class: "attr-table",
tbody {
tr {
th { "Nitrogen Fixer" }
td { class: if n_fixer == em { "placeholder" } else { "" }, "{n_fixer}" }
}
tr {
th { "Dynamic Accumulator" }
td { class: if dyn_acc == em { "placeholder" } else { "" }, "{dyn_acc}" }
}
tr {
th { "Attracts Pollinators" }
td { class: if pollinators == em { "placeholder" } else { "" }, "{pollinators}" }
}
tr {
th { "Attracts Beneficial Insects" }
td { class: if beneficial == em { "placeholder" } else { "" }, "{beneficial}" }
}
tr {
th { "Mulch Plant" }
td { class: if mulch == em { "placeholder" } else { "" }, "{mulch}" }
}
tr {
th { "Ground Cover Quality" }
td { class: if gc_quality == em { "placeholder" } else { "" }, "{gc_quality}" }
}
tr {
th { "Allelopathic" }
td { class: if allelo == em { "placeholder" } else { "" }, "{allelo}" }
}
tr {
th { "Guild Role" }
td { class: if guild == em { "placeholder" } else { "" }, "{guild}" }
}
}
}
}
// Card 6: External Links
div { class: "detail-card",
div { class: "detail-card-header", "External Links" }
table { class: "attr-table",
tbody {
tr {
th { "Wikidata QID" }
td {
if let Some(ref q) = qid {
if !q.is_empty() {
a { href: "https://www.wikidata.org/wiki/{q}", target: "_blank", class: "external-link", "{q}" }
} else {
span { class: "placeholder", "\u{2014}" }
}
} else {
span { class: "placeholder", "\u{2014}" }
}
}
}
tr {
th { "GBIF ID" }
td {
if let Some(ref g) = gbif {
if !g.is_empty() {
a { href: "https://www.gbif.org/species/{g}", target: "_blank", class: "external-link", "{g}" }
} else {
span { class: "placeholder", "\u{2014}" }
}
} else {
span { class: "placeholder", "\u{2014}" }
}
}
}
tr {
th { "EPPO Code" }
td { class: if eppo == em { "placeholder" } else { "" }, "{eppo}" }
}
tr {
th { "PFAF URL" }
td {
if let Some(ref u) = pfaf {
if !u.is_empty() {
a { href: "{u}", target: "_blank", class: "external-link", "View on PFAF" }
} else {
span { class: "placeholder", "\u{2014}" }
}
} else {
span { class: "placeholder", "\u{2014}" }
}
}
}
}
}
}
}
}
// Cultivars for this species (below the two-column layout)
h2 { "Cultivars" } h2 { "Cultivars" }
CultivarListForSpecies { species_slug: species_slug } CultivarListForSpecies { species_slug: species_slug }
} }
@@ -155,7 +632,7 @@ fn CultivarListForSpecies(species_slug: String) -> Element {
let slug = species_slug.clone(); let slug = species_slug.clone();
let cultivars = use_resource(move || { let cultivars = use_resource(move || {
let s = slug.clone(); let s = slug.clone();
async move { api::list_cultivars(1, Some(&s), None).await } async move { api::list_cultivars(1, 100, Some(&s), None).await }
}); });
rsx! { rsx! {
+81 -3
View File
@@ -2,40 +2,118 @@ use dioxus::prelude::*;
use crate::api; use crate::api;
use crate::app::Route; use crate::app::Route;
use crate::components::table_controls::*;
const STORAGE_KEY_COLS: &str = "herbapi_suppliers_cols";
fn supplier_columns() -> Vec<ColumnDef> {
vec![
ColumnDef { key: "name", label: "Name", default_visible: true },
ColumnDef { key: "country", label: "Country", default_visible: true },
ColumnDef { key: "organic", label: "Organic", default_visible: true },
ColumnDef { key: "demeter", label: "Demeter", default_visible: true },
ColumnDef { key: "website", label: "Website", default_visible: true },
ColumnDef { key: "notes", label: "Notes", default_visible: false },
]
}
#[component] #[component]
pub fn SupplierList() -> Element { pub fn SupplierList() -> Element {
let columns = supplier_columns();
let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &supplier_columns()));
let suppliers = use_resource(|| async { api::list_suppliers().await }); let suppliers = use_resource(|| async { api::list_suppliers().await });
rsx! { rsx! {
div { class: "page", div { class: "page",
h1 { "Suppliers" } h1 { "Suppliers" }
ColumnToggle {
columns: columns.clone(),
visible: visible_cols,
storage_key: STORAGE_KEY_COLS.to_string(),
}
match &*suppliers.read() { match &*suppliers.read() {
None => rsx! { p { "Loading..." } }, None => rsx! { p { "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)) => {
let vis = visible_cols.read();
rsx! {
p { class: "result-count", "{data.len()} suppliers" }
div { class: "table-wrap", div { class: "table-wrap",
table { table {
thead { thead {
tr { tr {
if is_col_visible(&vis, "name") {
th { "Name" } th { "Name" }
}
if is_col_visible(&vis, "country") {
th { "Country" } th { "Country" }
}
if is_col_visible(&vis, "organic") {
th { "Organic" } th { "Organic" }
}
if is_col_visible(&vis, "demeter") {
th { "Demeter" } th { "Demeter" }
} }
if is_col_visible(&vis, "website") {
th { "Website" }
}
if is_col_visible(&vis, "notes") {
th { "Notes" }
}
}
} }
tbody { tbody {
for s in data.iter() { for s in data.iter() {
tr { tr {
if is_col_visible(&vis, "name") {
td { td {
Link { to: Route::SupplierDetail { slug: s.slug.clone() }, Link { to: Route::SupplierDetail { slug: s.slug.clone() },
strong { "{s.name}" } strong { "{s.name}" }
} }
} }
}
if is_col_visible(&vis, "country") {
td { "{s.country.as_deref().unwrap_or(\"-\")}" } td { "{s.country.as_deref().unwrap_or(\"-\")}" }
td { if s.is_organic { "Yes" } else { "-" } } }
td { if s.is_demeter { "Yes" } else { "-" } } if is_col_visible(&vis, "organic") {
td {
if s.is_organic {
span { class: "badge organic", "Organic" }
} else {
"-"
}
}
}
if is_col_visible(&vis, "demeter") {
td {
if s.is_demeter {
span { class: "badge demeter", "Demeter" }
} else {
"-"
}
}
}
if is_col_visible(&vis, "website") {
td {
match &s.url {
Some(url) => rsx! {
a { href: "{url}", target: "_blank", class: "external-link",
"{truncate(url, 40)}"
}
},
None => rsx! { "-" },
}
}
}
if is_col_visible(&vis, "notes") {
td { class: "cell-truncated",
"{s.notes.as_deref().map(|n| truncate(n, 60)).unwrap_or_else(|| \"-\".to_string())}"
}
}
}
} }
} }
} }
+39 -6
View File
@@ -22,7 +22,8 @@ pub struct Family {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct Species { pub struct Species {
pub id: Uuid, pub id: Uuid,
pub slug: String, pub slug: String,
@@ -37,6 +38,7 @@ pub struct Species {
pub hardiness_zone_usda: Option<String>, pub hardiness_zone_usda: Option<String>,
pub hardiness_zone_at: Option<String>, pub hardiness_zone_at: Option<String>,
pub drought_tolerance: Option<String>, pub drought_tolerance: Option<String>,
pub salt_tolerance: Option<String>,
pub edibility_rating: Option<i16>, pub edibility_rating: Option<i16>,
pub food_uses: Option<String>, pub food_uses: Option<String>,
pub medicinal_uses: Option<String>, pub medicinal_uses: Option<String>,
@@ -45,13 +47,26 @@ pub struct Species {
pub plant_layer: Option<String>, pub plant_layer: Option<String>,
pub nitrogen_fixer: Option<bool>, pub nitrogen_fixer: Option<bool>,
pub dynamic_accumulator: Option<bool>, pub dynamic_accumulator: Option<bool>,
pub attracts_pollinators: Option<bool>,
pub attracts_beneficial_insects: Option<bool>,
pub wildlife_value: Option<String>,
pub mulch_plant: Option<bool>,
pub ground_cover_quality: Option<String>,
pub allelopathic: Option<bool>,
pub guild_role: Option<Vec<String>>,
pub succession_stage: Option<String>,
pub pollination_type: Option<String>,
pub wikidata_qid: Option<String>, pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub eppo_code: Option<String>,
pub pfaf_url: Option<String>,
pub primary_image_key: Option<String>, pub primary_image_key: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>, pub updated_at: Option<DateTime<Utc>>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct Cultivar { pub struct Cultivar {
pub id: Uuid, pub id: Uuid,
pub slug: String, pub slug: String,
@@ -59,22 +74,40 @@ pub struct Cultivar {
pub name: String, pub name: String,
pub name_en: Option<String>, pub name_en: Option<String>,
pub name_de: Option<String>, pub name_de: Option<String>,
pub name_scientific: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub is_organic: bool, pub is_organic: bool,
pub perennial: bool, pub perennial: bool,
pub growing_time_days: Option<i32>, pub growing_time_days: Option<i32>,
pub planting_depth_cm: Option<f64>,
pub row_spacing_cm: Option<f64>,
pub plant_spacing_cm: Option<f64>,
pub days_to_germination: Option<i32>, pub days_to_germination: Option<i32>,
pub germination_temp_c: Option<f64>,
pub light_requirement: Option<String>,
pub stratification_required: Option<bool>,
pub stratification_days: Option<i32>,
pub scarification_required: Option<bool>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub humidity: Option<String>,
pub light: Option<String>,
pub frost_tolerance: Option<String>, pub frost_tolerance: Option<String>,
pub min_light_hours_day: Option<f64>,
pub optimal_light_hours_day: Option<f64>,
pub indoor_sowing_months: Option<Vec<i32>>, pub indoor_sowing_months: Option<Vec<i32>>,
pub direct_sowing_months: Option<Vec<i32>>, pub direct_sowing_months: Option<Vec<i32>>,
pub transplanting_months: Option<Vec<i32>>, pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>, pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>, pub harvesting_months: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
pub pollination_group: Option<String>, pub pollination_group: Option<String>,
pub self_fertile: Option<bool>, pub self_fertile: Option<bool>,
pub primary_image_key: Option<String>, pub primary_image_key: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>, pub updated_at: Option<DateTime<Utc>>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]