From 4bdfc661f9fb73b3e3723b2e6ab8242b502d2373 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Wed, 4 Feb 2026 20:51:30 +0000 Subject: [PATCH] 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 --- src/application/routes/add.rs | 61 ++ src/application/routes/bags.rs | 61 +- src/application/routes/brews.rs | 78 +-- src/application/routes/cafes.rs | 69 +-- src/application/routes/cups.rs | 52 +- src/application/routes/data.rs | 262 ++++++++ src/application/routes/gear.rs | 43 +- src/application/routes/mod.rs | 17 +- src/application/routes/roasters.rs | 79 +-- src/application/routes/roasts.rs | 96 +-- src/application/routes/support.rs | 4 +- src/presentation/web/templates.rs | 140 +---- src/presentation/web/views/mod.rs | 48 +- src/presentation/web/views/timeline.rs | 24 +- templates/add.html | 797 +++++++++++++++++++++++++ templates/bags.html | 137 ----- templates/brews.html | 164 ----- templates/cafe_detail.html | 61 -- templates/cafes.html | 333 ----------- templates/cups.html | 94 --- templates/data.html | 39 ++ templates/gear.html | 96 --- templates/home.html | 20 +- templates/nav.html | 25 +- templates/partials/bag_card.html | 4 +- templates/partials/brew_list.html | 18 +- templates/partials/cup_list.html | 12 +- templates/partials/table.html | 2 +- templates/roast_detail.html | 102 ---- templates/roaster_detail.html | 106 ---- templates/roasters.html | 145 ----- templates/roasts.html | 178 ------ tests/server/datastar.rs | 35 +- tests/server/helpers.rs | 13 +- tests/server/timeline.rs | 12 +- 35 files changed, 1349 insertions(+), 2078 deletions(-) create mode 100644 src/application/routes/add.rs create mode 100644 src/application/routes/data.rs create mode 100644 templates/add.html delete mode 100644 templates/bags.html delete mode 100644 templates/brews.html delete mode 100644 templates/cafe_detail.html delete mode 100644 templates/cafes.html delete mode 100644 templates/cups.html create mode 100644 templates/data.html delete mode 100644 templates/gear.html delete mode 100644 templates/roast_detail.html delete mode 100644 templates/roaster_detail.html delete mode 100644 templates/roasters.html delete mode 100644 templates/roasts.html diff --git a/src/application/routes/add.rs b/src/application/routes/add.rs new file mode 100644 index 0000000..fb4e765 --- /dev/null +++ b/src/application/routes/add.rs @@ -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, + cookies: tower_cookies::Cookies, + Query(query): Query, +) -> Result { + 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) +} diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index d786a48..273c3a0 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -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, - bags: Paginated, - navigator: ListNavigator, +pub(super) struct BagPageData { + pub(super) open_bags: Vec, + pub(super) bags: Paginated, + pub(super) navigator: ListNavigator, } #[tracing::instrument(skip(state))] -async fn load_bag_page( +pub(super) async fn load_bag_page( state: &AppState, request: ListRequest, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index ba7a3d5..c8098ca 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -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, - navigator: ListNavigator, +pub(super) struct BrewPageData { + pub(super) brews: Paginated, + pub(super) navigator: ListNavigator, } -struct BrewFormData { - bag_options: Vec, - grinder_options: Vec, - brewer_options: Vec, - filter_paper_options: Vec, - defaults: BrewDefaultsView, +pub(super) struct BrewFormData { + pub(super) bag_options: Vec, + pub(super) grinder_options: Vec, + pub(super) brewer_options: Vec, + pub(super) filter_paper_options: Vec, + pub(super) defaults: BrewDefaultsView, } -async fn load_brew_form_data(state: &AppState) -> Result { +pub(super) async fn load_brew_form_data(state: &AppState) -> Result { 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 }) } -async fn load_gear_options( +pub(super) async fn load_gear_options( state: &AppState, category: GearCategory, request: &ListRequest, @@ -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, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, D::Error> where diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index 1182a5b..81b5cfd 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -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, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, - cookies: tower_cookies::Cookies, - Path(slug): Path, -) -> Result { - 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) -> Result>, ApiError> { let cafes = state diff --git a/src/application/routes/cups.rs b/src/application/routes/cups.rs index 70daba8..99f96ae 100644 --- a/src/application/routes/cups.rs +++ b/src/application/routes/cups.rs @@ -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, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, diff --git a/src/application/routes/data.rs b/src/application/routes/data.rs new file mode 100644 index 0000000..15d6b4c --- /dev/null +++ b/src/application/routes/data.rs @@ -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, + cookies: tower_cookies::Cookies, + headers: HeaderMap, + Query(query): Query, +) -> Result { + 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 = 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(template: T, label: &str) -> Result { + 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 { + // 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 { + use crate::domain::brews::BrewSortKey; + let (request, search) = list_query.into_request_and_search::(); + 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 { + use crate::domain::roasters::RoasterSortKey; + let (request, search) = list_query.into_request_and_search::(); + 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 { + use crate::domain::roasts::RoastSortKey; + let (request, search) = list_query.into_request_and_search::(); + 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 { + use crate::domain::bags::BagSortKey; + let (request, search) = list_query.into_request_and_search::(); + 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 { + use crate::domain::gear::GearSortKey; + let (request, search) = list_query.into_request_and_search::(); + 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 { + use crate::domain::cafes::CafeSortKey; + let (request, search) = list_query.into_request_and_search::(); + 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 { + use crate::domain::cups::CupSortKey; + let (request, search) = list_query.into_request_and_search::(); + let (cups, navigator) = super::cups::load_cup_page(state, request, search.as_deref()).await?; + render_list( + CupListTemplate { + is_authenticated, + cups, + navigator, + }, + "cups", + ) +} diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index 4dc2473..d70f388 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -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, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 6b10709..5bc346e 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -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)) diff --git a/src/application/routes/roasters.rs b/src/application/routes/roasters.rs index b493aa4..2205c4c 100644 --- a/src/application/routes/roasters.rs +++ b/src/application/routes/roasters.rs @@ -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, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, - cookies: tower_cookies::Cookies, - Path(slug): Path, -) -> Result, 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, diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index f258aa0..692f249 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -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, 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, - cookies: tower_cookies::Cookies, - headers: HeaderMap, - Query(query): Query, -) -> Result { - let (request, search) = query.into_request_and_search::(); - - 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, - cookies: tower_cookies::Cookies, - Path((roaster_slug, roast_slug)): Path<(String, String)>, -) -> Result, 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, diff --git a/src/application/routes/support.rs b/src/application/routes/support.rs index 6805465..149a529 100644 --- a/src/application/routes/support.rs +++ b/src/application/routes/support.rs @@ -114,8 +114,8 @@ pub fn build_page_view( page: Page, request: ListRequest, view_mapper: impl FnMut(T) -> V, - base_path: &'static str, - fragment_path: &'static str, + base_path: impl Into, + fragment_path: impl Into, search: Option, ) -> (Paginated, ListNavigator) where diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 76c0c50..ec7701c 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -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, - pub navigator: ListNavigator, -} - #[derive(Template)] #[template(path = "partials/roaster_list.html")] pub struct RoasterListTemplate { @@ -32,37 +22,6 @@ pub struct RoasterListTemplate { pub navigator: ListNavigator, } -#[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, -} - -#[derive(Template)] -#[template(path = "roasts.html")] -pub struct RoastsTemplate { - pub nav_active: &'static str, - pub is_authenticated: bool, - - pub roasts: Paginated, - pub roaster_options: Vec, - pub navigator: ListNavigator, -} - -#[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, -} - #[derive(Template)] #[template(path = "partials/roast_list.html")] pub struct RoastListTemplate { @@ -91,18 +50,6 @@ pub struct TimelineChunkTemplate { pub months: Vec, } -#[derive(Template)] -#[template(path = "bags.html")] -pub struct BagsTemplate { - pub nav_active: &'static str, - pub is_authenticated: bool, - - pub open_bags: Vec, - pub bags: Paginated, - pub roaster_options: Vec, - pub navigator: ListNavigator, -} - #[derive(Template)] #[template(path = "partials/bag_list.html")] pub struct BagListTemplate { @@ -112,16 +59,6 @@ pub struct BagListTemplate { pub navigator: ListNavigator, } -#[derive(Template)] -#[template(path = "gear.html")] -pub struct GearTemplate { - pub nav_active: &'static str, - pub is_authenticated: bool, - - pub gear: Paginated, - pub navigator: ListNavigator, -} - #[derive(Template)] #[template(path = "partials/gear_list.html")] pub struct GearListTemplate { @@ -136,21 +73,6 @@ pub struct RoastOptionsTemplate { pub roasts: Vec, } -#[derive(Template)] -#[template(path = "brews.html")] -pub struct BrewsTemplate { - pub nav_active: &'static str, - pub is_authenticated: bool, - - pub brews: Paginated, - pub bag_options: Vec, - pub grinder_options: Vec, - pub brewer_options: Vec, - pub filter_paper_options: Vec, - pub defaults: BrewDefaultsView, - pub navigator: ListNavigator, -} - #[derive(Template)] #[template(path = "partials/brew_list.html")] pub struct BrewListTemplate { @@ -159,16 +81,6 @@ pub struct BrewListTemplate { pub navigator: ListNavigator, } -#[derive(Template)] -#[template(path = "cafes.html")] -pub struct CafesTemplate { - pub nav_active: &'static str, - pub is_authenticated: bool, - - pub cafes: Paginated, - pub navigator: ListNavigator, -} - #[derive(Template)] #[template(path = "partials/cafe_list.html")] pub struct CafeListTemplate { @@ -177,27 +89,6 @@ pub struct CafeListTemplate { pub navigator: ListNavigator, } -#[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, - pub roast_options: Vec, - pub cafe_options: Vec, - pub navigator: ListNavigator, -} - #[derive(Template)] #[template(path = "partials/cup_list.html")] pub struct CupListTemplate { @@ -234,6 +125,37 @@ pub struct NearbyCafesFragment { pub cafes: Vec, } +#[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, + 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, + pub roast_options: Vec, + pub bag_options: Vec, + pub grinder_options: Vec, + pub brewer_options: Vec, + pub filter_paper_options: Vec, + pub cafe_options: Vec, + pub defaults: BrewDefaultsView, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index b072afa..da3880a 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -175,22 +175,22 @@ impl Paginated { #[derive(Clone, Debug)] pub struct ListNavigator { - base_path: &'static str, - fragment_path: &'static str, + base_path: String, + fragment_path: String, request: ListRequest, search: Option, } impl ListNavigator { pub fn new( - base_path: &'static str, - fragment_path: &'static str, + base_path: impl Into, + fragment_path: impl Into, request: ListRequest, search: Option, ) -> Self { Self { - base_path, - fragment_path, + base_path: base_path.into(), + fragment_path: fragment_path.into(), request, search, } @@ -221,31 +221,31 @@ impl ListNavigator { } 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 ListNavigator { /// 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 ListNavigator { ) } - fn build_href(&self, path: &str, request: ListRequest) -> 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) -> 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}") } } diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index 1b76017..a73dc7b 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -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("#"), }; diff --git a/templates/add.html b/templates/add.html new file mode 100644 index 0000000..ce81e68 --- /dev/null +++ b/templates/add.html @@ -0,0 +1,797 @@ +{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Add{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} + +
+

