diff --git a/herbapi-api/src/api/images.rs b/herbapi-api/src/api/images.rs index 86fec5c..4431d92 100644 --- a/herbapi-api/src/api/images.rs +++ b/herbapi-api/src/api/images.rs @@ -1,4 +1,6 @@ use axum::extract::{Multipart, Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; use axum::Json; use crate::auth::AuthUser; @@ -7,6 +9,38 @@ use crate::db::models::Image; use crate::error::{AppError, Result}; use crate::state::AppState; +/// Serve an image directly from S3, with Content-Type and cache headers. +pub async fn serve_image( + State(state): State, + Path(key): Path, +) -> impl IntoResponse { + match s3::download(&state.s3, &state.config.s3_bucket, &key).await { + Ok(data) => { + let ct = if key.ends_with(".png") { + "image/png" + } else if key.ends_with(".webp") { + "image/webp" + } else if key.ends_with(".gif") { + "image/gif" + } else if key.ends_with(".svg") { + "image/svg+xml" + } else { + "image/jpeg" + }; + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, ct), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + data, + ) + .into_response() + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + pub async fn list_for_entity( State(state): State, Path((entity_type, entity_id)): Path<(String, uuid::Uuid)>, diff --git a/herbapi-api/src/api/mod.rs b/herbapi-api/src/api/mod.rs index aca61fa..a8cdb43 100644 --- a/herbapi-api/src/api/mod.rs +++ b/herbapi-api/src/api/mod.rs @@ -41,6 +41,8 @@ pub fn router(state: AppState) -> Router { .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)) + // Image file serving (S3 proxy) + .route("/img/{*path}", get(images::serve_image)) // Search .route("/api/v1/search", get(search::search)) // OIDC auth diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index ed39227..11d0731 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -1165,6 +1165,38 @@ td.placeholder { color: #e65100; } +/* Species / cultivar images */ + +.species-image-wrap { + margin: 1rem 0 0.5rem; +} + +.species-image { + max-width: 360px; + max-height: 320px; + width: auto; + height: auto; + border-radius: var(--radius); + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + object-fit: cover; +} + +.image-attribution { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.35rem; + max-width: 360px; +} + +.attribution-link { + color: var(--accent); + font-size: 0.75rem; +} + +.attribution-link:hover { + text-decoration: underline; +} + /* Responsive */ @media (max-width: 768px) { diff --git a/herbapi-ui/src/pages/cultivars.rs b/herbapi-ui/src/pages/cultivars.rs index 7974e55..ba48d6a 100644 --- a/herbapi-ui/src/pages/cultivars.rs +++ b/herbapi-ui/src/pages/cultivars.rs @@ -323,6 +323,18 @@ pub fn CultivarDetail(slug: String) -> Element { api::list_suppliers().await.ok().unwrap_or_default() }); + // Fetch species images (to show species image on cultivar page) + let species_images = use_resource(move || { + let cv = cultivar.read().clone(); + async move { + if let Some(Ok(c)) = cv { + api::get_images("species", c.species_id).await.ok().unwrap_or_default() + } else { + vec![] + } + } + }); + rsx! { div { class: "page cultivar-detail", match &*cultivar.read() { @@ -373,12 +385,56 @@ pub fn CultivarDetail(slug: String) -> Element { let min_light_h = opt_f64_suffix(c.min_light_hours_day, " h"); let opt_light_h = opt_f64_suffix(c.optimal_light_hours_day, " h"); + // Species image: prefer primary from images API, else primary_image_key + let sp_primary_img: Option = { + let imgs = species_images.read(); + match &*imgs { + Some(v) if !v.is_empty() => { + v.iter().find(|i| i.is_primary).or(v.first()).cloned() + } + _ => None, + } + }; + let sp_img_key: Option = sp_primary_img.as_ref().map(|i| i.s3_key.clone()) + .or_else(|| { + let sp_read = species_data.read(); + match &*sp_read { + Some(Some(sp)) => sp.primary_image_key.clone(), + _ => None, + } + }); + rsx! { h1 { "{c.name}" } if common_name != c.name { p { class: "name-common", "{common_name}" } } + if let Some(ref key) = sp_img_key { + div { class: "species-image-wrap", + img { class: "species-image", src: "/img/{key}", alt: "{c.name}" } + if let Some(ref img) = sp_primary_img { + div { class: "image-attribution", + if let Some(ref caption) = img.caption { + span { "{caption}" } + } + if let Some(ref lic) = img.license { + if img.caption.is_some() { + " | " + } + span { "{lic}" } + } + if let Some(ref url) = img.source_url { + if img.caption.is_some() || img.license.is_some() { + " | " + } + a { href: "{url}", target: "_blank", class: "attribution-link", "Source" } + } + } + } + } + } + // Week-based planting calendar (full width) div { class: "detail-card", style: "margin-top: 1.25rem;", div { class: "detail-card-header", "Planting Calendar" } diff --git a/herbapi-ui/src/pages/species.rs b/herbapi-ui/src/pages/species.rs index 52ee3bf..a0bfc27 100644 --- a/herbapi-ui/src/pages/species.rs +++ b/herbapi-ui/src/pages/species.rs @@ -446,6 +446,18 @@ pub fn SpeciesDetail(slug: String) -> Element { } }); + // Fetch images for this species + let species_images = use_resource(move || { + let sp = species.read().clone(); + async move { + if let Some(Ok(s)) = sp { + api::get_images("species", s.id).await.ok().unwrap_or_default() + } else { + vec![] + } + } + }); + rsx! { div { class: "page species-detail", match &*species.read() { @@ -528,12 +540,50 @@ pub fn SpeciesDetail(slug: String) -> Element { let eppo = os(&s.eppo_code); let pfaf = s.pfaf_url.clone(); + // Primary image: prefer primary from images API, else primary_image_key + let primary_img: Option = { + let imgs = species_images.read(); + match &*imgs { + Some(v) if !v.is_empty() => { + v.iter().find(|i| i.is_primary).or(v.first()).cloned() + } + _ => None, + } + }; + let img_key: Option = primary_img.as_ref().map(|i| i.s3_key.clone()) + .or_else(|| s.primary_image_key.clone()); + rsx! { h1 { em { "{s.name_scientific}" } } if common_name != s.name_scientific { p { class: "name-common", "{common_name}" } } + if let Some(ref key) = img_key { + div { class: "species-image-wrap", + img { class: "species-image", src: "/img/{key}", alt: "{s.name_scientific}" } + if let Some(ref img) = primary_img { + div { class: "image-attribution", + if let Some(ref caption) = img.caption { + span { "{caption}" } + } + if let Some(ref lic) = img.license { + if img.caption.is_some() { + " | " + } + span { "{lic}" } + } + if let Some(ref url) = img.source_url { + if img.caption.is_some() || img.license.is_some() { + " | " + } + a { href: "{url}", target: "_blank", class: "attribution-link", "Source" } + } + } + } + } + } + div { class: "detail-row", // === LEFT COLUMN === div { class: "detail-col", diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index dfc8542..09cb0b9 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -173,6 +173,8 @@ pub struct Image { pub entity_id: Uuid, pub s3_key: String, pub caption: Option, + pub source_url: Option, + pub license: Option, pub is_primary: bool, }