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:
2026-03-15 15:13:06 +01:00
parent 170aa84a0f
commit 1cba5a9eaf
6 changed files with 176 additions and 0 deletions
+32
View File
@@ -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) {
+56
View File
@@ -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<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! {
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" }
+50
View File
@@ -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<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! {
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",
+2
View File
@@ -173,6 +173,8 @@ pub struct Image {
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,
}