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:
Jon Seager 2026-02-04 20:51:30 +00:00
parent 50a372015c
commit 4bdfc661f9
No known key found for this signature in database
35 changed files with 1349 additions and 2078 deletions

View 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)
}

View file

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

View file

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

View file

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

View file

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

View 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",
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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)} &middot; ${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 &mdash; 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&hellip;"
/>
</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&hellip;
</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&hellip;
</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 &amp; 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&hellip;" />
<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&hellip;
</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&hellip;" />
<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&hellip;
</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&hellip;" 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 %}

View file

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

View file

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

View file

@ -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>&mdash;</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 %}

View file

@ -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)} &middot; ${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&hellip;"
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 %}

View file

@ -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
View 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 %}

View file

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

View file

@ -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 &rarr;</a>
<a href="/data?type=brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews &rarr;</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 &rarr;</a>
<a href="/data?type=bags" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all bags &rarr;</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>

View file

@ -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") %}

View file

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

View file

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

View file

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

View file

@ -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 %}
/>

View file

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

View file

@ -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>&mdash;</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 %}

View file

@ -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&hellip;"
/>
<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&hellip;
</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 %}

View file

@ -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&hellip;"
/>
<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&hellip;
</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 %}

View file

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

View file

@ -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
);
}

View file

@ -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}"
);
}