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 %}
{% 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 %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+{% 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 %}
-
+
{{ icons::delete("h-4 w-4") }} Delete
-
{% 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 %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+{% 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 %}
-
+
{{ icons::delete("h-4 w-4") }} Delete
-
{% 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() %}
{% for event in recent_events %}
-
+
{% endfor %}
{% 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 %}
+
+
+
+ {{ 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 %}
+
+
+ {{ icons::delete("h-4 w-4") }} Delete
+
+
+{% 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 %}
-
+
{{ icons::delete("h-4 w-4") }} Delete
-
{% 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 %}
-
- {{ icons::x_circle("h-4 w-4") }} Close bag
-
- {% endif %}
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
- |
@@ -100,36 +81,13 @@
Closed
{% endif %}
-
- {{ icons::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
-
- |
- {% endif %}
-
- {% if is_authenticated %}
-
- |
-
|
- {% 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) %}
-
-{% 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) }}
- |
-
- {{ icons::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
- |
- {% 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::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
- |
-
-
- |
-
+
+ {{ 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 %}
-
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
- |
-
- {{ icons::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
- |
- {% endif %}
-
- {% if is_authenticated %}
-
- |
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
+
+ {{ 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 %}
-
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
- |
-
- {{ icons::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
- |
- {% endif %}
-
- {% if is_authenticated %}
-
- |
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
+
+ {{ 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 %}
-
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
- |
-
- {{ icons::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
- |
- {% endif %}
-
- {% if is_authenticated %}
-
- |
-
-
- {{ icons::delete("h-4 w-4") }} Delete
-
-
+
+ {{ 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 %}
-
-
- |
- {% endif %}
- {% if roaster.has_homepage || is_authenticated %}
-
- {{ icons::ellipsis_vertical("h-5 w-5") }}
- {{ icons::chevron_up("h-5 w-5") }}
-
- {% endif %}
+
+ {{ icons::chevron_right("h-5 w-5") }}
+
|
- {% if roaster.has_homepage || is_authenticated %}
-
- |
-
- |
-
- {% 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}"
);
}