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:
Jon Seager 2026-02-10 16:35:29 +00:00
parent 4b3f03f5d3
commit c3781de30c
No known key found for this signature in database
12 changed files with 241 additions and 16 deletions

View file

@ -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(

View file

@ -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!(

View file

@ -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) {

View file

@ -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!(

View file

@ -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 {

View file

@ -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))]

View file

@ -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 {

View file

@ -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()

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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"