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::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<i32>,
|
||||
#[serde(default)]
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
impl NewBrewSubmission {
|
||||
|
|
@ -243,6 +246,7 @@ pub(crate) async fn create_brew(
|
|||
) -> Result<Response, ApiError> {
|
||||
let (request, search) = query.into_request_and_search::<BrewSortKey>();
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<AppState>) -> Result<Json<Vec
|
|||
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))]
|
||||
pub(crate) async fn create_cafe(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewCafe>,
|
||||
payload: FlexiblePayload<NewCafeSubmission>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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 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!(
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
roast_id: String,
|
||||
#[serde(default)]
|
||||
cafe_image: Option<String>,
|
||||
#[serde(default)]
|
||||
cup_image: Option<String>,
|
||||
}
|
||||
|
||||
#[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) {
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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<Response, ApiError> {
|
||||
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
||||
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<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
impl NewGearSubmission {
|
||||
|
|
|
|||
|
|
@ -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<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))]
|
||||
pub(crate) async fn create_roaster(
|
||||
State(state): State<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewRoaster>,
|
||||
payload: FlexiblePayload<NewRoasterSubmission>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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 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))]
|
||||
|
|
|
|||
|
|
@ -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<Response, ApiError> {
|
||||
let (request, search) = query.into_request_and_search::<RoastSortKey>();
|
||||
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<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
impl NewRoastSubmission {
|
||||
|
|
|
|||
|
|
@ -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<f64>,
|
||||
#[serde(default)]
|
||||
matched_roast_id: Option<String>,
|
||||
#[serde(default)]
|
||||
scan_image: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<form
|
||||
id="roaster-extract-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"
|
||||
></form>
|
||||
<div data-show="!$_roasterExtracting" class="flex items-center gap-2">
|
||||
|
|
@ -188,6 +189,7 @@
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("roaster-image", "Add image (optional)") }}
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -240,7 +242,7 @@
|
|||
<form
|
||||
id="roast-extract-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"
|
||||
></form>
|
||||
<div data-show="!$_roastExtracting" class="flex items-center gap-2">
|
||||
|
|
@ -400,6 +402,7 @@
|
|||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("roast-image", "Add image (optional)") }}
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
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(',')"
|
||||
/>
|
||||
</div>
|
||||
{{ img::deferred_upload("brew-image", "Add image (optional)") }}
|
||||
<div class="sticky-submit flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -1018,6 +1022,7 @@
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("gear-image", "Add image (optional)") }}
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -1182,6 +1187,7 @@
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("cafe-image", "Add image (optional)") }}
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -210,11 +210,32 @@
|
|||
/>
|
||||
</label>
|
||||
</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">
|
||||
<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"
|
||||
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
|
||||
</button>
|
||||
|
|
@ -344,7 +365,7 @@
|
|||
</dl>
|
||||
|
||||
<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.' }"
|
||||
>
|
||||
<input type="hidden" name="cafe_id" data-attr:value="$_cafeId" />
|
||||
|
|
@ -363,6 +384,32 @@
|
|||
data-attr:value="$_cafeWebsite"
|
||||
/>
|
||||
<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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
<form
|
||||
id="scan-extract-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"
|
||||
></form>
|
||||
<brew-photo-capture
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<!-- Hidden inputs for submission (always present, bound to signals) -->
|
||||
<input type="hidden" name="scan_image" id="scan-image-save" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="matched_roast_id"
|
||||
|
|
|
|||
Loading…
Reference in a new issue