Quick Add — Scan Bag

+ + + + + +
+
+ +
+ + +
+ + +
+
+ + +
+
+
+

Roaster

+

If this roaster already exists, it will be matched automatically.

+
+ + + + +
+
+
+

Roast

+

Details about this specific coffee.

+
+ + + + + + +
+
+
+ +
+ +
+
+ + +
+ + +
+
+
+
+ + +
+

Manual Add

+ + +
+ {% for (key, label) in [("roaster", "Roaster"), ("roast", "Roast"), ("bag", "Bag"), ("brew", "Brew"), ("gear", "Gear"), ("cafe", "Cafe"), ("cup", "Cup")] %} + + {% endfor %} +
+ + + + + + + + + + + + + + + + + + + + + +
+{% endblock %} diff --git a/templates/bags.html b/templates/bags.html deleted file mode 100644 index 38cb351..0000000 --- a/templates/bags.html +++ /dev/null @@ -1,137 +0,0 @@ -{% extends "base.html" %} {% block title %}Brewlog · Bags{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-
-
-

Bags

-

Track your coffee bags.

-
- {% if is_authenticated && !roaster_options.is_empty() %} - - {% endif %} -
- - {% if is_authenticated %} {% if roaster_options.is_empty() %} -
-

Add a roaster first

-

- Bags need a roaster and a roast. - Create a roaster - to enable this form. -

-
- {% else %} - - {% endif %} {% endif %} - -
- -{% include "partials/bag_list.html" %} {% endblock %} diff --git a/templates/brews.html b/templates/brews.html deleted file mode 100644 index f1b42ae..0000000 --- a/templates/brews.html +++ /dev/null @@ -1,164 +0,0 @@ -{% extends "base.html" %} {% block title %}Brewlog · Brews{% endblock %} {% block content %} -
-
-
-

Brews

-

Log your coffee brews.

-
- {% if is_authenticated && !bag_options.is_empty() && !grinder_options.is_empty() && !brewer_options.is_empty() %} - - {% endif %} -
- - {% if is_authenticated %} - {% if bag_options.is_empty() %} -
-

Open a bag first

-

- Brews need an open bag of coffee. - Add a bag - to enable this form. -

-
- {% else if grinder_options.is_empty() || brewer_options.is_empty() %} -
-

Add your gear first

-

- Brews need a grinder and brewer. - Add your gear - to enable this form. -

-
- {% else %} - - {% endif %} - {% endif %} - -
- -{% include "partials/brew_list.html" %} {% endblock %} diff --git a/templates/cafe_detail.html b/templates/cafe_detail.html deleted file mode 100644 index a78deb6..0000000 --- a/templates/cafe_detail.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Cafe · {{ cafe.name }}{% endblock %} {% -block content %} -
-

{{ cafe.name }}

-

Detailed view for {{ cafe.name }}.

-
-
-
-
-
City
-
{{ cafe.city }}
-
-
-
Country
-
{{ cafe.country }}
-
-
-
Coordinates
-
{{ cafe.latitude }}, {{ cafe.longitude }}
-
- -
-
Website
-
- {% if cafe.has_website %} - - {% call icons::external_link("h-4 w-4") %} - Visit - - {% else %} - - {% endif %} -
-
-
-
Created
-
{{ cafe.created_at }}
-
-
-
-{% endblock %} diff --git a/templates/cafes.html b/templates/cafes.html deleted file mode 100644 index 9737830..0000000 --- a/templates/cafes.html +++ /dev/null @@ -1,333 +0,0 @@ -{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Cafes{% endblock %} - -{% block head %} -{% if is_authenticated %} - -{% endif %} -{% endblock %} - -{% block content %} -
-
-
-

Cafes

-

Discover and track your favourite cafes.

-
- {% if is_authenticated %} - - {% endif %} -
- - {% if is_authenticated %} - - {% endif %} -
- -{% include "partials/cafe_list.html" %} {% endblock %} diff --git a/templates/cups.html b/templates/cups.html deleted file mode 100644 index f7ae0da..0000000 --- a/templates/cups.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} {% block title %}Brewlog · Cups{% endblock %} - -{% block content %} -
-
-
-

Cups

-

Track coffees you've enjoyed at cafes.

-
- {% if is_authenticated %} - - {% endif %} -
- - {% if is_authenticated %} - - {% endif %} -
- -{% include "partials/cup_list.html" %} {% endblock %} diff --git a/templates/data.html b/templates/data.html new file mode 100644 index 0000000..91eea4c --- /dev/null +++ b/templates/data.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Data{% endblock %} +{% block content %} +
+
+

Data

+

Browse all your coffee data.

+
+ {% if is_authenticated %} + + {% call icons::plus_circle("h-4 w-4") %} + Add + + {% endif %} +
+ + + +
+ {{ content|safe }} +
+ +
+{% endblock %} diff --git a/templates/gear.html b/templates/gear.html deleted file mode 100644 index ad3c5e7..0000000 --- a/templates/gear.html +++ /dev/null @@ -1,96 +0,0 @@ -{% extends "base.html" %} -{% block title %}Brewlog · Gear{% endblock %} -{% block content %} -
-
-
-

Gear

-

Track your brewing equipment.

-
- {% if is_authenticated %} - - {% endif %} -
- - {% if is_authenticated %} - - {% endif %} - -
- -{% include "partials/gear_list.html" %} {% endblock %} diff --git a/templates/home.html b/templates/home.html index 37321a6..7d2ecf1 100644 --- a/templates/home.html +++ b/templates/home.html @@ -187,14 +187,14 @@
{% for brew in recent_brews %}
- {{ brew.roast_name }} + {{ brew.roast_name }}

{{ brew.roaster_name }}

@@ -241,7 +241,7 @@
{% for bag in open_bags %} @@ -278,37 +278,37 @@

Stats

- + {% call icons::beaker("h-4 w-4 text-amber-600") %} {{ stats.brews }} Brews - + {% call icons::fire("h-4 w-4 text-amber-600") %} {{ stats.roasts }} Roasts - + {% call icons::building("h-4 w-4 text-amber-600") %} {{ stats.roasters }} Roasters - + {% call icons::bag("h-4 w-4 text-amber-600") %} {{ stats.bags }} Bags - + {% call icons::cup("h-4 w-4 text-amber-600") %} {{ stats.cups }} Cups - + {% call icons::location("h-4 w-4 text-amber-600") %} {{ stats.cafes }} Cafes - + {% call icons::wrench("h-4 w-4 text-amber-600") %} {{ stats.gear }} Gear diff --git a/templates/nav.html b/templates/nav.html index 72143c9..1fcf550 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -4,17 +4,9 @@