Add image serving with CC license attribution
- S3 image proxy at /img/{path} with cache headers
- Species images displayed on species + cultivar detail pages
- Full CC attribution below each image: author, license, Wikimedia Commons link
- Image struct updated with source_url and license fields
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
use axum::extract::{Multipart, Path, State};
|
use axum::extract::{Multipart, Path, State};
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
@@ -7,6 +9,38 @@ use crate::db::models::Image;
|
|||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Serve an image directly from S3, with Content-Type and cache headers.
|
||||||
|
pub async fn serve_image(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
) -> 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(
|
pub async fn list_for_entity(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((entity_type, entity_id)): Path<(String, uuid::Uuid)>,
|
Path((entity_type, entity_id)): Path<(String, uuid::Uuid)>,
|
||||||
|
|||||||
@@ -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/{entity_type}/{entity_id}", get(images::list_for_entity))
|
||||||
.route("/api/v1/images", post(images::upload))
|
.route("/api/v1/images", post(images::upload))
|
||||||
.route("/api/v1/images/{id}", delete(images::remove))
|
.route("/api/v1/images/{id}", delete(images::remove))
|
||||||
|
// Image file serving (S3 proxy)
|
||||||
|
.route("/img/{*path}", get(images::serve_image))
|
||||||
// Search
|
// Search
|
||||||
.route("/api/v1/search", get(search::search))
|
.route("/api/v1/search", get(search::search))
|
||||||
// OIDC auth
|
// OIDC auth
|
||||||
|
|||||||
@@ -1165,6 +1165,38 @@ td.placeholder {
|
|||||||
color: #e65100;
|
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 */
|
/* Responsive */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -323,6 +323,18 @@ pub fn CultivarDetail(slug: String) -> Element {
|
|||||||
api::list_suppliers().await.ok().unwrap_or_default()
|
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! {
|
rsx! {
|
||||||
div { class: "page cultivar-detail",
|
div { class: "page cultivar-detail",
|
||||||
match &*cultivar.read() {
|
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 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");
|
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<crate::types::Image> = {
|
||||||
|
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<String> = 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! {
|
rsx! {
|
||||||
h1 { "{c.name}" }
|
h1 { "{c.name}" }
|
||||||
if common_name != c.name {
|
if common_name != c.name {
|
||||||
p { class: "name-common", "{common_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)
|
// Week-based planting calendar (full width)
|
||||||
div { class: "detail-card", style: "margin-top: 1.25rem;",
|
div { class: "detail-card", style: "margin-top: 1.25rem;",
|
||||||
div { class: "detail-card-header", "Planting Calendar" }
|
div { class: "detail-card-header", "Planting Calendar" }
|
||||||
|
|||||||
@@ -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! {
|
rsx! {
|
||||||
div { class: "page species-detail",
|
div { class: "page species-detail",
|
||||||
match &*species.read() {
|
match &*species.read() {
|
||||||
@@ -528,12 +540,50 @@ pub fn SpeciesDetail(slug: String) -> Element {
|
|||||||
let eppo = os(&s.eppo_code);
|
let eppo = os(&s.eppo_code);
|
||||||
let pfaf = s.pfaf_url.clone();
|
let pfaf = s.pfaf_url.clone();
|
||||||
|
|
||||||
|
// Primary image: prefer primary from images API, else primary_image_key
|
||||||
|
let primary_img: Option<crate::types::Image> = {
|
||||||
|
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<String> = primary_img.as_ref().map(|i| i.s3_key.clone())
|
||||||
|
.or_else(|| s.primary_image_key.clone());
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
h1 { em { "{s.name_scientific}" } }
|
h1 { em { "{s.name_scientific}" } }
|
||||||
if common_name != s.name_scientific {
|
if common_name != s.name_scientific {
|
||||||
p { class: "name-common", "{common_name}" }
|
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",
|
div { class: "detail-row",
|
||||||
// === LEFT COLUMN ===
|
// === LEFT COLUMN ===
|
||||||
div { class: "detail-col",
|
div { class: "detail-col",
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ pub struct Image {
|
|||||||
pub entity_id: Uuid,
|
pub entity_id: Uuid,
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub caption: Option<String>,
|
pub caption: Option<String>,
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
pub license: Option<String>,
|
||||||
pub is_primary: bool,
|
pub is_primary: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user