Initial HerbAPI implementation
Rust/Axum REST API (herbapi-api) with PostgreSQL, S3/Garage, OIDC auth. Dioxus 0.7 WASM frontend (herbapi-ui) with sidebar layout and botanical reference style. 9 SQL migrations covering families, species, cultivars, suppliers, companions, images, users, API tokens.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
/herbapi-api/target
|
||||
/herbapi-ui/target
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Generated
+4235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "herbapi-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||
axum-extra = { version = "0.10", features = ["typed-header"] }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
aws-sdk-s3 = "1"
|
||||
aws-credential-types = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
once_cell = "1"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
slug = "0.1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "uuid", "chrono", "migrate"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||
tower-sessions = { version = "0.15", features = ["private"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4", "v7", "serde"] }
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE families (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name_scientific TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
name_de TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_families_search ON families
|
||||
USING GIN (to_tsvector('english',
|
||||
coalesce(name_scientific,'') || ' ' ||
|
||||
coalesce(name_en,'') || ' ' ||
|
||||
coalesce(name_de,'')));
|
||||
@@ -0,0 +1,77 @@
|
||||
CREATE TYPE invasiveness_level AS ENUM ('none', 'watch_list', 'invasive', 'banned');
|
||||
CREATE TYPE plant_layer AS ENUM ('canopy', 'understory', 'shrub', 'herbaceous', 'ground_cover', 'vine', 'root');
|
||||
CREATE TYPE succession_stage AS ENUM ('pioneer', 'early', 'mid', 'climax');
|
||||
CREATE TYPE drought_tolerance AS ENUM ('none', 'low', 'moderate', 'high');
|
||||
CREATE TYPE salt_tolerance AS ENUM ('none', 'low', 'moderate', 'high');
|
||||
|
||||
CREATE TABLE species (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
family_id UUID NOT NULL REFERENCES families(id) ON DELETE RESTRICT,
|
||||
name_scientific TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
name_de TEXT,
|
||||
description TEXT,
|
||||
soil_moisture TEXT,
|
||||
drainage_requirement TEXT,
|
||||
organic_matter_pct NUMERIC(5,2),
|
||||
nitrogen_ppm INTEGER,
|
||||
phosphorus_ppm INTEGER,
|
||||
potassium_ppm INTEGER,
|
||||
boron_ppm NUMERIC(8,2),
|
||||
calcium_ppm INTEGER,
|
||||
copper_ppm NUMERIC(8,2),
|
||||
iron_ppm NUMERIC(8,2),
|
||||
magnesium_ppm INTEGER,
|
||||
manganese_ppm NUMERIC(8,2),
|
||||
molybdenum_ppm NUMERIC(8,2),
|
||||
sulfur_ppm INTEGER,
|
||||
zinc_ppm NUMERIC(8,2),
|
||||
ph_min NUMERIC(4,2),
|
||||
ph_max NUMERIC(4,2),
|
||||
soil_texture_preference TEXT[],
|
||||
hardiness_zone_usda TEXT,
|
||||
hardiness_zone_at TEXT,
|
||||
min_temp NUMERIC(5,2),
|
||||
max_temp NUMERIC(5,2),
|
||||
drought_tolerance drought_tolerance,
|
||||
water_requirement_mm_week NUMERIC(5,2),
|
||||
waterlogging_tolerance BOOLEAN,
|
||||
salt_tolerance salt_tolerance,
|
||||
edibility_rating SMALLINT,
|
||||
food_uses TEXT,
|
||||
medicinal_uses TEXT,
|
||||
other_uses TEXT,
|
||||
native_range TEXT,
|
||||
invasiveness invasiveness_level DEFAULT 'none',
|
||||
pollination_type TEXT,
|
||||
plant_layer plant_layer,
|
||||
nitrogen_fixer BOOLEAN,
|
||||
dynamic_accumulator BOOLEAN,
|
||||
dynamic_accumulator_nutrients TEXT[],
|
||||
attracts_pollinators BOOLEAN,
|
||||
attracts_beneficial_insects BOOLEAN,
|
||||
wildlife_value TEXT,
|
||||
mulch_plant BOOLEAN,
|
||||
ground_cover_quality TEXT,
|
||||
allelopathic BOOLEAN,
|
||||
guild_role TEXT[],
|
||||
succession_stage succession_stage,
|
||||
heavy_metal_tolerance BOOLEAN,
|
||||
wikidata_qid TEXT,
|
||||
gbif_id TEXT,
|
||||
eppo_code TEXT,
|
||||
pfaf_url TEXT,
|
||||
primary_image_key TEXT,
|
||||
source_urls TEXT[],
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_species_family ON species(family_id);
|
||||
CREATE INDEX idx_species_search ON species
|
||||
USING GIN (to_tsvector('english',
|
||||
coalesce(name_scientific,'') || ' ' ||
|
||||
coalesce(name_en,'') || ' ' ||
|
||||
coalesce(name_de,'') || ' ' ||
|
||||
coalesce(description,'')));
|
||||
@@ -0,0 +1,78 @@
|
||||
CREATE TYPE frost_tolerance AS ENUM ('none', 'light_frost', 'moderate_frost', 'hardy');
|
||||
|
||||
CREATE TABLE cultivars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
name_de TEXT,
|
||||
name_scientific TEXT,
|
||||
description TEXT,
|
||||
is_organic BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
perennial BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
growing_time_days INTEGER,
|
||||
planting_depth_cm NUMERIC(5,2),
|
||||
row_spacing_cm NUMERIC(5,2),
|
||||
plant_spacing_cm NUMERIC(5,2),
|
||||
days_to_germination INTEGER,
|
||||
germination_temp_c NUMERIC(5,2),
|
||||
light_requirement TEXT,
|
||||
stratification_required BOOLEAN,
|
||||
stratification_days INTEGER,
|
||||
scarification_required BOOLEAN,
|
||||
seed_viability_years INTEGER,
|
||||
storage_temp_c NUMERIC(5,2),
|
||||
storage_humidity TEXT,
|
||||
storage_notes TEXT,
|
||||
min_temp NUMERIC(5,2),
|
||||
max_temp NUMERIC(5,2),
|
||||
humidity TEXT,
|
||||
light TEXT,
|
||||
frost_tolerance frost_tolerance,
|
||||
min_light_hours_day NUMERIC(4,1),
|
||||
optimal_light_hours_day NUMERIC(4,1),
|
||||
greenhouse_min_temp_c NUMERIC(5,2),
|
||||
indoor_season_extension_weeks INTEGER,
|
||||
ventilation_requirement TEXT,
|
||||
heating_required BOOLEAN,
|
||||
indoor_sowing_months INTEGER[],
|
||||
direct_sowing_months INTEGER[],
|
||||
transplanting_months INTEGER[],
|
||||
glasshouse_months INTEGER[],
|
||||
harvesting_months INTEGER[],
|
||||
succession_planting_days INTEGER,
|
||||
planting_notes TEXT,
|
||||
propagation_methods TEXT[],
|
||||
cutting_season TEXT,
|
||||
rootstock_species_id UUID REFERENCES species(id),
|
||||
years_to_first_harvest INTEGER,
|
||||
productive_lifespan_years INTEGER,
|
||||
expected_yield_kg_per_m2 NUMERIC(8,2),
|
||||
yield_unit TEXT,
|
||||
expected_yield_value NUMERIC(8,2),
|
||||
harvest_window_days INTEGER,
|
||||
storage_method TEXT[],
|
||||
shelf_life_days INTEGER,
|
||||
cold_storage_days INTEGER,
|
||||
pollination_group TEXT,
|
||||
self_fertile BOOLEAN,
|
||||
rootstock_compatibility TEXT,
|
||||
wikidata_qid TEXT,
|
||||
gbif_id TEXT,
|
||||
pfaf_url TEXT,
|
||||
primary_image_key TEXT,
|
||||
source_urls TEXT[],
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cultivars_species ON cultivars(species_id);
|
||||
CREATE INDEX idx_cultivars_rootstock ON cultivars(rootstock_species_id) WHERE rootstock_species_id IS NOT NULL;
|
||||
CREATE INDEX idx_cultivars_search ON cultivars
|
||||
USING GIN (to_tsvector('english',
|
||||
coalesce(name,'') || ' ' ||
|
||||
coalesce(name_en,'') || ' ' ||
|
||||
coalesce(name_de,'') || ' ' ||
|
||||
coalesce(name_scientific,'') || ' ' ||
|
||||
coalesce(description,'')));
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE images (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
s3_key TEXT NOT NULL,
|
||||
caption TEXT,
|
||||
source_url TEXT,
|
||||
license TEXT,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_images_entity ON images(entity_type, entity_id);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE scrape_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_name TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
last_scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
raw_data JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scrape_entity ON scrape_log(entity_type, entity_id);
|
||||
CREATE INDEX idx_scrape_source ON scrape_log(source_name);
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE suppliers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
is_organic BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_demeter BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
country TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE cultivar_suppliers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cultivar_id UUID NOT NULL REFERENCES cultivars(id) ON DELETE CASCADE,
|
||||
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
|
||||
article_number TEXT,
|
||||
product_url TEXT,
|
||||
price_eur NUMERIC(8,2),
|
||||
pack_size NUMERIC(8,2),
|
||||
pack_unit TEXT,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(cultivar_id, supplier_id, article_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cs_cultivar ON cultivar_suppliers(cultivar_id);
|
||||
CREATE INDEX idx_cs_supplier ON cultivar_suppliers(supplier_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TYPE companion_type AS ENUM ('beneficial', 'neutral', 'antagonistic');
|
||||
|
||||
CREATE TABLE companion_relationships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
species_a_id UUID NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
species_b_id UUID NOT NULL REFERENCES species(id) ON DELETE CASCADE,
|
||||
relationship companion_type NOT NULL,
|
||||
mechanism TEXT,
|
||||
source_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(species_a_id, species_b_id),
|
||||
CHECK (species_a_id < species_b_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_companion_a ON companion_relationships(species_a_id);
|
||||
CREATE INDEX idx_companion_b ON companion_relationships(species_b_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR NOT NULL,
|
||||
name VARCHAR,
|
||||
nickname VARCHAR,
|
||||
avatar_url VARCHAR,
|
||||
provider VARCHAR NOT NULL DEFAULT 'authentik',
|
||||
provider_id VARCHAR,
|
||||
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_email_index ON users (email);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_provider_provider_id_index ON users (provider, provider_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{companions as db, models::*};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn list_for_species(
|
||||
State(state): State<AppState>,
|
||||
Path(r): Path<String>,
|
||||
) -> Result<Json<Vec<CompanionRelationship>>> {
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let companions = db::list_for_species(&state.pool, id).await?;
|
||||
Ok(Json(companions))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<CreateCompanion>,
|
||||
) -> Result<Json<CompanionRelationship>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let companion = db::create(&state.pool, &req).await?;
|
||||
Ok(Json(companion))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
db::delete(&state.pool, id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{cultivars as db, models::*};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<db::CultivarListParams>,
|
||||
) -> Result<Json<PaginatedResponse<Cultivar>>> {
|
||||
let result = db::list(&state.pool, ¶ms).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Cultivar>> {
|
||||
let cultivar = db::get_by_slug(&state.pool, &slug).await?;
|
||||
Ok(Json(cultivar))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<CreateCultivar>,
|
||||
) -> Result<Json<Cultivar>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let cultivar = db::create(&state.pool, &req).await?;
|
||||
Ok(Json(cultivar))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
Json(req): Json<CreateCultivar>,
|
||||
) -> Result<Json<Cultivar>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let cultivar = db::update(&state.pool, id, &req).await?;
|
||||
Ok(Json(cultivar))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.is_admin() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
db::delete(&state.pool, id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{families as db, models::*};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<PaginatedResponse<Family>>> {
|
||||
let result = db::list(&state.pool, ¶ms).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Family>> {
|
||||
let family = db::get_by_slug(&state.pool, &slug).await?;
|
||||
Ok(Json(family))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<CreateFamily>,
|
||||
) -> Result<Json<Family>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let family = db::create(&state.pool, &req).await?;
|
||||
Ok(Json(family))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
Json(req): Json<UpdateFamily>,
|
||||
) -> Result<Json<Family>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let family = db::update(&state.pool, id, &req).await?;
|
||||
Ok(Json(family))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.is_admin() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
db::delete(&state.pool, id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
pub async fn health() -> impl IntoResponse {
|
||||
axum::Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{images as db, s3};
|
||||
use crate::db::models::Image;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn list_for_entity(
|
||||
State(state): State<AppState>,
|
||||
Path((entity_type, entity_id)): Path<(String, uuid::Uuid)>,
|
||||
) -> Result<Json<Vec<Image>>> {
|
||||
let images = db::list_for_entity(&state.pool, &entity_type, entity_id).await?;
|
||||
Ok(Json(images))
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<Image>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
|
||||
let mut entity_type = None;
|
||||
let mut entity_id = None;
|
||||
let mut caption = None;
|
||||
let mut source_url = None;
|
||||
let mut license = None;
|
||||
let mut is_primary = false;
|
||||
let mut file_data = None;
|
||||
let mut content_type = "application/octet-stream".to_string();
|
||||
let mut file_name = String::new();
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("Multipart error: {e}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
match name.as_str() {
|
||||
"entity_type" => {
|
||||
entity_type = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
||||
}
|
||||
"entity_id" => {
|
||||
let text = field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
entity_id = Some(text.parse::<uuid::Uuid>().map_err(|e| AppError::BadRequest(format!("Invalid UUID: {e}")))?);
|
||||
}
|
||||
"caption" => {
|
||||
caption = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
||||
}
|
||||
"source_url" => {
|
||||
source_url = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
||||
}
|
||||
"license" => {
|
||||
license = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
|
||||
}
|
||||
"is_primary" => {
|
||||
let text = field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
is_primary = text == "true" || text == "1";
|
||||
}
|
||||
"file" => {
|
||||
content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
|
||||
file_name = field.file_name().unwrap_or("upload").to_string();
|
||||
file_data = Some(field.bytes().await.map_err(|e| AppError::BadRequest(format!("File read error: {e}")))?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let entity_type = entity_type.ok_or_else(|| AppError::BadRequest("entity_type required".into()))?;
|
||||
let entity_id = entity_id.ok_or_else(|| AppError::BadRequest("entity_id required".into()))?;
|
||||
let data = file_data.ok_or_else(|| AppError::BadRequest("file required".into()))?;
|
||||
|
||||
// Generate S3 key
|
||||
let ext = file_name.rsplit('.').next().unwrap_or("bin");
|
||||
let s3_key = format!("{entity_type}/{entity_id}/{}.{ext}", uuid::Uuid::now_v7());
|
||||
|
||||
// Upload to S3
|
||||
s3::upload(&state.s3, &state.config.s3_bucket, &s3_key, data.to_vec(), &content_type).await?;
|
||||
|
||||
// Record in DB
|
||||
let image = db::create(
|
||||
&state.pool,
|
||||
&entity_type,
|
||||
entity_id,
|
||||
&s3_key,
|
||||
caption.as_deref(),
|
||||
source_url.as_deref(),
|
||||
license.as_deref(),
|
||||
is_primary,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(image))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.is_admin() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
|
||||
let image = db::delete(&state.pool, id).await?;
|
||||
// Delete from S3
|
||||
s3::delete(&state.s3, &state.config.s3_bucket, &image.s3_key).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
mod companions;
|
||||
mod cultivars;
|
||||
mod families;
|
||||
mod health;
|
||||
mod images;
|
||||
mod search;
|
||||
mod species;
|
||||
mod suppliers;
|
||||
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
// Health
|
||||
.route("/health", get(health::health))
|
||||
// Families
|
||||
.route("/api/v1/families", get(families::list).post(families::create))
|
||||
.route("/api/v1/families/{ref}", get(families::get_by_slug).put(families::update).delete(families::remove))
|
||||
// Species
|
||||
.route("/api/v1/species", get(species::list).post(species::create))
|
||||
.route("/api/v1/species/{ref}", get(species::get_by_slug).put(species::update).delete(species::remove))
|
||||
.route("/api/v1/species/{ref}/companions", get(companions::list_for_species))
|
||||
// Cultivars
|
||||
.route("/api/v1/cultivars", get(cultivars::list).post(cultivars::create))
|
||||
.route("/api/v1/cultivars/{ref}", get(cultivars::get_by_slug).put(cultivars::update).delete(cultivars::remove))
|
||||
.route("/api/v1/cultivars/{ref}/suppliers", get(suppliers::list_for_cultivar).post(suppliers::link_cultivar))
|
||||
.route("/api/v1/cultivars/{cid}/suppliers/{sid}", delete(suppliers::unlink_cultivar))
|
||||
// Suppliers
|
||||
.route("/api/v1/suppliers", get(suppliers::list).post(suppliers::create))
|
||||
.route("/api/v1/suppliers/{ref}", get(suppliers::get_by_slug).put(suppliers::update).delete(suppliers::remove))
|
||||
// Companions
|
||||
.route("/api/v1/companions", post(companions::create))
|
||||
.route("/api/v1/companions/{id}", delete(companions::remove))
|
||||
// Images
|
||||
.route("/api/v1/images/{entity_type}/{entity_id}", get(images::list_for_entity))
|
||||
.route("/api/v1/images", post(images::upload))
|
||||
.route("/api/v1/images/{id}", delete(images::remove))
|
||||
// Search
|
||||
.route("/api/v1/search", get(search::search))
|
||||
// OIDC auth
|
||||
.route("/auth/oidc/login", get(crate::auth::oidc::login))
|
||||
.route("/auth/oidc/callback", get(crate::auth::oidc::callback))
|
||||
.route("/auth/oidc/logout", get(crate::auth::oidc::logout))
|
||||
.route("/auth/me", get(crate::auth::oidc::me))
|
||||
// SPA fallback
|
||||
.fallback_service(
|
||||
ServeDir::new("frontend")
|
||||
.fallback(tower::service_fn(|_req| async {
|
||||
match tokio::fs::read_to_string("frontend/index.html").await {
|
||||
Ok(html) => Ok(Html(html).into_response()),
|
||||
Err(_) => Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
||||
"HerbAPI — frontend not found, use /health or /api/v1/*",
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
})),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::db::models::SearchResult;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchParams {
|
||||
pub q: String,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<Vec<SearchResult>>> {
|
||||
let limit = params.limit.unwrap_or(20).min(100);
|
||||
let tsquery = params.q.split_whitespace().collect::<Vec<_>>().join(" & ");
|
||||
|
||||
// Search across families, species, cultivars
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Families
|
||||
let families: Vec<(uuid::Uuid, String, String, Option<String>, f32)> = sqlx::query_as(
|
||||
"SELECT id, slug, name_scientific, description,
|
||||
ts_rank(to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')),
|
||||
to_tsquery('english', $1)) AS rank
|
||||
FROM families
|
||||
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
|
||||
@@ to_tsquery('english', $1)
|
||||
ORDER BY rank DESC LIMIT $2"
|
||||
)
|
||||
.bind(&tsquery).bind(limit)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for (id, slug, name, desc, rank) in families {
|
||||
results.push(SearchResult {
|
||||
entity_type: "family".to_string(),
|
||||
id, slug, name,
|
||||
description: desc,
|
||||
rank,
|
||||
});
|
||||
}
|
||||
|
||||
// Species
|
||||
let species: Vec<(uuid::Uuid, String, String, Option<String>, f32)> = sqlx::query_as(
|
||||
"SELECT id, slug, name_scientific, description,
|
||||
ts_rank(to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,'')),
|
||||
to_tsquery('english', $1)) AS rank
|
||||
FROM species
|
||||
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
|
||||
@@ to_tsquery('english', $1)
|
||||
ORDER BY rank DESC LIMIT $2"
|
||||
)
|
||||
.bind(&tsquery).bind(limit)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for (id, slug, name, desc, rank) in species {
|
||||
results.push(SearchResult {
|
||||
entity_type: "species".to_string(),
|
||||
id, slug, name,
|
||||
description: desc,
|
||||
rank,
|
||||
});
|
||||
}
|
||||
|
||||
// Cultivars
|
||||
let cultivars: Vec<(uuid::Uuid, String, String, Option<String>, f32)> = sqlx::query_as(
|
||||
"SELECT id, slug, name, description,
|
||||
ts_rank(to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,'')),
|
||||
to_tsquery('english', $1)) AS rank
|
||||
FROM cultivars
|
||||
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
|
||||
@@ to_tsquery('english', $1)
|
||||
ORDER BY rank DESC LIMIT $2"
|
||||
)
|
||||
.bind(&tsquery).bind(limit)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for (id, slug, name, desc, rank) in cultivars {
|
||||
results.push(SearchResult {
|
||||
entity_type: "cultivar".to_string(),
|
||||
id, slug, name,
|
||||
description: desc,
|
||||
rank,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort all by rank descending, take limit
|
||||
results.sort_by(|a, b| b.rank.partial_cmp(&a.rank).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(limit as usize);
|
||||
|
||||
Ok(Json(results))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{species as db, models::*};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<db::SpeciesListParams>,
|
||||
) -> Result<Json<PaginatedResponse<Species>>> {
|
||||
let result = db::list(&state.pool, ¶ms).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Species>> {
|
||||
let species = db::get_by_slug(&state.pool, &slug).await?;
|
||||
Ok(Json(species))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<CreateSpecies>,
|
||||
) -> Result<Json<Species>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let species = db::create(&state.pool, &req).await?;
|
||||
Ok(Json(species))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
Json(req): Json<CreateSpecies>,
|
||||
) -> Result<Json<Species>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let species = db::update(&state.pool, id, &req).await?;
|
||||
Ok(Json(species))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.is_admin() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
db::delete(&state.pool, id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::db::{suppliers as db, models::*};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Supplier>>> {
|
||||
let suppliers = db::list(&state.pool).await?;
|
||||
Ok(Json(suppliers))
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<Supplier>> {
|
||||
let supplier = db::get_by_slug(&state.pool, &slug).await?;
|
||||
Ok(Json(supplier))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<CreateSupplier>,
|
||||
) -> Result<Json<Supplier>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let supplier = db::create(&state.pool, &req).await?;
|
||||
Ok(Json(supplier))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
Json(req): Json<CreateSupplier>,
|
||||
) -> Result<Json<Supplier>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let supplier = db::update(&state.pool, id, &req).await?;
|
||||
Ok(Json(supplier))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.is_admin() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
db::delete(&state.pool, id).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
|
||||
// Cultivar-supplier links
|
||||
|
||||
pub async fn list_for_cultivar(
|
||||
State(state): State<AppState>,
|
||||
Path(r): Path<String>,
|
||||
) -> Result<Json<Vec<CultivarSupplier>>> {
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let links = db::list_for_cultivar(&state.pool, id).await?;
|
||||
Ok(Json(links))
|
||||
}
|
||||
|
||||
pub async fn link_cultivar(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(r): Path<String>,
|
||||
Json(req): Json<CreateCultivarSupplier>,
|
||||
) -> Result<Json<CultivarSupplier>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
|
||||
let link = db::link_cultivar(&state.pool, id, &req).await?;
|
||||
Ok(Json(link))
|
||||
}
|
||||
|
||||
pub async fn unlink_cultivar(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((cid, sid)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
if !auth.can_write() {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
db::unlink_cultivar(&state.pool, cid, sid).await?;
|
||||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum_extra::headers::authorization::Bearer;
|
||||
use axum_extra::headers::Authorization;
|
||||
use axum_extra::TypedHeader;
|
||||
use chrono::{DateTime, Utc};
|
||||
use once_cell::sync::Lazy;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::FromRow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const CACHE_TTL_SECS: i64 = 60;
|
||||
|
||||
struct CachedToken {
|
||||
user: AuthUser,
|
||||
cached_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
static TOKEN_CACHE: Lazy<RwLock<HashMap<String, CachedToken>>> =
|
||||
Lazy::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.iter().any(|s| s == "*" || s == scope)
|
||||
}
|
||||
|
||||
pub fn can_write(&self) -> bool {
|
||||
self.has_scope("write") || self.has_scope("admin") || self.has_scope("*")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn can_read(&self) -> bool {
|
||||
self.has_scope("read") || self.can_write()
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.has_scope("admin")
|
||||
|| self.has_scope("*")
|
||||
|| self.groups.iter().any(|g| g == "g-sn-herbapi-admin")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct ApiTokenRow {
|
||||
name: String,
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthError {
|
||||
MissingAuth,
|
||||
InvalidToken,
|
||||
InvalidSession,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AuthError::MissingAuth => (StatusCode::UNAUTHORIZED, "Authentication required"),
|
||||
AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid authorization token"),
|
||||
AuthError::InvalidSession => {
|
||||
(StatusCode::UNAUTHORIZED, "Invalid or expired session")
|
||||
}
|
||||
};
|
||||
(status, message).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
// Try Bearer token first
|
||||
if let Ok(TypedHeader(Authorization(bearer))) =
|
||||
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
|
||||
{
|
||||
let token = bearer.token();
|
||||
|
||||
// Check admin token
|
||||
if let Some(ref admin) = state.config.admin_token
|
||||
&& token == admin
|
||||
{
|
||||
return Ok(AuthUser {
|
||||
name: "admin".into(),
|
||||
scopes: vec!["*".into()],
|
||||
groups: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Check cache
|
||||
let token_hash = hash_token(token);
|
||||
{
|
||||
let cache = TOKEN_CACHE.read().unwrap();
|
||||
if let Some(cached) = cache.get(&token_hash)
|
||||
&& (Utc::now() - cached.cached_at).num_seconds() < CACHE_TTL_SECS
|
||||
{
|
||||
return Ok(cached.user.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Look up in DB
|
||||
let row = sqlx::query_as::<_, ApiTokenRow>(
|
||||
"SELECT name, scopes FROM api_tokens WHERE token_hash = $1",
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Token lookup failed: {e}");
|
||||
AuthError::InvalidToken
|
||||
})?
|
||||
.ok_or(AuthError::InvalidToken)?;
|
||||
|
||||
// Update last_used_at (fire and forget)
|
||||
let pool = state.pool.clone();
|
||||
let hash = token_hash.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ =
|
||||
sqlx::query("UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1")
|
||||
.bind(&hash)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
});
|
||||
|
||||
let user = AuthUser {
|
||||
name: row.name,
|
||||
scopes: row.scopes,
|
||||
groups: vec![],
|
||||
};
|
||||
|
||||
// Cache it
|
||||
{
|
||||
let mut cache = TOKEN_CACHE.write().unwrap();
|
||||
cache.insert(
|
||||
token_hash,
|
||||
CachedToken {
|
||||
user: user.clone(),
|
||||
cached_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
// Try session cookie (for web UI via OIDC)
|
||||
let session = Session::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| AuthError::MissingAuth)?;
|
||||
|
||||
let user_id_str: Option<String> = session
|
||||
.get("user_id")
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?;
|
||||
|
||||
if let Some(user_id_str) = user_id_str {
|
||||
let user_id: Uuid = user_id_str.parse().map_err(|_| AuthError::InvalidSession)?;
|
||||
|
||||
let email: Option<String> = session
|
||||
.get("user_email")
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?;
|
||||
let name: Option<String> = session
|
||||
.get("user_name")
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?;
|
||||
let nickname: Option<String> = session
|
||||
.get("user_nickname")
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?;
|
||||
let admin: Option<bool> = session
|
||||
.get("user_admin")
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?;
|
||||
let groups: Option<Vec<String>> = session
|
||||
.get("user_groups")
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?;
|
||||
|
||||
let (email, name, nickname, admin) = if email.is_some() {
|
||||
(email, name, nickname, admin.unwrap_or(false))
|
||||
} else {
|
||||
let user = crate::db::users::find_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|_| AuthError::InvalidSession)?
|
||||
.ok_or(AuthError::InvalidSession)?;
|
||||
(
|
||||
Some(user.email),
|
||||
user.name,
|
||||
user.nickname,
|
||||
user.admin,
|
||||
)
|
||||
};
|
||||
|
||||
let display_name = nickname
|
||||
.or(name)
|
||||
.or(email)
|
||||
.unwrap_or_else(|| "user".to_string());
|
||||
|
||||
let scopes = if admin {
|
||||
vec!["*".to_string()]
|
||||
} else {
|
||||
vec!["read".to_string()]
|
||||
};
|
||||
|
||||
return Ok(AuthUser {
|
||||
name: display_name,
|
||||
scopes,
|
||||
groups: groups.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(AuthError::MissingAuth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod extractor;
|
||||
pub mod oidc;
|
||||
|
||||
pub use extractor::AuthUser;
|
||||
@@ -0,0 +1,255 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{IntoResponse, Redirect};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
pub async fn login(State(state): State<AppState>, session: Session) -> Result<impl IntoResponse> {
|
||||
if !state.config.oidc_enabled() {
|
||||
return Err(AppError::BadRequest("OIDC not configured".into()));
|
||||
}
|
||||
|
||||
let client_id = state.config.oidc_client_id.as_ref().unwrap();
|
||||
let issuer = &state.config.oidc_issuer;
|
||||
let redirect_uri = &state.config.oidc_redirect_uri;
|
||||
|
||||
let oauth_state = generate_random_string();
|
||||
let nonce = generate_random_string();
|
||||
|
||||
session
|
||||
.insert("oauth_state", &oauth_state)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
session
|
||||
.insert("oauth_nonce", &nonce)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
let base_url = authentik_base_url(issuer);
|
||||
|
||||
let auth_url = format!(
|
||||
"{}authorize/?response_type=code&client_id={}&redirect_uri={}&scope=openid+email+profile&state={}&nonce={}",
|
||||
base_url,
|
||||
urlencoding(client_id),
|
||||
urlencoding(redirect_uri),
|
||||
oauth_state,
|
||||
nonce,
|
||||
);
|
||||
|
||||
Ok(Redirect::temporary(&auth_url))
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<CallbackQuery>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
if !state.config.oidc_enabled() {
|
||||
return Err(AppError::BadRequest("OIDC not configured".into()));
|
||||
}
|
||||
|
||||
let saved_state: Option<String> = session
|
||||
.get("oauth_state")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
if saved_state.as_deref() != Some(&query.state) {
|
||||
return Err(AppError::BadRequest("Invalid OAuth state".into()));
|
||||
}
|
||||
|
||||
let client_id = state.config.oidc_client_id.as_ref().unwrap();
|
||||
let client_secret = state.config.oidc_client_secret.as_ref().unwrap();
|
||||
let issuer = &state.config.oidc_issuer;
|
||||
let redirect_uri = &state.config.oidc_redirect_uri;
|
||||
|
||||
let base_url = authentik_base_url(issuer);
|
||||
|
||||
let token_url = format!("{}token/", base_url);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &query.code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("client_id", client_id),
|
||||
("client_secret", client_secret),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Token exchange failed: {e}")))?;
|
||||
|
||||
let token_data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Token parse failed: {e}")))?;
|
||||
|
||||
let access_token = token_data
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::Internal("No access_token in response".into()))?;
|
||||
|
||||
let userinfo_url = format!("{}userinfo/", base_url);
|
||||
let userinfo: serde_json::Value = client
|
||||
.get(&userinfo_url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Userinfo failed: {e}")))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Userinfo parse failed: {e}")))?;
|
||||
|
||||
let email = userinfo
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::Internal("No email in userinfo".into()))?;
|
||||
let name = userinfo.get("name").and_then(|v| v.as_str());
|
||||
let nickname = userinfo
|
||||
.get("nickname")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| userinfo.get("preferred_username").and_then(|v| v.as_str()));
|
||||
let sub = userinfo
|
||||
.get("sub")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::Internal("No sub in userinfo".into()))?;
|
||||
|
||||
let groups_claim = userinfo.get("groups");
|
||||
let groups: Vec<String> = groups_claim
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
let mut set = BTreeSet::new();
|
||||
for g in arr.iter().filter_map(|v| v.as_str()) {
|
||||
set.insert(g.to_string());
|
||||
}
|
||||
set.into_iter().collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if groups_claim.is_none() {
|
||||
tracing::warn!("OIDC userinfo missing 'groups' claim; HerbAPI admin access requires Authentik groups mapping");
|
||||
}
|
||||
|
||||
let user = crate::db::users::upsert_oidc_user(&state.pool, email, name, nickname, sub).await?;
|
||||
|
||||
session
|
||||
.insert("user_id", user.id.to_string())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
session
|
||||
.insert("user_email", email.to_string())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
session
|
||||
.insert("user_name", name.map(|s| s.to_string()))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
session
|
||||
.insert("user_nickname", nickname.map(|s| s.to_string()))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
let is_admin = user.admin || groups.iter().any(|g| g == "g-sn-herbapi-admin");
|
||||
session
|
||||
.insert("user_admin", is_admin)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
session
|
||||
.insert("user_groups", groups)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
session
|
||||
.remove::<String>("oauth_state")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
session
|
||||
.remove::<String>("oauth_nonce")
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Redirect::temporary("/"))
|
||||
}
|
||||
|
||||
pub async fn logout(session: Session) -> impl IntoResponse {
|
||||
let _ = session.delete().await;
|
||||
Redirect::temporary("/")
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct MeResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub nickname: Option<String>,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<AppState>, session: Session) -> impl IntoResponse {
|
||||
async fn inner(
|
||||
state: &AppState,
|
||||
session: Session,
|
||||
) -> std::result::Result<MeResponse, axum::http::StatusCode> {
|
||||
let user_id_str: Option<String> = session
|
||||
.get("user_id")
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let user_id_str = user_id_str.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let user_id: uuid::Uuid = user_id_str
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let user = crate::db::users::find_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let session_admin: Option<bool> = session
|
||||
.get("user_admin")
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(MeResponse {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
nickname: user.nickname,
|
||||
admin: session_admin.unwrap_or(user.admin),
|
||||
})
|
||||
}
|
||||
|
||||
match inner(&state, session).await {
|
||||
Ok(response) => axum::Json(response).into_response(),
|
||||
Err(status) => status.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn authentik_base_url(issuer: &str) -> String {
|
||||
issuer
|
||||
.trim_end_matches('/')
|
||||
.rsplit_once('/')
|
||||
.map(|(base, _)| format!("{}/", base))
|
||||
.unwrap_or_else(|| issuer.to_string())
|
||||
}
|
||||
|
||||
fn generate_random_string() -> String {
|
||||
use rand::Rng;
|
||||
let bytes: [u8; 16] = rand::thread_rng().r#gen();
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn urlencoding(s: &str) -> String {
|
||||
s.replace(':', "%3A")
|
||||
.replace('/', "%2F")
|
||||
.replace('?', "%3F")
|
||||
.replace('&', "%26")
|
||||
.replace('=', "%3D")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::env;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub admin_token: Option<String>,
|
||||
pub tls_cert_path: Option<String>,
|
||||
pub tls_key_path: Option<String>,
|
||||
// OIDC (Authentik SSO)
|
||||
pub oidc_client_id: Option<String>,
|
||||
pub oidc_client_secret: Option<String>,
|
||||
pub oidc_issuer: String,
|
||||
pub oidc_redirect_uri: String,
|
||||
// S3 (Garage)
|
||||
pub s3_endpoint: String,
|
||||
pub s3_region: String,
|
||||
pub s3_access_key: String,
|
||||
pub s3_secret_key: String,
|
||||
pub s3_bucket: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
.context("PORT must be a valid u16")?,
|
||||
database_url: env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
||||
admin_token: env::var("ADMIN_TOKEN").ok().filter(|s| !s.is_empty()),
|
||||
tls_cert_path: env::var("TLS_CERT_PATH").ok().filter(|s| !s.is_empty()),
|
||||
tls_key_path: env::var("TLS_KEY_PATH").ok().filter(|s| !s.is_empty()),
|
||||
oidc_client_id: env::var("OIDC_CLIENT_ID").ok().filter(|s| !s.is_empty()),
|
||||
oidc_client_secret: env::var("OIDC_CLIENT_SECRET")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty()),
|
||||
oidc_issuer: env::var("OIDC_ISSUER").unwrap_or_else(|_| {
|
||||
"https://auth.sub-net.at/application/o/herbapi/".into()
|
||||
}),
|
||||
oidc_redirect_uri: env::var("OIDC_REDIRECT_URI").unwrap_or_else(|_| {
|
||||
"https://herbapi.naturalised.at/auth/oidc/callback".into()
|
||||
}),
|
||||
s3_endpoint: env::var("S3_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://s3.sub-net.at".into()),
|
||||
s3_region: env::var("S3_REGION").unwrap_or_else(|_| "garage".into()),
|
||||
s3_access_key: env::var("S3_ACCESS_KEY").unwrap_or_default(),
|
||||
s3_secret_key: env::var("S3_SECRET_KEY").unwrap_or_default(),
|
||||
s3_bucket: env::var("S3_BUCKET").unwrap_or_else(|_| "herbapi".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tls_enabled(&self) -> bool {
|
||||
self.tls_cert_path.is_some() && self.tls_key_path.is_some()
|
||||
}
|
||||
|
||||
pub fn oidc_enabled(&self) -> bool {
|
||||
self.oidc_client_id.is_some() && self.oidc_client_secret.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use super::models::{CompanionRelationship, CreateCompanion};
|
||||
|
||||
pub async fn list_for_species(pool: &PgPool, species_id: Uuid) -> Result<Vec<CompanionRelationship>> {
|
||||
sqlx::query_as::<_, CompanionRelationship>(
|
||||
"SELECT * FROM companion_relationships
|
||||
WHERE species_a_id = $1 OR species_b_id = $1
|
||||
ORDER BY relationship, created_at"
|
||||
)
|
||||
.bind(species_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, req: &CreateCompanion) -> Result<CompanionRelationship> {
|
||||
let id = Uuid::now_v7();
|
||||
// Enforce ordering: species_a_id < species_b_id
|
||||
let (a, b) = if req.species_a_id < req.species_b_id {
|
||||
(req.species_a_id, req.species_b_id)
|
||||
} else {
|
||||
(req.species_b_id, req.species_a_id)
|
||||
};
|
||||
|
||||
sqlx::query_as::<_, CompanionRelationship>(
|
||||
"INSERT INTO companion_relationships (id, species_a_id, species_b_id, relationship, mechanism, source_url)
|
||||
VALUES ($1, $2, $3, $4::companion_type, $5, $6) RETURNING *"
|
||||
)
|
||||
.bind(id).bind(a).bind(b).bind(&req.relationship)
|
||||
.bind(&req.mechanism).bind(&req.source_url)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM companion_relationships WHERE id = $1")
|
||||
.bind(id).execute(pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(format!("Relationship not found: {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use super::models::{CreateCultivar, Cultivar, PaginatedResponse};
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct CultivarListParams {
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
pub search: Option<String>,
|
||||
pub species: Option<String>,
|
||||
}
|
||||
|
||||
impl CultivarListParams {
|
||||
pub fn limit(&self) -> i64 { self.per_page.unwrap_or(25).min(100) }
|
||||
pub fn offset(&self) -> i64 { (self.page.unwrap_or(1) - 1).max(0) * self.limit() }
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool, params: &CultivarListParams) -> Result<PaginatedResponse<Cultivar>> {
|
||||
let limit = params.limit();
|
||||
let offset = params.offset();
|
||||
|
||||
let (rows, total) = match (¶ms.species, ¶ms.search) {
|
||||
(Some(species_slug), Some(search)) => {
|
||||
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
|
||||
let rows = sqlx::query_as::<_, Cultivar>(
|
||||
"SELECT c.* FROM cultivars c JOIN species s ON c.species_id = s.id
|
||||
WHERE s.slug = $1
|
||||
AND to_tsvector('english', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') || ' ' || coalesce(c.description,''))
|
||||
@@ to_tsquery('english', $2)
|
||||
ORDER BY c.name LIMIT $3 OFFSET $4"
|
||||
)
|
||||
.bind(species_slug).bind(&tsquery).bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM cultivars c JOIN species s ON c.species_id = s.id
|
||||
WHERE s.slug = $1
|
||||
AND to_tsvector('english', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') || ' ' || coalesce(c.description,''))
|
||||
@@ to_tsquery('english', $2)"
|
||||
)
|
||||
.bind(species_slug).bind(&tsquery)
|
||||
.fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
(Some(species_slug), None) => {
|
||||
let rows = sqlx::query_as::<_, Cultivar>(
|
||||
"SELECT c.* FROM cultivars c JOIN species s ON c.species_id = s.id
|
||||
WHERE s.slug = $1 ORDER BY c.name LIMIT $2 OFFSET $3"
|
||||
)
|
||||
.bind(species_slug).bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM cultivars c JOIN species s ON c.species_id = s.id WHERE s.slug = $1"
|
||||
)
|
||||
.bind(species_slug).fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
(None, Some(search)) => {
|
||||
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
|
||||
let rows = sqlx::query_as::<_, Cultivar>(
|
||||
"SELECT * FROM cultivars
|
||||
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
|
||||
@@ to_tsquery('english', $1)
|
||||
ORDER BY name LIMIT $2 OFFSET $3"
|
||||
)
|
||||
.bind(&tsquery).bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM cultivars
|
||||
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
|
||||
@@ to_tsquery('english', $1)"
|
||||
)
|
||||
.bind(&tsquery).fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
(None, None) => {
|
||||
let rows = sqlx::query_as::<_, Cultivar>(
|
||||
"SELECT * FROM cultivars ORDER BY name LIMIT $1 OFFSET $2"
|
||||
)
|
||||
.bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cultivars")
|
||||
.fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(PaginatedResponse {
|
||||
data: rows,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
per_page: limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Cultivar> {
|
||||
sqlx::query_as::<_, Cultivar>("SELECT * FROM cultivars WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Cultivar not found: {slug}")))
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result<Cultivar> {
|
||||
let id = Uuid::now_v7();
|
||||
// Get species slug for cultivar slug prefix
|
||||
let species_slug: (String,) = sqlx::query_as("SELECT slug FROM species WHERE id = $1")
|
||||
.bind(req.species_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Species not found".into()))?;
|
||||
let slug = format!("{}-{}", species_slug.0, slug::slugify(&req.name));
|
||||
|
||||
sqlx::query_as::<_, Cultivar>(
|
||||
"INSERT INTO cultivars (id, slug, species_id, name, name_en, name_de, name_scientific,
|
||||
description, is_organic, perennial, growing_time_days, planting_depth_cm, row_spacing_cm,
|
||||
plant_spacing_cm, days_to_germination, germination_temp_c, light_requirement,
|
||||
stratification_required, stratification_days, scarification_required,
|
||||
seed_viability_years, storage_temp_c, storage_humidity, storage_notes,
|
||||
min_temp, max_temp, humidity, light, frost_tolerance,
|
||||
min_light_hours_day, optimal_light_hours_day, greenhouse_min_temp_c,
|
||||
indoor_season_extension_weeks, ventilation_requirement, heating_required,
|
||||
indoor_sowing_months, direct_sowing_months, transplanting_months, glasshouse_months,
|
||||
harvesting_months, succession_planting_days, planting_notes,
|
||||
propagation_methods, cutting_season, rootstock_species_id,
|
||||
years_to_first_harvest, productive_lifespan_years,
|
||||
expected_yield_kg_per_m2, yield_unit, expected_yield_value,
|
||||
harvest_window_days, storage_method, shelf_life_days, cold_storage_days,
|
||||
pollination_group, self_fertile, rootstock_compatibility,
|
||||
wikidata_qid, gbif_id, pfaf_url, source_urls)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,$29::frost_tolerance,$30,$31,$32,$33,$34,$35,
|
||||
$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,$51,$52,$53,$54,
|
||||
$55,$56,$57,$58,$59,$60,$61)
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
|
||||
.bind(&req.name_en).bind(&req.name_de).bind(&req.name_scientific)
|
||||
.bind(&req.description).bind(req.is_organic.unwrap_or(false)).bind(req.perennial.unwrap_or(false))
|
||||
.bind(req.growing_time_days).bind(req.planting_depth_cm).bind(req.row_spacing_cm)
|
||||
.bind(req.plant_spacing_cm).bind(req.days_to_germination).bind(req.germination_temp_c)
|
||||
.bind(&req.light_requirement).bind(req.stratification_required).bind(req.stratification_days)
|
||||
.bind(req.scarification_required).bind(req.seed_viability_years).bind(req.storage_temp_c)
|
||||
.bind(&req.storage_humidity).bind(&req.storage_notes)
|
||||
.bind(req.min_temp).bind(req.max_temp).bind(&req.humidity).bind(&req.light)
|
||||
.bind(&req.frost_tolerance)
|
||||
.bind(req.min_light_hours_day).bind(req.optimal_light_hours_day).bind(req.greenhouse_min_temp_c)
|
||||
.bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required)
|
||||
.bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months)
|
||||
.bind(&req.transplanting_months).bind(&req.glasshouse_months)
|
||||
.bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes)
|
||||
.bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id)
|
||||
.bind(req.years_to_first_harvest).bind(req.productive_lifespan_years)
|
||||
.bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value)
|
||||
.bind(req.harvest_window_days).bind(&req.storage_method)
|
||||
.bind(req.shelf_life_days).bind(req.cold_storage_days)
|
||||
.bind(&req.pollination_group).bind(req.self_fertile).bind(&req.rootstock_compatibility)
|
||||
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.pfaf_url).bind(&req.source_urls)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateCultivar) -> Result<Cultivar> {
|
||||
let _existing = sqlx::query("SELECT id FROM cultivars WHERE id = $1")
|
||||
.bind(id).fetch_optional(pool).await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Cultivar not found: {id}")))?;
|
||||
|
||||
let species_slug: (String,) = sqlx::query_as("SELECT slug FROM species WHERE id = $1")
|
||||
.bind(req.species_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Species not found".into()))?;
|
||||
let slug = format!("{}-{}", species_slug.0, slug::slugify(&req.name));
|
||||
|
||||
sqlx::query_as::<_, Cultivar>(
|
||||
"UPDATE cultivars SET slug=$2, species_id=$3, name=$4, name_en=$5, name_de=$6,
|
||||
name_scientific=$7, description=$8, is_organic=$9, perennial=$10,
|
||||
growing_time_days=$11, planting_depth_cm=$12, row_spacing_cm=$13, plant_spacing_cm=$14,
|
||||
days_to_germination=$15, germination_temp_c=$16, light_requirement=$17,
|
||||
stratification_required=$18, stratification_days=$19, scarification_required=$20,
|
||||
seed_viability_years=$21, storage_temp_c=$22, storage_humidity=$23, storage_notes=$24,
|
||||
min_temp=$25, max_temp=$26, humidity=$27, light=$28, frost_tolerance=$29::frost_tolerance,
|
||||
min_light_hours_day=$30, optimal_light_hours_day=$31, greenhouse_min_temp_c=$32,
|
||||
indoor_season_extension_weeks=$33, ventilation_requirement=$34, heating_required=$35,
|
||||
indoor_sowing_months=$36, direct_sowing_months=$37, transplanting_months=$38,
|
||||
glasshouse_months=$39, harvesting_months=$40, succession_planting_days=$41,
|
||||
planting_notes=$42, propagation_methods=$43, cutting_season=$44,
|
||||
rootstock_species_id=$45, years_to_first_harvest=$46, productive_lifespan_years=$47,
|
||||
expected_yield_kg_per_m2=$48, yield_unit=$49, expected_yield_value=$50,
|
||||
harvest_window_days=$51, storage_method=$52, shelf_life_days=$53, cold_storage_days=$54,
|
||||
pollination_group=$55, self_fertile=$56, rootstock_compatibility=$57,
|
||||
wikidata_qid=$58, gbif_id=$59, pfaf_url=$60, source_urls=$61, updated_at=NOW()
|
||||
WHERE id=$1 RETURNING *"
|
||||
)
|
||||
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
|
||||
.bind(&req.name_en).bind(&req.name_de).bind(&req.name_scientific)
|
||||
.bind(&req.description).bind(req.is_organic.unwrap_or(false)).bind(req.perennial.unwrap_or(false))
|
||||
.bind(req.growing_time_days).bind(req.planting_depth_cm).bind(req.row_spacing_cm)
|
||||
.bind(req.plant_spacing_cm).bind(req.days_to_germination).bind(req.germination_temp_c)
|
||||
.bind(&req.light_requirement).bind(req.stratification_required).bind(req.stratification_days)
|
||||
.bind(req.scarification_required).bind(req.seed_viability_years).bind(req.storage_temp_c)
|
||||
.bind(&req.storage_humidity).bind(&req.storage_notes)
|
||||
.bind(req.min_temp).bind(req.max_temp).bind(&req.humidity).bind(&req.light)
|
||||
.bind(&req.frost_tolerance)
|
||||
.bind(req.min_light_hours_day).bind(req.optimal_light_hours_day).bind(req.greenhouse_min_temp_c)
|
||||
.bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required)
|
||||
.bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months)
|
||||
.bind(&req.transplanting_months).bind(&req.glasshouse_months)
|
||||
.bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes)
|
||||
.bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id)
|
||||
.bind(req.years_to_first_harvest).bind(req.productive_lifespan_years)
|
||||
.bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value)
|
||||
.bind(req.harvest_window_days).bind(&req.storage_method)
|
||||
.bind(req.shelf_life_days).bind(req.cold_storage_days)
|
||||
.bind(&req.pollination_group).bind(req.self_fertile).bind(&req.rootstock_compatibility)
|
||||
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.pfaf_url).bind(&req.source_urls)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM cultivars WHERE id = $1")
|
||||
.bind(id).execute(pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(format!("Cultivar not found: {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use super::models::{CreateFamily, Family, PaginatedResponse, PaginationParams, UpdateFamily};
|
||||
|
||||
pub async fn list(pool: &PgPool, params: &PaginationParams) -> Result<PaginatedResponse<Family>> {
|
||||
let limit = params.limit();
|
||||
let offset = params.offset();
|
||||
|
||||
let (rows, total) = if let Some(ref search) = params.search {
|
||||
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
|
||||
let rows = sqlx::query_as::<_, Family>(
|
||||
"SELECT * FROM families
|
||||
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
|
||||
@@ to_tsquery('english', $1)
|
||||
ORDER BY name_scientific LIMIT $2 OFFSET $3"
|
||||
)
|
||||
.bind(&tsquery)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM families
|
||||
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
|
||||
@@ to_tsquery('english', $1)"
|
||||
)
|
||||
.bind(&tsquery)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
(rows, count)
|
||||
} else {
|
||||
let rows = sqlx::query_as::<_, Family>(
|
||||
"SELECT * FROM families ORDER BY name_scientific LIMIT $1 OFFSET $2"
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM families")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
(rows, count)
|
||||
};
|
||||
|
||||
Ok(PaginatedResponse {
|
||||
data: rows,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
per_page: limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Family> {
|
||||
sqlx::query_as::<_, Family>("SELECT * FROM families WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Family not found: {slug}")))
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, req: &CreateFamily) -> Result<Family> {
|
||||
let id = Uuid::now_v7();
|
||||
let slug = slug::slugify(&req.name_scientific);
|
||||
|
||||
sqlx::query_as::<_, Family>(
|
||||
"INSERT INTO families (id, slug, name_scientific, name_en, name_de, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&slug)
|
||||
.bind(&req.name_scientific)
|
||||
.bind(&req.name_en)
|
||||
.bind(&req.name_de)
|
||||
.bind(&req.description)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, req: &UpdateFamily) -> Result<Family> {
|
||||
let existing = sqlx::query_as::<_, Family>("SELECT * FROM families WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Family not found: {id}")))?;
|
||||
|
||||
let name_sci = req.name_scientific.as_deref().unwrap_or(&existing.name_scientific);
|
||||
let new_slug = if req.name_scientific.is_some() {
|
||||
slug::slugify(name_sci)
|
||||
} else {
|
||||
existing.slug.clone()
|
||||
};
|
||||
|
||||
sqlx::query_as::<_, Family>(
|
||||
"UPDATE families SET slug = $2, name_scientific = $3,
|
||||
name_en = COALESCE($4, name_en), name_de = COALESCE($5, name_de),
|
||||
description = COALESCE($6, description), updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&new_slug)
|
||||
.bind(name_sci)
|
||||
.bind(&req.name_en)
|
||||
.bind(&req.name_de)
|
||||
.bind(&req.description)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM families WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(format!("Family not found: {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use super::models::Image;
|
||||
|
||||
pub async fn list_for_entity(pool: &PgPool, entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>> {
|
||||
sqlx::query_as::<_, Image>(
|
||||
"SELECT * FROM images WHERE entity_type = $1 AND entity_id = $2 ORDER BY is_primary DESC, created_at"
|
||||
)
|
||||
.bind(entity_type).bind(entity_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
entity_type: &str,
|
||||
entity_id: Uuid,
|
||||
s3_key: &str,
|
||||
caption: Option<&str>,
|
||||
source_url: Option<&str>,
|
||||
license: Option<&str>,
|
||||
is_primary: bool,
|
||||
) -> Result<Image> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query_as::<_, Image>(
|
||||
"INSERT INTO images (id, entity_type, entity_id, s3_key, caption, source_url, license, is_primary)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
|
||||
)
|
||||
.bind(id).bind(entity_type).bind(entity_id).bind(s3_key)
|
||||
.bind(caption).bind(source_url).bind(license).bind(is_primary)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Image> {
|
||||
sqlx::query_as::<_, Image>("DELETE FROM images WHERE id = $1 RETURNING *")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Image not found: {id}")))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod companions;
|
||||
pub mod cultivars;
|
||||
pub mod families;
|
||||
pub mod images;
|
||||
pub mod models;
|
||||
pub mod s3;
|
||||
pub mod species;
|
||||
pub mod suppliers;
|
||||
pub mod users;
|
||||
@@ -0,0 +1,427 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- Pagination ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
impl PaginationParams {
|
||||
pub fn limit(&self) -> i64 {
|
||||
self.per_page.unwrap_or(25).min(100)
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
(self.page.unwrap_or(1) - 1).max(0) * self.limit()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaginatedResponse<T: Serialize> {
|
||||
pub data: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
// --- Families ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct Family {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateFamily {
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateFamily {
|
||||
pub name_scientific: Option<String>,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// --- Species ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct Species {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub family_id: Uuid,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub soil_moisture: Option<String>,
|
||||
pub drainage_requirement: Option<String>,
|
||||
pub organic_matter_pct: Option<f64>,
|
||||
pub nitrogen_ppm: Option<i32>,
|
||||
pub phosphorus_ppm: Option<i32>,
|
||||
pub potassium_ppm: Option<i32>,
|
||||
pub boron_ppm: Option<f64>,
|
||||
pub calcium_ppm: Option<i32>,
|
||||
pub copper_ppm: Option<f64>,
|
||||
pub iron_ppm: Option<f64>,
|
||||
pub magnesium_ppm: Option<i32>,
|
||||
pub manganese_ppm: Option<f64>,
|
||||
pub molybdenum_ppm: Option<f64>,
|
||||
pub sulfur_ppm: Option<i32>,
|
||||
pub zinc_ppm: Option<f64>,
|
||||
pub ph_min: Option<f64>,
|
||||
pub ph_max: Option<f64>,
|
||||
pub soil_texture_preference: Option<Vec<String>>,
|
||||
pub hardiness_zone_usda: Option<String>,
|
||||
pub hardiness_zone_at: Option<String>,
|
||||
pub min_temp: Option<f64>,
|
||||
pub max_temp: Option<f64>,
|
||||
pub drought_tolerance: Option<String>,
|
||||
pub water_requirement_mm_week: Option<f64>,
|
||||
pub waterlogging_tolerance: Option<bool>,
|
||||
pub salt_tolerance: Option<String>,
|
||||
pub edibility_rating: Option<i16>,
|
||||
pub food_uses: Option<String>,
|
||||
pub medicinal_uses: Option<String>,
|
||||
pub other_uses: Option<String>,
|
||||
pub native_range: Option<String>,
|
||||
pub invasiveness: Option<String>,
|
||||
pub pollination_type: Option<String>,
|
||||
pub plant_layer: Option<String>,
|
||||
pub nitrogen_fixer: Option<bool>,
|
||||
pub dynamic_accumulator: Option<bool>,
|
||||
pub dynamic_accumulator_nutrients: Option<Vec<String>>,
|
||||
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 heavy_metal_tolerance: Option<bool>,
|
||||
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 source_urls: Option<Vec<String>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSpecies {
|
||||
pub family_id: Uuid,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub soil_moisture: Option<String>,
|
||||
pub drainage_requirement: Option<String>,
|
||||
pub ph_min: Option<f64>,
|
||||
pub ph_max: Option<f64>,
|
||||
pub soil_texture_preference: Option<Vec<String>>,
|
||||
pub hardiness_zone_usda: Option<String>,
|
||||
pub hardiness_zone_at: Option<String>,
|
||||
pub min_temp: Option<f64>,
|
||||
pub max_temp: Option<f64>,
|
||||
pub drought_tolerance: Option<String>,
|
||||
pub salt_tolerance: Option<String>,
|
||||
pub edibility_rating: Option<i16>,
|
||||
pub food_uses: Option<String>,
|
||||
pub medicinal_uses: Option<String>,
|
||||
pub other_uses: Option<String>,
|
||||
pub native_range: Option<String>,
|
||||
pub invasiveness: Option<String>,
|
||||
pub pollination_type: Option<String>,
|
||||
pub plant_layer: Option<String>,
|
||||
pub nitrogen_fixer: Option<bool>,
|
||||
pub dynamic_accumulator: Option<bool>,
|
||||
pub dynamic_accumulator_nutrients: Option<Vec<String>>,
|
||||
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 heavy_metal_tolerance: Option<bool>,
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub gbif_id: Option<String>,
|
||||
pub eppo_code: Option<String>,
|
||||
pub pfaf_url: Option<String>,
|
||||
pub source_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub type UpdateSpecies = CreateSpecies;
|
||||
|
||||
// --- Cultivars ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct Cultivar {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub species_id: Uuid,
|
||||
pub name: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub name_scientific: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub perennial: bool,
|
||||
pub growing_time_days: Option<i32>,
|
||||
pub planting_depth_cm: Option<f64>,
|
||||
pub row_spacing_cm: Option<f64>,
|
||||
pub plant_spacing_cm: Option<f64>,
|
||||
pub days_to_germination: Option<i32>,
|
||||
pub germination_temp_c: Option<f64>,
|
||||
pub light_requirement: Option<String>,
|
||||
pub stratification_required: Option<bool>,
|
||||
pub stratification_days: Option<i32>,
|
||||
pub scarification_required: Option<bool>,
|
||||
pub seed_viability_years: Option<i32>,
|
||||
pub storage_temp_c: Option<f64>,
|
||||
pub storage_humidity: Option<String>,
|
||||
pub storage_notes: Option<String>,
|
||||
pub min_temp: Option<f64>,
|
||||
pub max_temp: Option<f64>,
|
||||
pub humidity: Option<String>,
|
||||
pub light: Option<String>,
|
||||
pub frost_tolerance: Option<String>,
|
||||
pub min_light_hours_day: Option<f64>,
|
||||
pub optimal_light_hours_day: Option<f64>,
|
||||
pub greenhouse_min_temp_c: Option<f64>,
|
||||
pub indoor_season_extension_weeks: Option<i32>,
|
||||
pub ventilation_requirement: Option<String>,
|
||||
pub heating_required: Option<bool>,
|
||||
pub indoor_sowing_months: Option<Vec<i32>>,
|
||||
pub direct_sowing_months: Option<Vec<i32>>,
|
||||
pub transplanting_months: Option<Vec<i32>>,
|
||||
pub glasshouse_months: Option<Vec<i32>>,
|
||||
pub harvesting_months: Option<Vec<i32>>,
|
||||
pub succession_planting_days: Option<i32>,
|
||||
pub planting_notes: Option<String>,
|
||||
pub propagation_methods: Option<Vec<String>>,
|
||||
pub cutting_season: Option<String>,
|
||||
pub rootstock_species_id: Option<Uuid>,
|
||||
pub years_to_first_harvest: Option<i32>,
|
||||
pub productive_lifespan_years: Option<i32>,
|
||||
pub expected_yield_kg_per_m2: Option<f64>,
|
||||
pub yield_unit: Option<String>,
|
||||
pub expected_yield_value: Option<f64>,
|
||||
pub harvest_window_days: Option<i32>,
|
||||
pub storage_method: Option<Vec<String>>,
|
||||
pub shelf_life_days: Option<i32>,
|
||||
pub cold_storage_days: Option<i32>,
|
||||
pub pollination_group: Option<String>,
|
||||
pub self_fertile: Option<bool>,
|
||||
pub rootstock_compatibility: Option<String>,
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub gbif_id: Option<String>,
|
||||
pub pfaf_url: Option<String>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub source_urls: Option<Vec<String>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCultivar {
|
||||
pub species_id: Uuid,
|
||||
pub name: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub name_scientific: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_organic: Option<bool>,
|
||||
pub perennial: Option<bool>,
|
||||
pub growing_time_days: Option<i32>,
|
||||
pub planting_depth_cm: Option<f64>,
|
||||
pub row_spacing_cm: Option<f64>,
|
||||
pub plant_spacing_cm: Option<f64>,
|
||||
pub days_to_germination: Option<i32>,
|
||||
pub germination_temp_c: Option<f64>,
|
||||
pub light_requirement: Option<String>,
|
||||
pub stratification_required: Option<bool>,
|
||||
pub stratification_days: Option<i32>,
|
||||
pub scarification_required: Option<bool>,
|
||||
pub seed_viability_years: Option<i32>,
|
||||
pub storage_temp_c: Option<f64>,
|
||||
pub storage_humidity: Option<String>,
|
||||
pub storage_notes: Option<String>,
|
||||
pub min_temp: Option<f64>,
|
||||
pub max_temp: Option<f64>,
|
||||
pub humidity: Option<String>,
|
||||
pub light: Option<String>,
|
||||
pub frost_tolerance: Option<String>,
|
||||
pub min_light_hours_day: Option<f64>,
|
||||
pub optimal_light_hours_day: Option<f64>,
|
||||
pub greenhouse_min_temp_c: Option<f64>,
|
||||
pub indoor_season_extension_weeks: Option<i32>,
|
||||
pub ventilation_requirement: Option<String>,
|
||||
pub heating_required: Option<bool>,
|
||||
pub indoor_sowing_months: Option<Vec<i32>>,
|
||||
pub direct_sowing_months: Option<Vec<i32>>,
|
||||
pub transplanting_months: Option<Vec<i32>>,
|
||||
pub glasshouse_months: Option<Vec<i32>>,
|
||||
pub harvesting_months: Option<Vec<i32>>,
|
||||
pub succession_planting_days: Option<i32>,
|
||||
pub planting_notes: Option<String>,
|
||||
pub propagation_methods: Option<Vec<String>>,
|
||||
pub cutting_season: Option<String>,
|
||||
pub rootstock_species_id: Option<Uuid>,
|
||||
pub years_to_first_harvest: Option<i32>,
|
||||
pub productive_lifespan_years: Option<i32>,
|
||||
pub expected_yield_kg_per_m2: Option<f64>,
|
||||
pub yield_unit: Option<String>,
|
||||
pub expected_yield_value: Option<f64>,
|
||||
pub harvest_window_days: Option<i32>,
|
||||
pub storage_method: Option<Vec<String>>,
|
||||
pub shelf_life_days: Option<i32>,
|
||||
pub cold_storage_days: Option<i32>,
|
||||
pub pollination_group: Option<String>,
|
||||
pub self_fertile: Option<bool>,
|
||||
pub rootstock_compatibility: Option<String>,
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub gbif_id: Option<String>,
|
||||
pub pfaf_url: Option<String>,
|
||||
pub source_urls: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub type UpdateCultivar = CreateCultivar;
|
||||
|
||||
// --- Suppliers ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct Supplier {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub is_demeter: bool,
|
||||
pub country: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSupplier {
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub is_organic: Option<bool>,
|
||||
pub is_demeter: Option<bool>,
|
||||
pub country: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
pub type UpdateSupplier = CreateSupplier;
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct CultivarSupplier {
|
||||
pub id: Uuid,
|
||||
pub cultivar_id: Uuid,
|
||||
pub supplier_id: Uuid,
|
||||
pub article_number: Option<String>,
|
||||
pub product_url: Option<String>,
|
||||
pub price_eur: Option<f64>,
|
||||
pub pack_size: Option<f64>,
|
||||
pub pack_unit: Option<String>,
|
||||
pub last_checked_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCultivarSupplier {
|
||||
pub supplier_id: Uuid,
|
||||
pub article_number: Option<String>,
|
||||
pub product_url: Option<String>,
|
||||
pub price_eur: Option<f64>,
|
||||
pub pack_size: Option<f64>,
|
||||
pub pack_unit: Option<String>,
|
||||
}
|
||||
|
||||
// --- Companion Relationships ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct CompanionRelationship {
|
||||
pub id: Uuid,
|
||||
pub species_a_id: Uuid,
|
||||
pub species_b_id: Uuid,
|
||||
pub relationship: String,
|
||||
pub mechanism: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCompanion {
|
||||
pub species_a_id: Uuid,
|
||||
pub species_b_id: Uuid,
|
||||
pub relationship: String,
|
||||
pub mechanism: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
// --- Images ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct Image {
|
||||
pub id: Uuid,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub s3_key: String,
|
||||
pub caption: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub is_primary: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub nickname: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub provider: String,
|
||||
pub provider_id: Option<String>,
|
||||
pub admin: bool,
|
||||
pub inserted_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub entity_type: String,
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub rank: f32,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
pub fn build_client(config: &Config) -> S3Client {
|
||||
let creds = aws_credential_types::Credentials::new(
|
||||
&config.s3_access_key,
|
||||
&config.s3_secret_key,
|
||||
None,
|
||||
None,
|
||||
"herbapi-static",
|
||||
);
|
||||
|
||||
let s3_config = aws_sdk_s3::config::Builder::new()
|
||||
.endpoint_url(&config.s3_endpoint)
|
||||
.region(aws_sdk_s3::config::Region::new(config.s3_region.clone()))
|
||||
.credentials_provider(creds)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
|
||||
S3Client::from_conf(s3_config)
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
client: &S3Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<()> {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(data))
|
||||
.content_type(content_type)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::S3(format!("Upload failed for {key}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn download(client: &S3Client, bucket: &str, key: &str) -> Result<Vec<u8>> {
|
||||
let resp = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::S3(format!("Download failed for {key}: {e}")))?;
|
||||
|
||||
let bytes = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| AppError::S3(format!("Stream read failed for {key}: {e}")))?
|
||||
.into_bytes()
|
||||
.to_vec();
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub async fn delete(client: &S3Client, bucket: &str, key: &str) -> Result<()> {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::S3(format!("Delete failed for {key}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use super::models::{CreateSpecies, PaginatedResponse, Species};
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SpeciesListParams {
|
||||
pub page: Option<i64>,
|
||||
pub per_page: Option<i64>,
|
||||
pub search: Option<String>,
|
||||
pub family: Option<String>,
|
||||
}
|
||||
|
||||
impl SpeciesListParams {
|
||||
pub fn limit(&self) -> i64 { self.per_page.unwrap_or(25).min(100) }
|
||||
pub fn offset(&self) -> i64 { (self.page.unwrap_or(1) - 1).max(0) * self.limit() }
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool, params: &SpeciesListParams) -> Result<PaginatedResponse<Species>> {
|
||||
let limit = params.limit();
|
||||
let offset = params.offset();
|
||||
|
||||
let (rows, total) = match (¶ms.family, ¶ms.search) {
|
||||
(Some(family_slug), Some(search)) => {
|
||||
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
|
||||
let rows = sqlx::query_as::<_, Species>(
|
||||
"SELECT s.* FROM species s JOIN families f ON s.family_id = f.id
|
||||
WHERE f.slug = $1
|
||||
AND to_tsvector('english', coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'') || ' ' || coalesce(s.description,''))
|
||||
@@ to_tsquery('english', $2)
|
||||
ORDER BY s.name_scientific LIMIT $3 OFFSET $4"
|
||||
)
|
||||
.bind(family_slug).bind(&tsquery).bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM species s JOIN families f ON s.family_id = f.id
|
||||
WHERE f.slug = $1
|
||||
AND to_tsvector('english', coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'') || ' ' || coalesce(s.description,''))
|
||||
@@ to_tsquery('english', $2)"
|
||||
)
|
||||
.bind(family_slug).bind(&tsquery)
|
||||
.fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
(Some(family_slug), None) => {
|
||||
let rows = sqlx::query_as::<_, Species>(
|
||||
"SELECT s.* FROM species s JOIN families f ON s.family_id = f.id
|
||||
WHERE f.slug = $1 ORDER BY s.name_scientific LIMIT $2 OFFSET $3"
|
||||
)
|
||||
.bind(family_slug).bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM species s JOIN families f ON s.family_id = f.id WHERE f.slug = $1"
|
||||
)
|
||||
.bind(family_slug).fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
(None, Some(search)) => {
|
||||
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
|
||||
let rows = sqlx::query_as::<_, Species>(
|
||||
"SELECT * FROM species
|
||||
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
|
||||
@@ to_tsquery('english', $1)
|
||||
ORDER BY name_scientific LIMIT $2 OFFSET $3"
|
||||
)
|
||||
.bind(&tsquery).bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM species
|
||||
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
|
||||
@@ to_tsquery('english', $1)"
|
||||
)
|
||||
.bind(&tsquery).fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
(None, None) => {
|
||||
let rows = sqlx::query_as::<_, Species>(
|
||||
"SELECT * FROM species ORDER BY name_scientific LIMIT $1 OFFSET $2"
|
||||
)
|
||||
.bind(limit).bind(offset)
|
||||
.fetch_all(pool).await?;
|
||||
|
||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM species")
|
||||
.fetch_one(pool).await?;
|
||||
(rows, count)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(PaginatedResponse {
|
||||
data: rows,
|
||||
total,
|
||||
page: params.page.unwrap_or(1),
|
||||
per_page: limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Species> {
|
||||
sqlx::query_as::<_, Species>("SELECT * FROM species WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Species not found: {slug}")))
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, req: &CreateSpecies) -> Result<Species> {
|
||||
let id = Uuid::now_v7();
|
||||
let slug = slug::slugify(&req.name_scientific);
|
||||
|
||||
sqlx::query_as::<_, Species>(
|
||||
"INSERT INTO species (id, slug, family_id, name_scientific, name_en, name_de, description,
|
||||
soil_moisture, drainage_requirement, ph_min, ph_max, soil_texture_preference,
|
||||
hardiness_zone_usda, hardiness_zone_at, min_temp, max_temp,
|
||||
drought_tolerance, salt_tolerance, edibility_rating,
|
||||
food_uses, medicinal_uses, other_uses, native_range, invasiveness, pollination_type,
|
||||
plant_layer, nitrogen_fixer, dynamic_accumulator, dynamic_accumulator_nutrients,
|
||||
attracts_pollinators, attracts_beneficial_insects, wildlife_value, mulch_plant,
|
||||
ground_cover_quality, allelopathic, guild_role, succession_stage, heavy_metal_tolerance,
|
||||
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,
|
||||
$17::drought_tolerance,$18::salt_tolerance,$19,$20,$21,$22,$23,
|
||||
$24::invasiveness_level,$25,$26::plant_layer,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,
|
||||
$37::succession_stage,$38,$39,$40,$41,$42,$43)
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
|
||||
.bind(&req.name_en).bind(&req.name_de).bind(&req.description)
|
||||
.bind(&req.soil_moisture).bind(&req.drainage_requirement)
|
||||
.bind(req.ph_min).bind(req.ph_max).bind(&req.soil_texture_preference)
|
||||
.bind(&req.hardiness_zone_usda).bind(&req.hardiness_zone_at)
|
||||
.bind(req.min_temp).bind(req.max_temp)
|
||||
.bind(&req.drought_tolerance).bind(&req.salt_tolerance).bind(req.edibility_rating)
|
||||
.bind(&req.food_uses).bind(&req.medicinal_uses).bind(&req.other_uses)
|
||||
.bind(&req.native_range).bind(&req.invasiveness).bind(&req.pollination_type)
|
||||
.bind(&req.plant_layer).bind(req.nitrogen_fixer).bind(req.dynamic_accumulator)
|
||||
.bind(&req.dynamic_accumulator_nutrients)
|
||||
.bind(req.attracts_pollinators).bind(req.attracts_beneficial_insects)
|
||||
.bind(&req.wildlife_value).bind(req.mulch_plant)
|
||||
.bind(&req.ground_cover_quality).bind(req.allelopathic).bind(&req.guild_role)
|
||||
.bind(&req.succession_stage).bind(req.heavy_metal_tolerance)
|
||||
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.eppo_code).bind(&req.pfaf_url)
|
||||
.bind(&req.source_urls)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSpecies) -> Result<Species> {
|
||||
let _existing = sqlx::query("SELECT id FROM species WHERE id = $1")
|
||||
.bind(id).fetch_optional(pool).await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Species not found: {id}")))?;
|
||||
|
||||
let slug = slug::slugify(&req.name_scientific);
|
||||
|
||||
sqlx::query_as::<_, Species>(
|
||||
"UPDATE species SET slug=$2, family_id=$3, name_scientific=$4, name_en=$5, name_de=$6,
|
||||
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,
|
||||
min_temp=$15, max_temp=$16,
|
||||
drought_tolerance=$17::drought_tolerance, salt_tolerance=$18::salt_tolerance,
|
||||
edibility_rating=$19, food_uses=$20, medicinal_uses=$21, other_uses=$22,
|
||||
native_range=$23, invasiveness=$24::invasiveness_level, pollination_type=$25,
|
||||
plant_layer=$26::plant_layer, nitrogen_fixer=$27, dynamic_accumulator=$28,
|
||||
dynamic_accumulator_nutrients=$29, attracts_pollinators=$30, attracts_beneficial_insects=$31,
|
||||
wildlife_value=$32, mulch_plant=$33, ground_cover_quality=$34, allelopathic=$35,
|
||||
guild_role=$36, succession_stage=$37::succession_stage, heavy_metal_tolerance=$38,
|
||||
wikidata_qid=$39, gbif_id=$40, eppo_code=$41, pfaf_url=$42, source_urls=$43,
|
||||
updated_at=NOW()
|
||||
WHERE id=$1 RETURNING *"
|
||||
)
|
||||
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
|
||||
.bind(&req.name_en).bind(&req.name_de).bind(&req.description)
|
||||
.bind(&req.soil_moisture).bind(&req.drainage_requirement)
|
||||
.bind(req.ph_min).bind(req.ph_max).bind(&req.soil_texture_preference)
|
||||
.bind(&req.hardiness_zone_usda).bind(&req.hardiness_zone_at)
|
||||
.bind(req.min_temp).bind(req.max_temp)
|
||||
.bind(&req.drought_tolerance).bind(&req.salt_tolerance).bind(req.edibility_rating)
|
||||
.bind(&req.food_uses).bind(&req.medicinal_uses).bind(&req.other_uses)
|
||||
.bind(&req.native_range).bind(&req.invasiveness).bind(&req.pollination_type)
|
||||
.bind(&req.plant_layer).bind(req.nitrogen_fixer).bind(req.dynamic_accumulator)
|
||||
.bind(&req.dynamic_accumulator_nutrients)
|
||||
.bind(req.attracts_pollinators).bind(req.attracts_beneficial_insects)
|
||||
.bind(&req.wildlife_value).bind(req.mulch_plant)
|
||||
.bind(&req.ground_cover_quality).bind(req.allelopathic).bind(&req.guild_role)
|
||||
.bind(&req.succession_stage).bind(req.heavy_metal_tolerance)
|
||||
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.eppo_code).bind(&req.pfaf_url)
|
||||
.bind(&req.source_urls)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM species WHERE id = $1")
|
||||
.bind(id).execute(pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(format!("Species not found: {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use super::models::{CreateCultivarSupplier, CreateSupplier, CultivarSupplier, Supplier};
|
||||
|
||||
pub async fn list(pool: &PgPool) -> Result<Vec<Supplier>> {
|
||||
sqlx::query_as::<_, Supplier>("SELECT * FROM suppliers ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Supplier> {
|
||||
sqlx::query_as::<_, Supplier>("SELECT * FROM suppliers WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Supplier not found: {slug}")))
|
||||
}
|
||||
|
||||
pub async fn create(pool: &PgPool, req: &CreateSupplier) -> Result<Supplier> {
|
||||
let id = Uuid::now_v7();
|
||||
let s = slug::slugify(&req.name);
|
||||
|
||||
sqlx::query_as::<_, Supplier>(
|
||||
"INSERT INTO suppliers (id, slug, name, url, is_organic, is_demeter, country, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
|
||||
)
|
||||
.bind(id).bind(&s).bind(&req.name).bind(&req.url)
|
||||
.bind(req.is_organic.unwrap_or(false)).bind(req.is_demeter.unwrap_or(false))
|
||||
.bind(&req.country).bind(&req.notes)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSupplier) -> Result<Supplier> {
|
||||
let s = slug::slugify(&req.name);
|
||||
sqlx::query_as::<_, Supplier>(
|
||||
"UPDATE suppliers SET slug=$2, name=$3, url=$4, is_organic=$5, is_demeter=$6,
|
||||
country=$7, notes=$8, updated_at=NOW() WHERE id=$1 RETURNING *"
|
||||
)
|
||||
.bind(id).bind(&s).bind(&req.name).bind(&req.url)
|
||||
.bind(req.is_organic.unwrap_or(false)).bind(req.is_demeter.unwrap_or(false))
|
||||
.bind(&req.country).bind(&req.notes)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::RowNotFound => AppError::NotFound(format!("Supplier not found: {id}")),
|
||||
other => AppError::Database(other),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM suppliers WHERE id = $1")
|
||||
.bind(id).execute(pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound(format!("Supplier not found: {id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Cultivar-Supplier links
|
||||
|
||||
pub async fn list_for_cultivar(pool: &PgPool, cultivar_id: Uuid) -> Result<Vec<CultivarSupplier>> {
|
||||
sqlx::query_as::<_, CultivarSupplier>(
|
||||
"SELECT * FROM cultivar_suppliers WHERE cultivar_id = $1 ORDER BY created_at"
|
||||
)
|
||||
.bind(cultivar_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn link_cultivar(pool: &PgPool, cultivar_id: Uuid, req: &CreateCultivarSupplier) -> Result<CultivarSupplier> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query_as::<_, CultivarSupplier>(
|
||||
"INSERT INTO cultivar_suppliers (id, cultivar_id, supplier_id, article_number, product_url,
|
||||
price_eur, pack_size, pack_unit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
|
||||
)
|
||||
.bind(id).bind(cultivar_id).bind(req.supplier_id)
|
||||
.bind(&req.article_number).bind(&req.product_url)
|
||||
.bind(req.price_eur).bind(req.pack_size).bind(&req.pack_unit)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn unlink_cultivar(pool: &PgPool, cultivar_id: Uuid, supplier_id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM cultivar_suppliers WHERE cultivar_id = $1 AND supplier_id = $2"
|
||||
)
|
||||
.bind(cultivar_id).bind(supplier_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Link not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
use super::models::User;
|
||||
|
||||
pub async fn upsert_oidc_user(
|
||||
pool: &PgPool,
|
||||
email: &str,
|
||||
name: Option<&str>,
|
||||
nickname: Option<&str>,
|
||||
provider_id: &str,
|
||||
) -> Result<User> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, name, nickname, provider, provider_id)
|
||||
VALUES ($1, $2, $3, 'authentik', $4)
|
||||
ON CONFLICT (provider, provider_id)
|
||||
DO UPDATE SET email = $1, name = $2, nickname = $3, updated_at = NOW()
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(email).bind(name).bind(nickname).bind(provider_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("S3 error: {0}")]
|
||||
S3(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".to_string()),
|
||||
AppError::Database(e) => {
|
||||
tracing::error!("Database error: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database error".to_string(),
|
||||
)
|
||||
}
|
||||
AppError::S3(msg) => {
|
||||
tracing::error!("S3 error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Storage error".to_string())
|
||||
}
|
||||
AppError::Internal(msg) => {
|
||||
tracing::error!("Internal error: {msg}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = axum::Json(json!({ "error": message }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod state;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_sessions::cookie::time::Duration;
|
||||
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new("herbapi_api=info,sqlx=warn,tower_http=info")
|
||||
}))
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
tracing::info!("HerbAPI starting on port {}", config.port);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Running migrations...");
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
tracing::info!("Migrations complete");
|
||||
|
||||
let s3 = db::s3::build_client(&config);
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
config: Arc::new(config.clone()),
|
||||
s3,
|
||||
};
|
||||
|
||||
// Session layer for OIDC
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::hours(24)))
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_secure(config.tls_enabled());
|
||||
|
||||
if config.oidc_enabled() {
|
||||
tracing::info!("OIDC enabled (issuer: {})", config.oidc_issuer);
|
||||
}
|
||||
|
||||
let app = api::router(state.clone())
|
||||
.layer(session_layer)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], config.port));
|
||||
|
||||
if config.tls_enabled() {
|
||||
let cert_path = config.tls_cert_path.as_ref().unwrap();
|
||||
let key_path = config.tls_key_path.as_ref().unwrap();
|
||||
|
||||
tracing::info!("TLS enabled, loading certificates from {} and {}", cert_path, key_path);
|
||||
|
||||
let rustls_config = load_rustls_config(cert_path, key_path)?;
|
||||
tracing::info!("Listening on {} (TLS)", addr);
|
||||
|
||||
axum_server::bind_rustls(addr, rustls_config)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
} else {
|
||||
tracing::info!("Listening on {} (plain HTTP)", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_rustls_config(
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
) -> anyhow::Result<axum_server::tls_rustls::RustlsConfig> {
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
|
||||
let config = RustlsConfig::from_pem_file(cert_path, key_path);
|
||||
let config =
|
||||
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(config))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use aws_sdk_s3::Client as S3Client;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<Config>,
|
||||
pub s3: S3Client,
|
||||
}
|
||||
Generated
+3814
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "herbapi-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
dioxus = { version = "0.7", features = ["router", "web"] }
|
||||
gloo-net = { version = "0.6", features = ["http", "json"] }
|
||||
gloo-storage = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["serde", "v4", "js"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
web-sys = { version = "0.3", features = ["Document", "Element", "RequestCredentials", "Window", "HtmlInputElement", "FormData"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
tracing = "0.1"
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
[application]
|
||||
name = "herbapi-ui"
|
||||
default_platform = "web"
|
||||
|
||||
[web.app]
|
||||
title = "HerbAPI — Plant Database"
|
||||
|
||||
[web.watcher]
|
||||
watch_path = ["src", "assets"]
|
||||
|
||||
[web.resource.dev]
|
||||
style = ["/assets/herbapi.css"]
|
||||
|
||||
[web.resource.release]
|
||||
style = ["/assets/herbapi.css"]
|
||||
@@ -0,0 +1,557 @@
|
||||
/* HerbAPI — Plant Database Stylesheet */
|
||||
|
||||
:root {
|
||||
--bg: #faf9f6;
|
||||
--bg-card: #ffffff;
|
||||
--bg-sidebar: #2d3a2e;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #666;
|
||||
--text-sidebar: #e0e0e0;
|
||||
--accent: #4a7c59;
|
||||
--accent-hover: #3d6b4a;
|
||||
--accent-light: #e8f0ea;
|
||||
--border: #ddd;
|
||||
--radius: 6px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
|
||||
/* Planting calendar colors */
|
||||
--cal-indoor: #7b68ee;
|
||||
--cal-direct: #3cb371;
|
||||
--cal-transplant: #ff8c00;
|
||||
--cal-glass: #87ceeb;
|
||||
--cal-harvest: #dc143c;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
color: var(--text-sidebar);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
margin-top: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-link,
|
||||
.login-link {
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logout-link:hover,
|
||||
.login-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.name-common {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 1rem 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.95rem;
|
||||
background: var(--bg-card);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-bar button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plant-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.plant-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-scientific {
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-common {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.organic {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge.demeter {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
/* Info grid (species detail) */
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
.info-item.badge {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Planting Calendar */
|
||||
|
||||
.planting-calendar {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.cal-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px repeat(12, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cal-label {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cal-cell {
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
background: #f0f0f0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-indoor {
|
||||
background: var(--cal-indoor);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-direct {
|
||||
background: var(--cal-direct);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-transplant {
|
||||
background: var(--cal-transplant);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-glass {
|
||||
background: var(--cal-glass);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-harvest {
|
||||
background: var(--cal-harvest);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Search results */
|
||||
|
||||
.result-count {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-desc {
|
||||
width: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 404 */
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: 4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty states */
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c62828;
|
||||
background: #ffebee;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-user,
|
||||
.brand-text-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use gloo_net::http::Request;
|
||||
use serde::de::DeserializeOwned;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::*;
|
||||
|
||||
const API_BASE: &str = "/api/v1";
|
||||
|
||||
async fn get_json<T: DeserializeOwned>(path: &str) -> Result<T, String> {
|
||||
let resp = Request::get(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("JSON parse error: {e}"))
|
||||
}
|
||||
|
||||
async fn post_json<B: serde::Serialize, T: DeserializeOwned>(
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, String> {
|
||||
let resp = Request::post(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(body).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Request build error: {e}"))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("JSON parse error: {e}"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn put_json<B: serde::Serialize, T: DeserializeOwned>(
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, String> {
|
||||
let resp = Request::put(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(body).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Request build error: {e}"))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("JSON parse error: {e}"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn delete_req(path: &str) -> Result<(), String> {
|
||||
let resp = Request::delete(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
pub async fn get_current_user() -> Result<MeResponse, String> {
|
||||
get_json("/auth/me").await
|
||||
}
|
||||
|
||||
// --- Families ---
|
||||
pub async fn list_families(page: i64, search: Option<&str>) -> Result<PaginatedResponse<Family>, String> {
|
||||
let mut url = format!("{API_BASE}/families?page={page}&per_page=25");
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
get_json(&url).await
|
||||
}
|
||||
|
||||
pub async fn get_family(slug: &str) -> Result<Family, String> {
|
||||
get_json(&format!("{API_BASE}/families/{slug}")).await
|
||||
}
|
||||
|
||||
// --- Species ---
|
||||
pub async fn list_species(page: i64, family: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Species>, String> {
|
||||
let mut url = format!("{API_BASE}/species?page={page}&per_page=25");
|
||||
if let Some(f) = family {
|
||||
url.push_str(&format!("&family={f}"));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
get_json(&url).await
|
||||
}
|
||||
|
||||
pub async fn get_species(slug: &str) -> Result<Species, String> {
|
||||
get_json(&format!("{API_BASE}/species/{slug}")).await
|
||||
}
|
||||
|
||||
// --- Cultivars ---
|
||||
pub async fn list_cultivars(page: i64, species: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Cultivar>, String> {
|
||||
let mut url = format!("{API_BASE}/cultivars?page={page}&per_page=25");
|
||||
if let Some(s) = species {
|
||||
url.push_str(&format!("&species={s}"));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
get_json(&url).await
|
||||
}
|
||||
|
||||
pub async fn get_cultivar(slug: &str) -> Result<Cultivar, String> {
|
||||
get_json(&format!("{API_BASE}/cultivars/{slug}")).await
|
||||
}
|
||||
|
||||
// --- Suppliers ---
|
||||
pub async fn list_suppliers() -> Result<Vec<Supplier>, String> {
|
||||
get_json(&format!("{API_BASE}/suppliers")).await
|
||||
}
|
||||
|
||||
pub async fn get_supplier(slug: &str) -> Result<Supplier, String> {
|
||||
get_json(&format!("{API_BASE}/suppliers/{slug}")).await
|
||||
}
|
||||
|
||||
pub async fn get_cultivar_suppliers(id: Uuid) -> Result<Vec<CultivarSupplier>, String> {
|
||||
get_json(&format!("{API_BASE}/cultivars/{id}/suppliers")).await
|
||||
}
|
||||
|
||||
// --- Companions ---
|
||||
pub async fn get_companions(species_id: Uuid) -> Result<Vec<CompanionRelationship>, String> {
|
||||
get_json(&format!("{API_BASE}/species/{species_id}/companions")).await
|
||||
}
|
||||
|
||||
// --- Images ---
|
||||
pub async fn get_images(entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>, String> {
|
||||
get_json(&format!("{API_BASE}/images/{entity_type}/{entity_id}")).await
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
pub async fn search(query: &str, limit: i64) -> Result<Vec<SearchResult>, String> {
|
||||
get_json(&format!("{API_BASE}/search?q={query}&limit={limit}")).await
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::types::MeResponse;
|
||||
|
||||
#[derive(Routable, Clone, Debug, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(Layout)]
|
||||
#[route("/")]
|
||||
Home {},
|
||||
#[route("/families")]
|
||||
FamilyList {},
|
||||
#[route("/families/:slug")]
|
||||
FamilyDetail { slug: String },
|
||||
#[route("/species")]
|
||||
SpeciesList {},
|
||||
#[route("/species/:slug")]
|
||||
SpeciesDetail { slug: String },
|
||||
#[route("/cultivars")]
|
||||
CultivarList {},
|
||||
#[route("/cultivars/:slug")]
|
||||
CultivarDetail { slug: String },
|
||||
#[route("/suppliers")]
|
||||
SupplierList {},
|
||||
#[route("/suppliers/:slug")]
|
||||
SupplierDetail { slug: String },
|
||||
#[route("/search")]
|
||||
SearchPage {},
|
||||
#[end_layout]
|
||||
#[route("/:..segments")]
|
||||
NotFound { segments: Vec<String> },
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Layout() -> Element {
|
||||
// Try to get current user (may be None for public access)
|
||||
let auth = use_resource(|| async { api::get_current_user().await.ok() });
|
||||
let user: Option<MeResponse> = auth.read().as_ref().and_then(|r| r.clone());
|
||||
|
||||
rsx! {
|
||||
div { class: "app-layout",
|
||||
nav { class: "sidebar",
|
||||
div { class: "sidebar-brand",
|
||||
span { class: "brand-icon", "\u{1F33F}" }
|
||||
div { class: "brand-text-group",
|
||||
span { class: "brand-text", "HerbAPI" }
|
||||
span { class: "brand-sub", "Plant Database" }
|
||||
}
|
||||
}
|
||||
div { class: "sidebar-nav",
|
||||
NavLink { to: Route::Home {}, label: "Home" }
|
||||
NavLink { to: Route::FamilyList {}, label: "Families" }
|
||||
NavLink { to: Route::SpeciesList {}, label: "Species" }
|
||||
NavLink { to: Route::CultivarList {}, label: "Cultivars" }
|
||||
NavLink { to: Route::SupplierList {}, label: "Suppliers" }
|
||||
NavLink { to: Route::SearchPage {}, label: "Search" }
|
||||
}
|
||||
div { class: "sidebar-user",
|
||||
if let Some(ref u) = user {
|
||||
span { class: "user-name", "{u.nickname.as_deref().or(u.name.as_deref()).unwrap_or(&u.email)}" }
|
||||
a { class: "logout-link", href: "/auth/oidc/logout", "Logout" }
|
||||
} else {
|
||||
a { class: "login-link", href: "/auth/oidc/login", "Login" }
|
||||
}
|
||||
}
|
||||
}
|
||||
main { class: "content",
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NavLink(to: Route, label: &'static str) -> Element {
|
||||
rsx! {
|
||||
Link { to: to, class: "nav-link",
|
||||
span { class: "nav-label", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NotFound(segments: Vec<String>) -> Element {
|
||||
rsx! {
|
||||
div { class: "not-found",
|
||||
h1 { "404" }
|
||||
p { "Page not found: /{segments.join(\"/\")}" }
|
||||
Link { to: Route::Home {}, "Back to Home" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export page components for the router
|
||||
pub use crate::pages::cultivars::{CultivarDetail, CultivarList};
|
||||
pub use crate::pages::families::{FamilyDetail, FamilyList};
|
||||
pub use crate::pages::home::Home;
|
||||
pub use crate::pages::search::SearchPage;
|
||||
pub use crate::pages::species::{SpeciesDetail, SpeciesList};
|
||||
pub use crate::pages::suppliers::{SupplierDetail, SupplierList};
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod plant_card;
|
||||
pub mod planting_calendar;
|
||||
@@ -0,0 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn PlantCard(slug: String, name: String, name_common: Option<String>, entity_type: String) -> Element {
|
||||
let route = match entity_type.as_str() {
|
||||
"species" => Route::SpeciesDetail { slug: slug.clone() },
|
||||
"cultivar" => Route::CultivarDetail { slug: slug.clone() },
|
||||
"family" => Route::FamilyDetail { slug: slug.clone() },
|
||||
_ => Route::Home {},
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "plant-card",
|
||||
Link { to: route,
|
||||
em { class: "card-scientific", "{name}" }
|
||||
}
|
||||
if let Some(ref common) = name_common {
|
||||
p { class: "card-common", "{common}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const MONTH_LABELS: [&str; 12] = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
|
||||
|
||||
#[component]
|
||||
pub fn PlantingCalendar(
|
||||
indoor_sowing: Option<Vec<i32>>,
|
||||
direct_sowing: Option<Vec<i32>>,
|
||||
transplanting: Option<Vec<i32>>,
|
||||
glasshouse: Option<Vec<i32>>,
|
||||
harvesting: Option<Vec<i32>>,
|
||||
) -> Element {
|
||||
let rows: Vec<(&str, &str, &Option<Vec<i32>>)> = vec![
|
||||
("Indoor Sowing", "cal-indoor", &indoor_sowing),
|
||||
("Direct Sowing", "cal-direct", &direct_sowing),
|
||||
("Transplanting", "cal-transplant", &transplanting),
|
||||
("Glasshouse", "cal-glass", &glasshouse),
|
||||
("Harvesting", "cal-harvest", &harvesting),
|
||||
];
|
||||
|
||||
// Check if any data exists
|
||||
let has_data = rows.iter().any(|(_, _, months)| months.is_some());
|
||||
if !has_data {
|
||||
return rsx! { p { class: "empty", "No planting calendar data." } };
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "planting-calendar",
|
||||
// Header row with month labels
|
||||
div { class: "cal-row cal-header",
|
||||
div { class: "cal-label" }
|
||||
for label in MONTH_LABELS.iter() {
|
||||
div { class: "cal-cell", "{label}" }
|
||||
}
|
||||
}
|
||||
// Data rows
|
||||
for (name, class, months) in rows.iter() {
|
||||
if months.is_some() {
|
||||
div { class: "cal-row",
|
||||
div { class: "cal-label", "{name}" }
|
||||
for month in 1..=12i32 {
|
||||
{
|
||||
let active = months.as_ref()
|
||||
.map(|m| m.contains(&month))
|
||||
.unwrap_or(false);
|
||||
rsx! {
|
||||
div {
|
||||
class: if active { format!("cal-cell {class} active") } else { "cal-cell".to_string() },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod components;
|
||||
mod pages;
|
||||
mod types;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
dioxus::launch(app::App);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
use crate::components::planting_calendar::PlantingCalendar;
|
||||
|
||||
#[component]
|
||||
pub fn CultivarList() -> Element {
|
||||
let mut page = use_signal(|| 1i64);
|
||||
let mut search = use_signal(|| String::new());
|
||||
let current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
|
||||
let cultivars = use_resource(move || {
|
||||
let s = search_str.clone();
|
||||
async move {
|
||||
let q = if s.is_empty() { None } else { Some(s.as_str()) };
|
||||
api::list_cultivars(current_page, None, q).await
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Cultivars" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search cultivars...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
match &*cultivars.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Organic" }
|
||||
th { "Perennial" }
|
||||
th { "Frost Tolerance" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for c in data.data.iter() {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::CultivarDetail { slug: c.slug.clone() },
|
||||
strong { "{c.name}" }
|
||||
}
|
||||
}
|
||||
td { if c.is_organic { "Yes" } else { "-" } }
|
||||
td { if c.perennial { "Yes" } else { "Annual" } }
|
||||
td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CultivarDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let cultivar = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_cultivar(&s).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page cultivar-detail",
|
||||
match &*cultivar.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(c)) => rsx! {
|
||||
h1 { "{c.name}" }
|
||||
if let Some(ref en) = c.name_en {
|
||||
p { class: "name-common", "{en}" }
|
||||
}
|
||||
if let Some(ref desc) = c.description {
|
||||
div { class: "description", "{desc}" }
|
||||
}
|
||||
|
||||
div { class: "badges",
|
||||
if c.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
if c.perennial {
|
||||
span { class: "badge", "Perennial" }
|
||||
}
|
||||
if let Some(ref ft) = c.frost_tolerance {
|
||||
span { class: "badge", "Frost: {ft}" }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref dtg) = c.days_to_germination {
|
||||
p { "Days to germination: {dtg}" }
|
||||
}
|
||||
if let Some(ref gtd) = c.growing_time_days {
|
||||
p { "Growing time: {gtd} days" }
|
||||
}
|
||||
|
||||
// Planting calendar
|
||||
h2 { "Planting Calendar" }
|
||||
PlantingCalendar {
|
||||
indoor_sowing: c.indoor_sowing_months.clone(),
|
||||
direct_sowing: c.direct_sowing_months.clone(),
|
||||
transplanting: c.transplanting_months.clone(),
|
||||
glasshouse: c.glasshouse_months.clone(),
|
||||
harvesting: c.harvesting_months.clone(),
|
||||
}
|
||||
|
||||
if let Some(ref pg) = c.pollination_group {
|
||||
p { "Pollination group: {pg}" }
|
||||
}
|
||||
if let Some(sf) = c.self_fertile {
|
||||
if sf {
|
||||
p { "Self-fertile: Yes" }
|
||||
} else {
|
||||
p { "Self-fertile: No" }
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn FamilyList() -> Element {
|
||||
let mut page = use_signal(|| 1i64);
|
||||
let mut search = use_signal(|| String::new());
|
||||
let current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
|
||||
let families = use_resource(move || {
|
||||
let s = search_str.clone();
|
||||
async move {
|
||||
let q = if s.is_empty() { None } else { Some(s.as_str()) };
|
||||
api::list_families(current_page, q).await
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Plant Families" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search families...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
match &*families.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Scientific Name" }
|
||||
th { "English" }
|
||||
th { "German" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for f in data.data.iter() {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::FamilyDetail { slug: f.slug.clone() },
|
||||
em { "{f.name_scientific}" }
|
||||
}
|
||||
}
|
||||
td { "{f.name_en.as_deref().unwrap_or(\"-\")}" }
|
||||
td { "{f.name_de.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FamilyDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let family = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_family(&s).await }
|
||||
});
|
||||
|
||||
let slug_for_species = slug.clone();
|
||||
let species = use_resource(move || {
|
||||
let s = slug_for_species.clone();
|
||||
async move { api::list_species(1, Some(&s), None).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
match &*family.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(f)) => rsx! {
|
||||
h1 { em { "{f.name_scientific}" } }
|
||||
if let Some(ref en) = f.name_en {
|
||||
p { class: "name-common", "{en}" }
|
||||
}
|
||||
if let Some(ref de) = f.name_de {
|
||||
p { class: "name-common", "{de}" }
|
||||
}
|
||||
if let Some(ref desc) = f.description {
|
||||
p { "{desc}" }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
h2 { "Species in this Family" }
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter() {
|
||||
div { class: "plant-card",
|
||||
Link { to: Route::SpeciesDetail { slug: s.slug.clone() },
|
||||
em { "{s.name_scientific}" }
|
||||
}
|
||||
if let Some(ref en) = s.name_en {
|
||||
p { class: "card-common", "{en}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
use crate::components::plant_card::PlantCard;
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
let mut search_query = use_signal(|| String::new());
|
||||
let species = use_resource(|| async { api::list_species(1, None, None).await });
|
||||
|
||||
rsx! {
|
||||
div { class: "page home-page",
|
||||
h1 { "HerbAPI" }
|
||||
p { class: "subtitle", "Trilingual plant reference database" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search plants...",
|
||||
value: "{search_query}",
|
||||
oninput: move |e| search_query.set(e.value()),
|
||||
onkeydown: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
let nav = navigator();
|
||||
nav.push(Route::SearchPage {});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
h2 { "Recent Species" }
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter().take(12) {
|
||||
PlantCard {
|
||||
key: "{s.id}",
|
||||
slug: s.slug.clone(),
|
||||
name: s.name_scientific.clone(),
|
||||
name_common: s.name_en.clone(),
|
||||
entity_type: "species".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod cultivars;
|
||||
pub mod families;
|
||||
pub mod home;
|
||||
pub mod search;
|
||||
pub mod species;
|
||||
pub mod suppliers;
|
||||
@@ -0,0 +1,77 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn SearchPage() -> Element {
|
||||
let mut query = use_signal(|| String::new());
|
||||
let mut results = use_signal(|| None::<Result<Vec<crate::types::SearchResult>, String>>);
|
||||
|
||||
let trigger_search = move || {
|
||||
let q = query.read().clone();
|
||||
if !q.is_empty() {
|
||||
spawn(async move {
|
||||
let res = api::search(&q, 50).await;
|
||||
results.set(Some(res));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "page search-page",
|
||||
h1 { "Search" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search plants, families, cultivars...",
|
||||
value: "{query}",
|
||||
oninput: move |e| query.set(e.value()),
|
||||
onkeydown: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
trigger_search();
|
||||
}
|
||||
},
|
||||
}
|
||||
button { onclick: move |_| trigger_search(), "Search" }
|
||||
}
|
||||
|
||||
match &*results.read() {
|
||||
None => rsx! {},
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
p { class: "result-count", "{data.len()} results" }
|
||||
div { class: "search-results",
|
||||
for r in data.iter() {
|
||||
div { class: "search-result",
|
||||
span { class: "result-type badge", "{r.entity_type}" }
|
||||
match r.entity_type.as_str() {
|
||||
"family" => rsx! {
|
||||
Link { to: Route::FamilyDetail { slug: r.slug.clone() },
|
||||
em { "{r.name}" }
|
||||
}
|
||||
},
|
||||
"species" => rsx! {
|
||||
Link { to: Route::SpeciesDetail { slug: r.slug.clone() },
|
||||
em { "{r.name}" }
|
||||
}
|
||||
},
|
||||
"cultivar" => rsx! {
|
||||
Link { to: Route::CultivarDetail { slug: r.slug.clone() },
|
||||
strong { "{r.name}" }
|
||||
}
|
||||
},
|
||||
_ => rsx! { span { "{r.name}" } },
|
||||
}
|
||||
if let Some(ref desc) = r.description {
|
||||
p { class: "result-desc", "{desc}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
use crate::components::plant_card::PlantCard;
|
||||
|
||||
#[component]
|
||||
pub fn SpeciesList() -> Element {
|
||||
let mut page = use_signal(|| 1i64);
|
||||
let mut search = use_signal(|| String::new());
|
||||
let current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
|
||||
let species = use_resource(move || {
|
||||
let s = search_str.clone();
|
||||
async move {
|
||||
let q = if s.is_empty() { None } else { Some(s.as_str()) };
|
||||
api::list_species(current_page, None, q).await
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Species" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search species...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter() {
|
||||
PlantCard {
|
||||
key: "{s.id}",
|
||||
slug: s.slug.clone(),
|
||||
name: s.name_scientific.clone(),
|
||||
name_common: s.name_en.clone(),
|
||||
entity_type: "species".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SpeciesDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let species = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_species(&s).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page species-detail",
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(s)) => {
|
||||
let species_slug = s.slug.clone();
|
||||
rsx! {
|
||||
h1 { em { "{s.name_scientific}" } }
|
||||
if let Some(ref en) = s.name_en {
|
||||
p { class: "name-common", "{en}" }
|
||||
}
|
||||
if let Some(ref de) = s.name_de {
|
||||
p { class: "name-common", "{de}" }
|
||||
}
|
||||
if let Some(ref desc) = s.description {
|
||||
div { class: "description", "{desc}" }
|
||||
}
|
||||
|
||||
// Info grid
|
||||
div { class: "info-grid",
|
||||
if let Some(ref layer) = s.plant_layer {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Layer" }
|
||||
span { class: "info-value", "{layer}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref dt) = s.drought_tolerance {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Drought Tolerance" }
|
||||
span { class: "info-value", "{dt}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref hz) = s.hardiness_zone_usda {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "USDA Zone" }
|
||||
span { class: "info-value", "{hz}" }
|
||||
}
|
||||
}
|
||||
if let Some(rating) = s.edibility_rating {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Edibility" }
|
||||
span { class: "info-value", "{rating}/5" }
|
||||
}
|
||||
}
|
||||
if let Some(nf) = s.nitrogen_fixer {
|
||||
if nf {
|
||||
div { class: "info-item badge",
|
||||
"Nitrogen Fixer"
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(da) = s.dynamic_accumulator {
|
||||
if da {
|
||||
div { class: "info-item badge",
|
||||
"Dynamic Accumulator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cultivars for this species
|
||||
h2 { "Cultivars" }
|
||||
CultivarListForSpecies { species_slug: species_slug }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CultivarListForSpecies(species_slug: String) -> Element {
|
||||
let slug = species_slug.clone();
|
||||
let cultivars = use_resource(move || {
|
||||
let s = slug.clone();
|
||||
async move { api::list_cultivars(1, Some(&s), None).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
match &*cultivars.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => {
|
||||
if data.data.is_empty() {
|
||||
rsx! { p { class: "empty", "No cultivars yet." } }
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "card-grid",
|
||||
for c in data.data.iter() {
|
||||
div { class: "plant-card",
|
||||
Link { to: Route::CultivarDetail { slug: c.slug.clone() },
|
||||
strong { "{c.name}" }
|
||||
}
|
||||
if let Some(ref en) = c.name_en {
|
||||
p { class: "card-common", "{en}" }
|
||||
}
|
||||
if c.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn SupplierList() -> Element {
|
||||
let suppliers = use_resource(|| async { api::list_suppliers().await });
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Suppliers" }
|
||||
|
||||
match &*suppliers.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Country" }
|
||||
th { "Organic" }
|
||||
th { "Demeter" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for s in data.iter() {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::SupplierDetail { slug: s.slug.clone() },
|
||||
strong { "{s.name}" }
|
||||
}
|
||||
}
|
||||
td { "{s.country.as_deref().unwrap_or(\"-\")}" }
|
||||
td { if s.is_organic { "Yes" } else { "-" } }
|
||||
td { if s.is_demeter { "Yes" } else { "-" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SupplierDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let supplier = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_supplier(&s).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
match &*supplier.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(s)) => rsx! {
|
||||
h1 { "{s.name}" }
|
||||
if let Some(ref url) = s.url {
|
||||
p { a { href: "{url}", target: "_blank", "{url}" } }
|
||||
}
|
||||
div { class: "badges",
|
||||
if s.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
if s.is_demeter {
|
||||
span { class: "badge demeter", "Demeter" }
|
||||
}
|
||||
if let Some(ref country) = s.country {
|
||||
span { class: "badge", "{country}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref notes) = s.notes {
|
||||
p { "{notes}" }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Family {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Species {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub family_id: Uuid,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub soil_moisture: Option<String>,
|
||||
pub ph_min: Option<f64>,
|
||||
pub ph_max: Option<f64>,
|
||||
pub hardiness_zone_usda: Option<String>,
|
||||
pub hardiness_zone_at: Option<String>,
|
||||
pub drought_tolerance: Option<String>,
|
||||
pub edibility_rating: Option<i16>,
|
||||
pub food_uses: Option<String>,
|
||||
pub medicinal_uses: Option<String>,
|
||||
pub other_uses: Option<String>,
|
||||
pub native_range: Option<String>,
|
||||
pub plant_layer: Option<String>,
|
||||
pub nitrogen_fixer: Option<bool>,
|
||||
pub dynamic_accumulator: Option<bool>,
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Cultivar {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub species_id: Uuid,
|
||||
pub name: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub perennial: bool,
|
||||
pub growing_time_days: Option<i32>,
|
||||
pub days_to_germination: Option<i32>,
|
||||
pub frost_tolerance: Option<String>,
|
||||
pub indoor_sowing_months: Option<Vec<i32>>,
|
||||
pub direct_sowing_months: Option<Vec<i32>>,
|
||||
pub transplanting_months: Option<Vec<i32>>,
|
||||
pub glasshouse_months: Option<Vec<i32>>,
|
||||
pub harvesting_months: Option<Vec<i32>>,
|
||||
pub pollination_group: Option<String>,
|
||||
pub self_fertile: Option<bool>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Supplier {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub is_demeter: bool,
|
||||
pub country: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CultivarSupplier {
|
||||
pub id: Uuid,
|
||||
pub cultivar_id: Uuid,
|
||||
pub supplier_id: Uuid,
|
||||
pub article_number: Option<String>,
|
||||
pub product_url: Option<String>,
|
||||
pub price_eur: Option<f64>,
|
||||
pub pack_size: Option<f64>,
|
||||
pub pack_unit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CompanionRelationship {
|
||||
pub id: Uuid,
|
||||
pub species_a_id: Uuid,
|
||||
pub species_b_id: Uuid,
|
||||
pub relationship: String,
|
||||
pub mechanism: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Image {
|
||||
pub id: Uuid,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub s3_key: String,
|
||||
pub caption: Option<String>,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub entity_type: String,
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub rank: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct MeResponse {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub nickname: Option<String>,
|
||||
pub admin: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user