feat: add entity image support with display on detail pages

Add image infrastructure, API, and detail page integration:

- EntityImage domain model with ImageRepository trait
- SQLite storage for images and thumbnails as BLOBs
- Image processing with data URL decoding and thumbnail generation
- REST API: upload, get, delete, thumbnail at /{entity_type}/{id}/image
- resolve_image_url helper for image fallback chains
- image-upload web component for direct upload on detail pages
- Image display with vignette overlay and lightbox viewer
- Template macros: image_thumbnail, readonly_image, lightbox_script
- All 7 detail pages updated with image thumbnails
- Brew images fall back to roast; cup images fall back to cafe then roast
- Delete handler macro extended with optional image cleanup
This commit is contained in:
Jon Seager 2026-02-10 16:34:51 +00:00
parent 3d61f71f1d
commit 4b3f03f5d3
No known key found for this signature in database
34 changed files with 1149 additions and 72 deletions

101
Cargo.lock generated
View file

@ -415,6 +415,7 @@ dependencies = [
"chrono",
"clap",
"dotenvy",
"image",
"isocountry",
"once_cell",
"open",
@ -450,12 +451,24 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@ -836,6 +849,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@ -1373,6 +1395,32 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@ -1596,6 +1644,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "nom"
version = "7.1.3"
@ -1863,6 +1921,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "portpicker"
version = "0.1.1"
@ -1921,6 +1992,21 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
@ -3968,3 +4054,18 @@ name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
dependencies = [
"zune-core",
]

View file

@ -12,6 +12,7 @@ base64 = "0.22"
chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4.5", features = ["derive", "env"] }
dotenvy = "0.15"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
isocountry = "0.3"
open = "5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }

View file

@ -0,0 +1,12 @@
CREATE TABLE entity_images (
id INTEGER PRIMARY KEY,
entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast', 'gear', 'cafe', 'brew', 'cup')),
entity_id INTEGER NOT NULL,
content_type TEXT NOT NULL,
image_data BLOB NOT NULL,
thumbnail_data BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(entity_type, entity_id)
);
CREATE INDEX idx_entity_images_lookup ON entity_images (entity_type, entity_id);

View file

@ -0,0 +1,261 @@
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use tracing::info;
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::support::{FlexiblePayload, is_datastar_request, render_fragment};
use crate::application::state::AppState;
use crate::domain::images::EntityImage;
use crate::infrastructure::image_processing::process_data_url;
use crate::presentation::web::templates::ImageUploadTemplate;
const VALID_ENTITY_TYPES: &[&str] = &["roaster", "roast", "gear", "cafe", "brew", "cup"];
#[derive(Debug, Deserialize)]
pub(crate) struct ImageUpload {
pub image: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ImagePath {
pub entity_type: String,
pub id: i64,
}
fn validate_entity_type(entity_type: &str) -> Result<(), ApiError> {
if VALID_ENTITY_TYPES.contains(&entity_type) {
Ok(())
} else {
Err(AppError::validation(format!("invalid entity type: {entity_type}")).into())
}
}
async fn validate_entity_exists(
state: &AppState,
entity_type: &str,
id: i64,
) -> Result<(), ApiError> {
use crate::domain::ids::{BrewId, CafeId, CupId, GearId, RoastId, RoasterId};
match entity_type {
"roaster" => {
state
.roaster_repo
.get(RoasterId::from(id))
.await
.map_err(AppError::from)?;
}
"roast" => {
state
.roast_repo
.get(RoastId::from(id))
.await
.map_err(AppError::from)?;
}
"gear" => {
state
.gear_repo
.get(GearId::from(id))
.await
.map_err(AppError::from)?;
}
"cafe" => {
state
.cafe_repo
.get(CafeId::from(id))
.await
.map_err(AppError::from)?;
}
"brew" => {
state
.brew_repo
.get(BrewId::from(id))
.await
.map_err(AppError::from)?;
}
"cup" => {
state
.cup_repo
.get(CupId::from(id))
.await
.map_err(AppError::from)?;
}
_ => {
return Err(AppError::validation(format!("invalid entity type: {entity_type}")).into());
}
}
Ok(())
}
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn upload_image(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(path): Path<ImagePath>,
payload: FlexiblePayload<ImageUpload>,
) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?;
validate_entity_exists(&state, &path.entity_type, path.id).await?;
let (upload, _source) = payload.into_parts();
let processed = process_data_url(&upload.image)
.map_err(|e| AppError::validation(format!("invalid image: {e}")))?;
let image = EntityImage {
entity_type: path.entity_type.clone(),
entity_id: path.id,
content_type: processed.content_type,
image_data: processed.image_data,
thumbnail_data: processed.thumbnail_data,
};
state
.image_repo
.upsert(image)
.await
.map_err(AppError::from)?;
info!(entity_type = %path.entity_type, entity_id = path.id, "image uploaded");
if is_datastar_request(&headers) {
let image_url = format!("/api/v1/{}/{}/image", path.entity_type, path.id);
render_fragment(
ImageUploadTemplate {
entity_type: &path.entity_type,
entity_id: path.id,
image_url: Some(&image_url),
is_authenticated: true,
},
"#entity-image",
)
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
#[tracing::instrument(skip(state))]
pub(crate) async fn get_image(
State(state): State<AppState>,
Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?;
let image = state
.image_repo
.get(&path.entity_type, path.id)
.await
.map_err(AppError::from)?;
Ok(image_response(image.image_data, &image.content_type))
}
#[tracing::instrument(skip(state))]
pub(crate) async fn get_thumbnail(
State(state): State<AppState>,
Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?;
let image = state
.image_repo
.get_thumbnail(&path.entity_type, path.id)
.await
.map_err(AppError::from)?;
Ok(image_response(image.thumbnail_data, &image.content_type))
}
#[tracing::instrument(skip(state, _auth_user))]
pub(crate) async fn delete_image(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
headers: HeaderMap,
Path(path): Path<ImagePath>,
) -> Result<Response, ApiError> {
validate_entity_type(&path.entity_type)?;
state
.image_repo
.delete(&path.entity_type, path.id)
.await
.map_err(AppError::from)?;
info!(entity_type = %path.entity_type, entity_id = path.id, "image deleted");
if is_datastar_request(&headers) {
render_fragment(
ImageUploadTemplate {
entity_type: &path.entity_type,
entity_id: path.id,
image_url: None,
is_authenticated: true,
},
"#entity-image",
)
.map_err(ApiError::from)
} else {
Ok(StatusCode::NO_CONTENT.into_response())
}
}
/// Check if an entity has an image and return its URL if so.
pub(crate) async fn resolve_image_url(
state: &AppState,
entity_type: &str,
entity_id: i64,
) -> Option<String> {
state
.image_repo
.has_image(entity_type, entity_id)
.await
.unwrap_or(false)
.then(|| format!("/api/v1/{entity_type}/{entity_id}/image"))
}
/// Process and save a deferred image upload (from a create form).
/// Called after entity creation when the form included an image data URL.
/// Accepts `Option<&str>` and no-ops on `None` or empty strings.
pub(crate) async fn save_deferred_image(
state: &AppState,
entity_type: &str,
entity_id: i64,
data_url: Option<&str>,
) {
let Some(data_url) = data_url.filter(|s| !s.is_empty()) else {
return;
};
match process_data_url(data_url) {
Ok(processed) => {
let image = EntityImage {
entity_type: entity_type.to_string(),
entity_id,
content_type: processed.content_type,
image_data: processed.image_data,
thumbnail_data: processed.thumbnail_data,
};
if let Err(err) = state.image_repo.upsert(image).await {
tracing::warn!(entity_type, entity_id, error = %err, "failed to save deferred image");
}
}
Err(err) => {
tracing::warn!(entity_type, entity_id, error = %err, "failed to process deferred image");
}
}
}
fn image_response(data: Vec<u8>, content_type: &str) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=604800")
.body(Body::from(data))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}

View file

@ -87,6 +87,12 @@ macro_rules! define_enriched_get_handler {
/// ```
macro_rules! define_delete_handler {
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => {
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, None);
};
($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, image_type: $image_type:literal) => {
define_delete_handler!(@inner $fn_name, $id_type, $sort_key, $repo_field, $render_fragment, $referer_match, $redirect_url, Some($image_type));
};
(@inner $fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal, $image_type:expr) => {
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn $fn_name(
axum::extract::State(state): axum::extract::State<crate::application::state::AppState>,
@ -104,6 +110,12 @@ macro_rules! define_delete_handler {
.await
.map_err(crate::application::errors::AppError::from)?;
if let Some(img_type) = $image_type {
if let Err(err) = state.image_repo.delete(img_type, i64::from(id)).await {
tracing::warn!(%id, error = %err, "failed to delete entity image");
}
}
tracing::info!(%id, "entity deleted");
state.stats_invalidator.invalidate();

View file

@ -1,6 +1,7 @@
pub(crate) mod analytics;
pub(crate) mod auth;
pub(crate) mod coffee;
pub(crate) mod images;
pub(crate) mod macros;
pub(crate) mod system;
@ -95,6 +96,14 @@ pub(super) fn router() -> axum::Router<AppState> {
)
.route("/backup/reset", post(backup::reset_database))
.route("/stats/recompute", post(stats::recompute_stats))
.route(
"/{entity_type}/{id}/image",
get(images::get_image)
.put(images::upload_image)
.delete(images::delete_image)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
)
.route("/{entity_type}/{id}/thumbnail", get(images::get_thumbnail))
}
pub(super) fn webauthn_router() -> axum::Router<AppState> {

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::domain::ids::BagId;
@ -36,6 +37,8 @@ pub(crate) async fn bag_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "roast", i64::from(roast.id)).await;
let view = BagDetailView::from_parts(bag, &roast, &roaster);
let template = BagDetailTemplate {
@ -46,6 +49,7 @@ pub(crate) async fn bag_detail_page(
bag: view,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::domain::ids::BrewId;
@ -42,6 +43,10 @@ pub(crate) async fn brew_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "brew", i64::from(id))
.await
.or(resolve_image_url(&state, "roast", i64::from(roast.id)).await);
let view = BrewDetailView::from_parts(brew_details, &roast, &roaster);
let template = BrewDetailTemplate {
@ -52,6 +57,7 @@ pub(crate) async fn brew_detail_page(
brew: view,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::presentation::web::templates::CafeDetailTemplate;
@ -23,6 +24,8 @@ pub(crate) async fn cafe_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "cafe", i64::from(cafe.id)).await;
let view = CafeDetailView::from_domain(cafe);
let template = CafeDetailTemplate {
@ -31,6 +34,7 @@ pub(crate) async fn cafe_detail_page(
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
cafe: view,
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::domain::ids::CupId;
@ -47,6 +48,11 @@ pub(crate) async fn cup_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "cup", i64::from(id))
.await
.or(resolve_image_url(&state, "cafe", i64::from(cafe.id)).await)
.or(resolve_image_url(&state, "roast", i64::from(roast.id)).await);
let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
let template = CupDetailTemplate {
@ -58,6 +64,7 @@ pub(crate) async fn cup_detail_page(
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
cafe_slug: cafe.slug.clone(),
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::domain::ids::GearId;
@ -24,6 +25,8 @@ pub(crate) async fn gear_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "gear", i64::from(id)).await;
let view = GearDetailView::from_domain(gear);
let template = GearDetailTemplate {
@ -32,6 +35,7 @@ pub(crate) async fn gear_detail_page(
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
gear: view,
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -58,6 +58,10 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/static/js/location.js", get(location_js))
.route("/static/js/components/world-map.js", get(world_map_js))
.route("/static/js/components/donut-chart.js", get(donut_chart_js))
.route(
"/static/js/components/image-upload.js",
get(image_upload_js),
)
.route("/static/favicon-light.svg", get(favicon_light))
.route("/static/favicon-dark.svg", get(favicon_dark))
.route("/static/og-image.png", get(og_image))
@ -148,6 +152,16 @@ async fn donut_chart_js() -> impl IntoResponse {
)
}
async fn image_upload_js() -> impl IntoResponse {
(
[
("content-type", "application/javascript; charset=utf-8"),
("cache-control", "public, max-age=604800"),
],
include_str!("../../../../static/js/components/image-upload.js"),
)
}
async fn favicon_light() -> impl IntoResponse {
(
[

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::presentation::web::templates::RoasterDetailTemplate;
@ -23,6 +24,8 @@ pub(crate) async fn roaster_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "roaster", i64::from(roaster.id)).await;
let view = RoasterDetailView::from_domain(roaster);
let template = RoasterDetailTemplate {
@ -31,6 +34,7 @@ pub(crate) async fn roaster_detail_page(
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
roaster: view,
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
use tower_cookies::Cookies;
use crate::application::errors::map_app_error;
use crate::application::routes::api::images::resolve_image_url;
use crate::application::routes::render_html;
use crate::application::state::AppState;
use crate::presentation::web::templates::RoastDetailTemplate;
@ -29,6 +30,8 @@ pub(crate) async fn roast_detail_page(
.await
.map_err(|e| map_app_error(e.into()))?;
let image_url = resolve_image_url(&state, "roast", i64::from(roast.id)).await;
let view = RoastDetailView::from_parts(roast, &roaster);
let template = RoastDetailTemplate {
@ -38,6 +41,7 @@ pub(crate) async fn roast_detail_page(
base_url: crate::base_url(),
roast: view,
roaster_slug,
image_url,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -8,9 +8,9 @@ use crate::application::services::{
};
use crate::domain::repositories::{
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
GearRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository,
RoasterRepository, SessionRepository, StatsRepository, TimelineEventRepository,
TokenRepository, UserRepository,
GearRepository, ImageRepository, PasskeyCredentialRepository, RegistrationTokenRepository,
RoastRepository, RoasterRepository, SessionRepository, StatsRepository,
TimelineEventRepository, TokenRepository, UserRepository,
};
use crate::infrastructure::backup::BackupService;
use crate::infrastructure::database::Database;
@ -20,6 +20,7 @@ use crate::infrastructure::repositories::brews::SqlBrewRepository;
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
use crate::infrastructure::repositories::cups::SqlCupRepository;
use crate::infrastructure::repositories::gear::SqlGearRepository;
use crate::infrastructure::repositories::images::SqlImageRepository;
use crate::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository;
use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
@ -61,6 +62,7 @@ pub struct AppState {
pub passkey_repo: Arc<dyn PasskeyCredentialRepository>,
pub registration_token_repo: Arc<dyn RegistrationTokenRepository>,
pub ai_usage_repo: Arc<dyn AiUsageRepository>,
pub image_repo: Arc<dyn ImageRepository>,
pub stats_repo: Arc<dyn StatsRepository>,
pub webauthn: Arc<Webauthn>,
pub challenge_store: Arc<ChallengeStore>,
@ -108,6 +110,7 @@ impl AppState {
Arc::new(SqlRegistrationTokenRepository::new(pool.clone()));
let ai_usage_repo: Arc<dyn AiUsageRepository> =
Arc::new(SqlAiUsageRepository::new(pool.clone()));
let image_repo: Arc<dyn ImageRepository> = Arc::new(SqlImageRepository::new(pool.clone()));
let stats_repo: Arc<dyn StatsRepository> = Arc::new(SqlStatsRepository::new(pool.clone()));
let backup_service = Arc::new(BackupService::new(pool));
@ -145,6 +148,7 @@ impl AppState {
passkey_repo,
registration_token_repo,
ai_usage_repo,
image_repo,
stats_repo,
webauthn: config.webauthn,
challenge_store: Arc::new(ChallengeStore::new()),

8
src/domain/images.rs Normal file
View file

@ -0,0 +1,8 @@
/// An image associated with an entity (roaster, roast, gear, or cafe).
pub struct EntityImage {
pub entity_type: String,
pub entity_id: i64,
pub content_type: String,
pub image_data: Vec<u8>,
pub thumbnail_data: Vec<u8>,
}

View file

@ -5,6 +5,7 @@ pub mod countries;
pub mod errors;
pub mod formatting;
pub mod ids;
pub mod images;
pub mod listing;
pub mod repositories;

View file

@ -11,6 +11,7 @@ use crate::domain::ids::{
BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId,
RoasterId, SessionId, TokenId, UserId,
};
use crate::domain::images::EntityImage;
use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential};
use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken};
use crate::domain::roasters::RoasterSortKey;
@ -269,6 +270,19 @@ pub trait AiUsageRepository: Send + Sync {
async fn summary_for_user(&self, user_id: UserId) -> Result<AiUsageSummary, RepositoryError>;
}
#[async_trait]
pub trait ImageRepository: Send + Sync {
async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError>;
async fn get(&self, entity_type: &str, entity_id: i64) -> Result<EntityImage, RepositoryError>;
async fn get_thumbnail(
&self,
entity_type: &str,
entity_id: i64,
) -> Result<EntityImage, RepositoryError>;
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError>;
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError>;
}
#[async_trait]
pub trait StatsRepository: Send + Sync {
async fn roaster_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;

View file

@ -0,0 +1,112 @@
use anyhow::{Context, bail};
use base64::Engine;
use image::ImageReader;
use std::io::Cursor;
/// Maximum dimension (width or height) for the full-size image.
const MAX_FULL_SIZE: u32 = 1200;
/// Maximum dimension (width or height) for the thumbnail.
const MAX_THUMBNAIL_SIZE: u32 = 200;
/// JPEG quality for the full-size image (0-100).
const JPEG_QUALITY_FULL: u8 = 85;
/// JPEG quality for the thumbnail (0-100).
const JPEG_QUALITY_THUMBNAIL: u8 = 80;
/// Processed image data ready for storage.
pub struct ProcessedImage {
pub image_data: Vec<u8>,
pub thumbnail_data: Vec<u8>,
pub content_type: String,
}
/// Parse a data URL, decode the image, resize to a maximum dimension, and
/// produce both a full-size and thumbnail JPEG.
///
/// Returns `(image_data, thumbnail_data, content_type)`.
pub fn process_data_url(data_url: &str) -> anyhow::Result<ProcessedImage> {
let raw_bytes = decode_data_url(data_url)?;
process_image_bytes(&raw_bytes)
}
/// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs.
pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result<ProcessedImage> {
let img = ImageReader::new(Cursor::new(raw_bytes))
.with_guessed_format()
.context("failed to guess image format")?
.decode()
.context("failed to decode image")?;
let full = img.resize(
MAX_FULL_SIZE,
MAX_FULL_SIZE,
image::imageops::FilterType::Lanczos3,
);
let thumb = img.resize(
MAX_THUMBNAIL_SIZE,
MAX_THUMBNAIL_SIZE,
image::imageops::FilterType::Lanczos3,
);
let image_data = encode_jpeg(&full, JPEG_QUALITY_FULL)?;
let thumbnail_data = encode_jpeg(&thumb, JPEG_QUALITY_THUMBNAIL)?;
Ok(ProcessedImage {
image_data,
thumbnail_data,
content_type: "image/jpeg".to_string(),
})
}
/// Decode a `data:image/...;base64,...` URL into raw bytes.
fn decode_data_url(data_url: &str) -> anyhow::Result<Vec<u8>> {
let Some(rest) = data_url.strip_prefix("data:") else {
bail!("invalid data URL: missing data: prefix");
};
let Some((_mime, encoded)) = rest.split_once(',') else {
bail!("invalid data URL: missing comma separator");
};
base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.context("failed to decode base64 image data")
}
/// Encode a `DynamicImage` as JPEG bytes.
fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> anyhow::Result<Vec<u8>> {
let mut buf = Vec::new();
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
img.write_with_encoder(encoder)
.context("failed to encode image as JPEG")?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_data_url_valid() {
let data = base64::engine::general_purpose::STANDARD.encode(b"hello");
let url = format!("data:image/png;base64,{data}");
let result = decode_data_url(&url);
assert!(result.is_ok());
assert_eq!(result.unwrap(), b"hello");
}
#[test]
fn decode_data_url_missing_prefix() {
let result = decode_data_url("not-a-data-url");
assert!(result.is_err());
}
#[test]
fn decode_data_url_missing_comma() {
let result = decode_data_url("data:image/png;base64");
assert!(result.is_err());
}
}

View file

@ -4,5 +4,6 @@ pub mod backup;
pub mod client;
pub mod database;
pub mod foursquare;
pub mod image_processing;
pub mod repositories;
pub mod webauthn;

View file

@ -0,0 +1,139 @@
use async_trait::async_trait;
use sqlx::{query, query_as};
use crate::domain::RepositoryError;
use crate::domain::images::EntityImage;
use crate::domain::repositories::ImageRepository;
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlImageRepository {
pool: DatabasePool,
}
impl SqlImageRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn into_domain(record: ImageRecord) -> EntityImage {
EntityImage {
entity_type: record.entity_type,
entity_id: record.entity_id,
content_type: record.content_type,
image_data: record.image_data,
thumbnail_data: record.thumbnail_data,
}
}
fn thumbnail_to_domain(record: ThumbnailRecord) -> EntityImage {
EntityImage {
entity_type: record.entity_type,
entity_id: record.entity_id,
content_type: record.content_type,
image_data: Vec::new(),
thumbnail_data: record.thumbnail_data,
}
}
}
#[derive(sqlx::FromRow)]
struct ImageRecord {
entity_type: String,
entity_id: i64,
content_type: String,
image_data: Vec<u8>,
thumbnail_data: Vec<u8>,
}
#[derive(sqlx::FromRow)]
struct ThumbnailRecord {
entity_type: String,
entity_id: i64,
content_type: String,
thumbnail_data: Vec<u8>,
}
#[async_trait]
impl ImageRepository for SqlImageRepository {
async fn upsert(&self, image: EntityImage) -> Result<(), RepositoryError> {
query(
r"INSERT INTO entity_images (entity_type, entity_id, content_type, image_data, thumbnail_data)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (entity_type, entity_id)
DO UPDATE SET content_type = excluded.content_type,
image_data = excluded.image_data,
thumbnail_data = excluded.thumbnail_data,
created_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
)
.bind(&image.entity_type)
.bind(image.entity_id)
.bind(&image.content_type)
.bind(&image.image_data)
.bind(&image.thumbnail_data)
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::unexpected(e.to_string()))?;
Ok(())
}
async fn get(&self, entity_type: &str, entity_id: i64) -> Result<EntityImage, RepositoryError> {
let record = query_as::<_, ImageRecord>(
r"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data
FROM entity_images
WHERE entity_type = ? AND entity_id = ?",
)
.bind(entity_type)
.bind(entity_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| RepositoryError::unexpected(e.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Ok(Self::into_domain(record))
}
async fn get_thumbnail(
&self,
entity_type: &str,
entity_id: i64,
) -> Result<EntityImage, RepositoryError> {
let record = query_as::<_, ThumbnailRecord>(
r"SELECT entity_type, entity_id, content_type, thumbnail_data
FROM entity_images
WHERE entity_type = ? AND entity_id = ?",
)
.bind(entity_type)
.bind(entity_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| RepositoryError::unexpected(e.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Ok(Self::thumbnail_to_domain(record))
}
async fn delete(&self, entity_type: &str, entity_id: i64) -> Result<(), RepositoryError> {
query(r"DELETE FROM entity_images WHERE entity_type = ? AND entity_id = ?")
.bind(entity_type)
.bind(entity_id)
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::unexpected(e.to_string()))?;
Ok(())
}
async fn has_image(&self, entity_type: &str, entity_id: i64) -> Result<bool, RepositoryError> {
let row: (i64,) =
query_as(r"SELECT COUNT(*) FROM entity_images WHERE entity_type = ? AND entity_id = ?")
.bind(entity_type)
.bind(entity_id)
.fetch_one(&self.pool)
.await
.map_err(|e| RepositoryError::unexpected(e.to_string()))?;
Ok(row.0 > 0)
}
}

View file

@ -1,6 +1,7 @@
pub mod analytics;
pub mod auth;
pub mod coffee;
pub mod images;
pub(crate) mod macros;
pub mod pagination;

View file

@ -221,6 +221,7 @@ pub struct BagDetailTemplate {
pub bag: BagDetailView,
pub roaster_slug: String,
pub roast_slug: String,
pub image_url: Option<String>,
}
#[derive(Template)]
@ -233,6 +234,7 @@ pub struct BrewDetailTemplate {
pub brew: BrewDetailView,
pub roaster_slug: String,
pub roast_slug: String,
pub image_url: Option<String>,
}
#[derive(Template)]
@ -246,6 +248,7 @@ pub struct CupDetailTemplate {
pub roaster_slug: String,
pub roast_slug: String,
pub cafe_slug: String,
pub image_url: Option<String>,
}
#[derive(Template)]
@ -257,6 +260,7 @@ pub struct RoastDetailTemplate {
pub base_url: &'static str,
pub roast: RoastDetailView,
pub roaster_slug: String,
pub image_url: Option<String>,
}
#[derive(Template)]
@ -267,6 +271,7 @@ pub struct RoasterDetailTemplate {
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub roaster: RoasterDetailView,
pub image_url: Option<String>,
}
#[derive(Template)]
@ -277,6 +282,7 @@ pub struct CafeDetailTemplate {
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub cafe: CafeDetailView,
pub image_url: Option<String>,
}
#[derive(Template)]
@ -287,6 +293,16 @@ pub struct GearDetailTemplate {
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub gear: GearDetailView,
pub image_url: Option<String>,
}
#[derive(Template)]
#[template(path = "partials/image_upload.html")]
pub struct ImageUploadTemplate<'a> {
pub entity_type: &'a str,
pub entity_id: i64,
pub image_url: Option<&'a str>,
pub is_authenticated: bool,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {

View file

@ -0,0 +1,107 @@
customElements.define(
"image-upload",
class extends HTMLElement {
connectedCallback() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.hidden = true;
this.appendChild(input);
this.addEventListener("click", (e) => {
if (e.target !== input) input.click();
});
this.addEventListener("dragover", (e) => {
e.preventDefault();
this.classList.add("border-accent");
});
this.addEventListener("dragleave", () => {
this.classList.remove("border-accent");
});
this.addEventListener("drop", (e) => {
e.preventDefault();
this.classList.remove("border-accent");
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith("image/")) {
this._handleFile(file);
}
});
input.addEventListener("change", () => {
const file = input.files[0];
if (file) this._handleFile(file);
input.value = "";
});
}
_handleFile(file) {
const entityType = this.getAttribute("entity-type");
const entityId = this.getAttribute("entity-id");
const mode = this.getAttribute("mode");
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
if (entityType && entityId && mode !== "deferred") {
this._upload(entityType, entityId, dataUrl);
} else {
// Deferred mode: store data URL in a target hidden input
const targetId = this.getAttribute("target-input");
if (targetId) {
document.getElementById(targetId).value = dataUrl;
}
this._showPreview(dataUrl);
}
};
reader.readAsDataURL(file);
}
_showPreview(dataUrl) {
const container = this.closest("#entity-image") || this.parentElement;
let preview = container.querySelector(".image-upload-preview");
if (!preview) {
preview = document.createElement("div");
preview.className =
"image-upload-preview h-48 w-full bg-cover bg-center rounded-lg border mt-2";
container.appendChild(preview);
}
preview.style.backgroundImage = `url('${dataUrl}')`;
}
async _upload(entityType, entityId, dataUrl) {
const originalContent = this.innerHTML;
const isReplace = this.getAttribute("mode") === "replace";
if (!isReplace) {
this.innerHTML =
'<span class="text-sm text-text-muted">Uploading...</span>';
}
try {
const response = await fetch(
`/api/v1/${entityType}/${entityId}/image`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image: dataUrl }),
},
);
if (!response.ok) throw new Error("Upload failed");
window.location.reload();
} catch {
this.innerHTML = originalContent;
const errEl = document.createElement("p");
errEl.className = "text-xs text-red-500 mt-1";
errEl.textContent = "Upload failed. Try again.";
this.parentElement.appendChild(errEl);
setTimeout(() => errEl.remove(), 3000);
}
}
},
);

View file

@ -54,6 +54,7 @@
<script defer src="/static/js/components/chip-scroll.js"></script>
<script defer src="/static/js/components/world-map.js"></script>
<script defer src="/static/js/components/donut-chart.js"></script>
<script defer src="/static/js/components/image-upload.js"></script>
{% block head %}{% endblock %}
<script>
const showToast = (msg, ms = 3000) => {

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
{% block description %}
{{ bag.roast_name }}
@ -16,18 +17,32 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=bags"
class="text-accent hover:text-accent-hover transition"
>Bags</a
<div class="flex items-center gap-4 min-w-0">
{% if let Some(url) = image_url %}
{{ img::readonly_image(url, "roast image") }}
{% else %}
<div
class="flex shrink-0 items-center justify-center h-14 w-14 md:h-20 md:w-20 rounded-lg border-2 border-dashed border-text-muted/30 text-text-muted"
>
· {{ bag.created_date }}
</p>
{{ icons::bag("h-6 w-6") }}
</div>
{% endif %}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">{{ bag.roast_name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=bags"
class="text-accent hover:text-accent-hover transition"
>Bags</a
>
· {{ bag.created_date }}
</p>
</div>
</div>
</header>
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
{% block description %}
{{ brew.roast_name }}
@ -18,18 +19,24 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=brews"
class="text-accent hover:text-accent-hover transition"
>Brews</a
>
· {{ brew.created_date }}
</p>
<div class="flex items-center gap-4 min-w-0">
{{ img::image_thumbnail("brew", brew.id, image_url, is_authenticated) }}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">{{ brew.roast_name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=brews"
class="text-accent hover:text-accent-hover transition"
>Brews</a
>
· {{ brew.created_date }}
</p>
</div>
</div>
</header>
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
{% block og_description %}
@ -12,18 +13,22 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=cafes"
class="text-accent hover:text-accent-hover transition"
>Cafes</a
>
· {{ cafe.created_date }}
</p>
<div class="flex items-center gap-4 min-w-0">
{{ img::image_thumbnail("cafe", cafe.id, image_url, is_authenticated) }}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">{{ cafe.name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=cafes"
class="text-accent hover:text-accent-hover transition"
>Cafes</a
>
· {{ cafe.created_date }}
</p>
</div>
</div>
</header>
{{ img::lightbox_script() }}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
{% block description %}
{{ cup.roast_name }}
@ -16,18 +17,24 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=cups"
class="text-accent hover:text-accent-hover transition"
>Cups</a
>
· {{ cup.created_date }}
</p>
<div class="flex items-center gap-4 min-w-0">
{{ img::image_thumbnail("cup", cup.id, image_url, is_authenticated) }}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">{{ cup.roast_name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=cups"
class="text-accent hover:text-accent-hover transition"
>Cups</a
>
· {{ cup.created_date }}
</p>
</div>
</div>
</header>
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
{% block og_description %}
@ -13,18 +14,24 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ gear.make }} {{ gear.model }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=gear"
class="text-accent hover:text-accent-hover transition"
>Gear</a
>
· {{ gear.created_date }}
</p>
<div class="flex items-center gap-4 min-w-0">
{{ img::image_thumbnail("gear", gear.id, image_url, is_authenticated) }}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">
{{ gear.make }} {{ gear.model }}
</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=gear"
class="text-accent hover:text-accent-hover transition"
>Gear</a
>
· {{ gear.created_date }}
</p>
</div>
</div>
</header>
{{ img::lightbox_script() }}
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
{% block og_description %}
@ -12,18 +13,22 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=roasts"
class="text-accent hover:text-accent-hover transition"
>Roasts</a
>
· {{ roast.created_date }}
</p>
<div class="flex items-center gap-4 min-w-0">
{{ img::image_thumbnail("roast", roast.id, image_url, is_authenticated) }}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">{{ roast.name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=roasts"
class="text-accent hover:text-accent-hover transition"
>Roasts</a
>
· {{ roast.created_date }}
</p>
</div>
</div>
</header>
{{ img::lightbox_script() }}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes, roaster_slug, "") }}

View file

@ -1,6 +1,7 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
{% block og_description %}
@ -13,18 +14,22 @@
{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=roasters"
class="text-accent hover:text-accent-hover transition"
>Roasters</a
>
· {{ roaster.created_date }}
</p>
<div class="flex items-center gap-4 min-w-0">
{{ img::image_thumbnail("roaster", roaster.id, image_url, is_authenticated) }}
<div class="flex flex-col gap-1 min-w-0">
<h1 class="text-2xl font-semibold truncate">{{ roaster.name }}</h1>
<p class="text-sm text-text-secondary">
<a
href="/data?type=roasters"
class="text-accent hover:text-accent-hover transition"
>Roasters</a
>
· {{ roaster.created_date }}
</p>
</div>
</div>
</header>
{{ img::lightbox_script() }}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">

View file

@ -0,0 +1,127 @@
{% macro readonly_image(url, alt_text) %}
<div
class="relative shrink-0 h-14 w-14 md:h-20 md:w-20 rounded-lg overflow-hidden cursor-pointer"
onclick="openImageModal('{{ url }}')"
>
<img
src="{{ url }}"
class="h-full w-full object-cover"
alt="{{ alt_text }}"
/>
<div
class="absolute inset-0 pointer-events-none"
style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)"
></div>
</div>
{% endmacro %}
{% macro image_thumbnail(entity_type, entity_id, image_url, is_authenticated) %}
{% if let Some(url) = image_url %}
<div
class="relative shrink-0 h-14 w-14 md:h-20 md:w-20 rounded-lg overflow-hidden cursor-pointer"
onclick="openImageModal('{{ url }}')"
>
<img
src="{{ url }}"
class="h-full w-full object-cover"
alt="{{ entity_type }} image"
/>
<div
class="absolute inset-0 pointer-events-none"
style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)"
></div>
</div>
{% else if is_authenticated %}
<image-upload
entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}"
mode="upload"
class="flex shrink-0 items-center justify-center h-14 w-14 md:h-20 md:w-20 rounded-lg border-2 border-dashed border-text-muted/30 text-text-muted cursor-pointer hover:border-accent/40 hover:text-text-secondary transition"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
</image-upload>
{% else %}
<div
class="flex shrink-0 items-center justify-center h-14 w-14 md:h-20 md:w-20 rounded-lg border-2 border-dashed border-text-muted/30 text-text-muted"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
/>
</svg>
</div>
{% endif %}
{% endmacro %}
{% macro deferred_upload(input_id, label) %}
<input type="hidden" name="image" id="{{ input_id }}" />
<image-upload
mode="deferred"
target-input="{{ input_id }}"
class="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-text-muted/30 bg-surface p-4 text-center text-text-muted cursor-pointer hover:border-accent/40 hover:text-text-secondary transition"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
/>
</svg>
<span class="text-xs">{{ label }}</span>
</image-upload>
{% endmacro %}
{% macro lightbox_script() %}
<script>
const openImageModal = (src) => {
const overlay = document.createElement("div");
overlay.className =
"fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-pointer";
overlay.onclick = () => overlay.remove();
const img = document.createElement("img");
img.src = src;
img.className = "max-h-[90vh] max-w-[90vw] rounded-lg object-contain";
img.onclick = (e) => e.stopPropagation();
overlay.appendChild(img);
document.body.appendChild(overlay);
const onKey = (e) => {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", onKey);
}
};
document.addEventListener("keydown", onKey);
};
</script>
{% endmacro %}

View file

@ -0,0 +1,52 @@
<div id="entity-image">
{% if let Some(url) = image_url %}
<div class="relative rounded-lg border bg-surface overflow-hidden">
<div
class="h-48 w-full bg-cover bg-center"
style="background-image: url('{{ url }}')"
role="img"
aria-label="{{ entity_type }} image"
></div>
{% if is_authenticated %}
<div class="absolute top-2 right-2 flex gap-1">
<image-upload
entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}"
mode="replace"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-accent hover:bg-surface cursor-pointer backdrop-blur-sm"
>
Replace
</image-upload>
<button
onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE',headers:{'datastar-request':'true'}}).then(r=>r.text()).then(h=>{document.getElementById('entity-image').outerHTML=h})}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-red-500 hover:bg-surface cursor-pointer backdrop-blur-sm"
>
Remove
</button>
</div>
{% endif %}
</div>
{% else if is_authenticated %}
<image-upload
entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}"
mode="upload"
class="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-text-muted/30 bg-surface p-6 text-center text-text-muted cursor-pointer hover:border-accent/40 hover:text-text-secondary transition"
>
<svg
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
/>
</svg>
<span class="text-sm">Drop image, browse, or take a photo</span>
</image-upload>
{% endif %}
</div>