From f0eb346086c7a3aef8ed64db2f31d827383aa335 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 8 Feb 2026 17:48:49 +0000 Subject: [PATCH] feat(detail): add roast detail page, simplify lists and actions - Add roast detail page at /roasters/{slug}/roasts/{slug} - Remove expand/collapse detail rows from all 7 list views - List rows now navigate directly to entity detail pages - Replace three-dots action button with chevron-right link - Add delete buttons to brew and cup detail pages - Restyle all delete buttons: outlined with red text - Remove share buttons from all detail pages - Update timeline card links to point at detail pages - Make homepage activity cards clickable with hover effect - Replace all vanilla JS delete/close with Datastar actions - Extract render_redirect_script helper for Datastar redirects - Update delete macro with referer-based routing for detail pages --- src/application/routes/api/bags.rs | 33 ++++++----- src/application/routes/api/brews.rs | 16 ++---- src/application/routes/api/cafes.rs | 4 +- src/application/routes/api/cups.rs | 4 +- src/application/routes/api/gear.rs | 4 +- src/application/routes/api/macros.rs | 29 ++++++++-- src/application/routes/api/roasters.rs | 4 +- src/application/routes/api/roasts.rs | 39 ++++++++++--- src/application/routes/app/mod.rs | 5 ++ src/application/routes/app/roasts.rs | 43 +++++++++++++++ src/application/routes/support.rs | 15 +++++ src/presentation/web/templates.rs | 14 ++++- src/presentation/web/views/brews.rs | 2 + src/presentation/web/views/cups.rs | 2 + src/presentation/web/views/mod.rs | 2 +- src/presentation/web/views/roasts.rs | 64 ++++++++++++++++++++++ src/presentation/web/views/timeline.rs | 26 ++++++--- templates/pages/bag.html | 44 ++------------- templates/pages/brew.html | 14 ++++- templates/pages/cafe.html | 23 +------- templates/pages/cup.html | 14 ++++- templates/pages/gear.html | 24 +------- templates/pages/home.html | 8 +-- templates/pages/roast.html | 36 ++++++++++++ templates/pages/roaster.html | 23 +------- templates/partials/lists/bag_list.html | 54 ++---------------- templates/partials/lists/brew_list.html | 42 ++------------ templates/partials/lists/cafe_list.html | 58 ++------------------ templates/partials/lists/cup_list.html | 40 ++------------ templates/partials/lists/gear_list.html | 40 ++------------ templates/partials/lists/roast_list.html | 40 ++------------ templates/partials/lists/roaster_list.html | 56 ++----------------- tests/server/datastar.rs | 2 + tests/server/test_macros.rs | 1 + tests/server/timeline.rs | 8 +-- 35 files changed, 379 insertions(+), 454 deletions(-) create mode 100644 src/application/routes/app/roasts.rs create mode 100644 templates/pages/roast.html diff --git a/src/application/routes/api/bags.rs b/src/application/routes/api/bags.rs index 2eb67c8..86f5695 100644 --- a/src/application/routes/api/bags.rs +++ b/src/application/routes/api/bags.rs @@ -85,16 +85,8 @@ pub(crate) async fn create_bag( .await .map_err(ApiError::from) } else { - use axum::http::header::HeaderValue; - let script = format!(""); - let mut response = axum::response::Html(script).into_response(); - response - .headers_mut() - .insert("datastar-selector", HeaderValue::from_static("body")); - response - .headers_mut() - .insert("datastar-mode", HeaderValue::from_static("append")); - Ok(response) + crate::application::routes::support::render_redirect_script(&detail_url) + .map_err(ApiError::from) } } else if matches!(source, PayloadSource::Form) { Ok(Redirect::to(&detail_url).into_response()) @@ -175,9 +167,20 @@ pub(crate) async fn update_bag( state.stats_invalidator.invalidate(); if is_datastar_request(&headers) { - render_bag_list_fragment(state, request, search, true) - .await - .map_err(ApiError::from) + let from_bag_page = headers + .get("referer") + .and_then(|v| v.to_str().ok()) + .is_some_and(|r| r.contains("type=bags")); + + if from_bag_page { + render_bag_list_fragment(state, request, search, true) + .await + .map_err(ApiError::from) + } else { + let detail_url = format!("/bags/{id}"); + crate::application::routes::support::render_redirect_script(&detail_url) + .map_err(ApiError::from) + } } else { let enriched = state .bag_repo @@ -193,7 +196,9 @@ define_delete_handler!( BagId, BagSortKey, bag_repo, - render_bag_list_fragment + render_bag_list_fragment, + "type=bags", + "/data?type=bags" ); #[derive(Debug, Deserialize)] diff --git a/src/application/routes/api/brews.rs b/src/application/routes/api/brews.rs index 13fe1e9..238a9d4 100644 --- a/src/application/routes/api/brews.rs +++ b/src/application/routes/api/brews.rs @@ -269,16 +269,8 @@ pub(crate) async fn create_brew( .await .map_err(ApiError::from) } else { - use axum::http::header::HeaderValue; - let script = format!(""); - let mut response = axum::response::Html(script).into_response(); - response - .headers_mut() - .insert("datastar-selector", HeaderValue::from_static("body")); - response - .headers_mut() - .insert("datastar-mode", HeaderValue::from_static("append")); - Ok(response) + crate::application::routes::support::render_redirect_script(&detail_url) + .map_err(ApiError::from) } } else if matches!(source, PayloadSource::Form) { Ok(Redirect::to(&detail_url).into_response()) @@ -323,7 +315,9 @@ define_delete_handler!( BrewId, BrewSortKey, brew_repo, - render_brew_list_fragment + render_brew_list_fragment, + "type=brews", + "/data?type=brews" ); async fn render_brew_list_fragment( diff --git a/src/application/routes/api/cafes.rs b/src/application/routes/api/cafes.rs index 2f05427..bd843e4 100644 --- a/src/application/routes/api/cafes.rs +++ b/src/application/routes/api/cafes.rs @@ -141,7 +141,9 @@ define_delete_handler!( CafeId, CafeSortKey, cafe_repo, - render_cafe_list_fragment + render_cafe_list_fragment, + "type=cafes", + "/data?type=cafes" ); define_list_fragment_renderer!( diff --git a/src/application/routes/api/cups.rs b/src/application/routes/api/cups.rs index 8ba495b..0626999 100644 --- a/src/application/routes/api/cups.rs +++ b/src/application/routes/api/cups.rs @@ -104,7 +104,9 @@ define_delete_handler!( CupId, CupSortKey, cup_repo, - render_cup_list_fragment + render_cup_list_fragment, + "type=cups", + "/data?type=cups" ); define_list_fragment_renderer!( diff --git a/src/application/routes/api/gear.rs b/src/application/routes/api/gear.rs index eeeea6c..e1901af 100644 --- a/src/application/routes/api/gear.rs +++ b/src/application/routes/api/gear.rs @@ -156,7 +156,9 @@ define_delete_handler!( GearId, GearSortKey, gear_repo, - render_gear_list_fragment + render_gear_list_fragment, + "type=gear", + "/data?type=gear" ); #[derive(Debug, Deserialize)] diff --git a/src/application/routes/api/macros.rs b/src/application/routes/api/macros.rs index d0dc262..dec5fef 100644 --- a/src/application/routes/api/macros.rs +++ b/src/application/routes/api/macros.rs @@ -59,12 +59,19 @@ macro_rules! define_enriched_get_handler { /// Generates a DELETE handler with Datastar fragment re-rendering support. /// +/// When a Datastar request arrives from the data/list page (detected via referer +/// containing `$referer_match`), the handler re-renders the list fragment. When the +/// request comes from elsewhere (e.g. a detail page), it returns a redirect script +/// pointing at `$redirect_url`. Non-Datastar requests get a 204 No Content. +/// /// # Arguments /// * `$fn_name` - Name of the generated handler function /// * `$id_type` - Type of the ID path parameter (e.g., `RoasterId`) /// * `$sort_key` - Sort key type for list requests (e.g., `RoasterSortKey`) /// * `$repo_field` - Name of the repository field on `AppState` (e.g., `roaster_repo`) /// * `$render_fragment` - Path to the fragment render function +/// * `$referer_match` - String to look for in `Referer` header (e.g., `"type=roasters"`) +/// * `$redirect_url` - URL for the redirect script (e.g., `"/data?type=roasters"`) /// /// # Example /// ```ignore @@ -73,11 +80,13 @@ macro_rules! define_enriched_get_handler { /// RoasterId, /// RoasterSortKey, /// roaster_repo, -/// render_roaster_list_fragment +/// render_roaster_list_fragment, +/// "type=roasters", +/// "/data?type=roasters" /// ); /// ``` macro_rules! define_delete_handler { - ($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path) => { + ($fn_name:ident, $id_type:ty, $sort_key:ty, $repo_field:ident, $render_fragment:path, $referer_match:literal, $redirect_url:literal) => { #[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn $fn_name( axum::extract::State(state): axum::extract::State, @@ -99,9 +108,19 @@ macro_rules! define_delete_handler { state.stats_invalidator.invalidate(); if crate::application::routes::support::is_datastar_request(&headers) { - $render_fragment(state, request, search, true) - .await - .map_err(crate::application::errors::ApiError::from) + let from_data_page = headers + .get("referer") + .and_then(|v| v.to_str().ok()) + .is_some_and(|r| r.contains($referer_match)); + + if from_data_page { + $render_fragment(state, request, search, true) + .await + .map_err(crate::application::errors::ApiError::from) + } else { + crate::application::routes::support::render_redirect_script($redirect_url) + .map_err(crate::application::errors::ApiError::from) + } } else { Ok(axum::http::StatusCode::NO_CONTENT.into_response()) } diff --git a/src/application/routes/api/roasters.rs b/src/application/routes/api/roasters.rs index 03cc4a7..7e6eee1 100644 --- a/src/application/routes/api/roasters.rs +++ b/src/application/routes/api/roasters.rs @@ -141,7 +141,9 @@ define_delete_handler!( RoasterId, RoasterSortKey, roaster_repo, - render_roaster_list_fragment + render_roaster_list_fragment, + "type=roasters", + "/data?type=roasters" ); #[tracing::instrument(skip(state, auth_user, headers, payload))] diff --git a/src/application/routes/api/roasts.rs b/src/application/routes/api/roasts.rs index 32f4b62..f49acb3 100644 --- a/src/application/routes/api/roasts.rs +++ b/src/application/routes/api/roasts.rs @@ -74,14 +74,37 @@ pub(crate) async fn create_roast( info!(roast_id = %roast.id, name = %roast.name, "roast created"); state.stats_invalidator.invalidate(); + let roaster = state + .roaster_repo + .get(roast.roaster_id) + .await + .map_err(AppError::from)?; + let detail_url = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug); + if is_datastar_request(&headers) { - render_roast_list_fragment(state, request, search, true) - .await - .map_err(ApiError::from) + let from_data_page = headers + .get("referer") + .and_then(|v| v.to_str().ok()) + .is_some_and(|r| r.contains("type=roasts")); + + if from_data_page { + render_roast_list_fragment(state, request, search, true) + .await + .map_err(ApiError::from) + } else { + use axum::http::header::HeaderValue; + let script = format!(""); + let mut response = axum::response::Html(script).into_response(); + response + .headers_mut() + .insert("datastar-selector", HeaderValue::from_static("body")); + response + .headers_mut() + .insert("datastar-mode", HeaderValue::from_static("append")); + Ok(response) + } } else if matches!(source, PayloadSource::Form) { - let target = - ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request, search).page_href(1); - Ok(Redirect::to(&target).into_response()) + Ok(Redirect::to(&detail_url).into_response()) } else { let enriched = state .roast_repo @@ -146,7 +169,9 @@ define_delete_handler!( RoastId, RoastSortKey, roast_repo, - render_roast_list_fragment + render_roast_list_fragment, + "type=roasts", + "/data?type=roasts" ); #[tracing::instrument(skip(state, _auth_user))] diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index be196fc..ea4967c 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -10,6 +10,7 @@ mod data; mod gear; mod home; mod roasters; +mod roasts; mod stats; mod timeline; mod webauthn; @@ -39,6 +40,10 @@ pub(super) fn router() -> axum::Router { .route("/cups/{id}", get(cups::cup_detail_page)) .route("/gear/{id}", get(gear::gear_detail_page)) .route("/roasters/{slug}", get(roasters::roaster_detail_page)) + .route( + "/roasters/{roaster_slug}/roasts/{roast_slug}", + get(roasts::roast_detail_page), + ) .route("/styles.css", get(styles)) .route("/webauthn.js", get(webauthn_js)) .route("/components/photo-capture.js", get(photo_capture_js)) diff --git a/src/application/routes/app/roasts.rs b/src/application/routes/app/roasts.rs new file mode 100644 index 0000000..b1bf46f --- /dev/null +++ b/src/application/routes/app/roasts.rs @@ -0,0 +1,43 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use tower_cookies::Cookies; + +use crate::application::errors::map_app_error; +use crate::application::routes::render_html; +use crate::application::state::AppState; +use crate::presentation::web::templates::RoastDetailTemplate; +use crate::presentation::web::views::RoastDetailView; + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn roast_detail_page( + State(state): State, + cookies: Cookies, + Path((roaster_slug, roast_slug)): Path<(String, String)>, +) -> Result { + let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; + + let roaster = state + .roaster_repo + .get_by_slug(&roaster_slug) + .await + .map_err(|e| map_app_error(e.into()))?; + + let roast = state + .roast_repo + .get_by_slug(roaster.id, &roast_slug) + .await + .map_err(|e| map_app_error(e.into()))?; + + let view = RoastDetailView::from_parts(roast, &roaster); + + let template = RoastDetailTemplate { + nav_active: "", + is_authenticated, + version_info: &crate::VERSION_INFO, + base_url: crate::base_url(), + roast: view, + }; + + render_html(template).map(IntoResponse::into_response) +} diff --git a/src/application/routes/support.rs b/src/application/routes/support.rs index 7ae408c..1e9aa25 100644 --- a/src/application/routes/support.rs +++ b/src/application/routes/support.rs @@ -131,6 +131,21 @@ where (view_page, navigator) } +/// Return a Datastar response that redirects the browser to `url`. +/// +/// Works by appending a `"); + let mut response = Html(script).into_response(); + response + .headers_mut() + .insert("datastar-selector", HeaderValue::from_static("body")); + response + .headers_mut() + .insert("datastar-mode", HeaderValue::from_static("append")); + Ok(response) +} + pub fn render_fragment( template: T, selector: &'static str, diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index b400d50..57c205c 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -4,8 +4,8 @@ use super::views::{ BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeDetailView, CafeOptionView, CafeView, CupDetailView, CupView, GearDetailView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView, - RoastOptionView, RoastView, RoasterDetailView, RoasterOptionView, RoasterView, StatCard, - StatsView, TimelineEventView, TimelineMonthView, + RoastDetailView, RoastOptionView, RoastView, RoasterDetailView, RoasterOptionView, RoasterView, + StatCard, StatsView, TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; use crate::domain::brews::BrewSortKey; @@ -241,6 +241,16 @@ pub struct CupDetailTemplate { pub cup: CupDetailView, } +#[derive(Template)] +#[template(path = "pages/roast.html")] +pub struct RoastDetailTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub version_info: &'static crate::VersionInfo, + pub base_url: &'static str, + pub roast: RoastDetailView, +} + #[derive(Template)] #[template(path = "pages/roaster.html")] pub struct RoasterDetailTemplate { diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs index f323a3a..bb106d5 100644 --- a/src/presentation/web/views/brews.rs +++ b/src/presentation/web/views/brews.rs @@ -196,6 +196,7 @@ impl Default for BrewDefaultsView { } pub struct BrewDetailView { + pub id: String, // Coffee info pub roast_name: String, pub roaster_name: String, @@ -252,6 +253,7 @@ impl BrewDetailView { .join(", "); Self { + id: brew.brew.id.to_string(), roast_name: brew.roast_name, roaster_name: brew.roaster_name, origin: coffee.origin, diff --git a/src/presentation/web/views/cups.rs b/src/presentation/web/views/cups.rs index 78e52ca..4d08a7b 100644 --- a/src/presentation/web/views/cups.rs +++ b/src/presentation/web/views/cups.rs @@ -39,6 +39,7 @@ impl CupView { } pub struct CupDetailView { + pub id: String, // Coffee info pub roast_name: String, pub roaster_name: String, @@ -87,6 +88,7 @@ impl CupDetailView { let (map_countries, map_max) = build_map_data(&map_entries); Self { + id: cup.cup.id.to_string(), roast_name: cup.roast_name, roaster_name: cup.roaster_name, origin: coffee.origin, diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index ae76c50..b5a6cf1 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -14,7 +14,7 @@ pub use cafes::{CafeDetailView, CafeOptionView, CafeView, NearbyCafeView}; pub use cups::{CupDetailView, CupView}; pub use gear::{GearDetailView, GearOptionView, GearView}; pub use roasters::{RoasterDetailView, RoasterOptionView, RoasterView}; -pub use roasts::{RoastOptionView, RoastView}; +pub use roasts::{RoastDetailView, RoastOptionView, RoastView}; pub use tasting_notes::TastingNoteView; pub use timeline::{ TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, diff --git a/src/presentation/web/views/roasts.rs b/src/presentation/web/views/roasts.rs index 440a599..0f7aaa7 100644 --- a/src/presentation/web/views/roasts.rs +++ b/src/presentation/web/views/roasts.rs @@ -1,6 +1,8 @@ +use crate::domain::roasters::Roaster; use crate::domain::roasts::{Roast, RoastWithRoaster}; use super::tasting_notes::{self, TastingNoteView}; +use super::{build_coffee_info, build_map_data, build_roaster_info}; pub struct RoastView { pub id: String, @@ -90,6 +92,68 @@ impl RoastView { } } +pub struct RoastDetailView { + pub id: String, + pub name: String, + pub roaster_name: String, + pub roaster_slug: String, + // Coffee info + pub origin: String, + pub origin_flag: String, + pub region: String, + pub producer: String, + pub process: String, + pub tasting_notes: Vec, + // Roaster info + pub roaster_country: String, + pub roaster_country_flag: String, + pub roaster_city: Option, + pub roaster_homepage: Option, + // Map + pub map_countries: String, + pub map_max: u32, + // Dates + pub created_date: String, + pub created_time: String, +} + +impl RoastDetailView { + pub fn from_parts(roast: Roast, roaster: &Roaster) -> Self { + let coffee = build_coffee_info(&roast); + let roaster_info = build_roaster_info(roaster); + + let mut map_entries: Vec<(&str, u32)> = Vec::new(); + if let Some(ref o) = roast.origin + && !o.is_empty() + { + map_entries.push((o.as_str(), 2)); + } + map_entries.push((roaster.country.as_str(), 1)); + let (map_countries, map_max) = build_map_data(&map_entries); + + Self { + id: roast.id.to_string(), + name: roast.name, + roaster_name: roaster.name.clone(), + roaster_slug: roaster.slug.clone(), + origin: coffee.origin, + origin_flag: coffee.origin_flag, + region: coffee.region, + producer: coffee.producer, + process: coffee.process, + tasting_notes: coffee.tasting_notes, + roaster_country: roaster_info.country, + roaster_country_flag: roaster_info.country_flag, + roaster_city: roaster_info.city, + roaster_homepage: roaster_info.homepage, + map_countries, + map_max, + created_date: roast.created_at.format("%Y-%m-%d").to_string(), + created_time: roast.created_at.format("%H:%M").to_string(), + } + } +} + pub struct RoastOptionView { pub id: String, pub label: String, diff --git a/src/presentation/web/views/timeline.rs b/src/presentation/web/views/timeline.rs index 0dbd53d..bf21911 100644 --- a/src/presentation/web/views/timeline.rs +++ b/src/presentation/web/views/timeline.rs @@ -72,14 +72,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; @@ -96,11 +96,21 @@ impl TimelineEventView { }; 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(), + "brew" => format!("/brews/{entity_id}"), + "cup" => format!("/cups/{entity_id}"), + "bag" => format!("/bags/{entity_id}"), + "gear" => format!("/gear/{entity_id}"), + "roaster" => slug.as_deref().map_or_else( + || "/data?type=roasters".to_string(), + |s| format!("/roasters/{s}"), + ), + "cafe" => slug + .as_deref() + .map_or_else(|| "/data?type=cafes".to_string(), |s| format!("/cafes/{s}")), + "roast" => match (roaster_slug.as_deref(), slug.as_deref()) { + (Some(rs), Some(s)) => format!("/roasters/{rs}/roasts/{s}"), + _ => "/data?type=roasts".to_string(), + }, _ => String::from("#"), }; diff --git a/templates/pages/bag.html b/templates/pages/bag.html index 4d63275..94b95cc 100644 --- a/templates/pages/bag.html +++ b/templates/pages/bag.html @@ -14,11 +14,8 @@ {{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }}

- {{ detail::share_button() }} -{{ detail::share_script() }} - {# ── Coffee + map ── #}
{{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }} @@ -76,47 +73,18 @@
{% if !bag.closed %} - {% endif %} -
- {% endif %} {% endblock %} diff --git a/templates/pages/brew.html b/templates/pages/brew.html index 26cca95..d880d78 100644 --- a/templates/pages/brew.html +++ b/templates/pages/brew.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% import "partials/detail_cards.html" as detail %} +{% import "partials/icons.html" as icons %} {% block title %}Brewlog · {{ brew.roast_name }}{% endblock %} {% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %} {% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %} @@ -13,11 +14,8 @@ {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}

- {{ detail::share_button() }} -{{ detail::share_script() }} - {# ── Coffee + map ── #}
{{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }} @@ -85,4 +83,14 @@
+ +{% if is_authenticated %} +
+ +
+{% endif %} {% endblock %} diff --git a/templates/pages/cafe.html b/templates/pages/cafe.html index c802dab..7553f24 100644 --- a/templates/pages/cafe.html +++ b/templates/pages/cafe.html @@ -13,11 +13,8 @@ {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }}

- {{ detail::share_button() }} -{{ detail::share_script() }} -

Details

@@ -48,25 +45,11 @@ {% if is_authenticated %}
-
- {% endif %} {% endblock %} diff --git a/templates/pages/cup.html b/templates/pages/cup.html index eeb42a5..cf6fa4d 100644 --- a/templates/pages/cup.html +++ b/templates/pages/cup.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% import "partials/detail_cards.html" as detail %} +{% import "partials/icons.html" as icons %} {% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %} {% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %} {% block og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %} @@ -13,11 +14,8 @@ {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}

- {{ detail::share_button() }} -{{ detail::share_script() }} - {# ── Coffee + map ── #}
{{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }} @@ -56,4 +54,14 @@
+ +{% if is_authenticated %} +
+ +
+{% endif %} {% endblock %} diff --git a/templates/pages/gear.html b/templates/pages/gear.html index c290587..b9b5d47 100644 --- a/templates/pages/gear.html +++ b/templates/pages/gear.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% import "partials/detail_cards.html" as detail %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %} {% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %} @@ -13,11 +12,8 @@ {{ gear.category_label }} · Added {{ gear.created_date }}

- {{ detail::share_button() }} -{{ detail::share_script() }} -

Details

@@ -38,25 +34,11 @@ {% if is_authenticated %}
-
- {% endif %} {% endblock %} diff --git a/templates/pages/home.html b/templates/pages/home.html index 3833a98..a7e90fa 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -197,22 +197,20 @@ {% if !recent_events.is_empty() %} {% else %} diff --git a/templates/pages/roast.html b/templates/pages/roast.html new file mode 100644 index 0000000..f4113ef --- /dev/null +++ b/templates/pages/roast.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% import "partials/detail_cards.html" as detail %} +{% import "partials/icons.html" as icons %} +{% block title %}Brewlog · {{ roast.name }}{% endblock %} +{% block og_title %}{{ roast.name }} — Brewlog{% endblock %} +{% block og_description %}{{ roast.name }} by {{ roast.roaster_name }} — {{ roast.origin }}{% endblock %} +{% block head %}{% endblock %} +{% block content %} +
+
+

{{ roast.name }}

+

+ {{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} · Added {{ roast.created_date }} +

+
+
+ +
+ {{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes) }} + {{ detail::map_with_legend_2(roast.map_countries, roast.map_max, "Origin", "", "Roaster", "opacity-50") }} +
+ +
+ {{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage) }} +
+ +{% if is_authenticated %} +
+ +
+{% endif %} +{% endblock %} diff --git a/templates/pages/roaster.html b/templates/pages/roaster.html index 6cb6de0..82c1726 100644 --- a/templates/pages/roaster.html +++ b/templates/pages/roaster.html @@ -13,11 +13,8 @@ {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %} · {{ c }}{% endif %} · Added {{ roaster.created_date }}

- {{ detail::share_button() }} -{{ detail::share_script() }} -

Details

@@ -50,25 +47,11 @@ {% if is_authenticated %}
-
- {% endif %} {% endblock %} diff --git a/templates/partials/lists/bag_list.html b/templates/partials/lists/bag_list.html index 0993567..7c9c3e4 100644 --- a/templates/partials/lists/bag_list.html +++ b/templates/partials/lists/bag_list.html @@ -28,15 +28,13 @@ {{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }} {{ table::sortable_header("Status", "status", navigator, "#bag-list") }} {{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }} - {% if is_authenticated %} - Actions - {% endif %} + {% for bag in bags.items %}
{{ bag.created_date }}
@@ -76,23 +74,6 @@
{{ bag.finished_date }}
{% endif %} - {% if is_authenticated %} - -
- {% if !bag.closed %} - - {% endif %} - -
-
@@ -100,36 +81,13 @@ Closed {% endif %} - -
- - {% endif %} - - {% if is_authenticated %} - - -
- {% if !bag.closed %} - - {% endif %} - + + {{ icons::chevron_right("h-5 w-5") }} +
- {% endif %} {% endfor %} diff --git a/templates/partials/lists/brew_list.html b/templates/partials/lists/brew_list.html index f5a9e0e..2a79710 100644 --- a/templates/partials/lists/brew_list.html +++ b/templates/partials/lists/brew_list.html @@ -1,20 +1,6 @@ {% import "partials/lists/table.html" as table %} {% import "partials/icons.html" as icons %} -{% macro brew_actions(brew, navigator) %} -
- - {{ icons::plus_circle("h-4 w-4") }} Brew Again - - -
-{% endmacro %} -
{% if brews.items.is_empty() && !navigator.has_search() %}
Recipe Brewer Notes - {% if is_authenticated %} - Actions - {% endif %} + {% for brew in brews.items %}
{{ brew.created_date }}
@@ -96,27 +80,13 @@ No Notes {% endif %} - {% if is_authenticated %} - - {{ brew_actions(brew, navigator) }} - - - - {% endif %} - - {% if is_authenticated %} - - - {{ brew_actions(brew, navigator) }} + + {{ icons::chevron_right("h-5 w-5") }} + - {% endif %} {% endfor %} diff --git a/templates/partials/lists/cafe_list.html b/templates/partials/lists/cafe_list.html index 2cce61b..61c66d3 100644 --- a/templates/partials/lists/cafe_list.html +++ b/templates/partials/lists/cafe_list.html @@ -27,7 +27,7 @@ {{ table::sortable_header("Name", "name", navigator, "#cafe-list") }} {{ table::sortable_header("City", "city", navigator, "#cafe-list") }} {{ table::sortable_header("Country", "country", navigator, "#cafe-list") }} - Actions + @@ -39,7 +39,7 @@ data-sort-country="{{ cafe.country }}" data-sort-city="{{ cafe.city }}" class="transition hover:bg-surface-alt" - onclick="toggleRow(event)" + onclick="window.location.href='{{ cafe.detail_path }}'" >
{{ cafe.created_date }}
@@ -52,57 +52,11 @@ {{ cafe.city }} {{ cafe.city }} {{ cafe.country }} - -
- - {{ icons::location("h-4 w-4") }} View Map - - {% if cafe.has_website %} - - {{ icons::external_link("h-4 w-4") }} Homepage - - {% endif %} - {% if is_authenticated %} - - {% endif %} -
- - - - - - -
- - {{ icons::location("h-4 w-4") }} View Map - - {% if cafe.has_website %} - - {{ icons::external_link("h-4 w-4") }} Homepage - - {% endif %} - {% if is_authenticated %} - - {% endif %} -
+ + {{ icons::chevron_right("h-5 w-5") }} + {% endfor %} diff --git a/templates/partials/lists/cup_list.html b/templates/partials/lists/cup_list.html index 516e46e..3347dbd 100644 --- a/templates/partials/lists/cup_list.html +++ b/templates/partials/lists/cup_list.html @@ -28,9 +28,7 @@ {{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }} {{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }} {{ table::sortable_header("City", "city", navigator, "#cup-list") }} - {% if is_authenticated %} - Actions - {% endif %} + @@ -38,7 +36,7 @@
{{ cup.created_date }}
@@ -55,39 +53,13 @@ {{ cup.cafe_city }} {{ cup.cafe_city }} - {% if is_authenticated %} - -
- -
- - - - {% endif %} - - {% if is_authenticated %} - - -
- -
+ + {{ icons::chevron_right("h-5 w-5") }} + - {% endif %} {% endfor %} diff --git a/templates/partials/lists/gear_list.html b/templates/partials/lists/gear_list.html index 3a0f07b..a8566e4 100644 --- a/templates/partials/lists/gear_list.html +++ b/templates/partials/lists/gear_list.html @@ -28,15 +28,13 @@ {{ table::sortable_header("Category", "category", navigator, "#gear-list") }} {{ table::sortable_header("Make", "make", navigator, "#gear-list") }} {{ table::sortable_header("Model", "model", navigator, "#gear-list") }} - {% if is_authenticated %} - Actions - {% endif %} + {% for item in gear.items %}
{{ item.created_date }}
@@ -52,39 +50,13 @@ {{ item.model }} - {% if is_authenticated %} - -
- -
- - - - {% endif %} - - {% if is_authenticated %} - - -
- -
+ + {{ icons::chevron_right("h-5 w-5") }} + - {% endif %} {% endfor %} diff --git a/templates/partials/lists/roast_list.html b/templates/partials/lists/roast_list.html index 30cbc68..bf16daa 100644 --- a/templates/partials/lists/roast_list.html +++ b/templates/partials/lists/roast_list.html @@ -27,9 +27,7 @@ {{ table::sortable_header("Roast", "name", navigator, "#roast-list") }} {{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }} Tasting Notes - {% if is_authenticated %} - Actions - {% endif %} + @@ -42,7 +40,7 @@ data-sort-origin="{{ roast.origin }}" data-sort-producer="{{ roast.producer }}" class="transition hover:bg-surface-alt" - {% if is_authenticated %}onclick="toggleRow(event)"{% endif %} + onclick="window.location.href='{{ roast.detail_path }}'" >
{{ roast.created_date }}
@@ -73,39 +71,13 @@ No Notes {% endif %} - {% if is_authenticated %} - -
- -
- - - - {% endif %} - - {% if is_authenticated %} - - -
- -
+ + {{ icons::chevron_right("h-5 w-5") }} + - {% endif %} {% endfor %} diff --git a/templates/partials/lists/roaster_list.html b/templates/partials/lists/roaster_list.html index 514aca4..f27dc8b 100644 --- a/templates/partials/lists/roaster_list.html +++ b/templates/partials/lists/roaster_list.html @@ -27,7 +27,7 @@ {{ table::sortable_header("Name", "name", navigator, "#roaster-list") }} {{ table::sortable_header("Country", "country", navigator, "#roaster-list") }} {{ table::sortable_header("City", "city", navigator, "#roaster-list") }} - Actions + @@ -39,7 +39,7 @@ data-sort-country="{{ roaster.country }}" data-sort-city="{{ roaster.city }}" class="transition hover:bg-surface-alt" - {% if roaster.has_homepage || is_authenticated %}onclick="toggleRow(event)"{% endif %} + onclick="window.location.href='{{ roaster.detail_path }}'" >
{{ roaster.created_date }}
@@ -54,57 +54,13 @@ {% endif %} {{ roaster.country }} {{ roaster.city }} - {% if roaster.has_homepage || is_authenticated %} - -
- {% if roaster.has_homepage %} - - {{ icons::external_link("h-4 w-4") }} Homepage - - {% endif %} - {% if is_authenticated %} - - {% endif %} -
- - {% endif %} - {% if roaster.has_homepage || is_authenticated %} - - {% endif %} + + {{ icons::chevron_right("h-5 w-5") }} + - {% if roaster.has_homepage || is_authenticated %} - - -
- {% if roaster.has_homepage %} - - {{ icons::external_link("h-4 w-4") }} Homepage - - {% endif %} - {% if is_authenticated %} - - {% endif %} -
- - - {% endif %} {% endfor %} diff --git a/tests/server/datastar.rs b/tests/server/datastar.rs index 0269556..fa0729e 100644 --- a/tests/server/datastar.rs +++ b/tests/server/datastar.rs @@ -203,6 +203,7 @@ async fn roasts_create_with_datastar_header_returns_fragment() { .post(app.api_url("/roasts")) .bearer_auth(app.auth_token.as_ref().unwrap()) .header("datastar-request", "true") + .header("referer", format!("{}/data?type=roasts", app.address)) .json(&new_roast) .send() .await @@ -314,6 +315,7 @@ async fn bags_update_with_datastar_header_returns_fragment() { .put(app.api_url(&format!("/bags/{}", bag.id))) .bearer_auth(app.auth_token.as_ref().unwrap()) .header("datastar-request", "true") + .header("referer", format!("{}/data?type=bags", app.address)) .json(&update) .send() .await diff --git a/tests/server/test_macros.rs b/tests/server/test_macros.rs index 591c89a..0ff7a94 100644 --- a/tests/server/test_macros.rs +++ b/tests/server/test_macros.rs @@ -169,6 +169,7 @@ macro_rules! define_datastar_entity_tests { .delete(app.api_url(&format!("{}/{}", $api_path, entity_id))) .bearer_auth(app.auth_token.as_ref().unwrap()) .header("datastar-request", "true") + .header("referer", format!("{}/data?type={}", app.address, $type_param)) .send() .await .expect(concat!("failed to delete ", stringify!($entity))); diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index d94054a..faf1a91 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -124,8 +124,8 @@ async fn creating_a_roaster_surfaces_on_the_timeline() { "Expected roaster name to appear in timeline HTML, got: {body}" ); assert!( - body.contains("/data?type=roasters"), - "Expected roaster link in timeline HTML, got: {body}" + body.contains("/roasters/"), + "Expected roaster detail link in timeline HTML, got: {body}" ); } @@ -506,8 +506,8 @@ async fn creating_a_cafe_surfaces_on_the_timeline() { "Expected cafe name to appear in timeline HTML, got: {body}" ); assert!( - body.contains("/data?type=cafes"), - "Expected cafe link in timeline HTML, got: {body}" + body.contains("/cafes/"), + "Expected cafe detail link in timeline HTML, got: {body}" ); }