diff --git a/src/application/routes/api/coffee/brews.rs b/src/application/routes/api/coffee/brews.rs index 37b4e8b..c227cc3 100644 --- a/src/application/routes/api/coffee/brews.rs +++ b/src/application/routes/api/coffee/brews.rs @@ -8,6 +8,7 @@ use tracing::info; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::save_deferred_image; use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler}; use crate::application::routes::support::{ FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, @@ -193,6 +194,8 @@ pub(crate) struct NewBrewSubmission { brew_time: Option, #[serde(default)] created_at: Option>, + #[serde(default)] + image: Option, } impl NewBrewSubmission { @@ -243,6 +246,7 @@ pub(crate) async fn create_brew( ) -> Result { let (request, search) = query.into_request_and_search::(); let (submission, source) = payload.into_parts(); + let image_data_url = submission.image.clone(); let new_brew = submission.into_new_brew().map_err(ApiError::from)?; let enriched = state @@ -254,6 +258,14 @@ pub(crate) async fn create_brew( info!(brew_id = %enriched.brew.id, "brew created"); state.stats_invalidator.invalidate(); + save_deferred_image( + &state, + "brew", + i64::from(enriched.brew.id), + image_data_url.as_deref(), + ) + .await; + let detail_url = format!("/brews/{}", enriched.brew.id); if is_datastar_request(&headers) { @@ -317,7 +329,8 @@ define_delete_handler!( brew_repo, render_brew_list_fragment, "type=brews", - "/data?type=brews" + "/data?type=brews", + image_type: "brew" ); async fn render_brew_list_fragment( diff --git a/src/application/routes/api/coffee/cafes.rs b/src/application/routes/api/coffee/cafes.rs index 4fde57c..c6c3f89 100644 --- a/src/application/routes/api/coffee/cafes.rs +++ b/src/application/routes/api/coffee/cafes.rs @@ -6,6 +6,7 @@ use serde::Deserialize; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::save_deferred_image; use crate::application::routes::api::macros::{ define_delete_handler, define_get_handler, define_list_fragment_renderer, }; @@ -56,16 +57,47 @@ pub(crate) async fn list_cafes(State(state): State) -> Result, + #[serde(default)] + created_at: Option>, + #[serde(default)] + image: Option, +} + +impl NewCafeSubmission { + fn into_parts(self) -> (NewCafe, Option) { + let cafe = NewCafe { + name: self.name, + city: self.city, + country: self.country, + latitude: self.latitude, + longitude: self.longitude, + website: self.website, + created_at: self.created_at, + }; + (cafe, self.image) + } +} + #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_cafe( State(state): State, _auth_user: AuthenticatedUser, headers: HeaderMap, Query(query): Query, - payload: FlexiblePayload, + payload: FlexiblePayload, ) -> Result { let (request, search) = query.into_request_and_search::(); - let (new_cafe, source) = payload.into_parts(); + let (submission, source) = payload.into_parts(); + let (new_cafe, image_data_url) = submission.into_parts(); let new_cafe = new_cafe.normalize(); let cafe = state .cafe_service @@ -76,6 +108,14 @@ pub(crate) async fn create_cafe( info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created"); state.stats_invalidator.invalidate(); + save_deferred_image( + &state, + "cafe", + i64::from(cafe.id), + image_data_url.as_deref(), + ) + .await; + let detail_url = format!("/cafes/{}", cafe.slug); if is_datastar_request(&headers) { @@ -136,7 +176,8 @@ define_delete_handler!( cafe_repo, render_cafe_list_fragment, "type=cafes", - "/data?type=cafes" + "/data?type=cafes", + image_type: "cafe" ); define_list_fragment_renderer!( diff --git a/src/application/routes/api/coffee/checkin.rs b/src/application/routes/api/coffee/checkin.rs index 2a8779e..81e1926 100644 --- a/src/application/routes/api/coffee/checkin.rs +++ b/src/application/routes/api/coffee/checkin.rs @@ -6,6 +6,7 @@ use serde::Deserialize; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::save_deferred_image; use crate::application::routes::support::{ FlexiblePayload, PayloadSource, is_datastar_request, render_redirect_script, }; @@ -31,6 +32,10 @@ pub(crate) struct CheckInSubmission { #[serde(default)] cafe_website: Option, roast_id: String, + #[serde(default)] + cafe_image: Option, + #[serde(default)] + cup_image: Option, } #[tracing::instrument(skip(state, _auth_user, headers, payload))] @@ -76,6 +81,15 @@ pub(crate) async fn submit_checkin( .create(new_cafe) .await .map_err(AppError::from)?; + + save_deferred_image( + &state, + "cafe", + i64::from(cafe.id), + submission.cafe_image.as_deref(), + ) + .await; + cafe.id }; @@ -91,6 +105,14 @@ pub(crate) async fn submit_checkin( .await .map_err(AppError::from)?; + save_deferred_image( + &state, + "cup", + i64::from(cup.id), + submission.cup_image.as_deref(), + ) + .await; + let detail_url = format!("/cups/{}", cup.id); if is_datastar_request(&headers) { diff --git a/src/application/routes/api/coffee/cups.rs b/src/application/routes/api/coffee/cups.rs index 5ad38a3..a6cce88 100644 --- a/src/application/routes/api/coffee/cups.rs +++ b/src/application/routes/api/coffee/cups.rs @@ -97,7 +97,8 @@ define_delete_handler!( cup_repo, render_cup_list_fragment, "type=cups", - "/data?type=cups" + "/data?type=cups", + image_type: "cup" ); define_list_fragment_renderer!( diff --git a/src/application/routes/api/coffee/gear.rs b/src/application/routes/api/coffee/gear.rs index 0ffebb4..74f8509 100644 --- a/src/application/routes/api/coffee/gear.rs +++ b/src/application/routes/api/coffee/gear.rs @@ -10,6 +10,7 @@ use tracing::info; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::save_deferred_image; use crate::application::routes::api::macros::{ define_delete_handler, define_get_handler, define_list_fragment_renderer, }; @@ -58,6 +59,7 @@ pub(crate) async fn create_gear( ) -> Result { let (request, search) = query.into_request_and_search::(); let (submission, source) = payload.into_parts(); + let image_data_url = submission.image.clone(); let new_gear = submission.into_new_gear().map_err(ApiError::from)?; let gear = state @@ -69,6 +71,14 @@ pub(crate) async fn create_gear( info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created"); state.stats_invalidator.invalidate(); + save_deferred_image( + &state, + "gear", + i64::from(gear.id), + image_data_url.as_deref(), + ) + .await; + let detail_url = format!("/gear/{}", gear.id); if is_datastar_request(&headers) { @@ -151,7 +161,8 @@ define_delete_handler!( gear_repo, render_gear_list_fragment, "type=gear", - "/data?type=gear" + "/data?type=gear", + image_type: "gear" ); #[derive(Debug, Deserialize)] @@ -166,6 +177,8 @@ pub(crate) struct NewGearSubmission { model: String, #[serde(default)] created_at: Option>, + #[serde(default)] + image: Option, } impl NewGearSubmission { diff --git a/src/application/routes/api/coffee/roasters.rs b/src/application/routes/api/coffee/roasters.rs index a6ce5d9..61f3b14 100644 --- a/src/application/routes/api/coffee/roasters.rs +++ b/src/application/routes/api/coffee/roasters.rs @@ -2,9 +2,11 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; +use serde::Deserialize; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::save_deferred_image; use crate::application::routes::api::macros::{ define_delete_handler, define_get_handler, define_list_fragment_renderer, }; @@ -57,16 +59,44 @@ pub(crate) async fn list_roasters( Ok(Json(roasters)) } +#[derive(Debug, Deserialize)] +pub(crate) struct NewRoasterSubmission { + name: String, + country: String, + #[serde(default)] + city: Option, + #[serde(default)] + homepage: Option, + #[serde(default)] + created_at: Option>, + #[serde(default)] + image: Option, +} + +impl NewRoasterSubmission { + fn into_parts(self) -> (NewRoaster, Option) { + let roaster = NewRoaster { + name: self.name, + country: self.country, + city: self.city, + homepage: self.homepage, + created_at: self.created_at, + }; + (roaster, self.image) + } +} + #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_roaster( State(state): State, _auth_user: AuthenticatedUser, headers: HeaderMap, Query(query): Query, - payload: FlexiblePayload, + payload: FlexiblePayload, ) -> Result { let (request, search) = query.into_request_and_search::(); - let (new_roaster, source) = payload.into_parts(); + let (submission, source) = payload.into_parts(); + let (new_roaster, image_data_url) = submission.into_parts(); let new_roaster = new_roaster.normalize(); let roaster = state .roaster_service @@ -77,6 +107,14 @@ pub(crate) async fn create_roaster( info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created"); state.stats_invalidator.invalidate(); + save_deferred_image( + &state, + "roaster", + i64::from(roaster.id), + image_data_url.as_deref(), + ) + .await; + let detail_url = format!("/roasters/{}", roaster.slug); if is_datastar_request(&headers) { @@ -136,7 +174,8 @@ define_delete_handler!( roaster_repo, render_roaster_list_fragment, "type=roasters", - "/data?type=roasters" + "/data?type=roasters", + image_type: "roaster" ); #[tracing::instrument(skip(state, auth_user, headers, payload))] diff --git a/src/application/routes/api/coffee/roasts.rs b/src/application/routes/api/coffee/roasts.rs index 0c66f47..bd40822 100644 --- a/src/application/routes/api/coffee/roasts.rs +++ b/src/application/routes/api/coffee/roasts.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::save_deferred_image; use crate::application::routes::api::macros::{ define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer, }; @@ -57,6 +58,7 @@ pub(crate) async fn create_roast( ) -> Result { let (request, search) = query.into_request_and_search::(); let (submission, source) = payload.into_parts(); + let image_data_url = submission.image.clone(); let new_roast = submission.into_new_roast().map_err(ApiError::from)?; state @@ -74,6 +76,14 @@ pub(crate) async fn create_roast( info!(roast_id = %roast.id, name = %roast.name, "roast created"); state.stats_invalidator.invalidate(); + save_deferred_image( + &state, + "roast", + i64::from(roast.id), + image_data_url.as_deref(), + ) + .await; + let roaster = state .roaster_repo .get(roast.roaster_id) @@ -162,7 +172,8 @@ define_delete_handler!( roast_repo, render_roast_list_fragment, "type=roasts", - "/data?type=roasts" + "/data?type=roasts", + image_type: "roast" ); #[tracing::instrument(skip(state, _auth_user))] @@ -219,6 +230,8 @@ pub(crate) struct NewRoastSubmission { process: String, #[serde(default)] created_at: Option>, + #[serde(default)] + image: Option, } impl NewRoastSubmission { diff --git a/src/application/routes/api/coffee/scan.rs b/src/application/routes/api/coffee/scan.rs index 52e7ffd..ce1f46b 100644 --- a/src/application/routes/api/coffee/scan.rs +++ b/src/application/routes/api/coffee/scan.rs @@ -7,6 +7,7 @@ use tracing::info; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::api::images::{resolve_image_url, save_deferred_image}; use crate::application::routes::api::roasts::TastingNotesInput; use crate::application::routes::support::{FlexiblePayload, is_datastar_request}; use crate::application::state::AppState; @@ -184,6 +185,8 @@ pub(crate) struct BagScanSubmission { bag_amount: Option, #[serde(default)] matched_roast_id: Option, + #[serde(default)] + scan_image: Option, } #[derive(Debug, Serialize)] @@ -258,13 +261,22 @@ pub(crate) async fn submit_scan( // If the roast already exists (matched during extraction), skip creation if let Some(roast_id) = parse_matched_roast_id(submission.matched_roast_id.as_ref()) { - return submit_existing_roast(&state, &headers, roast_id, &submission).await; + let scan_image = submission.scan_image.take(); + return submit_existing_roast(&state, &headers, roast_id, &submission, scan_image).await; } // Check for raw input (image/prompt triggers extraction first) let has_raw_input = submission.image.as_deref().is_some_and(|s| !s.is_empty()) || submission.prompt.as_deref().is_some_and(|s| !s.is_empty()); + // Preserve scan image: either from the dedicated field (two-step Datastar flow) + // or from the raw image input (one-step API flow, before extraction consumes it) + let scan_image = submission + .scan_image + .take() + .or_else(|| submission.image.clone()) + .filter(|s| !s.is_empty()); + if has_raw_input { let usage = extract_into_submission(&state, &mut submission).await?; crate::application::routes::support::record_ai_usage( @@ -351,6 +363,14 @@ pub(crate) async fn submit_scan( info!(roaster_id = %roaster.id, roast_id = %roast.id, roast_name = %roast.name, "scan created roast"); + save_deferred_image( + &state, + "roast", + roast.id.into_inner(), + scan_image.as_deref(), + ) + .await; + // Optionally create a bag let wants_bag = submission .open_bag @@ -401,6 +421,7 @@ async fn submit_existing_roast( headers: &HeaderMap, roast_id: RoastId, submission: &BagScanSubmission, + scan_image: Option, ) -> Result { let roast_with_roaster = state .roast_repo @@ -411,6 +432,14 @@ async fn submit_existing_roast( let roast = &roast_with_roaster.roast; let roaster_slug = &roast_with_roaster.roaster_slug; + // Save scan image if roast doesn't have one yet + if resolve_image_url(state, "roast", roast.id.into_inner()) + .await + .is_none() + { + save_deferred_image(state, "roast", roast.id.into_inner(), scan_image.as_deref()).await; + } + let wants_bag = submission .open_bag .as_deref() diff --git a/templates/pages/add.html b/templates/pages/add.html index 98be299..b30697c 100644 --- a/templates/pages/add.html +++ b/templates/pages/add.html @@ -1,4 +1,5 @@ {% extends "base.html" %} {% import "partials/icons.html" as icons %} +{% import "partials/image_section.html" as img %} {% import "partials/location_search.html" as location %} {% block title %}Brewlog ยท Add{% endblock %} @@ -85,7 +86,7 @@
@@ -188,6 +189,7 @@ />
+ {{ img::deferred_upload("roaster-image", "Add image (optional)") }}
+ {{ img::deferred_upload("brew-image", "Add image (optional)") }}
+ {{ img::deferred_upload("gear-image", "Add image (optional)") }}
+ {{ img::deferred_upload("cafe-image", "Add image (optional)") }}
+ + + + + + Add cafe photo (optional) +
@@ -344,7 +365,7 @@
@@ -363,6 +384,32 @@ data-attr:value="$_cafeWebsite" /> + + + + + + + Add cup photo (optional) +