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:
@@ -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,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(),
|
||||||
|
|||||||
@@ -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 *"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,2 +1,3 @@
|
|||||||
pub mod plant_card;
|
pub mod plant_card;
|
||||||
pub mod planting_calendar;
|
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,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}" }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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! {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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! {
|
||||||
|
|||||||
@@ -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
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user