refactor: consolidate entity pages into unified /data and /add views
Replace per-entity pages (/roasters, /roasts, /bags, /brews, /gear, /cafes, /cups) and detail pages with a single tabbed /data view and a dedicated /add page for entity creation. - Add /data route with tab-based navigation using Datastar - Add /add route consolidating all create forms - Remove per-entity page handlers and standalone templates - Remove detail page routes, handlers, and templates - Update ListNavigator to accept String paths for query-param URLs - Update home page and timeline links to use new /data?type=X paths - Update nav to reference /data instead of individual entity pages
This commit is contained in:
parent
50a372015c
commit
4bdfc661f9
35 changed files with 1349 additions and 2078 deletions
61
src/application/routes/add.rs
Normal file
61
src/application/routes/add.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::application::errors::map_app_error;
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::routes::support::{
|
||||
load_cafe_options, load_roast_options, load_roaster_options,
|
||||
};
|
||||
use crate::application::server::AppState;
|
||||
use crate::presentation::web::templates::AddTemplate;
|
||||
|
||||
use super::brews::load_brew_form_data;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AddQuery {
|
||||
#[serde(rename = "type", default = "default_type")]
|
||||
entity_type: String,
|
||||
}
|
||||
|
||||
fn default_type() -> String {
|
||||
"roaster".to_string()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn add_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Query(query): Query<AddQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
if !is_authenticated {
|
||||
return Ok(Redirect::to("/login").into_response());
|
||||
}
|
||||
|
||||
let (roaster_options, roast_options, cafe_options, brew_form) = tokio::try_join!(
|
||||
async { load_roaster_options(&state).await },
|
||||
async { load_roast_options(&state).await },
|
||||
async { load_cafe_options(&state).await },
|
||||
async { load_brew_form_data(&state).await },
|
||||
)
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let template = AddTemplate {
|
||||
nav_active: "data",
|
||||
is_authenticated,
|
||||
active_type: query.entity_type,
|
||||
roaster_options,
|
||||
roast_options,
|
||||
bag_options: brew_form.bag_options,
|
||||
grinder_options: brew_form.grinder_options,
|
||||
brewer_options: brew_form.brewer_options,
|
||||
filter_paper_options: brew_form.filter_paper_options,
|
||||
cafe_options,
|
||||
defaults: brew_form.defaults,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
|
@ -6,30 +6,29 @@ use serde::Deserialize;
|
|||
|
||||
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, load_roaster_options,
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
use crate::application::server::AppState;
|
||||
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
||||
use crate::domain::ids::{BagId, RoastId};
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
use crate::presentation::web::templates::{BagListTemplate, BagsTemplate};
|
||||
use crate::presentation::web::templates::BagListTemplate;
|
||||
use crate::presentation::web::views::{BagView, ListNavigator, Paginated};
|
||||
|
||||
const BAG_PAGE_PATH: &str = "/bags";
|
||||
const BAG_FRAGMENT_PATH: &str = "/bags#bag-list";
|
||||
const BAG_PAGE_PATH: &str = "/data?type=bags";
|
||||
const BAG_FRAGMENT_PATH: &str = "/data?type=bags#bag-list";
|
||||
|
||||
struct BagPageData {
|
||||
open_bags: Vec<BagView>,
|
||||
bags: Paginated<BagView>,
|
||||
navigator: ListNavigator<BagSortKey>,
|
||||
pub(super) struct BagPageData {
|
||||
pub(super) open_bags: Vec<BagView>,
|
||||
pub(super) bags: Paginated<BagView>,
|
||||
pub(super) navigator: ListNavigator<BagSortKey>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_bag_page(
|
||||
pub(super) async fn load_bag_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<BagSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -68,46 +67,6 @@ async fn load_bag_page(
|
|||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn bags_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<BagSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_bag_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
|
||||
|
||||
let BagPageData {
|
||||
open_bags,
|
||||
bags,
|
||||
navigator,
|
||||
} = load_bag_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = BagsTemplate {
|
||||
nav_active: "bags",
|
||||
is_authenticated,
|
||||
open_bags,
|
||||
bags,
|
||||
roaster_options,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn create_bag(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ use serde::{Deserialize, Deserializer};
|
|||
|
||||
use super::macros::{define_delete_handler, define_enriched_get_handler};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
|
|
@ -18,28 +17,28 @@ use crate::domain::gear::{GearCategory, GearFilter, GearSortKey};
|
|||
use crate::domain::ids::{BagId, BrewId, GearId};
|
||||
use crate::domain::listing::{ListRequest, PageSize, SortDirection};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineBrewData, TimelineEventDetail};
|
||||
use crate::presentation::web::templates::{BrewListTemplate, BrewsTemplate};
|
||||
use crate::presentation::web::templates::BrewListTemplate;
|
||||
use crate::presentation::web::views::{
|
||||
BagOptionView, BrewDefaultsView, BrewView, GearOptionView, ListNavigator, Paginated,
|
||||
};
|
||||
|
||||
const BREW_PAGE_PATH: &str = "/brews";
|
||||
const BREW_FRAGMENT_PATH: &str = "/brews#brew-list";
|
||||
const BREW_PAGE_PATH: &str = "/data?type=brews";
|
||||
const BREW_FRAGMENT_PATH: &str = "/data?type=brews#brew-list";
|
||||
|
||||
struct BrewPageData {
|
||||
brews: Paginated<BrewView>,
|
||||
navigator: ListNavigator<BrewSortKey>,
|
||||
pub(super) struct BrewPageData {
|
||||
pub(super) brews: Paginated<BrewView>,
|
||||
pub(super) navigator: ListNavigator<BrewSortKey>,
|
||||
}
|
||||
|
||||
struct BrewFormData {
|
||||
bag_options: Vec<BagOptionView>,
|
||||
grinder_options: Vec<GearOptionView>,
|
||||
brewer_options: Vec<GearOptionView>,
|
||||
filter_paper_options: Vec<GearOptionView>,
|
||||
defaults: BrewDefaultsView,
|
||||
pub(super) struct BrewFormData {
|
||||
pub(super) bag_options: Vec<BagOptionView>,
|
||||
pub(super) grinder_options: Vec<GearOptionView>,
|
||||
pub(super) brewer_options: Vec<GearOptionView>,
|
||||
pub(super) filter_paper_options: Vec<GearOptionView>,
|
||||
pub(super) defaults: BrewDefaultsView,
|
||||
}
|
||||
|
||||
async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError> {
|
||||
pub(super) async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError> {
|
||||
let open_bags_request = ListRequest::show_all(
|
||||
crate::domain::bags::BagSortKey::RoastDate,
|
||||
SortDirection::Desc,
|
||||
|
|
@ -89,7 +88,7 @@ async fn load_brew_form_data(state: &AppState) -> Result<BrewFormData, AppError>
|
|||
})
|
||||
}
|
||||
|
||||
async fn load_gear_options(
|
||||
pub(super) async fn load_gear_options(
|
||||
state: &AppState,
|
||||
category: GearCategory,
|
||||
request: &ListRequest<GearSortKey>,
|
||||
|
|
@ -103,7 +102,7 @@ async fn load_gear_options(
|
|||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_brew_page(
|
||||
pub(super) async fn load_brew_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<BrewSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -126,51 +125,6 @@ async fn load_brew_page(
|
|||
Ok(BrewPageData { brews, navigator })
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn brews_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<BrewSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_brew_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let BrewFormData {
|
||||
bag_options,
|
||||
grinder_options,
|
||||
brewer_options,
|
||||
filter_paper_options,
|
||||
defaults,
|
||||
} = load_brew_form_data(&state).await.map_err(map_app_error)?;
|
||||
|
||||
let BrewPageData { brews, navigator } = load_brew_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = BrewsTemplate {
|
||||
nav_active: "brews",
|
||||
is_authenticated,
|
||||
brews,
|
||||
bag_options,
|
||||
grinder_options,
|
||||
brewer_options,
|
||||
filter_paper_options,
|
||||
defaults,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
/// Deserializes an optional `GearId`, treating empty strings (from HTML forms) as None.
|
||||
fn deserialize_optional_gear_id<'de, D>(deserializer: D) -> Result<Option<GearId>, D::Error>
|
||||
where
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ use serde::Deserialize;
|
|||
|
||||
use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
|
|
@ -16,16 +15,14 @@ use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
|||
use crate::domain::ids::CafeId;
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::infrastructure::foursquare;
|
||||
use crate::presentation::web::templates::{
|
||||
CafeDetailTemplate, CafeListTemplate, CafesTemplate, NearbyCafesFragment,
|
||||
};
|
||||
use crate::presentation::web::templates::{CafeListTemplate, NearbyCafesFragment};
|
||||
use crate::presentation::web::views::{CafeView, ListNavigator, NearbyCafeView, Paginated};
|
||||
|
||||
const CAFE_PAGE_PATH: &str = "/cafes";
|
||||
const CAFE_FRAGMENT_PATH: &str = "/cafes#cafe-list";
|
||||
const CAFE_PAGE_PATH: &str = "/data?type=cafes";
|
||||
const CAFE_FRAGMENT_PATH: &str = "/data?type=cafes#cafe-list";
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_cafe_page(
|
||||
pub(super) async fn load_cafe_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<CafeSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -46,62 +43,6 @@ async fn load_cafe_page(
|
|||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn cafes_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<CafeSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_cafe_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let (cafes, navigator) = load_cafe_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = CafesTemplate {
|
||||
nav_active: "cafes",
|
||||
is_authenticated,
|
||||
cafes,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn cafe_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let cafe = state
|
||||
.cafe_repo
|
||||
.get_by_slug(&slug)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let cafe_view = CafeView::from(cafe);
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = CafeDetailTemplate {
|
||||
nav_active: "cafes",
|
||||
is_authenticated,
|
||||
cafe: cafe_view,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub(crate) async fn list_cafes(State(state): State<AppState>) -> Result<Json<Vec<Cafe>>, ApiError> {
|
||||
let cafes = state
|
||||
|
|
|
|||
|
|
@ -7,24 +7,22 @@ use super::macros::{
|
|||
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
||||
};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, load_cafe_options,
|
||||
load_roast_options,
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
use crate::application::server::AppState;
|
||||
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
||||
use crate::domain::ids::CupId;
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::presentation::web::templates::{CupListTemplate, CupsTemplate};
|
||||
use crate::presentation::web::templates::CupListTemplate;
|
||||
use crate::presentation::web::views::{CupView, ListNavigator, Paginated};
|
||||
|
||||
const CUP_PAGE_PATH: &str = "/cups";
|
||||
const CUP_FRAGMENT_PATH: &str = "/cups#cup-list";
|
||||
const CUP_PAGE_PATH: &str = "/data?type=cups";
|
||||
const CUP_FRAGMENT_PATH: &str = "/data?type=cups#cup-list";
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_cup_page(
|
||||
pub(super) async fn load_cup_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<CupSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -45,44 +43,6 @@ async fn load_cup_page(
|
|||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn cups_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<CupSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_cup_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let (cups, navigator) = load_cup_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let roast_options = load_roast_options(&state).await.map_err(map_app_error)?;
|
||||
|
||||
let cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?;
|
||||
|
||||
let template = CupsTemplate {
|
||||
nav_active: "cups",
|
||||
is_authenticated,
|
||||
cups,
|
||||
roast_options,
|
||||
cafe_options,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn create_cup(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
262
src/application/routes/data.rs
Normal file
262
src/application/routes/data.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
use axum::extract::{Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::application::errors::{AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::routes::support::{ListQuery, is_datastar_request};
|
||||
use crate::application::server::AppState;
|
||||
use crate::presentation::web::templates::{
|
||||
BagListTemplate, BrewListTemplate, CafeListTemplate, CupListTemplate, DataTab, DataTemplate,
|
||||
GearListTemplate, RoastListTemplate, RoasterListTemplate, render_template,
|
||||
};
|
||||
|
||||
const TABS: &[DataTab] = &[
|
||||
DataTab {
|
||||
key: "brews",
|
||||
label: "Brews",
|
||||
},
|
||||
DataTab {
|
||||
key: "roasters",
|
||||
label: "Roasters",
|
||||
},
|
||||
DataTab {
|
||||
key: "roasts",
|
||||
label: "Roasts",
|
||||
},
|
||||
DataTab {
|
||||
key: "bags",
|
||||
label: "Bags",
|
||||
},
|
||||
DataTab {
|
||||
key: "gear",
|
||||
label: "Gear",
|
||||
},
|
||||
DataTab {
|
||||
key: "cafes",
|
||||
label: "Cafes",
|
||||
},
|
||||
DataTab {
|
||||
key: "cups",
|
||||
label: "Cups",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DataQuery {
|
||||
#[serde(rename = "type", default = "default_type")]
|
||||
entity_type: String,
|
||||
#[serde(flatten)]
|
||||
list: ListQuery,
|
||||
}
|
||||
|
||||
fn default_type() -> String {
|
||||
"brews".to_string()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn data_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<DataQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let entity_type = query.entity_type.clone();
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let content = render_entity_content(&state, &entity_type, query.list, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
use axum::http::header::HeaderValue;
|
||||
use axum::response::Html;
|
||||
|
||||
let mut response = Html(content).into_response();
|
||||
response.headers_mut().insert(
|
||||
"datastar-selector",
|
||||
HeaderValue::from_static("#data-content"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("datastar-mode", HeaderValue::from_static("inner"));
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let tabs: Vec<DataTab> = TABS
|
||||
.iter()
|
||||
.map(|t| DataTab {
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let template = DataTemplate {
|
||||
nav_active: "data",
|
||||
is_authenticated,
|
||||
active_type: entity_type,
|
||||
tabs,
|
||||
content,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
fn render_list<T: askama::Template>(template: T, label: &str) -> Result<String, AppError> {
|
||||
render_template(template)
|
||||
.map_err(|err| AppError::unexpected(format!("failed to render {label}: {err}")))
|
||||
}
|
||||
|
||||
async fn render_entity_content(
|
||||
state: &AppState,
|
||||
entity_type: &str,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
// Normalize unknown types to brews
|
||||
let entity_type = match entity_type {
|
||||
"brews" | "roasters" | "roasts" | "bags" | "gear" | "cafes" | "cups" => entity_type,
|
||||
_ => "brews",
|
||||
};
|
||||
|
||||
match entity_type {
|
||||
"roasters" => render_roasters(state, list_query, is_authenticated).await,
|
||||
"roasts" => render_roasts(state, list_query, is_authenticated).await,
|
||||
"bags" => render_bags(state, list_query, is_authenticated).await,
|
||||
"gear" => render_gear(state, list_query, is_authenticated).await,
|
||||
"cafes" => render_cafes(state, list_query, is_authenticated).await,
|
||||
"cups" => render_cups(state, list_query, is_authenticated).await,
|
||||
_ => render_brews(state, list_query, is_authenticated).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_brews(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::brews::BrewSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<BrewSortKey>();
|
||||
let data = super::brews::load_brew_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
BrewListTemplate {
|
||||
is_authenticated,
|
||||
brews: data.brews,
|
||||
navigator: data.navigator,
|
||||
},
|
||||
"brews",
|
||||
)
|
||||
}
|
||||
|
||||
async fn render_roasters(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::roasters::RoasterSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<RoasterSortKey>();
|
||||
let (roasters, navigator) =
|
||||
super::roasters::load_roaster_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
RoasterListTemplate {
|
||||
is_authenticated,
|
||||
roasters,
|
||||
navigator,
|
||||
},
|
||||
"roasters",
|
||||
)
|
||||
}
|
||||
|
||||
async fn render_roasts(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::roasts::RoastSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<RoastSortKey>();
|
||||
let (roasts, navigator) =
|
||||
super::roasts::load_roast_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
RoastListTemplate {
|
||||
is_authenticated,
|
||||
roasts,
|
||||
navigator,
|
||||
},
|
||||
"roasts",
|
||||
)
|
||||
}
|
||||
|
||||
async fn render_bags(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::bags::BagSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<BagSortKey>();
|
||||
let data = super::bags::load_bag_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
BagListTemplate {
|
||||
is_authenticated,
|
||||
open_bags: data.open_bags,
|
||||
bags: data.bags,
|
||||
navigator: data.navigator,
|
||||
},
|
||||
"bags",
|
||||
)
|
||||
}
|
||||
|
||||
async fn render_gear(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::gear::GearSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<GearSortKey>();
|
||||
let (gear, navigator) = super::gear::load_gear_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
GearListTemplate {
|
||||
is_authenticated,
|
||||
gear,
|
||||
navigator,
|
||||
},
|
||||
"gear",
|
||||
)
|
||||
}
|
||||
|
||||
async fn render_cafes(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::cafes::CafeSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<CafeSortKey>();
|
||||
let (cafes, navigator) =
|
||||
super::cafes::load_cafe_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
CafeListTemplate {
|
||||
is_authenticated,
|
||||
cafes,
|
||||
navigator,
|
||||
},
|
||||
"cafes",
|
||||
)
|
||||
}
|
||||
|
||||
async fn render_cups(
|
||||
state: &AppState,
|
||||
list_query: ListQuery,
|
||||
is_authenticated: bool,
|
||||
) -> Result<String, AppError> {
|
||||
use crate::domain::cups::CupSortKey;
|
||||
let (request, search) = list_query.into_request_and_search::<CupSortKey>();
|
||||
let (cups, navigator) = super::cups::load_cup_page(state, request, search.as_deref()).await?;
|
||||
render_list(
|
||||
CupListTemplate {
|
||||
is_authenticated,
|
||||
cups,
|
||||
navigator,
|
||||
},
|
||||
"cups",
|
||||
)
|
||||
}
|
||||
|
|
@ -8,8 +8,7 @@ use serde::Deserialize;
|
|||
|
||||
use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
|
|
@ -18,14 +17,14 @@ use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear,
|
|||
use crate::domain::ids::GearId;
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
|
||||
use crate::presentation::web::templates::{GearListTemplate, GearTemplate};
|
||||
use crate::presentation::web::templates::GearListTemplate;
|
||||
use crate::presentation::web::views::{GearView, ListNavigator, Paginated};
|
||||
|
||||
const GEAR_PAGE_PATH: &str = "/gear";
|
||||
const GEAR_FRAGMENT_PATH: &str = "/gear#gear-list";
|
||||
const GEAR_PAGE_PATH: &str = "/data?type=gear";
|
||||
const GEAR_FRAGMENT_PATH: &str = "/data?type=gear#gear-list";
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_gear_page(
|
||||
pub(super) async fn load_gear_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<GearSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -46,38 +45,6 @@ async fn load_gear_page(
|
|||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn gear_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_gear_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let (gear, navigator) = load_gear_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = GearTemplate {
|
||||
nav_active: "gear",
|
||||
is_authenticated,
|
||||
gear,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn create_gear(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod add;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod bags;
|
||||
|
|
@ -5,6 +6,7 @@ pub mod brews;
|
|||
pub mod cafes;
|
||||
pub mod checkin;
|
||||
pub mod cups;
|
||||
pub mod data;
|
||||
pub mod gear;
|
||||
pub mod home;
|
||||
mod macros;
|
||||
|
|
@ -107,19 +109,8 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
.route("/", get(home::home_page))
|
||||
.route("/login", get(auth::login_page).post(auth::login_submit))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/roasters", get(roasters::roasters_page))
|
||||
.route("/roasters/:slug", get(roasters::roaster_page))
|
||||
.route("/roasts", get(roasts::roasts_page))
|
||||
.route(
|
||||
"/roasters/:roaster_slug/roasts/:roast_slug",
|
||||
get(roasts::roast_page),
|
||||
)
|
||||
.route("/bags", get(bags::bags_page))
|
||||
.route("/brews", get(brews::brews_page))
|
||||
.route("/gear", get(gear::gear_page))
|
||||
.route("/cafes", get(cafes::cafes_page))
|
||||
.route("/cafes/:slug", get(cafes::cafe_page))
|
||||
.route("/cups", get(cups::cups_page))
|
||||
.route("/data", get(data::data_page))
|
||||
.route("/add", get(add::add_page))
|
||||
.route("/scan", get(scan_redirect))
|
||||
.route("/check-in", get(checkin::checkin_page))
|
||||
.route("/timeline", get(timeline::timeline_page))
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
|
||||
use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
|
|
@ -15,16 +14,14 @@ use crate::domain::ids::RoasterId;
|
|||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
||||
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||
use crate::presentation::web::templates::{
|
||||
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
|
||||
};
|
||||
use crate::presentation::web::views::{ListNavigator, Paginated, RoastView, RoasterView};
|
||||
use crate::presentation::web::templates::RoasterListTemplate;
|
||||
use crate::presentation::web::views::{ListNavigator, Paginated, RoasterView};
|
||||
|
||||
const ROASTER_PAGE_PATH: &str = "/roasters";
|
||||
const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list";
|
||||
const ROASTER_PAGE_PATH: &str = "/data?type=roasters";
|
||||
const ROASTER_FRAGMENT_PATH: &str = "/data?type=roasters#roaster-list";
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_roaster_page(
|
||||
pub(super) async fn load_roaster_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<RoasterSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -45,68 +42,6 @@ async fn load_roaster_page(
|
|||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn roasters_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_roaster_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let (roasters, navigator) = load_roaster_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = RoastersTemplate {
|
||||
nav_active: "roasters",
|
||||
is_authenticated,
|
||||
roasters,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn roaster_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Html<String>, StatusCode> {
|
||||
let roaster = state
|
||||
.roaster_repo
|
||||
.get_by_slug(&slug)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
let roasts = state
|
||||
.roast_repo
|
||||
.list_by_roaster(roaster.id)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let roaster_view = RoasterView::from(roaster);
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = RoasterDetailTemplate {
|
||||
nav_active: "roasters",
|
||||
is_authenticated,
|
||||
roaster: roaster_view,
|
||||
roasts: roasts.into_iter().map(RoastView::from_list_item).collect(),
|
||||
};
|
||||
|
||||
render_html(template)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub(crate) async fn list_roasters(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -1,34 +1,30 @@
|
|||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::macros::{
|
||||
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
||||
};
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::errors::{ApiError, AppError, map_app_error};
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::errors::{ApiError, AppError};
|
||||
use crate::application::routes::support::{
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, load_roaster_options,
|
||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
||||
};
|
||||
use crate::application::server::AppState;
|
||||
use crate::domain::bags::{BagFilter, BagSortKey};
|
||||
use crate::domain::ids::{RoastId, RoasterId};
|
||||
use crate::domain::listing::{ListRequest, SortDirection};
|
||||
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
||||
use crate::infrastructure::ai::{self, ExtractionInput};
|
||||
use crate::presentation::web::templates::{
|
||||
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
||||
};
|
||||
use crate::presentation::web::templates::{RoastListTemplate, RoastOptionsTemplate};
|
||||
use crate::presentation::web::views::{ListNavigator, Paginated, RoastView};
|
||||
|
||||
const ROAST_PAGE_PATH: &str = "/roasts";
|
||||
const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list";
|
||||
const ROAST_PAGE_PATH: &str = "/data?type=roasts";
|
||||
const ROAST_FRAGMENT_PATH: &str = "/data?type=roasts#roast-list";
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
async fn load_roast_page(
|
||||
pub(super) async fn load_roast_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<RoastSortKey>,
|
||||
search: Option<&str>,
|
||||
|
|
@ -49,84 +45,6 @@ async fn load_roast_page(
|
|||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies, headers, query))]
|
||||
pub(crate) async fn roasts_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let (request, search) = query.into_request_and_search::<RoastSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_roast_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
|
||||
|
||||
let (roasts, navigator) = load_roast_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = RoastsTemplate {
|
||||
nav_active: "roasts",
|
||||
is_authenticated,
|
||||
roasts,
|
||||
roaster_options,
|
||||
navigator,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn roast_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Path((roaster_slug, roast_slug)): Path<(String, String)>,
|
||||
) -> Result<Html<String>, StatusCode> {
|
||||
let roaster = state
|
||||
.roaster_repo
|
||||
.get_by_slug(&roaster_slug)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let roast = state
|
||||
.roast_repo
|
||||
.get_by_slug(roaster.id, &roast_slug)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let bag_request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
|
||||
let bags_page = state
|
||||
.bag_repo
|
||||
.list(BagFilter::for_roast(roast.id), &bag_request, None)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let bag_views = bags_page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(crate::presentation::web::views::BagView::from_domain)
|
||||
.collect();
|
||||
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let template = RoastDetailTemplate {
|
||||
nav_active: "roasts",
|
||||
is_authenticated,
|
||||
roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug),
|
||||
bags: bag_views,
|
||||
};
|
||||
|
||||
render_html(template)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state, _auth_user, headers, query))]
|
||||
pub(crate) async fn create_roast(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -114,8 +114,8 @@ pub fn build_page_view<K, T, V>(
|
|||
page: Page<T>,
|
||||
request: ListRequest<K>,
|
||||
view_mapper: impl FnMut(T) -> V,
|
||||
base_path: &'static str,
|
||||
fragment_path: &'static str,
|
||||
base_path: impl Into<String>,
|
||||
fragment_path: impl Into<String>,
|
||||
search: Option<String>,
|
||||
) -> (Paginated<V>, ListNavigator<K>)
|
||||
where
|
||||
|
|
|
|||
|
|
@ -14,16 +14,6 @@ use crate::domain::roasters::RoasterSortKey;
|
|||
use crate::domain::roasts::{RoastSortKey, RoastWithRoaster};
|
||||
use crate::domain::timeline::TimelineSortKey;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "roasters.html")]
|
||||
pub struct RoastersTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub roasters: Paginated<RoasterView>,
|
||||
pub navigator: ListNavigator<RoasterSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/roaster_list.html")]
|
||||
pub struct RoasterListTemplate {
|
||||
|
|
@ -32,37 +22,6 @@ pub struct RoasterListTemplate {
|
|||
pub navigator: ListNavigator<RoasterSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "roaster_detail.html")]
|
||||
pub struct RoasterDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub roaster: RoasterView,
|
||||
pub roasts: Vec<RoastView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "roasts.html")]
|
||||
pub struct RoastsTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub roasts: Paginated<RoastView>,
|
||||
pub roaster_options: Vec<RoasterOptionView>,
|
||||
pub navigator: ListNavigator<RoastSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "roast_detail.html")]
|
||||
pub struct RoastDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub roast: RoastView,
|
||||
pub bags: Vec<BagView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/roast_list.html")]
|
||||
pub struct RoastListTemplate {
|
||||
|
|
@ -91,18 +50,6 @@ pub struct TimelineChunkTemplate {
|
|||
pub months: Vec<TimelineMonthView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "bags.html")]
|
||||
pub struct BagsTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub open_bags: Vec<BagView>,
|
||||
pub bags: Paginated<BagView>,
|
||||
pub roaster_options: Vec<RoasterOptionView>,
|
||||
pub navigator: ListNavigator<BagSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/bag_list.html")]
|
||||
pub struct BagListTemplate {
|
||||
|
|
@ -112,16 +59,6 @@ pub struct BagListTemplate {
|
|||
pub navigator: ListNavigator<BagSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "gear.html")]
|
||||
pub struct GearTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub gear: Paginated<GearView>,
|
||||
pub navigator: ListNavigator<GearSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/gear_list.html")]
|
||||
pub struct GearListTemplate {
|
||||
|
|
@ -136,21 +73,6 @@ pub struct RoastOptionsTemplate {
|
|||
pub roasts: Vec<RoastWithRoaster>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "brews.html")]
|
||||
pub struct BrewsTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub brews: Paginated<BrewView>,
|
||||
pub bag_options: Vec<BagOptionView>,
|
||||
pub grinder_options: Vec<GearOptionView>,
|
||||
pub brewer_options: Vec<GearOptionView>,
|
||||
pub filter_paper_options: Vec<GearOptionView>,
|
||||
pub defaults: BrewDefaultsView,
|
||||
pub navigator: ListNavigator<BrewSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/brew_list.html")]
|
||||
pub struct BrewListTemplate {
|
||||
|
|
@ -159,16 +81,6 @@ pub struct BrewListTemplate {
|
|||
pub navigator: ListNavigator<BrewSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "cafes.html")]
|
||||
pub struct CafesTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub cafes: Paginated<CafeView>,
|
||||
pub navigator: ListNavigator<CafeSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/cafe_list.html")]
|
||||
pub struct CafeListTemplate {
|
||||
|
|
@ -177,27 +89,6 @@ pub struct CafeListTemplate {
|
|||
pub navigator: ListNavigator<CafeSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "cafe_detail.html")]
|
||||
pub struct CafeDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub cafe: CafeView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "cups.html")]
|
||||
pub struct CupsTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub cups: Paginated<CupView>,
|
||||
pub roast_options: Vec<RoastOptionView>,
|
||||
pub cafe_options: Vec<CafeOptionView>,
|
||||
pub navigator: ListNavigator<CupSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/cup_list.html")]
|
||||
pub struct CupListTemplate {
|
||||
|
|
@ -234,6 +125,37 @@ pub struct NearbyCafesFragment {
|
|||
pub cafes: Vec<NearbyCafeView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "data.html")]
|
||||
pub struct DataTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub active_type: String,
|
||||
pub tabs: Vec<DataTab>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub struct DataTab {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "add.html")]
|
||||
pub struct AddTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub active_type: String,
|
||||
pub roaster_options: Vec<RoasterOptionView>,
|
||||
pub roast_options: Vec<RoastOptionView>,
|
||||
pub bag_options: Vec<BagOptionView>,
|
||||
pub grinder_options: Vec<GearOptionView>,
|
||||
pub brewer_options: Vec<GearOptionView>,
|
||||
pub filter_paper_options: Vec<GearOptionView>,
|
||||
pub cafe_options: Vec<CafeOptionView>,
|
||||
pub defaults: BrewDefaultsView,
|
||||
}
|
||||
|
||||
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
||||
template.render()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,22 +175,22 @@ impl<T> Paginated<T> {
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ListNavigator<K: SortKey> {
|
||||
base_path: &'static str,
|
||||
fragment_path: &'static str,
|
||||
base_path: String,
|
||||
fragment_path: String,
|
||||
request: ListRequest<K>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
impl<K: SortKey> ListNavigator<K> {
|
||||
pub fn new(
|
||||
base_path: &'static str,
|
||||
fragment_path: &'static str,
|
||||
base_path: impl Into<String>,
|
||||
fragment_path: impl Into<String>,
|
||||
request: ListRequest<K>,
|
||||
search: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_path,
|
||||
fragment_path,
|
||||
base_path: base_path.into(),
|
||||
fragment_path: fragment_path.into(),
|
||||
request,
|
||||
search,
|
||||
}
|
||||
|
|
@ -221,31 +221,31 @@ impl<K: SortKey> ListNavigator<K> {
|
|||
}
|
||||
|
||||
pub fn page_href(&self, page: u32) -> String {
|
||||
self.build_href(self.base_path, self.request.with_page(page))
|
||||
self.build_href(&self.base_path, self.request.with_page(page))
|
||||
}
|
||||
|
||||
pub fn fragment_page_href(&self, page: u32) -> String {
|
||||
self.build_href(self.fragment_path, self.request.with_page(page))
|
||||
self.build_href(&self.fragment_path, self.request.with_page(page))
|
||||
}
|
||||
|
||||
pub fn rows_href(&self, value: &str) -> String {
|
||||
self.build_href(self.base_path, Self::request_for_rows(self.request, value))
|
||||
self.build_href(&self.base_path, Self::request_for_rows(self.request, value))
|
||||
}
|
||||
|
||||
pub fn fragment_rows_href(&self, value: &str) -> String {
|
||||
self.build_href(
|
||||
self.fragment_path,
|
||||
&self.fragment_path,
|
||||
Self::request_for_rows(self.request, value),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn sort_href(&self, key: &str) -> String {
|
||||
self.build_href(self.base_path, Self::request_for_sort(self.request, key))
|
||||
self.build_href(&self.base_path, Self::request_for_sort(self.request, key))
|
||||
}
|
||||
|
||||
pub fn fragment_sort_href(&self, key: &str) -> String {
|
||||
self.build_href(
|
||||
self.fragment_path,
|
||||
&self.fragment_path,
|
||||
Self::request_for_sort(self.request, key),
|
||||
)
|
||||
}
|
||||
|
|
@ -290,7 +290,7 @@ impl<K: SortKey> ListNavigator<K> {
|
|||
|
||||
/// Returns the base path (e.g., "/roasters") without query or fragment.
|
||||
pub fn path(&self) -> &str {
|
||||
self.base_path
|
||||
&self.base_path
|
||||
}
|
||||
|
||||
/// Returns query params for search actions (page reset to 1, preserves `sort/page_size`).
|
||||
|
|
@ -304,11 +304,25 @@ impl<K: SortKey> ListNavigator<K> {
|
|||
)
|
||||
}
|
||||
|
||||
fn build_href(&self, path: &str, request: ListRequest<K>) -> String {
|
||||
if let Some((base, fragment)) = path.split_once('#') {
|
||||
format!("{}?{}#{}", base, self.build_query_string(request), fragment)
|
||||
/// Returns the full URL prefix for search: `{path}?{query_base}&q=` or `{path}&{query_base}&q=`
|
||||
/// depending on whether the base path already contains query parameters.
|
||||
pub fn search_href_prefix(&self) -> String {
|
||||
let sep = if self.base_path.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
format!("{}?{}", path, self.build_query_string(request))
|
||||
'?'
|
||||
};
|
||||
format!("{}{sep}{}&q=", self.base_path, self.search_query_base())
|
||||
}
|
||||
|
||||
fn build_href(&self, path: &str, request: ListRequest<K>) -> String {
|
||||
let qs = self.build_query_string(request);
|
||||
if let Some((base, fragment)) = path.split_once('#') {
|
||||
let sep = if base.contains('?') { '&' } else { '?' };
|
||||
format!("{base}{sep}{qs}#{fragment}")
|
||||
} else {
|
||||
let sep = if path.contains('?') { '&' } else { '?' };
|
||||
format!("{path}{sep}{qs}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,14 +55,14 @@ impl TimelineEventView {
|
|||
let TimelineEvent {
|
||||
id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
entity_id: _,
|
||||
action,
|
||||
occurred_at,
|
||||
title,
|
||||
details,
|
||||
tasting_notes,
|
||||
slug,
|
||||
roaster_slug,
|
||||
slug: _,
|
||||
roaster_slug: _,
|
||||
brew_data,
|
||||
} = event;
|
||||
|
||||
|
|
@ -78,18 +78,12 @@ impl TimelineEventView {
|
|||
_ => "Event",
|
||||
};
|
||||
|
||||
let link = match (entity_type.as_str(), slug, roaster_slug) {
|
||||
("roaster", Some(slug), _) => format!("/roasters/{slug}"),
|
||||
// Roasts, bags, brews, and cups link to the roast page when we have slug info
|
||||
("roast" | "bag" | "brew" | "cup", Some(slug), Some(roaster_slug)) => {
|
||||
format!("/roasters/{roaster_slug}/roasts/{slug}")
|
||||
}
|
||||
("cafe", Some(slug), _) => format!("/cafes/{slug}"),
|
||||
("cup", _, _) => "/cups".to_string(),
|
||||
("gear", _, _) => "/gear".to_string(),
|
||||
("brew", _, _) => "/brews".to_string(),
|
||||
("roaster", None, _) => format!("/roasters/{entity_id}"),
|
||||
("roast", None, _) => format!("/roasts/{entity_id}"),
|
||||
let link = match entity_type.as_str() {
|
||||
"roaster" => "/data?type=roasters".to_string(),
|
||||
"roast" | "bag" | "brew" => format!("/data?type={entity_type}s"),
|
||||
"cafe" => "/data?type=cafes".to_string(),
|
||||
"cup" => "/data?type=cups".to_string(),
|
||||
"gear" => "/data?type=gear".to_string(),
|
||||
_ => String::from("#"),
|
||||
};
|
||||
|
||||
|
|
|
|||
797
templates/add.html
Normal file
797
templates/add.html
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Add{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script>
|
||||
let _userLat = null;
|
||||
let _userLng = null;
|
||||
let _searchTimeout = null;
|
||||
let _nearbyCafes = [];
|
||||
|
||||
function locateUser(btn) {
|
||||
const errorEl = document.getElementById('nearby-error');
|
||||
const searchWrap = document.getElementById('nearby-search-wrap');
|
||||
errorEl.classList.add('hidden');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
errorEl.textContent = 'Geolocation is not supported by your browser.';
|
||||
errorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.textContent = 'Locating\u2026';
|
||||
btn.disabled = true;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
_userLat = pos.coords.latitude;
|
||||
_userLng = pos.coords.longitude;
|
||||
btn.classList.add('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
document.getElementById('nearby-search').focus();
|
||||
},
|
||||
(err) => {
|
||||
btn.textContent = 'Find nearby';
|
||||
btn.disabled = false;
|
||||
if (err.code === 1) {
|
||||
errorEl.textContent = 'Location access denied. Please allow location access and try again.';
|
||||
} else if (err.code === 3) {
|
||||
errorEl.textContent = 'Location request timed out. Please try again.';
|
||||
} else {
|
||||
errorEl.textContent = 'Could not determine your location. Please try again.';
|
||||
}
|
||||
errorEl.classList.remove('hidden');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000 }
|
||||
);
|
||||
}
|
||||
|
||||
function onSearchInput(input) {
|
||||
clearTimeout(_searchTimeout);
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
|
||||
if (input.value.trim().length < 2) {
|
||||
resultsEl.classList.add('hidden');
|
||||
resultsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
_searchTimeout = setTimeout(() => {
|
||||
searchNearby(input.value.trim());
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function toggleCitySearch(checkbox) {
|
||||
const locateBtn = document.getElementById('locate-btn');
|
||||
const cityWrap = document.getElementById('city-search-wrap');
|
||||
const searchWrap = document.getElementById('nearby-search-wrap');
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
|
||||
if (checkbox.checked) {
|
||||
locateBtn.classList.add('hidden');
|
||||
cityWrap.classList.remove('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
document.getElementById('city-input').focus();
|
||||
} else {
|
||||
cityWrap.classList.add('hidden');
|
||||
document.getElementById('city-input').value = '';
|
||||
if (_userLat === null) {
|
||||
searchWrap.classList.add('hidden');
|
||||
locateBtn.classList.remove('hidden');
|
||||
locateBtn.textContent = 'Find nearby';
|
||||
locateBtn.disabled = false;
|
||||
}
|
||||
resultsEl.classList.add('hidden');
|
||||
resultsEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function searchNearby(query) {
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
const errorEl = document.getElementById('nearby-error');
|
||||
const spinnerEl = document.getElementById('nearby-spinner');
|
||||
errorEl.classList.add('hidden');
|
||||
spinnerEl.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const cityInput = document.getElementById('city-input');
|
||||
const cityValue = cityInput ? cityInput.value.trim() : '';
|
||||
let url;
|
||||
if (cityValue.length >= 2) {
|
||||
url = `/api/v1/nearby-cafes?near=${encodeURIComponent(cityValue)}&q=${encodeURIComponent(query)}`;
|
||||
} else {
|
||||
url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||
|
||||
const cafes = await resp.json();
|
||||
_nearbyCafes = cafes;
|
||||
|
||||
if (cafes.length === 0) {
|
||||
resultsEl.innerHTML = '<div class="px-3 py-2 text-sm text-stone-500">No results found.</div>';
|
||||
resultsEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < cafes.length; i++) {
|
||||
const dist = cafes[i].distance_meters;
|
||||
const distLabel = dist < 1000
|
||||
? `${dist} m`
|
||||
: `${(dist / 1000).toFixed(1)} km`;
|
||||
const location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
|
||||
html += `<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(${i})">`
|
||||
+ `<span class="font-medium text-amber-900">${escapeHtml(cafes[i].name)}</span>`
|
||||
+ `<span class="ml-2 text-xs text-stone-500">${escapeHtml(location)} · ${distLabel}</span>`
|
||||
+ `</button>`;
|
||||
}
|
||||
resultsEl.innerHTML = html;
|
||||
resultsEl.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Search failed. Please try again.';
|
||||
errorEl.classList.remove('hidden');
|
||||
} finally {
|
||||
spinnerEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function selectPlace(index) {
|
||||
const cafe = _nearbyCafes[index];
|
||||
if (!cafe) return;
|
||||
|
||||
const form = document.getElementById('cafe-form');
|
||||
form.querySelector('[name="name"]').value = cafe.name;
|
||||
form.querySelector('[name="city"]').value = cafe.city || '';
|
||||
form.querySelector('[name="country"]').value = cafe.country || '';
|
||||
form.querySelector('[name="latitude"]').value = cafe.latitude;
|
||||
form.querySelector('[name="longitude"]').value = cafe.longitude;
|
||||
form.querySelector('[name="website"]').value = cafe.website || '';
|
||||
|
||||
document.getElementById('nearby-results').classList.add('hidden');
|
||||
document.getElementById('nearby-search').value = '';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Scan Bag (Quick Add) -->
|
||||
<section
|
||||
data-signals:_extracting="false"
|
||||
data-signals:_extract-error="''"
|
||||
data-signals:_scan-extracted="false"
|
||||
data-signals:_scan-submitting="false"
|
||||
data-signals:_scan-error="''"
|
||||
data-signals:_roaster-name="''"
|
||||
data-signals:_roaster-country="''"
|
||||
data-signals:_roaster-city="''"
|
||||
data-signals:_roaster-homepage="''"
|
||||
data-signals:_roast-name="''"
|
||||
data-signals:_origin="''"
|
||||
data-signals:_region="''"
|
||||
data-signals:_producer="''"
|
||||
data-signals:_process="''"
|
||||
data-signals:_tasting-notes="''"
|
||||
data-signals:_open-bag="true"
|
||||
data-signals:_bag-amount="250"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-amber-700 mb-3">Quick Add — Scan Bag</h2>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden"
|
||||
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=''}" />
|
||||
|
||||
<!-- Input: photo or text -->
|
||||
<div data-show="!$_scanExtracted" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<form id="scan-extract-form"
|
||||
data-on:submit="$_extracting = true; $_extractError = ''; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; @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.' }"
|
||||
>
|
||||
<input type="hidden" name="image" id="scan-image" />
|
||||
<div data-show="!$_extracting" class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('scan-photo').click()"
|
||||
class="shrink-0 inline-flex items-center justify-center rounded-md border border-amber-500 p-2.5 text-amber-700 transition hover:bg-amber-50"
|
||||
aria-label="Take Photo"
|
||||
>
|
||||
{% call icons::camera("h-5 w-5") %}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
name="prompt"
|
||||
class="input-field w-full text-sm"
|
||||
placeholder="Describe the coffee bag…"
|
||||
/>
|
||||
</div>
|
||||
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-5 w-5") %}
|
||||
Waiting for response…
|
||||
</div>
|
||||
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Scan result form -->
|
||||
<div data-show="$_scanExtracted" style="display: none">
|
||||
<form
|
||||
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm flex flex-col gap-6"
|
||||
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 = '/data?type=bags' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-amber-700">Roaster</h3>
|
||||
<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">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Name *</span>
|
||||
<input type="text" name="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Country *</span>
|
||||
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" data-bind:_roaster-country />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">City</span>
|
||||
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Homepage</span>
|
||||
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-amber-700">Roast</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast Name *</span>
|
||||
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Origin *</span>
|
||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Region *</span>
|
||||
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_region />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Producer *</span>
|
||||
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Process *</span>
|
||||
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_process />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
|
||||
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" name="open_bag" value="true" class="accent-amber-600" data-bind:_open-bag />
|
||||
<span class="font-semibold text-amber-700">Open a bag of this coffee</span>
|
||||
</label>
|
||||
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Amount (grams)</span>
|
||||
<input type="number" name="bag_amount" min="1" step="any" class="input-field" placeholder="250" data-bind:_bag-amount />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
|
||||
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-5 w-5") %}
|
||||
Saving…
|
||||
</div>
|
||||
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
Save Roaster & Roast
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Manual Add -->
|
||||
<section
|
||||
data-signals:_add-type="'{{ active_type }}'"
|
||||
data-signals:_add-extracting="false"
|
||||
data-signals:_add-extract-error="''"
|
||||
data-signals:_add-submitting="false"
|
||||
data-signals:_add-roaster-name="''"
|
||||
data-signals:_add-roaster-country="''"
|
||||
data-signals:_add-roaster-city="''"
|
||||
data-signals:_add-roaster-homepage="''"
|
||||
data-signals:_add-roast-name="''"
|
||||
data-signals:_add-origin="''"
|
||||
data-signals:_add-region="''"
|
||||
data-signals:_add-producer="''"
|
||||
data-signals:_add-process="''"
|
||||
data-signals:_add-tasting-notes="''"
|
||||
data-signals:_add-roaster-id="''"
|
||||
data-signals:_brew-temp="{{ defaults.water_temp }}"
|
||||
data-signals:_brew-grind="{{ defaults.grind_setting }}"
|
||||
data-signals:_brew-volume="{{ defaults.water_volume }}"
|
||||
data-signals:_brew-weight="{{ defaults.coffee_weight }}"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-amber-700 mb-3">Manual Add</h2>
|
||||
|
||||
<!-- Entity type selector pills -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{% for (key, label) in [("roaster", "Roaster"), ("roast", "Roast"), ("bag", "Bag"), ("brew", "Brew"), ("gear", "Gear"), ("cafe", "Cafe"), ("cup", "Cup")] %}
|
||||
<button
|
||||
type="button"
|
||||
data-on:click="$_addType = '{{ key }}'"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition border"
|
||||
data-class:bg-amber-600="$_addType === '{{ key }}'"
|
||||
data-class:text-amber-50="$_addType === '{{ key }}'"
|
||||
data-class:border-amber-600="$_addType === '{{ key }}'"
|
||||
data-class:bg-transparent="$_addType !== '{{ key }}'"
|
||||
data-class:text-stone-600="$_addType !== '{{ key }}'"
|
||||
data-class:border-amber-300="$_addType !== '{{ key }}'"
|
||||
data-class:hover--bg-amber-50="$_addType !== '{{ key }}'"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ========== ROASTER FORM ========== -->
|
||||
<div data-show="$_addType === 'roaster'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Roaster</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Provide the core details and Brewlog will keep track of everything for you.</p>
|
||||
</div>
|
||||
|
||||
<input type="file" id="add-roaster-photo" accept="image/*" capture="environment" class="hidden"
|
||||
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('add-roaster-image').value=r.result;document.getElementById('add-roaster-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
|
||||
|
||||
<form id="add-roaster-extract-form" class="mt-4 border-b border-amber-200 pb-4"
|
||||
data-on:submit="$_addExtracting = true; $_addExtractError = ''; @post('/api/v1/extract-roaster', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_addExtracting) return; if (evt.detail.type === 'finished') { $_addExtracting = false } else if (evt.detail.type === 'error') { $_addExtracting = false; $_addExtractError = 'Extraction failed. Please try again.' }"
|
||||
>
|
||||
<input type="hidden" name="image" id="add-roaster-image" />
|
||||
<div data-show="!$_addExtracting" class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onclick="document.getElementById('add-roaster-photo').click()"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">
|
||||
{% call icons::camera("h-4 w-4") %}
|
||||
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-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-show="$_addExtracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-4 w-4") %}
|
||||
Waiting for response…
|
||||
</div>
|
||||
<p data-show="$_addExtractError" data-text="$_addExtractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/api/v1/roasters" class="mt-4 flex flex-col gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Name *</span>
|
||||
<input type="text" name="name" required class="input-field" placeholder="Example Coffee Roasters" data-bind:_add-roaster-name />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Country *</span>
|
||||
<input type="text" name="country" required class="input-field" placeholder="United States" data-bind:_add-roaster-country />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">City</span>
|
||||
<input type="text" name="city" class="input-field" placeholder="Portland" data-bind:_add-roaster-city />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Homepage</span>
|
||||
<input type="url" name="homepage" class="input-field" placeholder="https://example.coffee" data-bind:_add-roaster-homepage />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Save Roaster
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ========== ROAST FORM ========== -->
|
||||
<div data-show="$_addType === 'roast'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
{% if roaster_options.is_empty() %}
|
||||
<div class="text-sm text-stone-600">
|
||||
<h3 class="text-lg font-semibold text-amber-700">Add a roaster first</h3>
|
||||
<p class="mt-2">Roasts need a roaster. Add a roaster above to enable this form.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Roast</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Select a roaster, describe the roast, and Brewlog will take care of the rest.</p>
|
||||
</div>
|
||||
|
||||
<input type="file" id="add-roast-photo" accept="image/*" capture="environment" class="hidden"
|
||||
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('add-roast-image').value=r.result;document.getElementById('add-roast-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
|
||||
|
||||
<form id="add-roast-extract-form" class="mt-4 border-b border-amber-200 pb-4"
|
||||
data-on:submit="$_addExtracting = true; $_addExtractError = ''; @post('/api/v1/extract-roast', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_addExtracting) return; if (evt.detail.type === 'finished') { $_addExtracting = false } else if (evt.detail.type === 'error') { $_addExtracting = false; $_addExtractError = 'Extraction failed. Please try again.' }"
|
||||
>
|
||||
<input type="hidden" name="image" id="add-roast-image" />
|
||||
<div data-show="!$_addExtracting" class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onclick="document.getElementById('add-roast-photo').click()"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">
|
||||
{% call icons::camera("h-4 w-4") %}
|
||||
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-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-show="$_addExtracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-4 w-4") %}
|
||||
Waiting for response…
|
||||
</div>
|
||||
<p data-show="$_addExtractError" data-text="$_addExtractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/api/v1/roasts" class="mt-4 flex flex-col gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roaster *</span>
|
||||
<select name="roaster_id" required class="input-field" data-bind:_add-roaster-id>
|
||||
<option value="">Select a roaster</option>
|
||||
{% for roaster in roaster_options %}
|
||||
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast Name *</span>
|
||||
<input type="text" name="name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_add-roast-name />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Origin *</span>
|
||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_add-origin />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Region *</span>
|
||||
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_add-region />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Producer *</span>
|
||||
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" data-bind:_add-producer />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Process *</span>
|
||||
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_add-process />
|
||||
</label>
|
||||
<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>
|
||||
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine" data-bind:_add-tasting-notes></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Save Roast
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ========== BAG FORM ========== -->
|
||||
<div data-show="$_addType === 'bag'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
{% if roaster_options.is_empty() %}
|
||||
<div class="text-sm text-stone-600">
|
||||
<h3 class="text-lg font-semibold text-amber-700">Add a roaster first</h3>
|
||||
<p class="mt-2">Bags need a roaster and a roast. Add a roaster to enable this form.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Bag</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Select a roaster, then a roast, and enter the details.</p>
|
||||
</div>
|
||||
<form method="post" action="/api/v1/bags" class="mt-4 flex flex-col gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roaster *</span>
|
||||
<select name="roaster_id" required class="input-field"
|
||||
data-on:change="@get('/api/v1/roasts?roaster_id=' + evt.target.value, {responseOverrides: {selector: '#add-roast-select-options', mode: 'replace'}})">
|
||||
<option value="">Select a roaster</option>
|
||||
{% for roaster in roaster_options %}
|
||||
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast *</span>
|
||||
<select name="roast_id" required class="input-field" id="add-roast-select">
|
||||
<option value="">Select a roast</option>
|
||||
<optgroup id="add-roast-select-options"></optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast Date</span>
|
||||
<input type="date" name="roast_date" class="input-field" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Amount (g) *</span>
|
||||
<input type="number" name="amount" step="0.1" required class="input-field" placeholder="250" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Save Bag
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ========== BREW FORM ========== -->
|
||||
<div data-show="$_addType === 'brew'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
{% if bag_options.is_empty() %}
|
||||
<div class="text-sm text-stone-600">
|
||||
<h3 class="text-lg font-semibold text-amber-700">Open a bag first</h3>
|
||||
<p class="mt-2">Brews need an open bag of coffee. Add a bag to enable this form.</p>
|
||||
</div>
|
||||
{% else if grinder_options.is_empty() || brewer_options.is_empty() %}
|
||||
<div class="text-sm text-stone-600">
|
||||
<h3 class="text-lg font-semibold text-amber-700">Add your gear first</h3>
|
||||
<p class="mt-2">Brews need a grinder and brewer. Add your gear to enable this form.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Brew</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Log a cup of coffee.</p>
|
||||
</div>
|
||||
<form method="post" action="/api/v1/brews" class="mt-4 flex flex-col gap-4 overflow-hidden">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Bag *</span>
|
||||
<select name="bag_id" required class="input-field">
|
||||
<option value="">Select a bag</option>
|
||||
{% for bag in bag_options %}
|
||||
<option value="{{ bag.id }}"{% if bag.id == defaults.bag_id %} selected{% endif %}>{{ bag.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Grinder *</span>
|
||||
<select name="grinder_id" required class="input-field">
|
||||
{% for grinder in grinder_options %}
|
||||
<option value="{{ grinder.id }}"{% if grinder.id == defaults.grinder_id %} selected{% endif %}>{{ grinder.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Brewer *</span>
|
||||
<select name="brewer_id" required class="input-field">
|
||||
{% for brewer in brewer_options %}
|
||||
<option value="{{ brewer.id }}"{% if brewer.id == defaults.brewer_id %} selected{% endif %}>{{ brewer.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Filter Paper</span>
|
||||
<select name="filter_paper_id" class="input-field">
|
||||
<option value="">None</option>
|
||||
{% for fp in filter_paper_options %}
|
||||
<option value="{{ fp.id }}"{% if fp.id == defaults.filter_paper_id %} selected{% endif %}>{{ fp.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Coffee (g) *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewWeight = Math.max(1, $_brewWeight - 0.5)">-</button>
|
||||
<input type="number" name="coffee_weight" step="any" min="1" required class="input-field flex-1 text-center" data-attr:value="$_brewWeight" data-on:input="$_brewWeight = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewWeight = $_brewWeight + 0.5">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Grind Setting *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewGrind = Math.max(0, $_brewGrind - 0.5)">-</button>
|
||||
<input type="number" name="grind_setting" step="any" min="0" required class="input-field flex-1 text-center" data-attr:value="$_brewGrind" data-on:input="$_brewGrind = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewGrind = $_brewGrind + 0.5">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Water (ml) *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewVolume = Math.max(10, $_brewVolume - 10)">-</button>
|
||||
<input type="number" name="water_volume" step="any" min="10" required class="input-field flex-1 text-center" data-attr:value="$_brewVolume" data-on:input="$_brewVolume = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewVolume = $_brewVolume + 10">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Temp (C) *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewTemp = Math.max(0, $_brewTemp - 0.5)">-</button>
|
||||
<input type="number" name="water_temp" step="any" min="0" max="100" required class="input-field flex-1 text-center" data-attr:value="$_brewTemp" data-on:input="$_brewTemp = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_brewTemp = Math.min(100, $_brewTemp + 0.5)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Log Brew
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ========== GEAR FORM ========== -->
|
||||
<div data-show="$_addType === 'gear'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Gear</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Add brewing equipment to your collection.</p>
|
||||
</div>
|
||||
<form method="post" action="/api/v1/gear" class="mt-4 flex flex-col gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Category *</span>
|
||||
<select name="category" required class="input-field">
|
||||
<option value="">Select a category</option>
|
||||
<option value="grinder">Grinder</option>
|
||||
<option value="brewer">Brewer</option>
|
||||
<option value="filter_paper">Filter Paper</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Make *</span>
|
||||
<input type="text" name="make" required class="input-field" placeholder="Baratza" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Model *</span>
|
||||
<input type="text" name="model" required class="input-field" placeholder="Encore" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Save Gear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ========== CAFE FORM ========== -->
|
||||
<div data-show="$_addType === 'cafe'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Cafe</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Add a cafe you have visited or want to remember.</p>
|
||||
</div>
|
||||
<form id="cafe-form" method="post" action="/api/v1/cafes" class="mt-4 flex flex-col gap-4">
|
||||
<div class="relative border-b border-amber-200 pb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" id="locate-btn" onclick="locateUser(this)"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">
|
||||
{% call icons::location("h-4 w-4") %}
|
||||
Find nearby
|
||||
</button>
|
||||
<label class="inline-flex items-center gap-1.5 text-sm text-stone-600 cursor-pointer select-none">
|
||||
<input type="checkbox" onchange="toggleCitySearch(this)" class="accent-amber-600" />
|
||||
Search by city
|
||||
</label>
|
||||
<div id="city-search-wrap" class="hidden flex-1 min-w-[140px]">
|
||||
<input type="text" id="city-input" class="input-field w-full text-sm" placeholder="e.g. Tokyo, London" autocomplete="off" />
|
||||
</div>
|
||||
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
|
||||
<input type="text" id="nearby-search" oninput="onSearchInput(this)" class="input-field w-full text-sm pr-8" placeholder="Search for a cafe…" autocomplete="off" />
|
||||
<span id="nearby-spinner" class="hidden absolute right-2 top-1/2 -translate-y-1/2">
|
||||
{% call icons::spinner("h-4 w-4") %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p id="nearby-error" class="hidden mt-2 text-sm text-red-600"></p>
|
||||
<div id="nearby-results"
|
||||
class="hidden absolute left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-md border border-amber-300 bg-white shadow-lg divide-y divide-amber-100"></div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Name *</span>
|
||||
<input type="text" name="name" required class="input-field" placeholder="Blue Bottle Coffee" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">City *</span>
|
||||
<input type="text" name="city" required class="input-field" placeholder="San Francisco" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Country *</span>
|
||||
<input type="text" name="country" required class="input-field" placeholder="United States" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Website</span>
|
||||
<input type="url" name="website" class="input-field" placeholder="https://bluebottlecoffee.com" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Latitude *</span>
|
||||
<input type="number" name="latitude" step="any" required class="input-field" placeholder="37.7749" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Longitude *</span>
|
||||
<input type="number" name="longitude" step="any" required class="input-field" placeholder="-122.4194" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Save Cafe
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ========== CUP FORM ========== -->
|
||||
<div data-show="$_addType === 'cup'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-amber-700">New Cup</h3>
|
||||
<p class="mt-1 text-sm text-stone-600">Record a coffee you had at a cafe.</p>
|
||||
</div>
|
||||
<form method="post" action="/api/v1/cups" class="mt-4 flex flex-col gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Coffee *</span>
|
||||
<select name="roast_id" required class="input-field">
|
||||
<option value="">Select a roast...</option>
|
||||
{% for roast in roast_options %}
|
||||
<option value="{{ roast.id }}">{{ roast.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Cafe *</span>
|
||||
<select name="cafe_id" required class="input-field">
|
||||
<option value="">Select a cafe...</option>
|
||||
{% for cafe in cafe_options %}
|
||||
<option value="{{ cafe.id }}">{{ cafe.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Rating</span>
|
||||
<select name="rating" class="input-field">
|
||||
<option value="">No rating</option>
|
||||
<option value="5">5 - Exceptional</option>
|
||||
<option value="4">4 - Great</option>
|
||||
<option value="3">3 - Good</option>
|
||||
<option value="2">2 - Fair</option>
|
||||
<option value="1">1 - Poor</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500">
|
||||
Save Cup
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
{% extends "base.html" %} {% block title %}Brewlog · Bags{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script>
|
||||
const closeBag = async (bagId, cardEl) => {
|
||||
if (!confirm('Close this bag? This will mark it as finished.')) return;
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const resp = await fetch(`/api/v1/bags/${bagId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
alert(`Failed to close bag: ${e.message}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section data-signals:_show-form="false" data-signals:_is-submitting="false">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Bags</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Track your coffee bags.</p>
|
||||
</div>
|
||||
{% if is_authenticated && !roaster_options.is_empty() %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new bag"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %} {% if roaster_options.is_empty() %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-amber-700">Add a roaster first</h2>
|
||||
<p class="mt-2">
|
||||
Bags need a roaster and a roast.
|
||||
<a class="text-amber-700 hover:text-amber-600 underline" href="/roasters"
|
||||
>Create a roaster</a
|
||||
>
|
||||
to enable this form.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Bag</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Select a roaster, then a roast, and enter the details.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="/api/v1/bags"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
data-on:submit="$_isSubmitting = true; @post('/api/v1/bags?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#bag-list', mode: 'replace'}})"
|
||||
data-ref="_form"
|
||||
data-on:datastar-fetch="evt.detail.type === 'finished' && $_isSubmitting && ($_showForm = false, $_form && $_form.reset(), $_isSubmitting = false)"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roaster *</span>
|
||||
<select
|
||||
name="roaster_id"
|
||||
required
|
||||
class="input-field"
|
||||
data-on:change="@get('/api/v1/roasts?roaster_id=' + evt.target.value, {responseOverrides: {selector: '#roast-select-options', mode: 'replace'}})"
|
||||
>
|
||||
<option value="">Select a roaster</option>
|
||||
{% for roaster in roaster_options %}
|
||||
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast *</span>
|
||||
<select name="roast_id" required class="input-field" id="roast-select">
|
||||
<option value="">Select a roast</option>
|
||||
<optgroup id="roast-select-options"></optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast Date</span>
|
||||
<input type="date" name="roast_date" class="input-field" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Amount (g) *</span>
|
||||
<input
|
||||
type="number"
|
||||
name="amount"
|
||||
step="0.1"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="250"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="$_showForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
>
|
||||
Save Bag
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %} {% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
{% include "partials/bag_list.html" %} {% endblock %}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
{% extends "base.html" %} {% block title %}Brewlog · Brews{% endblock %} {% block content %}
|
||||
<section data-signals:_show-form="false" data-signals:_is-submitting="false" data-signals:_temp="{{ defaults.water_temp }}" data-signals:_grind="{{ defaults.grind_setting }}" data-signals:_volume="{{ defaults.water_volume }}" data-signals:_weight="{{ defaults.coffee_weight }}">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Brews</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Log your coffee brews.</p>
|
||||
</div>
|
||||
{% if is_authenticated && !bag_options.is_empty() && !grinder_options.is_empty() && !brewer_options.is_empty() %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new brew"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
{% if bag_options.is_empty() %}
|
||||
<div class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600">
|
||||
<h2 class="text-lg font-semibold text-amber-700">Open a bag first</h2>
|
||||
<p class="mt-2">
|
||||
Brews need an open bag of coffee.
|
||||
<a class="text-amber-700 hover:text-amber-600 underline" href="/bags">Add a bag</a>
|
||||
to enable this form.
|
||||
</p>
|
||||
</div>
|
||||
{% else if grinder_options.is_empty() || brewer_options.is_empty() %}
|
||||
<div class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600">
|
||||
<h2 class="text-lg font-semibold text-amber-700">Add your gear first</h2>
|
||||
<p class="mt-2">
|
||||
Brews need a grinder and brewer.
|
||||
<a class="text-amber-700 hover:text-amber-600 underline" href="/gear">Add your gear</a>
|
||||
to enable this form.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Brew</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">Log a cup of coffee.</p>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="/api/v1/brews"
|
||||
class="mt-4 flex flex-col gap-4 overflow-hidden"
|
||||
data-on:submit="$_isSubmitting = true; @post('/api/v1/brews?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})"
|
||||
data-ref="_form"
|
||||
data-on:datastar-fetch="evt.detail.type === 'finished' && $_isSubmitting && ($_showForm = false, $_isSubmitting = false)"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Bag selector -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Bag *</span>
|
||||
<select name="bag_id" required class="input-field">
|
||||
<option value="">Select a bag</option>
|
||||
{% for bag in bag_options %}
|
||||
<option value="{{ bag.id }}"{% if bag.id == defaults.bag_id %} selected{% endif %}>{{ bag.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Grinder selector -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Grinder *</span>
|
||||
<select name="grinder_id" required class="input-field">
|
||||
{% for grinder in grinder_options %}
|
||||
<option value="{{ grinder.id }}"{% if grinder.id == defaults.grinder_id %} selected{% endif %}>{{ grinder.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Brewer selector -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Brewer *</span>
|
||||
<select name="brewer_id" required class="input-field">
|
||||
{% for brewer in brewer_options %}
|
||||
<option value="{{ brewer.id }}"{% if brewer.id == defaults.brewer_id %} selected{% endif %}>{{ brewer.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Filter Paper selector (optional) -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Filter Paper</span>
|
||||
<select name="filter_paper_id" class="input-field">
|
||||
<option value="">None</option>
|
||||
{% for fp in filter_paper_options %}
|
||||
<option value="{{ fp.id }}"{% if fp.id == defaults.filter_paper_id %} selected{% endif %}>{{ fp.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Coffee Weight with +/- buttons -->
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Coffee (g) *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_weight = Math.max(1, $_weight - 0.5)">-</button>
|
||||
<input type="number" name="coffee_weight" step="any" min="1" required class="input-field flex-1 text-center" data-attr:value="$_weight" data-on:input="$_weight = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_weight = $_weight + 0.5">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grind Setting with +/- buttons -->
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Grind Setting *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_grind = Math.max(0, $_grind - 0.5)">-</button>
|
||||
<input type="number" name="grind_setting" step="any" min="0" required class="input-field flex-1 text-center" data-attr:value="$_grind" data-on:input="$_grind = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_grind = $_grind + 0.5">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Water Volume with +/- buttons -->
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Water (ml) *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_volume = Math.max(10, $_volume - 10)">-</button>
|
||||
<input type="number" name="water_volume" step="any" min="10" required class="input-field flex-1 text-center" data-attr:value="$_volume" data-on:input="$_volume = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_volume = $_volume + 10">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Water Temperature with +/- buttons -->
|
||||
<div class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Temp (C) *</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="btn-adjust" data-on:click="$_temp = Math.max(0, $_temp - 0.5)">-</button>
|
||||
<input type="number" name="water_temp" step="any" min="0" max="100" required class="input-field flex-1 text-center" data-attr:value="$_temp" data-on:input="$_temp = this.valueAsNumber" />
|
||||
<button type="button" class="btn-adjust" data-on:click="$_temp = Math.min(100, $_temp + 0.5)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="$_showForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
>
|
||||
Log Brew
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
{% include "partials/brew_list.html" %} {% endblock %}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Cafe · {{ cafe.name }}{% endblock %} {%
|
||||
block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Detailed view for {{ cafe.name }}.</p>
|
||||
</header>
|
||||
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<dl class="grid gap-2 text-sm text-stone-700">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">City</dt>
|
||||
<dd class="text-right">{{ cafe.city }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Country</dt>
|
||||
<dd class="text-right">{{ cafe.country }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Coordinates</dt>
|
||||
<dd class="text-right">{{ cafe.latitude }}, {{ cafe.longitude }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Map</dt>
|
||||
<dd class="flex justify-end">
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
|
||||
href="{{ cafe.map_url }}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Open {{ cafe.name }} in Google Maps"
|
||||
>
|
||||
{% call icons::location("h-4 w-4") %}
|
||||
<span>Open in Maps</span>
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Website</dt>
|
||||
<dd class="flex justify-end">
|
||||
{% if cafe.has_website %}
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
|
||||
href="{{ cafe.website_url }}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Visit {{ cafe.name }} website"
|
||||
>
|
||||
{% call icons::external_link("h-4 w-4") %}
|
||||
<span>Visit</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span>—</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Created</dt>
|
||||
<dd class="text-right">{{ cafe.created_at }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Cafes{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% if is_authenticated %}
|
||||
<script>
|
||||
let _userLat = null;
|
||||
let _userLng = null;
|
||||
let _searchTimeout = null;
|
||||
let _nearbyCafes = [];
|
||||
|
||||
function locateUser(btn) {
|
||||
const errorEl = document.getElementById('nearby-error');
|
||||
const searchWrap = document.getElementById('nearby-search-wrap');
|
||||
errorEl.classList.add('hidden');
|
||||
errorEl.textContent = '';
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
errorEl.textContent = 'Geolocation is not supported by your browser.';
|
||||
errorEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.textContent = 'Locating\u2026';
|
||||
btn.disabled = true;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
_userLat = pos.coords.latitude;
|
||||
_userLng = pos.coords.longitude;
|
||||
btn.classList.add('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
document.getElementById('nearby-search').focus();
|
||||
},
|
||||
(err) => {
|
||||
btn.textContent = 'Find nearby';
|
||||
btn.disabled = false;
|
||||
if (err.code === 1) {
|
||||
errorEl.textContent = 'Location access denied. Please allow location access and try again.';
|
||||
} else if (err.code === 3) {
|
||||
errorEl.textContent = 'Location request timed out. Please try again.';
|
||||
} else {
|
||||
errorEl.textContent = 'Could not determine your location. Please try again.';
|
||||
}
|
||||
errorEl.classList.remove('hidden');
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000 }
|
||||
);
|
||||
}
|
||||
|
||||
function onSearchInput(input) {
|
||||
clearTimeout(_searchTimeout);
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
|
||||
if (input.value.trim().length < 2) {
|
||||
resultsEl.classList.add('hidden');
|
||||
resultsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
_searchTimeout = setTimeout(() => {
|
||||
searchNearby(input.value.trim());
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function toggleCitySearch(checkbox) {
|
||||
const locateBtn = document.getElementById('locate-btn');
|
||||
const cityWrap = document.getElementById('city-search-wrap');
|
||||
const searchWrap = document.getElementById('nearby-search-wrap');
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
|
||||
if (checkbox.checked) {
|
||||
locateBtn.classList.add('hidden');
|
||||
cityWrap.classList.remove('hidden');
|
||||
searchWrap.classList.remove('hidden');
|
||||
document.getElementById('city-input').focus();
|
||||
} else {
|
||||
cityWrap.classList.add('hidden');
|
||||
document.getElementById('city-input').value = '';
|
||||
if (_userLat === null) {
|
||||
searchWrap.classList.add('hidden');
|
||||
locateBtn.classList.remove('hidden');
|
||||
locateBtn.textContent = 'Find nearby';
|
||||
locateBtn.disabled = false;
|
||||
}
|
||||
resultsEl.classList.add('hidden');
|
||||
resultsEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function searchNearby(query) {
|
||||
const resultsEl = document.getElementById('nearby-results');
|
||||
const errorEl = document.getElementById('nearby-error');
|
||||
const spinnerEl = document.getElementById('nearby-spinner');
|
||||
errorEl.classList.add('hidden');
|
||||
spinnerEl.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const cityInput = document.getElementById('city-input');
|
||||
const cityValue = cityInput ? cityInput.value.trim() : '';
|
||||
let url;
|
||||
if (cityValue.length >= 2) {
|
||||
url = `/api/v1/nearby-cafes?near=${encodeURIComponent(cityValue)}&q=${encodeURIComponent(query)}`;
|
||||
} else {
|
||||
url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
|
||||
}
|
||||
const resp = await fetch(url, { credentials: 'same-origin' });
|
||||
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
||||
|
||||
const cafes = await resp.json();
|
||||
_nearbyCafes = cafes;
|
||||
|
||||
if (cafes.length === 0) {
|
||||
resultsEl.innerHTML = '<div class="px-3 py-2 text-sm text-stone-500">No results found.</div>';
|
||||
resultsEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < cafes.length; i++) {
|
||||
const dist = cafes[i].distance_meters;
|
||||
const distLabel = dist < 1000
|
||||
? `${dist} m`
|
||||
: `${(dist / 1000).toFixed(1)} km`;
|
||||
const location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
|
||||
html += `<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(${i})">`
|
||||
+ `<span class="font-medium text-amber-900">${escapeHtml(cafes[i].name)}</span>`
|
||||
+ `<span class="ml-2 text-xs text-stone-500">${escapeHtml(location)} · ${distLabel}</span>`
|
||||
+ `</button>`;
|
||||
}
|
||||
resultsEl.innerHTML = html;
|
||||
resultsEl.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Search failed. Please try again.';
|
||||
errorEl.classList.remove('hidden');
|
||||
} finally {
|
||||
spinnerEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function selectPlace(index) {
|
||||
const cafe = _nearbyCafes[index];
|
||||
if (!cafe) return;
|
||||
|
||||
const form = document.getElementById('nearby-search').closest('form');
|
||||
form.querySelector('[name="name"]').value = cafe.name;
|
||||
form.querySelector('[name="city"]').value = cafe.city || '';
|
||||
form.querySelector('[name="country"]').value = cafe.country || '';
|
||||
form.querySelector('[name="latitude"]').value = cafe.latitude;
|
||||
form.querySelector('[name="longitude"]').value = cafe.longitude;
|
||||
form.querySelector('[name="website"]').value = cafe.website || '';
|
||||
|
||||
document.getElementById('nearby-results').classList.add('hidden');
|
||||
document.getElementById('nearby-search').value = '';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section data-signals:_show-form="false">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Cafes</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Discover and track your favourite cafes.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new cafe"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Cafe</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Add a cafe you have visited or want to remember.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="/api/v1/cafes"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
data-on:submit="@post('/api/v1/cafes?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#cafe-list', mode: 'replace'}})"
|
||||
data-ref="_form"
|
||||
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
||||
>
|
||||
<div class="relative border-b border-amber-200 pb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="locate-btn"
|
||||
onclick="locateUser(this)"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
||||
>
|
||||
{% call icons::location("h-4 w-4") %}
|
||||
Find nearby
|
||||
</button>
|
||||
<label class="inline-flex items-center gap-1.5 text-sm text-stone-600 cursor-pointer select-none">
|
||||
<input type="checkbox" onchange="toggleCitySearch(this)" class="accent-amber-600" />
|
||||
Search by city
|
||||
</label>
|
||||
<div id="city-search-wrap" class="hidden flex-1 min-w-[140px]">
|
||||
<input
|
||||
type="text"
|
||||
id="city-input"
|
||||
class="input-field w-full text-sm"
|
||||
placeholder="e.g. Tokyo, London"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
id="nearby-search"
|
||||
oninput="onSearchInput(this)"
|
||||
class="input-field w-full text-sm pr-8"
|
||||
placeholder="Search for a cafe…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span id="nearby-spinner" class="hidden absolute right-2 top-1/2 -translate-y-1/2">
|
||||
{% call icons::spinner("h-4 w-4") %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p id="nearby-error" class="hidden mt-2 text-sm text-red-600"></p>
|
||||
<div
|
||||
id="nearby-results"
|
||||
class="hidden absolute left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-md border border-amber-300 bg-white shadow-lg divide-y divide-amber-100"
|
||||
></div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Name *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Blue Bottle Coffee"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">City *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Country *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="country"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="United States"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Website</span>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
class="input-field"
|
||||
placeholder="https://bluebottlecoffee.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Latitude *</span>
|
||||
<input
|
||||
type="number"
|
||||
name="latitude"
|
||||
step="any"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="37.7749"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Longitude *</span>
|
||||
<input
|
||||
type="number"
|
||||
name="longitude"
|
||||
step="any"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="-122.4194"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="($_showForm = false, $_form && $_form.reset())"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
Save Cafe
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "partials/cafe_list.html" %} {% endblock %}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
{% extends "base.html" %} {% block title %}Brewlog · Cups{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section data-signals:_show-form="false">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Cups</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Track coffees you've enjoyed at cafes.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new cup"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Cup</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Record a coffee you had at a cafe.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="/api/v1/cups"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
data-on:submit="@post('/api/v1/cups?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#cup-list', mode: 'replace'}})"
|
||||
data-ref="_form"
|
||||
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Coffee *</span>
|
||||
<select name="roast_id" required class="input-field">
|
||||
<option value="">Select a roast...</option>
|
||||
{% for roast in roast_options %}
|
||||
<option value="{{ roast.id }}">{{ roast.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Cafe *</span>
|
||||
<select name="cafe_id" required class="input-field">
|
||||
<option value="">Select a cafe...</option>
|
||||
{% for cafe in cafe_options %}
|
||||
<option value="{{ cafe.id }}">{{ cafe.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Rating</span>
|
||||
<select name="rating" class="input-field">
|
||||
<option value="">No rating</option>
|
||||
<option value="5">5 - Exceptional</option>
|
||||
<option value="4">4 - Great</option>
|
||||
<option value="3">3 - Good</option>
|
||||
<option value="2">2 - Fair</option>
|
||||
<option value="1">1 - Poor</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="($_showForm = false, $_form && $_form.reset())"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
Save Cup
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "partials/cup_list.html" %} {% endblock %}
|
||||
39
templates/data.html
Normal file
39
templates/data.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Data{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Data</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Browse all your coffee data.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<a
|
||||
href="/add?type={{ active_type }}"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-amber-600 px-4 py-2.5 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
{% call icons::plus_circle("h-4 w-4") %}
|
||||
Add
|
||||
</a>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<nav class="flex flex-wrap gap-1 rounded-lg border border-amber-300 bg-amber-100/80 p-1.5 shadow-sm"
|
||||
data-signals:_active-tab="'{{ active_type }}'">
|
||||
{% for tab in tabs %}
|
||||
<a
|
||||
href="/data?type={{ tab.key }}"
|
||||
data-class:bg-amber-600="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-white="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:shadow-sm="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
data-on:click__prevent="$_activeTab = '{{ tab.key }}'; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
|
||||
>{{ tab.label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div id="data-content">
|
||||
{{ content|safe }}
|
||||
</div>
|
||||
|
||||
<div id="detail-panel"></div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Brewlog · Gear{% endblock %}
|
||||
{% block content %}
|
||||
<section data-signals:_show-form="false" data-signals:_is-submitting="false">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Gear</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Track your brewing equipment.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new gear"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Gear</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Add brewing equipment to your collection.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="/api/v1/gear"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
data-on:submit="$_isSubmitting = true; @post('/api/v1/gear?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#gear-list', mode: 'replace'}})"
|
||||
data-ref="_form"
|
||||
data-on:datastar-fetch="evt.detail.type === 'finished' && $_isSubmitting && ($_showForm = false, $_form && $_form.reset(), $_isSubmitting = false)"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Category *</span>
|
||||
<select name="category" required class="input-field">
|
||||
<option value="">Select a category</option>
|
||||
<option value="grinder">Grinder</option>
|
||||
<option value="brewer">Brewer</option>
|
||||
<option value="filter_paper">Filter Paper</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Make *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="make"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Baratza"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Model *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="model"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Encore"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="$_showForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
>
|
||||
Save Gear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
{% include "partials/gear_list.html" %} {% endblock %}
|
||||
|
|
@ -187,14 +187,14 @@
|
|||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-amber-700">Recent Brews</h2>
|
||||
<a href="/brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews →</a>
|
||||
<a href="/data?type=brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews →</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
|
||||
{% for brew in recent_brews %}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="block truncate font-semibold text-amber-800 hover:text-amber-600">{{ brew.roast_name }}</a>
|
||||
<span class="block truncate font-semibold text-amber-800">{{ brew.roast_name }}</span>
|
||||
<p class="truncate text-sm text-stone-500">{{ brew.roaster_name }}</p>
|
||||
</div>
|
||||
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ brew.relative_date_label }}</time>
|
||||
|
|
@ -241,7 +241,7 @@
|
|||
<section id="open-bags-section">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-amber-700">Open Bags</h2>
|
||||
<a href="/bags" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all bags →</a>
|
||||
<a href="/data?type=bags" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all bags →</a>
|
||||
</div>
|
||||
<div id="open-bags-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for bag in open_bags %}
|
||||
|
|
@ -278,37 +278,37 @@
|
|||
<h2 class="text-lg font-semibold text-amber-700">Stats</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 md:flex md:flex-wrap md:justify-center">
|
||||
<a href="/brews" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=brews" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::beaker("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.brews }}</span>
|
||||
<span class="text-xs text-stone-500">Brews</span>
|
||||
</a>
|
||||
<a href="/roasts" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=roasts" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::fire("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasts }}</span>
|
||||
<span class="text-xs text-stone-500">Roasts</span>
|
||||
</a>
|
||||
<a href="/roasters" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=roasters" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::building("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasters }}</span>
|
||||
<span class="text-xs text-stone-500">Roasters</span>
|
||||
</a>
|
||||
<a href="/bags" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=bags" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::bag("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.bags }}</span>
|
||||
<span class="text-xs text-stone-500">Bags</span>
|
||||
</a>
|
||||
<a href="/cups" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=cups" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::cup("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cups }}</span>
|
||||
<span class="text-xs text-stone-500">Cups</span>
|
||||
</a>
|
||||
<a href="/cafes" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=cafes" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::location("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cafes }}</span>
|
||||
<span class="text-xs text-stone-500">Cafes</span>
|
||||
</a>
|
||||
<a href="/gear" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
<a href="/data?type=gear" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80">
|
||||
{% call icons::wrench("h-4 w-4 text-amber-600") %}
|
||||
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.gear }}</span>
|
||||
<span class="text-xs text-stone-500">Gear</span>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,9 @@
|
|||
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/">B{rew}log</a></div>
|
||||
<!-- Desktop links -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "bags" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/bags">Bags</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "brews" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/brews">Brews</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "cafes" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/cafes">Cafes</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "cups" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/cups">Cups</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "home" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/">Home</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "data" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/data">Data</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "home" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/" aria-label="Home">
|
||||
{% call icons::home("inline h-4 w-4") %}
|
||||
</a>
|
||||
{% if is_authenticated %}
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "checkin" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/check-in" aria-label="Check In">
|
||||
{% call icons::checkin("inline h-4 w-4") %}
|
||||
|
|
@ -38,18 +30,9 @@
|
|||
</div>
|
||||
<!-- Mobile menu -->
|
||||
<div class="md:hidden flex flex-col gap-2 pt-3 mt-3 border-t border-amber-300" data-show="$_navOpen" style="display:none">
|
||||
<a class="py-1 transition {% if nav_active == "roasters" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/roasters">Roasters</a>
|
||||
<a class="py-1 transition {% if nav_active == "roasts" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/roasts">Roasts</a>
|
||||
<a class="py-1 transition {% if nav_active == "bags" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/bags">Bags</a>
|
||||
<a class="py-1 transition {% if nav_active == "brews" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/brews">Brews</a>
|
||||
<a class="py-1 transition {% if nav_active == "cafes" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/cafes">Cafes</a>
|
||||
<a class="py-1 transition {% if nav_active == "cups" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/cups">Cups</a>
|
||||
<a class="py-1 transition {% if nav_active == "gear" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/gear">Gear</a>
|
||||
<a class="py-1 transition {% if nav_active == "home" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/">Home</a>
|
||||
<a class="py-1 transition {% if nav_active == "data" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/data">Data</a>
|
||||
<a class="py-1 transition {% if nav_active == "timeline" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/timeline">Timeline</a>
|
||||
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "home" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/">
|
||||
{% call icons::home("inline h-4 w-4") %}
|
||||
Home
|
||||
</a>
|
||||
{% if is_authenticated %}
|
||||
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "checkin" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/check-in">
|
||||
{% call icons::checkin("inline h-4 w-4") %}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
{% macro card(bag, is_authenticated) %}
|
||||
<div id="bag-card-{{ bag.id }}" class="rounded-lg border border-amber-200 bg-amber-50 p-4 shadow-sm">
|
||||
<div class="min-w-0">
|
||||
<a href="/roasters/{{ bag.roaster_slug }}/roasts/{{ bag.roast_slug }}" class="block truncate font-semibold text-amber-800 hover:text-amber-600">{{ bag.roast_name }}</a>
|
||||
<span class="block truncate font-semibold text-amber-800">{{ bag.roast_name }}</span>
|
||||
<p class="truncate text-sm text-stone-500">{{ bag.roaster_name }}</p>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-sm text-stone-600">
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
</p>
|
||||
{% if is_authenticated %}
|
||||
<div class="mt-3 pt-3 border-t border-amber-100 flex gap-3">
|
||||
<a href="/brews?bag_id={{ bag.id }}" class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500">
|
||||
<a href="/data?type=brews" class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500">
|
||||
{% call icons::plus_circle("h-4 w-4") %}
|
||||
Brew
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -37,22 +37,10 @@
|
|||
{{ brew.created_at }}
|
||||
</td>
|
||||
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="font-medium text-stone-800">
|
||||
<a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="hover:text-amber-700 hover:underline">
|
||||
{{ brew.roast_name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden md:block text-xs text-stone-500">
|
||||
<a href="/roasters/{{ brew.roaster_slug }}" class="hover:text-amber-700 hover:underline">
|
||||
{{ brew.roaster_name }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">
|
||||
<a href="/roasters/{{ brew.roaster_slug }}" class="hover:text-amber-700 hover:underline">
|
||||
{{ brew.roaster_name }}
|
||||
</a>
|
||||
<div class="font-medium text-stone-800">{{ brew.roast_name }}</div>
|
||||
<div class="hidden md:block text-xs text-stone-500">{{ brew.roaster_name }}</div>
|
||||
</td>
|
||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td>
|
||||
<td data-label="Grind" class="px-4 py-3 whitespace-nowrap">
|
||||
<div>{{ brew.grind_setting }}</div>
|
||||
<div class="hidden md:block text-xs text-stone-500">{{ brew.grinder_name }}</div>
|
||||
|
|
|
|||
|
|
@ -36,20 +36,12 @@
|
|||
{{ cup.created_at }}
|
||||
</td>
|
||||
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
|
||||
<a
|
||||
href="/roasters/{{ cup.roaster_slug }}/roasts/{{ cup.roast_slug }}"
|
||||
class="font-semibold text-amber-800 hover:text-amber-600"
|
||||
>{{ cup.roast_name }}</a
|
||||
>
|
||||
<span class="font-semibold text-amber-800">{{ cup.roast_name }}</span>
|
||||
<div class="hidden md:block text-xs text-stone-500">{{ cup.roaster_name }}</div>
|
||||
</td>
|
||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.roaster_name }}</td>
|
||||
<td data-label="Cafe" class="px-4 py-3 whitespace-nowrap">
|
||||
<a
|
||||
href="/cafes/{{ cup.cafe_slug }}"
|
||||
class="text-amber-800 hover:text-amber-600"
|
||||
>{{ cup.cafe_name }}</a
|
||||
>
|
||||
<span class="text-amber-800">{{ cup.cafe_name }}</span>
|
||||
</td>
|
||||
<td data-label="Rating" class="px-4 py-3 whitespace-nowrap">{{ cup.rating }}</td>
|
||||
<td data-label="" class="px-4 py-3 text-right">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
placeholder="Search..."
|
||||
value="{{ navigator.search_value() }}"
|
||||
class="input-field w-full text-sm"
|
||||
data-search-url="{{ navigator.path()|safe }}?{{ navigator.search_query_base()|safe }}&q="
|
||||
data-search-url="{{ navigator.search_href_prefix()|safe }}"
|
||||
data-on:input__debounce.300ms="history.pushState(null, '', el.dataset.searchUrl + el.value); @get(el.dataset.searchUrl + el.value, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
|
||||
{% if navigator.has_search() %}data-init="el.focus(); el.selectionStart = el.value.length"{% endif %}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
{% extends "base.html" %} {% block title %}Brewlog · Roast · {{ roast.name }}{% endblock %} {%
|
||||
block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Detailed view for this roast.</p>
|
||||
</header>
|
||||
|
||||
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<dl class="grid gap-2 text-sm text-stone-700">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Roaster</dt>
|
||||
<dd class="text-right">{{ roast.roaster_label }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Origin</dt>
|
||||
<dd class="text-right">{{ roast.origin }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Region</dt>
|
||||
<dd class="text-right">{{ roast.region }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Producer</dt>
|
||||
<dd class="text-right">{{ roast.producer }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Process</dt>
|
||||
<dd class="text-right">{{ roast.process }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Created</dt>
|
||||
<dd class="text-right">{{ roast.created_at }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% if roast.tasting_notes.is_empty() %}
|
||||
<p class="text-sm text-stone-600">No tasting notes yet.</p>
|
||||
{% else %}
|
||||
<ul class="flex flex-wrap gap-2 text-sm">
|
||||
{% for note in roast.tasting_notes %}
|
||||
<li>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>{{ note }}</span
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="mb-4 text-xl font-semibold text-amber-800">Bags</h2>
|
||||
{% if bags.is_empty() %}
|
||||
<p class="text-sm text-stone-600">No bags recorded for this roast.</p>
|
||||
{% else %}
|
||||
<div class="overflow-hidden rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
|
||||
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
|
||||
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">Created</th>
|
||||
<th scope="col" class="px-4 py-3">Amount</th>
|
||||
<th scope="col" class="px-4 py-3">Remaining</th>
|
||||
<th scope="col" class="px-4 py-3">Roast Date</th>
|
||||
<th scope="col" class="px-4 py-3">Status</th>
|
||||
<th scope="col" class="px-4 py-3">Finished</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-amber-200/70">
|
||||
{% for bag in bags %}
|
||||
<tr class="bg-amber-50/40 transition hover:bg-amber-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-xs font-medium text-stone-600">
|
||||
{{ bag.created_at }}
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ bag.amount }}g</td>
|
||||
<td class="px-4 py-3">{{ bag.remaining }}g</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if let Some(date) = bag.roast_date %}{{ date }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if bag.closed %}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-800"
|
||||
>
|
||||
Closed
|
||||
</span>
|
||||
{% else %}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ bag.finished_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Roaster · {{ roaster.name }}{% endblock %} {%
|
||||
block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">{{ roaster.name }}</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Detailed view for {{ roaster.name }}.</p>
|
||||
</header>
|
||||
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<dl class="grid gap-2 text-sm text-stone-700">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Country</dt>
|
||||
<dd class="text-right">{{ roaster.country }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">City</dt>
|
||||
<dd class="text-right">{{ roaster.city }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Homepage</dt>
|
||||
<dd class="flex justify-end">
|
||||
{% if roaster.has_homepage %}
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
|
||||
href="{{ roaster.homepage_url }}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Visit {{ roaster.name }} homepage"
|
||||
>
|
||||
{% call icons::external_link("h-4 w-4") %}
|
||||
<span>Visit</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<span>—</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="font-medium text-stone-500">Created</dt>
|
||||
<dd class="text-right">{{ roaster.created_at }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-semibold text-amber-800">Roasts</h2>
|
||||
</div>
|
||||
{% if roasts.is_empty() %}
|
||||
<p class="text-sm text-stone-600">This roaster has no roasts yet. Use the CLI to add one.</p>
|
||||
{% else %}
|
||||
<div class="overflow-x-auto rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
|
||||
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
|
||||
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">Added</th>
|
||||
<th scope="col" class="px-4 py-3">Roast</th>
|
||||
<th scope="col" class="px-4 py-3">Origin</th>
|
||||
<th scope="col" class="px-4 py-3">Region</th>
|
||||
<th scope="col" class="px-4 py-3">Producer</th>
|
||||
<th scope="col" class="px-4 py-3">Process</th>
|
||||
<th scope="col" class="px-4 py-3">Tasting Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-amber-200/70">
|
||||
{% for roast in roasts %}
|
||||
<tr
|
||||
data-star-key="{{ roast.full_id }}"
|
||||
class="bg-amber-50/40 transition hover:bg-amber-50"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||
{{ roast.created_at }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a
|
||||
href="{{ roast.detail_path }}"
|
||||
class="font-semibold text-amber-800 hover:text-amber-600"
|
||||
>{{ roast.name }}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ roast.origin }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ roast.region }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ roast.producer }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ roast.process }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if roast.tasting_notes.is_empty() %}
|
||||
<span class="text-xs text-stone-500">No tasting notes yet.</span>
|
||||
{% else %}
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{% for note in roast.tasting_notes %}
|
||||
<li>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>{{ note }}</span
|
||||
>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Roasters{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Roasters</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Browse the coffee roasters known to Brewlog.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new roaster"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Roaster</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Provide the core details and Brewlog will keep track of everything for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
||||
>
|
||||
{% call icons::camera("h-4 w-4") %}
|
||||
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-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-4 w-4") %}
|
||||
Waiting for response…
|
||||
</div>
|
||||
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||
</form>
|
||||
|
||||
<form
|
||||
id="roaster-form"
|
||||
method="post"
|
||||
action="/api/v1/roasters"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
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-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 }"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Name *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Example Coffee Roasters"
|
||||
data-bind:_roaster-name
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Country *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="country"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="United States"
|
||||
data-bind:_roaster-country
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">City</span>
|
||||
<input type="text" name="city" class="input-field" placeholder="Portland" data-bind:_roaster-city />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Homepage</span>
|
||||
<input
|
||||
type="url"
|
||||
name="homepage"
|
||||
class="input-field"
|
||||
placeholder="https://example.coffee"
|
||||
data-bind:_roaster-homepage
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="($_showForm = false, $_form && $_form.reset())"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
Save Roaster
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "partials/roaster_list.html" %} {% endblock %}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Roasts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Roasts</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Explore the latest roasts logged in Brewlog.</p>
|
||||
</div>
|
||||
{% if is_authenticated && !roaster_options.is_empty() %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$_showForm"
|
||||
data-on:click="$_showForm = true"
|
||||
aria-label="Add new roast"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
{% if roaster_options.is_empty() %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-amber-700">Add a roaster first</h2>
|
||||
<p class="mt-2">
|
||||
Roasts need a roaster.
|
||||
<a class="text-amber-700 hover:text-amber-600 underline" href="/roasters"
|
||||
>Create a roaster</a
|
||||
>
|
||||
to enable this form.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$_showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Roast</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Select a roaster, describe the roast, and Brewlog will take care of the rest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
||||
>
|
||||
{% call icons::camera("h-4 w-4") %}
|
||||
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-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
|
||||
{% call icons::spinner("h-4 w-4") %}
|
||||
Waiting for response…
|
||||
</div>
|
||||
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
|
||||
</form>
|
||||
|
||||
<form
|
||||
id="roast-form"
|
||||
method="post"
|
||||
action="/api/v1/roasts"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
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-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 }"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roaster *</span>
|
||||
<select name="roaster_id" required class="input-field" data-bind:_roaster-id>
|
||||
<option value="">Select a roaster</option>
|
||||
{% for roaster in roaster_options %}
|
||||
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Roast Name *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Ethiopia Yirgacheffe"
|
||||
data-bind:_roast-name
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Origin *</span>
|
||||
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_origin />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Region *</span>
|
||||
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_region />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Producer *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="producer"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Chelbesa Cooperative"
|
||||
data-bind:_producer
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Process *</span>
|
||||
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_process />
|
||||
</label>
|
||||
<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>
|
||||
<textarea
|
||||
name="tasting_notes"
|
||||
rows="2"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Blueberry, Jasmine"
|
||||
data-bind:_tasting-notes
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
data-on:click="($_showForm = false, $_form && $_form.reset())"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
Save Roast
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "partials/roast_list.html" %} {% endblock %}
|
||||
|
|
@ -4,8 +4,9 @@
|
|||
//! Datastar headers when the `datastar-request: true` header is present.
|
||||
|
||||
use crate::helpers::{
|
||||
assert_datastar_headers, assert_full_page, assert_html_fragment, create_default_bag,
|
||||
create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth,
|
||||
assert_datastar_headers, assert_datastar_headers_with_mode, assert_full_page,
|
||||
assert_html_fragment, create_default_bag, create_default_cafe, create_default_roast,
|
||||
create_default_roaster, spawn_app_with_auth,
|
||||
};
|
||||
use brewlog::domain::bags::UpdateBag;
|
||||
use brewlog::domain::cafes::NewCafe;
|
||||
|
|
@ -24,14 +25,14 @@ async fn roasters_list_with_datastar_header_returns_fragment() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/roasters", app.address))
|
||||
.get(format!("{}/data?type=roasters", app.address))
|
||||
.header("datastar-request", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch roasters");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_datastar_headers(&response, "#roaster-list");
|
||||
assert_datastar_headers_with_mode(&response, "#data-content", "inner");
|
||||
|
||||
let body = response.text().await.expect("failed to read body");
|
||||
assert_html_fragment(&body);
|
||||
|
|
@ -48,7 +49,7 @@ async fn roasters_list_without_datastar_header_returns_full_page() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/roasters", app.address))
|
||||
.get(format!("{}/data?type=roasters", app.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch roasters");
|
||||
|
|
@ -170,14 +171,14 @@ async fn roasts_list_with_datastar_header_returns_fragment() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/roasts", app.address))
|
||||
.get(format!("{}/data?type=roasts", app.address))
|
||||
.header("datastar-request", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch roasts");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_datastar_headers(&response, "#roast-list");
|
||||
assert_datastar_headers_with_mode(&response, "#data-content", "inner");
|
||||
|
||||
let body = response.text().await.expect("failed to read body");
|
||||
assert_html_fragment(&body);
|
||||
|
|
@ -195,7 +196,7 @@ async fn roasts_list_without_datastar_header_returns_full_page() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/roasts", app.address))
|
||||
.get(format!("{}/data?type=roasts", app.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch roasts");
|
||||
|
|
@ -320,14 +321,14 @@ async fn bags_list_with_datastar_header_returns_fragment() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/bags", app.address))
|
||||
.get(format!("{}/data?type=bags", app.address))
|
||||
.header("datastar-request", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch bags");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_datastar_headers(&response, "#bag-list");
|
||||
assert_datastar_headers_with_mode(&response, "#data-content", "inner");
|
||||
|
||||
let body = response.text().await.expect("failed to read body");
|
||||
assert_html_fragment(&body);
|
||||
|
|
@ -346,7 +347,7 @@ async fn bags_list_without_datastar_header_returns_full_page() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/bags", app.address))
|
||||
.get(format!("{}/data?type=bags", app.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch bags");
|
||||
|
|
@ -536,14 +537,14 @@ async fn gear_list_with_datastar_header_returns_fragment() {
|
|||
.expect("failed to create gear");
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/gear", app.address))
|
||||
.get(format!("{}/data?type=gear", app.address))
|
||||
.header("datastar-request", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch gear");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_datastar_headers(&response, "#gear-list");
|
||||
assert_datastar_headers_with_mode(&response, "#data-content", "inner");
|
||||
|
||||
let body = response.text().await.expect("failed to read body");
|
||||
assert_html_fragment(&body);
|
||||
|
|
@ -559,7 +560,7 @@ async fn gear_list_without_datastar_header_returns_full_page() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/gear", app.address))
|
||||
.get(format!("{}/data?type=gear", app.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch gear");
|
||||
|
|
@ -764,14 +765,14 @@ async fn cafes_list_with_datastar_header_returns_fragment() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/cafes", app.address))
|
||||
.get(format!("{}/data?type=cafes", app.address))
|
||||
.header("datastar-request", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch cafes");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_datastar_headers(&response, "#cafe-list");
|
||||
assert_datastar_headers_with_mode(&response, "#data-content", "inner");
|
||||
|
||||
let body = response.text().await.expect("failed to read body");
|
||||
assert_html_fragment(&body);
|
||||
|
|
@ -788,7 +789,7 @@ async fn cafes_list_without_datastar_header_returns_full_page() {
|
|||
let client = Client::new();
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/cafes", app.address))
|
||||
.get(format!("{}/data?type=cafes", app.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to fetch cafes");
|
||||
|
|
|
|||
|
|
@ -371,6 +371,14 @@ pub async fn create_default_bag(
|
|||
|
||||
/// Asserts that the response has valid Datastar fragment headers
|
||||
pub fn assert_datastar_headers(response: &reqwest::Response, expected_selector: &str) {
|
||||
assert_datastar_headers_with_mode(response, expected_selector, "replace");
|
||||
}
|
||||
|
||||
pub fn assert_datastar_headers_with_mode(
|
||||
response: &reqwest::Response,
|
||||
expected_selector: &str,
|
||||
expected_mode: &str,
|
||||
) {
|
||||
let selector = response
|
||||
.headers()
|
||||
.get("datastar-selector")
|
||||
|
|
@ -389,8 +397,9 @@ pub fn assert_datastar_headers(response: &reqwest::Response, expected_selector:
|
|||
.and_then(|v| v.to_str().ok());
|
||||
assert_eq!(
|
||||
mode,
|
||||
Some("replace"),
|
||||
"Expected datastar-mode header to be 'replace', got {:?}",
|
||||
Some(expected_mode),
|
||||
"Expected datastar-mode header to be '{}', got {:?}",
|
||||
expected_mode,
|
||||
mode
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
|
|||
let client = Client::new();
|
||||
|
||||
let roaster_name = "Timeline Roasters";
|
||||
let roaster = create_roaster_with_payload(
|
||||
create_roaster_with_payload(
|
||||
&app,
|
||||
NewRoaster {
|
||||
name: roaster_name.to_string(),
|
||||
|
|
@ -117,8 +117,8 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
|
|||
"Expected roaster name to appear in timeline HTML, got: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(&format!("/roasters/{}", roaster.slug)),
|
||||
"Expected roaster detail link in timeline HTML, got: {body}"
|
||||
body.contains("/data?type=roasters"),
|
||||
"Expected roaster link in timeline HTML, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -462,7 +462,7 @@ async fn creating_a_cafe_surfaces_on_the_timeline() {
|
|||
let client = Client::new();
|
||||
|
||||
let cafe_name = "Timeline Test Cafe";
|
||||
let cafe = create_cafe_with_payload(
|
||||
create_cafe_with_payload(
|
||||
&app,
|
||||
NewCafe {
|
||||
name: cafe_name.to_string(),
|
||||
|
|
@ -495,7 +495,7 @@ async fn creating_a_cafe_surfaces_on_the_timeline() {
|
|||
"Expected cafe name to appear in timeline HTML, got: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(&format!("/cafes/{}", cafe.slug)),
|
||||
"Expected cafe detail link in timeline HTML, got: {body}"
|
||||
body.contains("/data?type=cafes"),
|
||||
"Expected cafe link in timeline HTML, got: {body}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue