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:
@@ -150,7 +150,6 @@ em {
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page {
|
||||
@@ -498,6 +497,231 @@ tr:hover td {
|
||||
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 */
|
||||
|
||||
.not-found {
|
||||
@@ -525,6 +749,184 @@ tr:hover td {
|
||||
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 */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -554,4 +956,13 @@ tr:hover td {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.attr-table th {
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ pub async fn get_current_user() -> Result<MeResponse, String> {
|
||||
}
|
||||
|
||||
// --- Families ---
|
||||
pub async fn list_families(page: i64, search: Option<&str>) -> Result<PaginatedResponse<Family>, String> {
|
||||
let mut url = format!("{API_BASE}/families?page={page}&per_page=25");
|
||||
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={per_page}");
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
@@ -99,8 +99,8 @@ pub async fn get_family(slug: &str) -> Result<Family, String> {
|
||||
}
|
||||
|
||||
// --- Species ---
|
||||
pub async fn list_species(page: i64, family: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Species>, String> {
|
||||
let mut url = format!("{API_BASE}/species?page={page}&per_page=25");
|
||||
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={per_page}");
|
||||
if let Some(f) = family {
|
||||
url.push_str(&format!("&family={f}"));
|
||||
}
|
||||
@@ -115,8 +115,8 @@ pub async fn get_species(slug: &str) -> Result<Species, String> {
|
||||
}
|
||||
|
||||
// --- Cultivars ---
|
||||
pub async fn list_cultivars(page: i64, species: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Cultivar>, String> {
|
||||
let mut url = format!("{API_BASE}/cultivars?page={page}&per_page=25");
|
||||
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={per_page}");
|
||||
if let Some(s) = species {
|
||||
url.push_str(&format!("&species={s}"));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub enum Route {
|
||||
SupplierDetail { slug: String },
|
||||
#[route("/search")]
|
||||
SearchPage {},
|
||||
#[route("/sources")]
|
||||
Sources {},
|
||||
#[end_layout]
|
||||
#[route("/:..segments")]
|
||||
NotFound { segments: Vec<String> },
|
||||
@@ -62,6 +64,7 @@ fn Layout() -> Element {
|
||||
NavLink { to: Route::CultivarList {}, label: "Cultivars" }
|
||||
NavLink { to: Route::SupplierList {}, label: "Suppliers" }
|
||||
NavLink { to: Route::SearchPage {}, label: "Search" }
|
||||
NavLink { to: Route::Sources {}, label: "Sources" }
|
||||
}
|
||||
div { class: "sidebar-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::home::Home;
|
||||
pub use crate::pages::search::SearchPage;
|
||||
pub use crate::pages::sources::Sources;
|
||||
pub use crate::pages::species::{SpeciesDetail, SpeciesList};
|
||||
pub use crate::pages::suppliers::{SupplierDetail, SupplierList};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod plant_card;
|
||||
pub mod planting_calendar;
|
||||
pub mod table_controls;
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,273 @@
|
||||
use dioxus::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api;
|
||||
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]
|
||||
pub fn CultivarList() -> Element {
|
||||
let columns = cultivar_columns();
|
||||
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 current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &cultivar_columns()));
|
||||
|
||||
// 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 s = search_str.clone();
|
||||
let p = *page.read();
|
||||
let pp = *per_page.read();
|
||||
let s = search.read().clone();
|
||||
async move {
|
||||
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! {
|
||||
div { class: "page",
|
||||
h1 { "Cultivars" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search cultivars...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
div { class: "table-toolbar",
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search cultivars...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
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() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Organic" }
|
||||
th { "Perennial" }
|
||||
th { "Frost Tolerance" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for c in data.data.iter() {
|
||||
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",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::CultivarDetail { slug: c.slug.clone() },
|
||||
strong { "{c.name}" }
|
||||
if is_col_visible(&vis, "name") {
|
||||
th { "Name" }
|
||||
}
|
||||
if is_col_visible(&vis, "species") {
|
||||
th { "Species" }
|
||||
}
|
||||
if is_col_visible(&vis, "organic") {
|
||||
th { "Organic" }
|
||||
}
|
||||
if is_col_visible(&vis, "perennial") {
|
||||
th { "Perennial" }
|
||||
}
|
||||
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 {
|
||||
for c in data.data.iter() {
|
||||
{
|
||||
let species_name: &str = sm.get(&c.species_id).map(String::as_str).unwrap_or("-");
|
||||
rsx! {
|
||||
tr {
|
||||
if is_col_visible(&vis, "name") {
|
||||
td {
|
||||
Link { to: Route::CultivarDetail { slug: c.slug.clone() },
|
||||
strong { "{c.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
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" } }
|
||||
}
|
||||
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(\"-\")}" }
|
||||
}
|
||||
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! { "-" },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td { if c.is_organic { "Yes" } else { "-" } }
|
||||
td { if c.perennial { "Yes" } else { "Annual" } }
|
||||
td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,57 +285,442 @@ pub fn CultivarDetail(slug: String) -> Element {
|
||||
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! {
|
||||
div { class: "page cultivar-detail",
|
||||
match &*cultivar.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(c)) => rsx! {
|
||||
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}" }
|
||||
}
|
||||
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);
|
||||
|
||||
div { class: "badges",
|
||||
if c.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
if c.perennial {
|
||||
span { class: "badge", "Perennial" }
|
||||
}
|
||||
if let Some(ref ft) = c.frost_tolerance {
|
||||
span { class: "badge", "Frost: {ft}" }
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
|
||||
if let Some(ref dtg) = c.days_to_germination {
|
||||
p { "Days to germination: {dtg}" }
|
||||
}
|
||||
if let Some(ref gtd) = c.growing_time_days {
|
||||
p { "Growing time: {gtd} days" }
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Planting calendar
|
||||
h2 { "Planting Calendar" }
|
||||
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(),
|
||||
}
|
||||
// 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);
|
||||
|
||||
if let Some(ref pg) = c.pollination_group {
|
||||
p { "Pollination group: {pg}" }
|
||||
}
|
||||
if let Some(sf) = c.self_fertile {
|
||||
if sf {
|
||||
p { "Self-fertile: Yes" }
|
||||
} else {
|
||||
p { "Self-fertile: No" }
|
||||
// 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}" }
|
||||
|
||||
div { class: "detail-row",
|
||||
// === LEFT COLUMN ===
|
||||
div { class: "detail-col",
|
||||
|
||||
// Card 1: Cultivar Details
|
||||
div { class: "detail-card",
|
||||
div { class: "detail-card-header", "Cultivar Details" }
|
||||
table { class: "attr-table",
|
||||
tbody {
|
||||
tr {
|
||||
th { "Name" }
|
||||
td { "{c.name}" }
|
||||
}
|
||||
tr {
|
||||
th { "Name EN" }
|
||||
td { class: if name_en == "\u{2014}" { "placeholder" } else { "" }, "{name_en}" }
|
||||
}
|
||||
tr {
|
||||
th { "Name DE" }
|
||||
td { class: if name_de == "\u{2014}" { "placeholder" } else { "" }, "{name_de}" }
|
||||
}
|
||||
tr {
|
||||
th { "Scientific Name" }
|
||||
td { class: if name_sci == "\u{2014}" { "placeholder" } else { "" },
|
||||
if name_sci != "\u{2014}" {
|
||||
em { "{name_sci}" }
|
||||
} else {
|
||||
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}" }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,78 +2,133 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
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]
|
||||
pub fn FamilyList() -> Element {
|
||||
let columns = family_columns();
|
||||
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 current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &family_columns()));
|
||||
|
||||
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 {
|
||||
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! {
|
||||
div { class: "page",
|
||||
h1 { "Plant Families" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search families...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
div { class: "table-toolbar",
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search families...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
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() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Scientific Name" }
|
||||
th { "English" }
|
||||
th { "German" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for f in data.data.iter() {
|
||||
Some(Ok(data)) => {
|
||||
let vis = visible_cols.read();
|
||||
rsx! {
|
||||
p { class: "result-count", "{data.total} families" }
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::FamilyDetail { slug: f.slug.clone() },
|
||||
em { "{f.name_scientific}" }
|
||||
if is_col_visible(&vis, "name_scientific") {
|
||||
th { "Scientific Name" }
|
||||
}
|
||||
if is_col_visible(&vis, "name_en") {
|
||||
th { "English" }
|
||||
}
|
||||
if is_col_visible(&vis, "name_de") {
|
||||
th { "German" }
|
||||
}
|
||||
if is_col_visible(&vis, "description") {
|
||||
th { "Description" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for f in data.data.iter() {
|
||||
tr {
|
||||
if is_col_visible(&vis, "name_scientific") {
|
||||
td {
|
||||
Link { to: Route::FamilyDetail { slug: f.slug.clone() },
|
||||
em { "{f.name_scientific}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_col_visible(&vis, "name_en") {
|
||||
td { "{f.name_en.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
if is_col_visible(&vis, "name_de") {
|
||||
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())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
td { "{f.name_en.as_deref().unwrap_or(\"-\")}" }
|
||||
td { "{f.name_de.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +149,7 @@ pub fn FamilyDetail(slug: String) -> Element {
|
||||
let slug_for_species = slug.clone();
|
||||
let species = use_resource(move || {
|
||||
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! {
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::components::plant_card::PlantCard;
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
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! {
|
||||
div { class: "page home-page",
|
||||
|
||||
@@ -2,5 +2,6 @@ pub mod cultivars;
|
||||
pub mod families;
|
||||
pub mod home;
|
||||
pub mod search;
|
||||
pub mod sources;
|
||||
pub mod species;
|
||||
pub mod suppliers;
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+555
-78
@@ -1,67 +1,273 @@
|
||||
use dioxus::prelude::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
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]
|
||||
pub fn SpeciesList() -> Element {
|
||||
let columns = species_columns();
|
||||
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 current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
let visible_cols = use_signal(|| load_visible_columns(STORAGE_KEY_COLS, &species_columns()));
|
||||
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 s = search_str.clone();
|
||||
let s = search.read().clone();
|
||||
let p = *page.read();
|
||||
let pp = *per_page.read();
|
||||
async move {
|
||||
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! {
|
||||
div { class: "page",
|
||||
h1 { "Species" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search species...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
div { class: "table-toolbar",
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search species...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
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() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter() {
|
||||
PlantCard {
|
||||
key: "{s.id}",
|
||||
slug: s.slug.clone(),
|
||||
name: s.name_scientific.clone(),
|
||||
name_common: s.name_en.clone(),
|
||||
entity_type: "species".to_string(),
|
||||
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",
|
||||
for s in data.data.iter() {
|
||||
PlantCard {
|
||||
key: "{s.id}",
|
||||
slug: s.slug.clone(),
|
||||
name: s.name_scientific.clone(),
|
||||
name_common: s.name_en.clone(),
|
||||
entity_type: "species".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +285,25 @@ pub fn SpeciesDetail(slug: String) -> Element {
|
||||
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! {
|
||||
div { class: "page species-detail",
|
||||
match &*species.read() {
|
||||
@@ -86,61 +311,313 @@ pub fn SpeciesDetail(slug: String) -> Element {
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(s)) => {
|
||||
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! {
|
||||
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: "info-grid",
|
||||
if let Some(ref layer) = s.plant_layer {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Layer" }
|
||||
span { class: "info-value", "{layer}" }
|
||||
div { class: "detail-row",
|
||||
// === LEFT COLUMN ===
|
||||
div { class: "detail-col",
|
||||
|
||||
// Card 1: Species Details
|
||||
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}" }
|
||||
}
|
||||
tr {
|
||||
th { "Name DE" }
|
||||
td { class: if name_de == em { "placeholder" } else { "" }, "{name_de}" }
|
||||
}
|
||||
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}" }
|
||||
}
|
||||
} else {
|
||||
span { class: "placeholder", "\u{2014}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tr {
|
||||
th { "Description" }
|
||||
td { class: if desc == em { "placeholder" } else { "" }, "{desc}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref dt) = s.drought_tolerance {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Drought Tolerance" }
|
||||
span { class: "info-value", "{dt}" }
|
||||
|
||||
// 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref hz) = s.hardiness_zone_usda {
|
||||
div { class: "info-item",
|
||||
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"
|
||||
|
||||
// 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(da) = s.dynamic_accumulator {
|
||||
if da {
|
||||
div { class: "info-item badge",
|
||||
"Dynamic Accumulator"
|
||||
|
||||
// === 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
|
||||
// Cultivars for this species (below the two-column layout)
|
||||
h2 { "Cultivars" }
|
||||
CultivarListForSpecies { species_slug: species_slug }
|
||||
}
|
||||
@@ -155,7 +632,7 @@ fn CultivarListForSpecies(species_slug: String) -> Element {
|
||||
let slug = species_slug.clone();
|
||||
let cultivars = use_resource(move || {
|
||||
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! {
|
||||
|
||||
@@ -2,40 +2,118 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
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]
|
||||
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 });
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Suppliers" }
|
||||
|
||||
ColumnToggle {
|
||||
columns: columns.clone(),
|
||||
visible: visible_cols,
|
||||
storage_key: STORAGE_KEY_COLS.to_string(),
|
||||
}
|
||||
|
||||
match &*suppliers.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Country" }
|
||||
th { "Organic" }
|
||||
th { "Demeter" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for s in data.iter() {
|
||||
Some(Ok(data)) => {
|
||||
let vis = visible_cols.read();
|
||||
rsx! {
|
||||
p { class: "result-count", "{data.len()} suppliers" }
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::SupplierDetail { slug: s.slug.clone() },
|
||||
strong { "{s.name}" }
|
||||
if is_col_visible(&vis, "name") {
|
||||
th { "Name" }
|
||||
}
|
||||
if is_col_visible(&vis, "country") {
|
||||
th { "Country" }
|
||||
}
|
||||
if is_col_visible(&vis, "organic") {
|
||||
th { "Organic" }
|
||||
}
|
||||
if is_col_visible(&vis, "demeter") {
|
||||
th { "Demeter" }
|
||||
}
|
||||
if is_col_visible(&vis, "website") {
|
||||
th { "Website" }
|
||||
}
|
||||
if is_col_visible(&vis, "notes") {
|
||||
th { "Notes" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for s in data.iter() {
|
||||
tr {
|
||||
if is_col_visible(&vis, "name") {
|
||||
td {
|
||||
Link { to: Route::SupplierDetail { slug: s.slug.clone() },
|
||||
strong { "{s.name}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_col_visible(&vis, "country") {
|
||||
td { "{s.country.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
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())}"
|
||||
}
|
||||
}
|
||||
}
|
||||
td { "{s.country.as_deref().unwrap_or(\"-\")}" }
|
||||
td { if s.is_organic { "Yes" } else { "-" } }
|
||||
td { if s.is_demeter { "Yes" } else { "-" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-6
@@ -22,7 +22,8 @@ pub struct Family {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Species {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
@@ -37,6 +38,7 @@ pub struct Species {
|
||||
pub hardiness_zone_usda: Option<String>,
|
||||
pub hardiness_zone_at: Option<String>,
|
||||
pub drought_tolerance: Option<String>,
|
||||
pub salt_tolerance: Option<String>,
|
||||
pub edibility_rating: Option<i16>,
|
||||
pub food_uses: Option<String>,
|
||||
pub medicinal_uses: Option<String>,
|
||||
@@ -45,13 +47,26 @@ pub struct Species {
|
||||
pub plant_layer: Option<String>,
|
||||
pub nitrogen_fixer: 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 gbif_id: Option<String>,
|
||||
pub eppo_code: Option<String>,
|
||||
pub pfaf_url: Option<String>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: Option<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 id: Uuid,
|
||||
pub slug: String,
|
||||
@@ -59,22 +74,40 @@ pub struct Cultivar {
|
||||
pub name: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub name_scientific: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub perennial: bool,
|
||||
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 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 min_light_hours_day: Option<f64>,
|
||||
pub optimal_light_hours_day: Option<f64>,
|
||||
pub indoor_sowing_months: Option<Vec<i32>>,
|
||||
pub direct_sowing_months: Option<Vec<i32>>,
|
||||
pub transplanting_months: Option<Vec<i32>>,
|
||||
pub glasshouse_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 self_fertile: Option<bool>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user