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:
parent
3d61f71f1d
commit
4b3f03f5d3
34 changed files with 1149 additions and 72 deletions
101
Cargo.lock
generated
101
Cargo.lock
generated
|
|
@ -415,6 +415,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"image",
|
||||||
"isocountry",
|
"isocountry",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"open",
|
"open",
|
||||||
|
|
@ -450,12 +451,24 @@ version = "3.19.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
|
|
@ -836,6 +849,15 @@ version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
|
@ -1373,6 +1395,32 @@ dependencies = [
|
||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
|
|
@ -1596,6 +1644,16 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
|
|
@ -1863,6 +1921,19 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
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]]
|
[[package]]
|
||||||
name = "portpicker"
|
name = "portpicker"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -1921,6 +1992,21 @@ dependencies = [
|
||||||
"psl-types",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
|
|
@ -3968,3 +4054,18 @@ name = "zmij"
|
||||||
version = "1.0.19"
|
version = "1.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ base64 = "0.22"
|
||||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
isocountry = "0.3"
|
isocountry = "0.3"
|
||||||
open = "5"
|
open = "5"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
|
||||||
|
|
|
||||||
12
migrations/0007_entity_images.sql
Normal file
12
migrations/0007_entity_images.sql
Normal 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);
|
||||||
261
src/application/routes/api/images.rs
Normal file
261
src/application/routes/api/images.rs
Normal 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())
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,12 @@ macro_rules! define_enriched_get_handler {
|
||||||
/// ```
|
/// ```
|
||||||
macro_rules! define_delete_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) => {
|
($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))]
|
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||||
pub(crate) async fn $fn_name(
|
pub(crate) async fn $fn_name(
|
||||||
axum::extract::State(state): axum::extract::State<crate::application::state::AppState>,
|
axum::extract::State(state): axum::extract::State<crate::application::state::AppState>,
|
||||||
|
|
@ -104,6 +110,12 @@ macro_rules! define_delete_handler {
|
||||||
.await
|
.await
|
||||||
.map_err(crate::application::errors::AppError::from)?;
|
.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");
|
tracing::info!(%id, "entity deleted");
|
||||||
state.stats_invalidator.invalidate();
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub(crate) mod analytics;
|
pub(crate) mod analytics;
|
||||||
pub(crate) mod auth;
|
pub(crate) mod auth;
|
||||||
pub(crate) mod coffee;
|
pub(crate) mod coffee;
|
||||||
|
pub(crate) mod images;
|
||||||
pub(crate) mod macros;
|
pub(crate) mod macros;
|
||||||
pub(crate) mod system;
|
pub(crate) mod system;
|
||||||
|
|
||||||
|
|
@ -95,6 +96,14 @@ pub(super) fn router() -> axum::Router<AppState> {
|
||||||
)
|
)
|
||||||
.route("/backup/reset", post(backup::reset_database))
|
.route("/backup/reset", post(backup::reset_database))
|
||||||
.route("/stats/recompute", post(stats::recompute_stats))
|
.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> {
|
pub(super) fn webauthn_router() -> axum::Router<AppState> {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::ids::BagId;
|
use crate::domain::ids::BagId;
|
||||||
|
|
@ -36,6 +37,8 @@ pub(crate) async fn bag_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = BagDetailView::from_parts(bag, &roast, &roaster);
|
||||||
|
|
||||||
let template = BagDetailTemplate {
|
let template = BagDetailTemplate {
|
||||||
|
|
@ -46,6 +49,7 @@ pub(crate) async fn bag_detail_page(
|
||||||
bag: view,
|
bag: view,
|
||||||
roaster_slug: roaster.slug.clone(),
|
roaster_slug: roaster.slug.clone(),
|
||||||
roast_slug: roast.slug.clone(),
|
roast_slug: roast.slug.clone(),
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::ids::BrewId;
|
use crate::domain::ids::BrewId;
|
||||||
|
|
@ -42,6 +43,10 @@ pub(crate) async fn brew_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = BrewDetailView::from_parts(brew_details, &roast, &roaster);
|
||||||
|
|
||||||
let template = BrewDetailTemplate {
|
let template = BrewDetailTemplate {
|
||||||
|
|
@ -52,6 +57,7 @@ pub(crate) async fn brew_detail_page(
|
||||||
brew: view,
|
brew: view,
|
||||||
roaster_slug: roaster.slug.clone(),
|
roaster_slug: roaster.slug.clone(),
|
||||||
roast_slug: roast.slug.clone(),
|
roast_slug: roast.slug.clone(),
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::presentation::web::templates::CafeDetailTemplate;
|
use crate::presentation::web::templates::CafeDetailTemplate;
|
||||||
|
|
@ -23,6 +24,8 @@ pub(crate) async fn cafe_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = CafeDetailView::from_domain(cafe);
|
||||||
|
|
||||||
let template = CafeDetailTemplate {
|
let template = CafeDetailTemplate {
|
||||||
|
|
@ -31,6 +34,7 @@ pub(crate) async fn cafe_detail_page(
|
||||||
version_info: &crate::VERSION_INFO,
|
version_info: &crate::VERSION_INFO,
|
||||||
base_url: crate::base_url(),
|
base_url: crate::base_url(),
|
||||||
cafe: view,
|
cafe: view,
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::ids::CupId;
|
use crate::domain::ids::CupId;
|
||||||
|
|
@ -47,6 +48,11 @@ pub(crate) async fn cup_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
|
||||||
|
|
||||||
let template = CupDetailTemplate {
|
let template = CupDetailTemplate {
|
||||||
|
|
@ -58,6 +64,7 @@ pub(crate) async fn cup_detail_page(
|
||||||
roaster_slug: roaster.slug.clone(),
|
roaster_slug: roaster.slug.clone(),
|
||||||
roast_slug: roast.slug.clone(),
|
roast_slug: roast.slug.clone(),
|
||||||
cafe_slug: cafe.slug.clone(),
|
cafe_slug: cafe.slug.clone(),
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::ids::GearId;
|
use crate::domain::ids::GearId;
|
||||||
|
|
@ -24,6 +25,8 @@ pub(crate) async fn gear_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = GearDetailView::from_domain(gear);
|
||||||
|
|
||||||
let template = GearDetailTemplate {
|
let template = GearDetailTemplate {
|
||||||
|
|
@ -32,6 +35,7 @@ pub(crate) async fn gear_detail_page(
|
||||||
version_info: &crate::VERSION_INFO,
|
version_info: &crate::VERSION_INFO,
|
||||||
base_url: crate::base_url(),
|
base_url: crate::base_url(),
|
||||||
gear: view,
|
gear: view,
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ pub(super) fn router() -> axum::Router<AppState> {
|
||||||
.route("/static/js/location.js", get(location_js))
|
.route("/static/js/location.js", get(location_js))
|
||||||
.route("/static/js/components/world-map.js", get(world_map_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/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-light.svg", get(favicon_light))
|
||||||
.route("/static/favicon-dark.svg", get(favicon_dark))
|
.route("/static/favicon-dark.svg", get(favicon_dark))
|
||||||
.route("/static/og-image.png", get(og_image))
|
.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 {
|
async fn favicon_light() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::presentation::web::templates::RoasterDetailTemplate;
|
use crate::presentation::web::templates::RoasterDetailTemplate;
|
||||||
|
|
@ -23,6 +24,8 @@ pub(crate) async fn roaster_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = RoasterDetailView::from_domain(roaster);
|
||||||
|
|
||||||
let template = RoasterDetailTemplate {
|
let template = RoasterDetailTemplate {
|
||||||
|
|
@ -31,6 +34,7 @@ pub(crate) async fn roaster_detail_page(
|
||||||
version_info: &crate::VERSION_INFO,
|
version_info: &crate::VERSION_INFO,
|
||||||
base_url: crate::base_url(),
|
base_url: crate::base_url(),
|
||||||
roaster: view,
|
roaster: view,
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use axum::response::{IntoResponse, Response};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::application::errors::map_app_error;
|
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::routes::render_html;
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::presentation::web::templates::RoastDetailTemplate;
|
use crate::presentation::web::templates::RoastDetailTemplate;
|
||||||
|
|
@ -29,6 +30,8 @@ pub(crate) async fn roast_detail_page(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| map_app_error(e.into()))?;
|
.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 view = RoastDetailView::from_parts(roast, &roaster);
|
||||||
|
|
||||||
let template = RoastDetailTemplate {
|
let template = RoastDetailTemplate {
|
||||||
|
|
@ -38,6 +41,7 @@ pub(crate) async fn roast_detail_page(
|
||||||
base_url: crate::base_url(),
|
base_url: crate::base_url(),
|
||||||
roast: view,
|
roast: view,
|
||||||
roaster_slug,
|
roaster_slug,
|
||||||
|
image_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ use crate::application::services::{
|
||||||
};
|
};
|
||||||
use crate::domain::repositories::{
|
use crate::domain::repositories::{
|
||||||
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
|
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
|
||||||
GearRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository,
|
GearRepository, ImageRepository, PasskeyCredentialRepository, RegistrationTokenRepository,
|
||||||
RoasterRepository, SessionRepository, StatsRepository, TimelineEventRepository,
|
RoastRepository, RoasterRepository, SessionRepository, StatsRepository,
|
||||||
TokenRepository, UserRepository,
|
TimelineEventRepository, TokenRepository, UserRepository,
|
||||||
};
|
};
|
||||||
use crate::infrastructure::backup::BackupService;
|
use crate::infrastructure::backup::BackupService;
|
||||||
use crate::infrastructure::database::Database;
|
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::cafes::SqlCafeRepository;
|
||||||
use crate::infrastructure::repositories::cups::SqlCupRepository;
|
use crate::infrastructure::repositories::cups::SqlCupRepository;
|
||||||
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
||||||
|
use crate::infrastructure::repositories::images::SqlImageRepository;
|
||||||
use crate::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository;
|
use crate::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository;
|
||||||
use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository;
|
use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository;
|
||||||
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||||
|
|
@ -61,6 +62,7 @@ pub struct AppState {
|
||||||
pub passkey_repo: Arc<dyn PasskeyCredentialRepository>,
|
pub passkey_repo: Arc<dyn PasskeyCredentialRepository>,
|
||||||
pub registration_token_repo: Arc<dyn RegistrationTokenRepository>,
|
pub registration_token_repo: Arc<dyn RegistrationTokenRepository>,
|
||||||
pub ai_usage_repo: Arc<dyn AiUsageRepository>,
|
pub ai_usage_repo: Arc<dyn AiUsageRepository>,
|
||||||
|
pub image_repo: Arc<dyn ImageRepository>,
|
||||||
pub stats_repo: Arc<dyn StatsRepository>,
|
pub stats_repo: Arc<dyn StatsRepository>,
|
||||||
pub webauthn: Arc<Webauthn>,
|
pub webauthn: Arc<Webauthn>,
|
||||||
pub challenge_store: Arc<ChallengeStore>,
|
pub challenge_store: Arc<ChallengeStore>,
|
||||||
|
|
@ -108,6 +110,7 @@ impl AppState {
|
||||||
Arc::new(SqlRegistrationTokenRepository::new(pool.clone()));
|
Arc::new(SqlRegistrationTokenRepository::new(pool.clone()));
|
||||||
let ai_usage_repo: Arc<dyn AiUsageRepository> =
|
let ai_usage_repo: Arc<dyn AiUsageRepository> =
|
||||||
Arc::new(SqlAiUsageRepository::new(pool.clone()));
|
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 stats_repo: Arc<dyn StatsRepository> = Arc::new(SqlStatsRepository::new(pool.clone()));
|
||||||
|
|
||||||
let backup_service = Arc::new(BackupService::new(pool));
|
let backup_service = Arc::new(BackupService::new(pool));
|
||||||
|
|
@ -145,6 +148,7 @@ impl AppState {
|
||||||
passkey_repo,
|
passkey_repo,
|
||||||
registration_token_repo,
|
registration_token_repo,
|
||||||
ai_usage_repo,
|
ai_usage_repo,
|
||||||
|
image_repo,
|
||||||
stats_repo,
|
stats_repo,
|
||||||
webauthn: config.webauthn,
|
webauthn: config.webauthn,
|
||||||
challenge_store: Arc::new(ChallengeStore::new()),
|
challenge_store: Arc::new(ChallengeStore::new()),
|
||||||
|
|
|
||||||
8
src/domain/images.rs
Normal file
8
src/domain/images.rs
Normal 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>,
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod countries;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
|
pub mod images;
|
||||||
pub mod listing;
|
pub mod listing;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::domain::ids::{
|
||||||
BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId,
|
BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId,
|
||||||
RoasterId, SessionId, TokenId, UserId,
|
RoasterId, SessionId, TokenId, UserId,
|
||||||
};
|
};
|
||||||
|
use crate::domain::images::EntityImage;
|
||||||
use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential};
|
use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential};
|
||||||
use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken};
|
use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken};
|
||||||
use crate::domain::roasters::RoasterSortKey;
|
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 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]
|
#[async_trait]
|
||||||
pub trait StatsRepository: Send + Sync {
|
pub trait StatsRepository: Send + Sync {
|
||||||
async fn roaster_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
async fn roaster_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
||||||
|
|
|
||||||
112
src/infrastructure/image_processing.rs
Normal file
112
src/infrastructure/image_processing.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,6 @@ pub mod backup;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod foursquare;
|
pub mod foursquare;
|
||||||
|
pub mod image_processing;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod webauthn;
|
pub mod webauthn;
|
||||||
|
|
|
||||||
139
src/infrastructure/repositories/images.rs
Normal file
139
src/infrastructure/repositories/images.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod coffee;
|
pub mod coffee;
|
||||||
|
pub mod images;
|
||||||
pub(crate) mod macros;
|
pub(crate) mod macros;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ pub struct BagDetailTemplate {
|
||||||
pub bag: BagDetailView,
|
pub bag: BagDetailView,
|
||||||
pub roaster_slug: String,
|
pub roaster_slug: String,
|
||||||
pub roast_slug: String,
|
pub roast_slug: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -233,6 +234,7 @@ pub struct BrewDetailTemplate {
|
||||||
pub brew: BrewDetailView,
|
pub brew: BrewDetailView,
|
||||||
pub roaster_slug: String,
|
pub roaster_slug: String,
|
||||||
pub roast_slug: String,
|
pub roast_slug: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -246,6 +248,7 @@ pub struct CupDetailTemplate {
|
||||||
pub roaster_slug: String,
|
pub roaster_slug: String,
|
||||||
pub roast_slug: String,
|
pub roast_slug: String,
|
||||||
pub cafe_slug: String,
|
pub cafe_slug: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -257,6 +260,7 @@ pub struct RoastDetailTemplate {
|
||||||
pub base_url: &'static str,
|
pub base_url: &'static str,
|
||||||
pub roast: RoastDetailView,
|
pub roast: RoastDetailView,
|
||||||
pub roaster_slug: String,
|
pub roaster_slug: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -267,6 +271,7 @@ pub struct RoasterDetailTemplate {
|
||||||
pub version_info: &'static crate::VersionInfo,
|
pub version_info: &'static crate::VersionInfo,
|
||||||
pub base_url: &'static str,
|
pub base_url: &'static str,
|
||||||
pub roaster: RoasterDetailView,
|
pub roaster: RoasterDetailView,
|
||||||
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -277,6 +282,7 @@ pub struct CafeDetailTemplate {
|
||||||
pub version_info: &'static crate::VersionInfo,
|
pub version_info: &'static crate::VersionInfo,
|
||||||
pub base_url: &'static str,
|
pub base_url: &'static str,
|
||||||
pub cafe: CafeDetailView,
|
pub cafe: CafeDetailView,
|
||||||
|
pub image_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -287,6 +293,16 @@ pub struct GearDetailTemplate {
|
||||||
pub version_info: &'static crate::VersionInfo,
|
pub version_info: &'static crate::VersionInfo,
|
||||||
pub base_url: &'static str,
|
pub base_url: &'static str,
|
||||||
pub gear: GearDetailView,
|
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> {
|
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
||||||
|
|
|
||||||
107
static/js/components/image-upload.js
Normal file
107
static/js/components/image-upload.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
<script defer src="/static/js/components/chip-scroll.js"></script>
|
<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/world-map.js"></script>
|
||||||
<script defer src="/static/js/components/donut-chart.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 %}
|
{% block head %}{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
const showToast = (msg, ms = 3000) => {
|
const showToast = (msg, ms = 3000) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
|
{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %}
|
||||||
{% block description %}
|
{% block description %}
|
||||||
{{ bag.roast_name }}
|
{{ bag.roast_name }}
|
||||||
|
|
@ -16,8 +17,18 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ bag.roast_name }}</h1>
|
{% 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"
|
||||||
|
>
|
||||||
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=bags"
|
href="/data?type=bags"
|
||||||
|
|
@ -27,7 +38,11 @@
|
||||||
· {{ bag.created_date }}
|
· {{ bag.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{% if image_url.is_some() %}
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Coffee + map ── #}
|
{# ── Coffee + map ── #}
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
||||||
{% block description %}
|
{% block description %}
|
||||||
{{ brew.roast_name }}
|
{{ brew.roast_name }}
|
||||||
|
|
@ -18,8 +19,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=brews"
|
href="/data?type=brews"
|
||||||
|
|
@ -29,7 +32,11 @@
|
||||||
· {{ brew.created_date }}
|
· {{ brew.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{% if image_url.is_some() %}
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Coffee + map ── #}
|
{# ── Coffee + map ── #}
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
|
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
|
||||||
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}
|
{% block og_description %}
|
||||||
|
|
@ -12,8 +13,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=cafes"
|
href="/data?type=cafes"
|
||||||
|
|
@ -23,7 +26,9 @@
|
||||||
· {{ cafe.created_date }}
|
· {{ cafe.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% 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 title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
|
||||||
{% block description %}
|
{% block description %}
|
||||||
{{ cup.roast_name }}
|
{{ cup.roast_name }}
|
||||||
|
|
@ -16,8 +17,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=cups"
|
href="/data?type=cups"
|
||||||
|
|
@ -27,7 +30,11 @@
|
||||||
· {{ cup.created_date }}
|
· {{ cup.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{% if image_url.is_some() %}
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── Coffee + map ── #}
|
{# ── Coffee + map ── #}
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
|
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
|
||||||
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
|
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}
|
{% block og_description %}
|
||||||
|
|
@ -13,8 +14,12 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ gear.make }} {{ gear.model }}</h1>
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=gear"
|
href="/data?type=gear"
|
||||||
|
|
@ -24,7 +29,9 @@
|
||||||
· {{ gear.created_date }}
|
· {{ gear.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
|
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
|
{% block title %}Brewlog · {{ roast.name }}{% endblock %}
|
||||||
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ roast.name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}
|
{% block og_description %}
|
||||||
|
|
@ -12,8 +13,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=roasts"
|
href="/data?type=roasts"
|
||||||
|
|
@ -23,7 +26,9 @@
|
||||||
· {{ roast.created_date }}
|
· {{ roast.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<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, "") }}
|
{{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes, roaster_slug, "") }}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/detail_cards.html" as detail %}
|
{% import "partials/detail_cards.html" as detail %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
|
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
|
||||||
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
|
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
|
||||||
{% block og_description %}
|
{% block og_description %}
|
||||||
|
|
@ -13,8 +14,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="flex items-start justify-between gap-4">
|
<header class="flex items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
|
{{ 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">
|
<p class="text-sm text-text-secondary">
|
||||||
<a
|
<a
|
||||||
href="/data?type=roasters"
|
href="/data?type=roasters"
|
||||||
|
|
@ -24,7 +27,9 @@
|
||||||
· {{ roaster.created_date }}
|
· {{ roaster.created_date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{{ img::lightbox_script() }}
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<div class="rounded-lg border bg-surface p-5">
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
|
|
||||||
127
templates/partials/image_section.html
Normal file
127
templates/partials/image_section.html
Normal 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 %}
|
||||||
52
templates/partials/image_upload.html
Normal file
52
templates/partials/image_upload.html
Normal 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>
|
||||||
Loading…
Reference in a new issue