refactor(extraction): Datastar-native AI extraction across all pages
Extraction endpoints now accept FlexiblePayload (form or JSON) and return application/json signal patches for Datastar requests. Templates use data-bind for two-way signal binding and data-on:datastar-fetch with per-form guards to prevent event bubbling between forms. - extract_roaster, extract_roast_info, extract_bag_scan return signals - submit_scan extended with optional image/prompt for combined extract+save - match_roaster_id fuzzy-matches extracted roaster name to existing IDs - Templates use data-bind:_signal-name (not data-model) for form binding - Each datastar-fetch handler guards with its own in-progress signal
This commit is contained in:
parent
48afde8448
commit
bc30555447
6 changed files with 501 additions and 307 deletions
|
|
@ -14,7 +14,7 @@ use crate::application::server::AppState;
|
||||||
use crate::domain::ids::RoasterId;
|
use crate::domain::ids::RoasterId;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
||||||
use crate::infrastructure::ai::{self, ExtractedRoaster, ExtractionInput};
|
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||||
use crate::presentation::web::templates::{
|
use crate::presentation::web::templates::{
|
||||||
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
|
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
|
||||||
};
|
};
|
||||||
|
|
@ -185,22 +185,48 @@ define_delete_handler!(
|
||||||
render_roaster_list_fragment
|
render_roaster_list_fragment
|
||||||
);
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn extract_roaster(
|
pub(crate) async fn extract_roaster(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
Json(input): Json<ExtractionInput>,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<ExtractedRoaster>, ApiError> {
|
payload: FlexiblePayload<ExtractionInput>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
let api_key = state
|
let api_key = state
|
||||||
.openrouter_api_key
|
.openrouter_api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
||||||
|
|
||||||
|
let (input, _) = payload.into_parts();
|
||||||
let result = ai::extract_roaster(&state.http_client, api_key, &state.openrouter_model, &input)
|
let result = ai::extract_roaster(&state.http_client, api_key, &state.openrouter_model, &input)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
Ok(Json(result))
|
if is_datastar_request(&headers) {
|
||||||
|
use serde_json::Value;
|
||||||
|
let signals = vec![
|
||||||
|
(
|
||||||
|
"_roaster-name",
|
||||||
|
Value::String(result.name.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roaster-country",
|
||||||
|
Value::String(result.country.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roaster-city",
|
||||||
|
Value::String(result.city.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roaster-homepage",
|
||||||
|
Value::String(result.homepage.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
("_extracted", Value::Bool(true)),
|
||||||
|
];
|
||||||
|
crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from)
|
||||||
|
} else {
|
||||||
|
Ok(Json(result).into_response())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
define_list_fragment_renderer!(
|
define_list_fragment_renderer!(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use crate::domain::bags::{BagFilter, BagSortKey};
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
||||||
use crate::infrastructure::ai::{self, ExtractedRoast, ExtractionInput};
|
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||||
use crate::presentation::web::templates::{
|
use crate::presentation::web::templates::{
|
||||||
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
||||||
};
|
};
|
||||||
|
|
@ -341,22 +341,92 @@ impl TastingNotesInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn extract_roast_info(
|
pub(crate) async fn extract_roast_info(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
Json(input): Json<ExtractionInput>,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<ExtractedRoast>, ApiError> {
|
payload: FlexiblePayload<ExtractionInput>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
let api_key = state
|
let api_key = state
|
||||||
.openrouter_api_key
|
.openrouter_api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
||||||
|
|
||||||
|
let (input, _) = payload.into_parts();
|
||||||
let result = ai::extract_roast(&state.http_client, api_key, &state.openrouter_model, &input)
|
let result = ai::extract_roast(&state.http_client, api_key, &state.openrouter_model, &input)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
Ok(Json(result))
|
if is_datastar_request(&headers) {
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
// Try to match extracted roaster name to an existing roaster ID
|
||||||
|
let roaster_id = if let Some(ref roaster_name) = result.roaster_name {
|
||||||
|
match_roaster_id(&state, roaster_name)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let tasting_notes = result
|
||||||
|
.tasting_notes
|
||||||
|
.as_ref()
|
||||||
|
.map(|notes| notes.join(", "))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let signals = vec![
|
||||||
|
(
|
||||||
|
"_roast-name",
|
||||||
|
Value::String(result.name.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
("_origin", Value::String(result.origin.unwrap_or_default())),
|
||||||
|
("_region", Value::String(result.region.unwrap_or_default())),
|
||||||
|
(
|
||||||
|
"_producer",
|
||||||
|
Value::String(result.producer.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_process",
|
||||||
|
Value::String(result.process.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
("_tasting-notes", Value::String(tasting_notes)),
|
||||||
|
("_roaster-id", Value::String(roaster_id)),
|
||||||
|
("_extracted", Value::Bool(true)),
|
||||||
|
];
|
||||||
|
crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from)
|
||||||
|
} else {
|
||||||
|
Ok(Json(result).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuzzy-match a roaster name against existing roasters and return the matched ID.
|
||||||
|
async fn match_roaster_id(state: &AppState, roaster_name: &str) -> Option<String> {
|
||||||
|
let roasters = state
|
||||||
|
.roaster_repo
|
||||||
|
.list_all_sorted(
|
||||||
|
crate::domain::roasters::RoasterSortKey::Name,
|
||||||
|
SortDirection::Asc,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let lower = roaster_name.to_lowercase();
|
||||||
|
|
||||||
|
// Exact match first
|
||||||
|
if let Some(roaster) = roasters.iter().find(|r| r.name.to_lowercase() == lower) {
|
||||||
|
return Some(roaster.id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substring match (either direction)
|
||||||
|
roasters
|
||||||
|
.iter()
|
||||||
|
.find(|r| {
|
||||||
|
let r_lower = r.name.to_lowercase();
|
||||||
|
r_lower.contains(&lower) || lower.contains(&r_lower)
|
||||||
|
})
|
||||||
|
.map(|r| r.id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
define_list_fragment_renderer!(
|
define_list_fragment_renderer!(
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,119 @@
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
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::roasts::TastingNotesInput;
|
use crate::application::routes::roasts::TastingNotesInput;
|
||||||
|
use crate::application::routes::support::{FlexiblePayload, is_datastar_request};
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
use crate::domain::errors::RepositoryError;
|
use crate::domain::errors::RepositoryError;
|
||||||
use crate::domain::roasters::NewRoaster;
|
use crate::domain::roasters::NewRoaster;
|
||||||
use crate::domain::roasts::NewRoast;
|
use crate::domain::roasts::NewRoast;
|
||||||
use crate::infrastructure::ai::{self, ExtractedBagScan, ExtractionInput};
|
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn extract_bag_scan(
|
pub(crate) async fn extract_bag_scan(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
Json(input): Json<ExtractionInput>,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<ExtractedBagScan>, ApiError> {
|
payload: FlexiblePayload<ExtractionInput>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
let api_key = state
|
let api_key = state
|
||||||
.openrouter_api_key
|
.openrouter_api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
||||||
|
|
||||||
|
let (input, _) = payload.into_parts();
|
||||||
let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input)
|
let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
Ok(Json(result))
|
if is_datastar_request(&headers) {
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
let tasting_notes = result
|
||||||
|
.roast
|
||||||
|
.tasting_notes
|
||||||
|
.as_ref()
|
||||||
|
.map(|notes| notes.join(", "))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let signals = vec![
|
||||||
|
(
|
||||||
|
"_roaster-name",
|
||||||
|
Value::String(result.roaster.name.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roaster-country",
|
||||||
|
Value::String(result.roaster.country.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roaster-city",
|
||||||
|
Value::String(result.roaster.city.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roaster-homepage",
|
||||||
|
Value::String(result.roaster.homepage.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_roast-name",
|
||||||
|
Value::String(result.roast.name.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_origin",
|
||||||
|
Value::String(result.roast.origin.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_region",
|
||||||
|
Value::String(result.roast.region.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_producer",
|
||||||
|
Value::String(result.roast.producer.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"_process",
|
||||||
|
Value::String(result.roast.process.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
("_tasting-notes", Value::String(tasting_notes)),
|
||||||
|
("_scan-extracted", Value::Bool(true)),
|
||||||
|
];
|
||||||
|
crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from)
|
||||||
|
} else {
|
||||||
|
Ok(Json(result).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tasting_notes() -> TastingNotesInput {
|
||||||
|
TastingNotesInput::Text(String::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct BagScanSubmission {
|
pub(crate) struct BagScanSubmission {
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
roaster_name: String,
|
roaster_name: String,
|
||||||
|
#[serde(default)]
|
||||||
roaster_country: String,
|
roaster_country: String,
|
||||||
roaster_city: Option<String>,
|
roaster_city: Option<String>,
|
||||||
roaster_homepage: Option<String>,
|
roaster_homepage: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
roast_name: String,
|
roast_name: String,
|
||||||
|
#[serde(default)]
|
||||||
origin: String,
|
origin: String,
|
||||||
|
#[serde(default)]
|
||||||
region: String,
|
region: String,
|
||||||
|
#[serde(default)]
|
||||||
producer: String,
|
producer: String,
|
||||||
|
#[serde(default)]
|
||||||
process: String,
|
process: String,
|
||||||
|
#[serde(default = "default_tasting_notes")]
|
||||||
tasting_notes: TastingNotesInput,
|
tasting_notes: TastingNotesInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,12 +123,75 @@ struct ScanResult {
|
||||||
roast_id: i64,
|
roast_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user))]
|
/// Populate a `BagScanSubmission` from AI extraction when image/prompt is provided.
|
||||||
|
async fn extract_into_submission(
|
||||||
|
state: &AppState,
|
||||||
|
submission: &mut BagScanSubmission,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let api_key = state
|
||||||
|
.openrouter_api_key
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
|
||||||
|
|
||||||
|
let input = ExtractionInput {
|
||||||
|
image: submission.image.take(),
|
||||||
|
prompt: submission.prompt.take(),
|
||||||
|
};
|
||||||
|
let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
if let Some(name) = result.roaster.name {
|
||||||
|
submission.roaster_name = name;
|
||||||
|
}
|
||||||
|
if let Some(country) = result.roaster.country {
|
||||||
|
submission.roaster_country = country;
|
||||||
|
}
|
||||||
|
if result.roaster.city.is_some() {
|
||||||
|
submission.roaster_city = result.roaster.city;
|
||||||
|
}
|
||||||
|
if result.roaster.homepage.is_some() {
|
||||||
|
submission.roaster_homepage = result.roaster.homepage;
|
||||||
|
}
|
||||||
|
if let Some(name) = result.roast.name {
|
||||||
|
submission.roast_name = name;
|
||||||
|
}
|
||||||
|
if let Some(origin) = result.roast.origin {
|
||||||
|
submission.origin = origin;
|
||||||
|
}
|
||||||
|
if let Some(region) = result.roast.region {
|
||||||
|
submission.region = region;
|
||||||
|
}
|
||||||
|
if let Some(producer) = result.roast.producer {
|
||||||
|
submission.producer = producer;
|
||||||
|
}
|
||||||
|
if let Some(process) = result.roast.process {
|
||||||
|
submission.process = process;
|
||||||
|
}
|
||||||
|
if let Some(notes) = result.roast.tasting_notes {
|
||||||
|
submission.tasting_notes = TastingNotesInput::Text(notes.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn submit_scan(
|
pub(crate) async fn submit_scan(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth_user: AuthenticatedUser,
|
_auth_user: AuthenticatedUser,
|
||||||
Json(submission): Json<BagScanSubmission>,
|
headers: HeaderMap,
|
||||||
|
payload: FlexiblePayload<BagScanSubmission>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
|
let (mut submission, _) = payload.into_parts();
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
|
||||||
|
if has_raw_input {
|
||||||
|
extract_into_submission(&state, &mut submission).await?;
|
||||||
|
}
|
||||||
|
|
||||||
// Build and normalize the roaster
|
// Build and normalize the roaster
|
||||||
let new_roaster = NewRoaster {
|
let new_roaster = NewRoaster {
|
||||||
name: submission.roaster_name,
|
name: submission.roaster_name,
|
||||||
|
|
@ -81,27 +216,44 @@ pub(crate) async fn submit_scan(
|
||||||
|
|
||||||
// Validate and build the roast
|
// Validate and build the roast
|
||||||
let tasting_notes = submission.tasting_notes.into_vec();
|
let tasting_notes = submission.tasting_notes.into_vec();
|
||||||
if tasting_notes.is_empty() {
|
if !has_raw_input && tasting_notes.is_empty() {
|
||||||
return Err(AppError::validation("tasting notes are required").into());
|
return Err(AppError::validation("tasting notes are required").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require(field: &str, value: &str) -> Result<String, AppError> {
|
let new_roast = if has_raw_input {
|
||||||
let trimmed = value.trim();
|
if submission.roast_name.trim().is_empty() {
|
||||||
if trimmed.is_empty() {
|
return Err(
|
||||||
Err(AppError::validation(format!("{field} is required")))
|
AppError::validation("could not extract a roast name from the image/text").into(),
|
||||||
} else {
|
);
|
||||||
Ok(trimmed.to_string())
|
}
|
||||||
|
NewRoast {
|
||||||
|
roaster_id: roaster.id,
|
||||||
|
name: submission.roast_name.trim().to_string(),
|
||||||
|
origin: submission.origin.trim().to_string(),
|
||||||
|
region: submission.region.trim().to_string(),
|
||||||
|
producer: submission.producer.trim().to_string(),
|
||||||
|
process: submission.process.trim().to_string(),
|
||||||
|
tasting_notes,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fn require(field: &str, value: &str) -> Result<String, AppError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Err(AppError::validation(format!("{field} is required")))
|
||||||
|
} else {
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let new_roast = NewRoast {
|
NewRoast {
|
||||||
roaster_id: roaster.id,
|
roaster_id: roaster.id,
|
||||||
name: require("roast name", &submission.roast_name)?,
|
name: require("roast name", &submission.roast_name)?,
|
||||||
origin: require("origin", &submission.origin)?,
|
origin: require("origin", &submission.origin)?,
|
||||||
region: require("region", &submission.region)?,
|
region: require("region", &submission.region)?,
|
||||||
producer: require("producer", &submission.producer)?,
|
producer: require("producer", &submission.producer)?,
|
||||||
process: require("process", &submission.process)?,
|
process: require("process", &submission.process)?,
|
||||||
tasting_notes,
|
tasting_notes,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let roast = state
|
let roast = state
|
||||||
|
|
@ -112,5 +264,15 @@ pub(crate) async fn submit_scan(
|
||||||
|
|
||||||
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
|
||||||
let roast_id = roast.id.into_inner();
|
let roast_id = roast.id.into_inner();
|
||||||
Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response())
|
|
||||||
|
if is_datastar_request(&headers) {
|
||||||
|
use serde_json::Value;
|
||||||
|
let signals = vec![
|
||||||
|
("_roast-id", Value::String(roast_id.to_string())),
|
||||||
|
("_scan-success", Value::String(roast.name.clone())),
|
||||||
|
];
|
||||||
|
crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from)
|
||||||
|
} else {
|
||||||
|
Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,8 @@
|
||||||
{% extends "base.html" %} {% block title %}Brewlog{% endblock %}
|
{% extends "base.html" %} {% block title %}Brewlog{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% if is_authenticated && has_ai_extract %}
|
{% if is_authenticated %}
|
||||||
<script src="/extract.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
let _submitting = false;
|
|
||||||
|
|
||||||
const fillScanForms = (data) => {
|
|
||||||
const form = document.getElementById('scan-form');
|
|
||||||
if (!form) return;
|
|
||||||
if (data.roaster) {
|
|
||||||
if (data.roaster.name) form.querySelector('[name="roaster_name"]').value = data.roaster.name;
|
|
||||||
if (data.roaster.country) form.querySelector('[name="roaster_country"]').value = data.roaster.country;
|
|
||||||
if (data.roaster.city) form.querySelector('[name="roaster_city"]').value = data.roaster.city;
|
|
||||||
if (data.roaster.homepage) form.querySelector('[name="roaster_homepage"]').value = data.roaster.homepage;
|
|
||||||
}
|
|
||||||
if (data.roast) {
|
|
||||||
if (data.roast.name) form.querySelector('[name="roast_name"]').value = data.roast.name;
|
|
||||||
if (data.roast.origin) form.querySelector('[name="origin"]').value = data.roast.origin;
|
|
||||||
if (data.roast.region) form.querySelector('[name="region"]').value = data.roast.region;
|
|
||||||
if (data.roast.producer) form.querySelector('[name="producer"]').value = data.roast.producer;
|
|
||||||
if (data.roast.process) form.querySelector('[name="process"]').value = data.roast.process;
|
|
||||||
if (data.roast.tasting_notes && data.roast.tasting_notes.length > 0) {
|
|
||||||
form.querySelector('[name="tasting_notes"]').value = data.roast.tasting_notes.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.getElementById('scan-input-section').style.display = 'none';
|
|
||||||
document.getElementById('scan-form-section').style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetScan = () => {
|
|
||||||
document.getElementById('scan-section').style.display = 'none';
|
|
||||||
document.getElementById('scan-input-section').style.display = 'block';
|
|
||||||
document.getElementById('scan-form-section').style.display = 'none';
|
|
||||||
document.getElementById('scan-form').reset();
|
|
||||||
document.getElementById('scan-submit-error').classList.add('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleScan = () => {
|
|
||||||
const el = document.getElementById('scan-section');
|
|
||||||
if (el.style.display === 'none') {
|
|
||||||
el.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
resetScan();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitScan = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (_submitting) return;
|
|
||||||
_submitting = true;
|
|
||||||
const errorEl = document.getElementById('scan-submit-error');
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
const form = document.getElementById('scan-form');
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const body = {};
|
|
||||||
formData.forEach((value, key) => { body[key] = value; });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v1/scan', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const errData = await resp.json().catch(() => ({}));
|
|
||||||
throw new Error(errData.message || `Server returned ${resp.status}`);
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
window.location.href = data.redirect || '/roasts';
|
|
||||||
} catch (e) {
|
|
||||||
errorEl.textContent = `Save failed: ${e.message}`;
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
} finally {
|
|
||||||
_submitting = false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeBag = async (bagId, cardEl) => {
|
const closeBag = async (bagId, cardEl) => {
|
||||||
if (!confirm('Close this bag? This will mark it as finished.')) return;
|
if (!confirm('Close this bag? This will mark it as finished.')) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,11 +30,18 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
{% if is_authenticated %}
|
{% if is_authenticated %}
|
||||||
<section class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<section class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||||
|
data-signals:_show-scan="false"
|
||||||
|
data-signals:_extracting="false"
|
||||||
|
data-signals:_extract-error="''"
|
||||||
|
data-signals:_scan-extracted="false"
|
||||||
|
data-signals:_scan-submitting="false"
|
||||||
|
data-signals:_scan-error="''"
|
||||||
|
>
|
||||||
{% if has_ai_extract %}
|
{% if has_ai_extract %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="toggleScan()"
|
data-on:click="$_showScan = !$_showScan; !$_showScan && ($_scanExtracted = false, $_extracting = false, $_extractError = '', $_scanError = '')"
|
||||||
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-6 py-5 text-lg font-semibold text-amber-800 shadow-sm transition hover:bg-amber-100 hover:border-amber-500"
|
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-6 py-5 text-lg font-semibold text-amber-800 shadow-sm transition hover:bg-amber-100 hover:border-amber-500"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
|
@ -134,70 +64,93 @@
|
||||||
|
|
||||||
<!-- Inline Scan Section (hidden by default) -->
|
<!-- Inline Scan Section (hidden by default) -->
|
||||||
{% if has_ai_extract %}
|
{% if has_ai_extract %}
|
||||||
<section id="scan-section" style="display: none">
|
<section data-show="$_showScan" style="display: none"
|
||||||
<!-- Input: photo or text -->
|
data-signals:_roaster-name="''"
|
||||||
<div id="scan-input-section" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
data-signals:_roaster-country="''"
|
||||||
<div id="scan-extract-controls" class="flex flex-wrap items-center gap-3">
|
data-signals:_roaster-city="''"
|
||||||
<button
|
data-signals:_roaster-homepage="''"
|
||||||
type="button"
|
data-signals:_roast-name="''"
|
||||||
onclick="triggerPhotoExtract('scan', '/api/v1/extract-bag-scan', fillScanForms)"
|
data-signals:_origin="''"
|
||||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
data-signals:_region="''"
|
||||||
>
|
data-signals:_producer="''"
|
||||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
data-signals:_process="''"
|
||||||
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
data-signals:_tasting-notes="''"
|
||||||
</svg>
|
>
|
||||||
Take Photo
|
<!-- Hidden file input — minimal JS for FileReader API -->
|
||||||
</button>
|
<input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden"
|
||||||
<span class="text-xs text-stone-400">or</span>
|
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('scan-image').value=r.result;document.getElementById('scan-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
|
||||||
<div class="flex flex-1 min-w-[200px] gap-2">
|
|
||||||
<input
|
<!-- Input: photo or text (shown when not yet extracted) -->
|
||||||
type="text"
|
<div data-show="!$_scanExtracted" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
id="scan-extract-text"
|
<form id="scan-extract-form"
|
||||||
class="input-field w-full text-sm"
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
|
||||||
placeholder="Describe the coffee bag…"
|
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('scan','/api/v1/extract-bag-scan',fillScanForms)}"
|
>
|
||||||
/>
|
<input type="hidden" name="image" id="scan-image" />
|
||||||
|
<div data-show="!$_extracting" class="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="extractFromText('scan', '/api/v1/extract-bag-scan', fillScanForms)"
|
onclick="document.getElementById('scan-photo').click()"
|
||||||
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
>
|
>
|
||||||
Go
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Take Photo
|
||||||
</button>
|
</button>
|
||||||
|
<span class="text-xs text-stone-400">or</span>
|
||||||
|
<div class="flex flex-1 min-w-[200px] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="prompt"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Describe the coffee bag…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||||
<div id="scan-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700">
|
<svg class="h-5 w-5 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<svg class="h-5 w-5 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
</svg>
|
||||||
</svg>
|
Waiting for response…
|
||||||
Waiting for response…
|
</div>
|
||||||
</div>
|
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||||
<p id="scan-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form: pre-filled roaster + roast -->
|
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
|
||||||
<div id="scan-form-section" style="display: none">
|
<div data-show="$_scanExtracted" style="display: none">
|
||||||
<form id="scan-form" class="mt-4 flex flex-col gap-4" onsubmit="return submitScan(event)">
|
<form
|
||||||
|
class="mt-4 flex flex-col gap-4"
|
||||||
|
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
|
||||||
|
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; window.location.href = '/roasts' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
||||||
|
>
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2>
|
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2>
|
||||||
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
|
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Name *</span>
|
<span class="text-stone-700">Name *</span>
|
||||||
<input type="text" name="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" />
|
<input type="text" name="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Country *</span>
|
<span class="text-stone-700">Country *</span>
|
||||||
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" />
|
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" data-bind:_roaster-country />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">City</span>
|
<span class="text-stone-700">City</span>
|
||||||
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" />
|
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Homepage</span>
|
<span class="text-stone-700">Homepage</span>
|
||||||
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" />
|
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,35 +160,42 @@
|
||||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Roast Name *</span>
|
<span class="text-stone-700">Roast Name *</span>
|
||||||
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" />
|
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Origin *</span>
|
<span class="text-stone-700">Origin *</span>
|
||||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
|
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Region *</span>
|
<span class="text-stone-700">Region *</span>
|
||||||
<input type="text" name="region" required class="input-field" placeholder="Guji" />
|
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_region />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Producer *</span>
|
<span class="text-stone-700">Producer *</span>
|
||||||
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" />
|
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Process *</span>
|
<span class="text-stone-700">Process *</span>
|
||||||
<input type="text" name="process" required class="input-field" placeholder="Washed" />
|
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_process />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
|
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
|
||||||
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine"></textarea>
|
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="scan-submit-error" class="hidden text-sm text-red-600"></p>
|
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||||
|
<svg class="h-5 w-5 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Saving…
|
||||||
|
</div>
|
||||||
|
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="resetScan()"
|
data-on:click="$_scanExtracted = false"
|
||||||
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
|
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,7 @@
|
||||||
{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %}
|
{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{% if is_authenticated && has_ai_extract %}
|
|
||||||
<script src="/extract.js"></script>
|
|
||||||
<script>
|
|
||||||
function fillRoasterForm(data) {
|
|
||||||
var form = document.getElementById('roaster-form');
|
|
||||||
if (!form) return;
|
|
||||||
if (data.name) form.querySelector('[name="name"]').value = data.name;
|
|
||||||
if (data.country) form.querySelector('[name="country"]').value = data.country;
|
|
||||||
if (data.city) form.querySelector('[name="city"]').value = data.city;
|
|
||||||
if (data.homepage) form.querySelector('[name="homepage"]').value = data.homepage;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section data-signals:_show-form="false">
|
<section data-signals:_show-form="false" data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false">
|
||||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">Roasters</h1>
|
<h1 class="text-3xl font-semibold">Roasters</h1>
|
||||||
|
|
@ -48,56 +32,65 @@
|
||||||
Provide the core details and Brewlog will keep track of everything for you.
|
Provide the core details and Brewlog will keep track of everything for you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<!-- Hidden file input — minimal JS for FileReader API -->
|
||||||
|
<input type="file" id="roaster-photo" accept="image/*" capture="environment" class="hidden"
|
||||||
|
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('roaster-image').value=r.result;document.getElementById('roaster-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
|
||||||
|
|
||||||
|
<!-- Extraction form -->
|
||||||
|
<form id="roaster-extract-form" class="mt-4 border-b border-amber-200 pb-4"
|
||||||
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-roaster', {contentType: 'form'})"
|
||||||
|
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="image" id="roaster-image" />
|
||||||
|
<div data-show="!$_extracting" class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="document.getElementById('roaster-photo').click()"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Extract from photo
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-stone-400">or</span>
|
||||||
|
<div class="flex flex-1 min-w-[200px] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="prompt"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Describe the roaster…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||||
|
<svg class="h-4 w-4 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Waiting for response…
|
||||||
|
</div>
|
||||||
|
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="roaster-form"
|
id="roaster-form"
|
||||||
method="post"
|
method="post"
|
||||||
action="/api/v1/roasters"
|
action="/api/v1/roasters"
|
||||||
class="mt-4 flex flex-col gap-4"
|
class="mt-4 flex flex-col gap-4"
|
||||||
data-on:submit="@post('/api/v1/roasters?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
data-on:submit="$_submitting = true; @post('/api/v1/roasters?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
|
||||||
data-ref="_form"
|
data-ref="_form"
|
||||||
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'finished') { $_submitting = false; $_showForm = false; $_form && $_form.reset() } else if (evt.detail.type === 'error') { $_submitting = false }"
|
||||||
>
|
>
|
||||||
{% if has_ai_extract %}
|
|
||||||
<div class="relative border-b border-amber-200 pb-4">
|
|
||||||
<div id="roaster-form-extract-controls" class="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="triggerPhotoExtract('roaster-form', '/api/v1/extract-roaster', fillRoasterForm)"
|
|
||||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
|
||||||
>
|
|
||||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
||||||
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
Extract from photo
|
|
||||||
</button>
|
|
||||||
<span class="text-xs text-stone-400">or</span>
|
|
||||||
<div class="flex flex-1 min-w-[200px] gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="roaster-form-extract-text"
|
|
||||||
class="input-field w-full text-sm"
|
|
||||||
placeholder="Describe the roaster…"
|
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roaster-form','/api/v1/extract-roaster',fillRoasterForm)}"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="extractFromText('roaster-form', '/api/v1/extract-roaster', fillRoasterForm)"
|
|
||||||
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
|
||||||
>
|
|
||||||
Go
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="roaster-form-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700">
|
|
||||||
<svg class="h-4 w-4 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
Waiting for response…
|
|
||||||
</div>
|
|
||||||
<p id="roaster-form-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Name *</span>
|
<span class="text-stone-700">Name *</span>
|
||||||
|
|
@ -107,6 +100,7 @@
|
||||||
required
|
required
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="Example Coffee Roasters"
|
placeholder="Example Coffee Roasters"
|
||||||
|
data-bind:_roaster-name
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
|
@ -117,11 +111,12 @@
|
||||||
required
|
required
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="United States"
|
placeholder="United States"
|
||||||
|
data-bind:_roaster-country
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">City</span>
|
<span class="text-stone-700">City</span>
|
||||||
<input type="text" name="city" class="input-field" placeholder="Portland" />
|
<input type="text" name="city" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Homepage</span>
|
<span class="text-stone-700">Homepage</span>
|
||||||
|
|
@ -130,6 +125,7 @@
|
||||||
name="homepage"
|
name="homepage"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="https://example.coffee"
|
placeholder="https://example.coffee"
|
||||||
|
data-bind:_roaster-homepage
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,7 @@
|
||||||
{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %}
|
{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
{% if is_authenticated && has_ai_extract %}
|
|
||||||
<script src="/extract.js"></script>
|
|
||||||
<script>
|
|
||||||
function fillRoastForm(data) {
|
|
||||||
var form = document.getElementById('roast-form');
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
if (data.roaster_name) {
|
|
||||||
var select = form.querySelector('[name="roaster_id"]');
|
|
||||||
var lowerName = data.roaster_name.toLowerCase();
|
|
||||||
for (var i = 0; i < select.options.length; i++) {
|
|
||||||
if (select.options[i].text.toLowerCase().includes(lowerName)) {
|
|
||||||
select.selectedIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.name) form.querySelector('[name="name"]').value = data.name;
|
|
||||||
if (data.origin) form.querySelector('[name="origin"]').value = data.origin;
|
|
||||||
if (data.region) form.querySelector('[name="region"]').value = data.region;
|
|
||||||
if (data.producer) form.querySelector('[name="producer"]').value = data.producer;
|
|
||||||
if (data.process) form.querySelector('[name="process"]').value = data.process;
|
|
||||||
if (data.tasting_notes && data.tasting_notes.length > 0) {
|
|
||||||
form.querySelector('[name="tasting_notes"]').value = data.tasting_notes.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section data-signals:_show-form="false">
|
<section data-signals:_show-form="false" data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false">
|
||||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1 class="text-3xl font-semibold">Roasts</h1>
|
<h1 class="text-3xl font-semibold">Roasts</h1>
|
||||||
|
|
@ -78,60 +46,69 @@
|
||||||
Select a roaster, describe the roast, and Brewlog will take care of the rest.
|
Select a roaster, describe the roast, and Brewlog will take care of the rest.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<!-- Hidden file input — minimal JS for FileReader API -->
|
||||||
|
<input type="file" id="roast-photo" accept="image/*" capture="environment" class="hidden"
|
||||||
|
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('roast-image').value=r.result;document.getElementById('roast-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
|
||||||
|
|
||||||
|
<!-- Extraction form -->
|
||||||
|
<form id="roast-extract-form" class="mt-4 border-b border-amber-200 pb-4"
|
||||||
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-roast', {contentType: 'form'})"
|
||||||
|
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="image" id="roast-image" />
|
||||||
|
<div data-show="!$_extracting" class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="document.getElementById('roast-photo').click()"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Extract from photo
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-stone-400">or</span>
|
||||||
|
<div class="flex flex-1 min-w-[200px] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="prompt"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Describe the coffee…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||||
|
<svg class="h-4 w-4 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Waiting for response…
|
||||||
|
</div>
|
||||||
|
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
id="roast-form"
|
id="roast-form"
|
||||||
method="post"
|
method="post"
|
||||||
action="/api/v1/roasts"
|
action="/api/v1/roasts"
|
||||||
class="mt-4 flex flex-col gap-4"
|
class="mt-4 flex flex-col gap-4"
|
||||||
data-on:submit="@post('/api/v1/roasts?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
data-on:submit="$_submitting = true; @post('/api/v1/roasts?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
|
||||||
data-ref="_form"
|
data-ref="_form"
|
||||||
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
data-on:datastar-fetch="if (!$_submitting) return; if (evt.detail.type === 'finished') { $_submitting = false; $_showForm = false; $_form && $_form.reset() } else if (evt.detail.type === 'error') { $_submitting = false }"
|
||||||
>
|
>
|
||||||
{% if has_ai_extract %}
|
|
||||||
<div class="relative border-b border-amber-200 pb-4">
|
|
||||||
<div id="roast-form-extract-controls" class="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="triggerPhotoExtract('roast-form', '/api/v1/extract-roast', fillRoastForm)"
|
|
||||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
|
||||||
>
|
|
||||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
||||||
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
Extract from photo
|
|
||||||
</button>
|
|
||||||
<span class="text-xs text-stone-400">or</span>
|
|
||||||
<div class="flex flex-1 min-w-[200px] gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="roast-form-extract-text"
|
|
||||||
class="input-field w-full text-sm"
|
|
||||||
placeholder="Describe the coffee…"
|
|
||||||
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roast-form','/api/v1/extract-roast',fillRoastForm)}"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="extractFromText('roast-form', '/api/v1/extract-roast', fillRoastForm)"
|
|
||||||
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
|
||||||
>
|
|
||||||
Go
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="roast-form-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700">
|
|
||||||
<svg class="h-4 w-4 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
Waiting for response…
|
|
||||||
</div>
|
|
||||||
<p id="roast-form-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Roaster *</span>
|
<span class="text-stone-700">Roaster *</span>
|
||||||
<select name="roaster_id" required class="input-field">
|
<select name="roaster_id" required class="input-field" data-bind:_roaster-id>
|
||||||
<option value="">Select a roaster</option>
|
<option value="">Select a roaster</option>
|
||||||
{% for roaster in roaster_options %}
|
{% for roaster in roaster_options %}
|
||||||
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
||||||
|
|
@ -146,15 +123,16 @@
|
||||||
required
|
required
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="Ethiopia Yirgacheffe"
|
placeholder="Ethiopia Yirgacheffe"
|
||||||
|
data-bind:_roast-name
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Origin *</span>
|
<span class="text-stone-700">Origin *</span>
|
||||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
|
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Region *</span>
|
<span class="text-stone-700">Region *</span>
|
||||||
<input type="text" name="region" required class="input-field" placeholder="Guji" />
|
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_region />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Producer *</span>
|
<span class="text-stone-700">Producer *</span>
|
||||||
|
|
@ -164,11 +142,12 @@
|
||||||
required
|
required
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="Chelbesa Cooperative"
|
placeholder="Chelbesa Cooperative"
|
||||||
|
data-bind:_producer
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Process *</span>
|
<span class="text-stone-700">Process *</span>
|
||||||
<input type="text" name="process" required class="input-field" placeholder="Washed" />
|
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_process />
|
||||||
</label>
|
</label>
|
||||||
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
|
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Tasting Notes * (comma or newline separated)</span>
|
<span class="text-stone-700">Tasting Notes * (comma or newline separated)</span>
|
||||||
|
|
@ -178,6 +157,7 @@
|
||||||
required
|
required
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="Blueberry, Jasmine"
|
placeholder="Blueberry, Jasmine"
|
||||||
|
data-bind:_tasting-notes
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue