feat: add image upload to create and check-in forms
Add deferred image upload support to entity creation flows: - Roaster, roast, gear, cafe forms save images on create via save_deferred_image helper - Brew form accepts optional image upload - Check-in form accepts optional cup photo - Scan flow preserves captured image for new roasts and skips overwriting existing roast images - Homepage updated with image-upload component registration - Delete handlers for brews and cups clean up associated images
This commit is contained in:
parent
4b3f03f5d3
commit
c3781de30c
12 changed files with 241 additions and 16 deletions
|
|
@ -8,6 +8,7 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
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::api::macros::{define_delete_handler, define_enriched_get_handler};
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||||
|
|
@ -193,6 +194,8 @@ pub(crate) struct NewBrewSubmission {
|
||||||
brew_time: Option<i32>,
|
brew_time: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
created_at: Option<DateTime<Utc>>,
|
created_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewBrewSubmission {
|
impl NewBrewSubmission {
|
||||||
|
|
@ -243,6 +246,7 @@ pub(crate) async fn create_brew(
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let (request, search) = query.into_request_and_search::<BrewSortKey>();
|
let (request, search) = query.into_request_and_search::<BrewSortKey>();
|
||||||
let (submission, source) = payload.into_parts();
|
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 new_brew = submission.into_new_brew().map_err(ApiError::from)?;
|
||||||
|
|
||||||
let enriched = state
|
let enriched = state
|
||||||
|
|
@ -254,6 +258,14 @@ pub(crate) async fn create_brew(
|
||||||
info!(brew_id = %enriched.brew.id, "brew created");
|
info!(brew_id = %enriched.brew.id, "brew created");
|
||||||
state.stats_invalidator.invalidate();
|
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);
|
let detail_url = format!("/brews/{}", enriched.brew.id);
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
@ -317,7 +329,8 @@ define_delete_handler!(
|
||||||
brew_repo,
|
brew_repo,
|
||||||
render_brew_list_fragment,
|
render_brew_list_fragment,
|
||||||
"type=brews",
|
"type=brews",
|
||||||
"/data?type=brews"
|
"/data?type=brews",
|
||||||
|
image_type: "brew"
|
||||||
);
|
);
|
||||||
|
|
||||||
async fn render_brew_list_fragment(
|
async fn render_brew_list_fragment(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use serde::Deserialize;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
|
@ -56,16 +57,47 @@ pub(crate) async fn list_cafes(State(state): State<AppState>) -> Result<Json<Vec
|
||||||
Ok(Json(cafes))
|
Ok(Json(cafes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct NewCafeSubmission {
|
||||||
|
name: String,
|
||||||
|
city: String,
|
||||||
|
country: String,
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
website: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewCafeSubmission {
|
||||||
|
fn into_parts(self) -> (NewCafe, Option<String>) {
|
||||||
|
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))]
|
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||||
pub(crate) async fn create_cafe(
|
pub(crate) async fn create_cafe(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(query): Query<ListQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
payload: FlexiblePayload<NewCafe>,
|
payload: FlexiblePayload<NewCafeSubmission>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let (request, search) = query.into_request_and_search::<CafeSortKey>();
|
let (request, search) = query.into_request_and_search::<CafeSortKey>();
|
||||||
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 new_cafe = new_cafe.normalize();
|
||||||
let cafe = state
|
let cafe = state
|
||||||
.cafe_service
|
.cafe_service
|
||||||
|
|
@ -76,6 +108,14 @@ pub(crate) async fn create_cafe(
|
||||||
info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created");
|
info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created");
|
||||||
state.stats_invalidator.invalidate();
|
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);
|
let detail_url = format!("/cafes/{}", cafe.slug);
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
@ -136,7 +176,8 @@ define_delete_handler!(
|
||||||
cafe_repo,
|
cafe_repo,
|
||||||
render_cafe_list_fragment,
|
render_cafe_list_fragment,
|
||||||
"type=cafes",
|
"type=cafes",
|
||||||
"/data?type=cafes"
|
"/data?type=cafes",
|
||||||
|
image_type: "cafe"
|
||||||
);
|
);
|
||||||
|
|
||||||
define_list_fragment_renderer!(
|
define_list_fragment_renderer!(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use serde::Deserialize;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, PayloadSource, is_datastar_request, render_redirect_script,
|
FlexiblePayload, PayloadSource, is_datastar_request, render_redirect_script,
|
||||||
};
|
};
|
||||||
|
|
@ -31,6 +32,10 @@ pub(crate) struct CheckInSubmission {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
cafe_website: Option<String>,
|
cafe_website: Option<String>,
|
||||||
roast_id: String,
|
roast_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
cafe_image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cup_image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
|
|
@ -76,6 +81,15 @@ pub(crate) async fn submit_checkin(
|
||||||
.create(new_cafe)
|
.create(new_cafe)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
save_deferred_image(
|
||||||
|
&state,
|
||||||
|
"cafe",
|
||||||
|
i64::from(cafe.id),
|
||||||
|
submission.cafe_image.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
cafe.id
|
cafe.id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -91,6 +105,14 @@ pub(crate) async fn submit_checkin(
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.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);
|
let detail_url = format!("/cups/{}", cup.id);
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,8 @@ define_delete_handler!(
|
||||||
cup_repo,
|
cup_repo,
|
||||||
render_cup_list_fragment,
|
render_cup_list_fragment,
|
||||||
"type=cups",
|
"type=cups",
|
||||||
"/data?type=cups"
|
"/data?type=cups",
|
||||||
|
image_type: "cup"
|
||||||
);
|
);
|
||||||
|
|
||||||
define_list_fragment_renderer!(
|
define_list_fragment_renderer!(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
|
@ -58,6 +59,7 @@ pub(crate) async fn create_gear(
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
||||||
let (submission, source) = payload.into_parts();
|
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 new_gear = submission.into_new_gear().map_err(ApiError::from)?;
|
||||||
|
|
||||||
let gear = state
|
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");
|
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created");
|
||||||
state.stats_invalidator.invalidate();
|
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);
|
let detail_url = format!("/gear/{}", gear.id);
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
@ -151,7 +161,8 @@ define_delete_handler!(
|
||||||
gear_repo,
|
gear_repo,
|
||||||
render_gear_list_fragment,
|
render_gear_list_fragment,
|
||||||
"type=gear",
|
"type=gear",
|
||||||
"/data?type=gear"
|
"/data?type=gear",
|
||||||
|
image_type: "gear"
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -166,6 +177,8 @@ pub(crate) struct NewGearSubmission {
|
||||||
model: String,
|
model: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
created_at: Option<DateTime<Utc>>,
|
created_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewGearSubmission {
|
impl NewGearSubmission {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ use axum::Json;
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Redirect, Response};
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
|
@ -57,16 +59,44 @@ pub(crate) async fn list_roasters(
|
||||||
Ok(Json(roasters))
|
Ok(Json(roasters))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct NewRoasterSubmission {
|
||||||
|
name: String,
|
||||||
|
country: String,
|
||||||
|
#[serde(default)]
|
||||||
|
city: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
homepage: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewRoasterSubmission {
|
||||||
|
fn into_parts(self) -> (NewRoaster, Option<String>) {
|
||||||
|
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))]
|
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||||
pub(crate) async fn create_roaster(
|
pub(crate) async fn create_roaster(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(query): Query<ListQuery>,
|
Query(query): Query<ListQuery>,
|
||||||
payload: FlexiblePayload<NewRoaster>,
|
payload: FlexiblePayload<NewRoasterSubmission>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
|
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
|
||||||
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 new_roaster = new_roaster.normalize();
|
||||||
let roaster = state
|
let roaster = state
|
||||||
.roaster_service
|
.roaster_service
|
||||||
|
|
@ -77,6 +107,14 @@ pub(crate) async fn create_roaster(
|
||||||
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
|
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
|
||||||
state.stats_invalidator.invalidate();
|
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);
|
let detail_url = format!("/roasters/{}", roaster.slug);
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
@ -136,7 +174,8 @@ define_delete_handler!(
|
||||||
roaster_repo,
|
roaster_repo,
|
||||||
render_roaster_list_fragment,
|
render_roaster_list_fragment,
|
||||||
"type=roasters",
|
"type=roasters",
|
||||||
"/data?type=roasters"
|
"/data?type=roasters",
|
||||||
|
image_type: "roaster"
|
||||||
);
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, auth_user, headers, payload))]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use serde::Deserialize;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
|
@ -57,6 +58,7 @@ pub(crate) async fn create_roast(
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let (request, search) = query.into_request_and_search::<RoastSortKey>();
|
let (request, search) = query.into_request_and_search::<RoastSortKey>();
|
||||||
let (submission, source) = payload.into_parts();
|
let (submission, source) = payload.into_parts();
|
||||||
|
let image_data_url = submission.image.clone();
|
||||||
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
|
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
|
||||||
|
|
||||||
state
|
state
|
||||||
|
|
@ -74,6 +76,14 @@ pub(crate) async fn create_roast(
|
||||||
info!(roast_id = %roast.id, name = %roast.name, "roast created");
|
info!(roast_id = %roast.id, name = %roast.name, "roast created");
|
||||||
state.stats_invalidator.invalidate();
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
|
save_deferred_image(
|
||||||
|
&state,
|
||||||
|
"roast",
|
||||||
|
i64::from(roast.id),
|
||||||
|
image_data_url.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let roaster = state
|
let roaster = state
|
||||||
.roaster_repo
|
.roaster_repo
|
||||||
.get(roast.roaster_id)
|
.get(roast.roaster_id)
|
||||||
|
|
@ -162,7 +172,8 @@ define_delete_handler!(
|
||||||
roast_repo,
|
roast_repo,
|
||||||
render_roast_list_fragment,
|
render_roast_list_fragment,
|
||||||
"type=roasts",
|
"type=roasts",
|
||||||
"/data?type=roasts"
|
"/data?type=roasts",
|
||||||
|
image_type: "roast"
|
||||||
);
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
|
@ -219,6 +230,8 @@ pub(crate) struct NewRoastSubmission {
|
||||||
process: String,
|
process: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
created_at: Option<DateTime<Utc>>,
|
created_at: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewRoastSubmission {
|
impl NewRoastSubmission {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use tracing::info;
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::errors::{ApiError, AppError};
|
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::api::roasts::TastingNotesInput;
|
||||||
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
|
|
@ -184,6 +185,8 @@ pub(crate) struct BagScanSubmission {
|
||||||
bag_amount: Option<f64>,
|
bag_amount: Option<f64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
matched_roast_id: Option<String>,
|
matched_roast_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
scan_image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -258,13 +261,22 @@ pub(crate) async fn submit_scan(
|
||||||
|
|
||||||
// If the roast already exists (matched during extraction), skip creation
|
// 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()) {
|
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)
|
// Check for raw input (image/prompt triggers extraction first)
|
||||||
let has_raw_input = submission.image.as_deref().is_some_and(|s| !s.is_empty())
|
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());
|
|| 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 {
|
if has_raw_input {
|
||||||
let usage = extract_into_submission(&state, &mut submission).await?;
|
let usage = extract_into_submission(&state, &mut submission).await?;
|
||||||
crate::application::routes::support::record_ai_usage(
|
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");
|
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
|
// Optionally create a bag
|
||||||
let wants_bag = submission
|
let wants_bag = submission
|
||||||
.open_bag
|
.open_bag
|
||||||
|
|
@ -401,6 +421,7 @@ async fn submit_existing_roast(
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
roast_id: RoastId,
|
roast_id: RoastId,
|
||||||
submission: &BagScanSubmission,
|
submission: &BagScanSubmission,
|
||||||
|
scan_image: Option<String>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let roast_with_roaster = state
|
let roast_with_roaster = state
|
||||||
.roast_repo
|
.roast_repo
|
||||||
|
|
@ -411,6 +432,14 @@ async fn submit_existing_roast(
|
||||||
let roast = &roast_with_roaster.roast;
|
let roast = &roast_with_roaster.roast;
|
||||||
let roaster_slug = &roast_with_roaster.roaster_slug;
|
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
|
let wants_bag = submission
|
||||||
.open_bag
|
.open_bag
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/image_section.html" as img %}
|
||||||
{% import "partials/location_search.html" as location %}
|
{% import "partials/location_search.html" as location %}
|
||||||
{% block title %}Brewlog · Add{% endblock %}
|
{% block title %}Brewlog · Add{% endblock %}
|
||||||
|
|
||||||
|
|
@ -85,7 +86,7 @@
|
||||||
<form
|
<form
|
||||||
id="roaster-extract-form"
|
id="roaster-extract-form"
|
||||||
data-on:submit="$_roasterExtracting = true; $_roasterExtractError = ''; @post('/api/v1/extract-roaster', {contentType: 'form'})"
|
data-on:submit="$_roasterExtracting = true; $_roasterExtractError = ''; @post('/api/v1/extract-roaster', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_roasterExtracting) return; if (evt.detail.type === 'finished') { $_roasterExtracting = false; document.getElementById('roaster-extract-form').reset() } else if (evt.detail.type === 'error') { $_roasterExtracting = false; $_roasterExtractError = 'Extraction failed. Please try again.' }"
|
data-on:datastar-fetch="if (!$_roasterExtracting) return; if (evt.detail.type === 'finished') { $_roasterExtracting = false; const img = document.getElementById('roaster-extract-image').value; document.getElementById('roaster-extract-form').reset(); if (img) { document.getElementById('roaster-image').value = img; const el = document.querySelector('image-upload[target-input=roaster-image]'); if (el) el._showPreview(img) } } else if (evt.detail.type === 'error') { $_roasterExtracting = false; $_roasterExtractError = 'Extraction failed. Please try again.' }"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
></form>
|
></form>
|
||||||
<div data-show="!$_roasterExtracting" class="flex items-center gap-2">
|
<div data-show="!$_roasterExtracting" class="flex items-center gap-2">
|
||||||
|
|
@ -188,6 +189,7 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{{ img::deferred_upload("roaster-image", "Add image (optional)") }}
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -240,7 +242,7 @@
|
||||||
<form
|
<form
|
||||||
id="roast-extract-form"
|
id="roast-extract-form"
|
||||||
data-on:submit="$_roastExtracting = true; $_roastExtractError = ''; @post('/api/v1/extract-roast', {contentType: 'form'})"
|
data-on:submit="$_roastExtracting = true; $_roastExtractError = ''; @post('/api/v1/extract-roast', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_roastExtracting) return; if (evt.detail.type === 'finished') { $_roastExtracting = false; document.getElementById('roast-extract-form').reset() } else if (evt.detail.type === 'error') { $_roastExtracting = false; $_roastExtractError = 'Extraction failed. Please try again.' }"
|
data-on:datastar-fetch="if (!$_roastExtracting) return; if (evt.detail.type === 'finished') { $_roastExtracting = false; const img = document.getElementById('roast-extract-image').value; document.getElementById('roast-extract-form').reset(); if (img) { document.getElementById('roast-image').value = img; const el = document.querySelector('image-upload[target-input=roast-image]'); if (el) el._showPreview(img) } } else if (evt.detail.type === 'error') { $_roastExtracting = false; $_roastExtractError = 'Extraction failed. Please try again.' }"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
></form>
|
></form>
|
||||||
<div data-show="!$_roastExtracting" class="flex items-center gap-2">
|
<div data-show="!$_roastExtracting" class="flex items-center gap-2">
|
||||||
|
|
@ -400,6 +402,7 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{{ img::deferred_upload("roast-image", "Add image (optional)") }}
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -943,6 +946,7 @@
|
||||||
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
|
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{{ img::deferred_upload("brew-image", "Add image (optional)") }}
|
||||||
<div class="sticky-submit flex items-center justify-end">
|
<div class="sticky-submit flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -1018,6 +1022,7 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{{ img::deferred_upload("gear-image", "Add image (optional)") }}
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -1182,6 +1187,7 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{{ img::deferred_upload("cafe-image", "Add image (optional)") }}
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -210,11 +210,32 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="hidden" name="cafe_image" id="checkin-cafe-image" />
|
||||||
|
<image-upload
|
||||||
|
mode="deferred"
|
||||||
|
target-input="checkin-cafe-image"
|
||||||
|
class="mt-4 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">Add cafe photo (optional)</span>
|
||||||
|
</image-upload>
|
||||||
<div class="mt-4 flex items-center justify-end gap-2">
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text transition hover:bg-surface-alt"
|
||||||
data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''"
|
data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; document.getElementById('checkin-cafe-image').value = ''"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -344,7 +365,7 @@
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
|
data-on:submit="$_submitting = true; $_error = ''; document.getElementById('checkin-cafe-image-submit').value = document.getElementById('checkin-cafe-image').value || ''; @post('/api/v1/check-in', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'finished') { sessionStorage.setItem('toast', 'Checked in') } else if (evt.detail.type === 'error') { $_submitting = false; $_error = 'Check-in failed. Please try again.' }"
|
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'finished') { sessionStorage.setItem('toast', 'Checked in') } else if (evt.detail.type === 'error') { $_submitting = false; $_error = 'Check-in failed. Please try again.' }"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
||||||
|
|
@ -363,6 +384,32 @@
|
||||||
data-attr:value="$_cafeWebsite"
|
data-attr:value="$_cafeWebsite"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="roast_id" data-attr:value="$_roastId" />
|
<input type="hidden" name="roast_id" data-attr:value="$_roastId" />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="cafe_image"
|
||||||
|
id="checkin-cafe-image-submit"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="cup_image" id="checkin-cup-image" />
|
||||||
|
<image-upload
|
||||||
|
mode="deferred"
|
||||||
|
target-input="checkin-cup-image"
|
||||||
|
class="mb-4 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">Add cup photo (optional)</span>
|
||||||
|
</image-upload>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<form
|
<form
|
||||||
id="scan-extract-form"
|
id="scan-extract-form"
|
||||||
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-image-save').value = document.getElementById('scan-image').value; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
></form>
|
></form>
|
||||||
<brew-photo-capture
|
<brew-photo-capture
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<!-- Hidden inputs for submission (always present, bound to signals) -->
|
<!-- Hidden inputs for submission (always present, bound to signals) -->
|
||||||
|
<input type="hidden" name="scan_image" id="scan-image-save" />
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="matched_roast_id"
|
name="matched_roast_id"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue