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
This commit is contained in:
Jon Seager 2026-02-08 17:48:49 +00:00
parent 958c8de6cc
commit f0eb346086
No known key found for this signature in database
35 changed files with 379 additions and 454 deletions

View file

@ -85,16 +85,8 @@ pub(crate) async fn create_bag(
.await .await
.map_err(ApiError::from) .map_err(ApiError::from)
} else { } else {
use axum::http::header::HeaderValue; crate::application::routes::support::render_redirect_script(&detail_url)
let script = format!("<script>window.location.href='{detail_url}'</script>"); .map_err(ApiError::from)
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) { } else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response()) Ok(Redirect::to(&detail_url).into_response())
@ -175,9 +167,20 @@ pub(crate) async fn update_bag(
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
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) render_bag_list_fragment(state, request, search, true)
.await .await
.map_err(ApiError::from) .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 { } else {
let enriched = state let enriched = state
.bag_repo .bag_repo
@ -193,7 +196,9 @@ define_delete_handler!(
BagId, BagId,
BagSortKey, BagSortKey,
bag_repo, bag_repo,
render_bag_list_fragment render_bag_list_fragment,
"type=bags",
"/data?type=bags"
); );
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -269,16 +269,8 @@ pub(crate) async fn create_brew(
.await .await
.map_err(ApiError::from) .map_err(ApiError::from)
} else { } else {
use axum::http::header::HeaderValue; crate::application::routes::support::render_redirect_script(&detail_url)
let script = format!("<script>window.location.href='{detail_url}'</script>"); .map_err(ApiError::from)
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) { } else if matches!(source, PayloadSource::Form) {
Ok(Redirect::to(&detail_url).into_response()) Ok(Redirect::to(&detail_url).into_response())
@ -323,7 +315,9 @@ define_delete_handler!(
BrewId, BrewId,
BrewSortKey, BrewSortKey,
brew_repo, brew_repo,
render_brew_list_fragment render_brew_list_fragment,
"type=brews",
"/data?type=brews"
); );
async fn render_brew_list_fragment( async fn render_brew_list_fragment(

View file

@ -141,7 +141,9 @@ define_delete_handler!(
CafeId, CafeId,
CafeSortKey, CafeSortKey,
cafe_repo, cafe_repo,
render_cafe_list_fragment render_cafe_list_fragment,
"type=cafes",
"/data?type=cafes"
); );
define_list_fragment_renderer!( define_list_fragment_renderer!(

View file

@ -104,7 +104,9 @@ define_delete_handler!(
CupId, CupId,
CupSortKey, CupSortKey,
cup_repo, cup_repo,
render_cup_list_fragment render_cup_list_fragment,
"type=cups",
"/data?type=cups"
); );
define_list_fragment_renderer!( define_list_fragment_renderer!(

View file

@ -156,7 +156,9 @@ define_delete_handler!(
GearId, GearId,
GearSortKey, GearSortKey,
gear_repo, gear_repo,
render_gear_list_fragment render_gear_list_fragment,
"type=gear",
"/data?type=gear"
); );
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]

View file

@ -59,12 +59,19 @@ macro_rules! define_enriched_get_handler {
/// Generates a DELETE handler with Datastar fragment re-rendering support. /// 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 /// # Arguments
/// * `$fn_name` - Name of the generated handler function /// * `$fn_name` - Name of the generated handler function
/// * `$id_type` - Type of the ID path parameter (e.g., `RoasterId`) /// * `$id_type` - Type of the ID path parameter (e.g., `RoasterId`)
/// * `$sort_key` - Sort key type for list requests (e.g., `RoasterSortKey`) /// * `$sort_key` - Sort key type for list requests (e.g., `RoasterSortKey`)
/// * `$repo_field` - Name of the repository field on `AppState` (e.g., `roaster_repo`) /// * `$repo_field` - Name of the repository field on `AppState` (e.g., `roaster_repo`)
/// * `$render_fragment` - Path to the fragment render function /// * `$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 /// # Example
/// ```ignore /// ```ignore
@ -73,11 +80,13 @@ macro_rules! define_enriched_get_handler {
/// RoasterId, /// RoasterId,
/// RoasterSortKey, /// RoasterSortKey,
/// roaster_repo, /// roaster_repo,
/// render_roaster_list_fragment /// render_roaster_list_fragment,
/// "type=roasters",
/// "/data?type=roasters"
/// ); /// );
/// ``` /// ```
macro_rules! define_delete_handler { 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))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn $fn_name( pub(crate) async fn $fn_name(
axum::extract::State(state): axum::extract::State<crate::application::state::AppState>, axum::extract::State(state): axum::extract::State<crate::application::state::AppState>,
@ -99,9 +108,19 @@ macro_rules! define_delete_handler {
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
if crate::application::routes::support::is_datastar_request(&headers) { if crate::application::routes::support::is_datastar_request(&headers) {
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) $render_fragment(state, request, search, true)
.await .await
.map_err(crate::application::errors::ApiError::from) .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 { } else {
Ok(axum::http::StatusCode::NO_CONTENT.into_response()) Ok(axum::http::StatusCode::NO_CONTENT.into_response())
} }

View file

@ -141,7 +141,9 @@ define_delete_handler!(
RoasterId, RoasterId,
RoasterSortKey, RoasterSortKey,
roaster_repo, roaster_repo,
render_roaster_list_fragment render_roaster_list_fragment,
"type=roasters",
"/data?type=roasters"
); );
#[tracing::instrument(skip(state, auth_user, headers, payload))] #[tracing::instrument(skip(state, auth_user, headers, payload))]

View file

@ -74,14 +74,37 @@ pub(crate) async fn create_roast(
info!(roast_id = %roast.id, name = %roast.name, "roast created"); info!(roast_id = %roast.id, name = %roast.name, "roast created");
state.stats_invalidator.invalidate(); 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) { if is_datastar_request(&headers) {
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) render_roast_list_fragment(state, request, search, true)
.await .await
.map_err(ApiError::from) .map_err(ApiError::from)
} else {
use axum::http::header::HeaderValue;
let script = format!("<script>window.location.href='{detail_url}'</script>");
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) { } else if matches!(source, PayloadSource::Form) {
let target = Ok(Redirect::to(&detail_url).into_response())
ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request, search).page_href(1);
Ok(Redirect::to(&target).into_response())
} else { } else {
let enriched = state let enriched = state
.roast_repo .roast_repo
@ -146,7 +169,9 @@ define_delete_handler!(
RoastId, RoastId,
RoastSortKey, RoastSortKey,
roast_repo, roast_repo,
render_roast_list_fragment render_roast_list_fragment,
"type=roasts",
"/data?type=roasts"
); );
#[tracing::instrument(skip(state, _auth_user))] #[tracing::instrument(skip(state, _auth_user))]

View file

@ -10,6 +10,7 @@ mod data;
mod gear; mod gear;
mod home; mod home;
mod roasters; mod roasters;
mod roasts;
mod stats; mod stats;
mod timeline; mod timeline;
mod webauthn; mod webauthn;
@ -39,6 +40,10 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/cups/{id}", get(cups::cup_detail_page)) .route("/cups/{id}", get(cups::cup_detail_page))
.route("/gear/{id}", get(gear::gear_detail_page)) .route("/gear/{id}", get(gear::gear_detail_page))
.route("/roasters/{slug}", get(roasters::roaster_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("/styles.css", get(styles))
.route("/webauthn.js", get(webauthn_js)) .route("/webauthn.js", get(webauthn_js))
.route("/components/photo-capture.js", get(photo_capture_js)) .route("/components/photo-capture.js", get(photo_capture_js))

View file

@ -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<AppState>,
cookies: Cookies,
Path((roaster_slug, roast_slug)): Path<(String, String)>,
) -> Result<Response, StatusCode> {
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)
}

View file

@ -131,6 +131,21 @@ where
(view_page, navigator) (view_page, navigator)
} }
/// Return a Datastar response that redirects the browser to `url`.
///
/// Works by appending a `<script>` tag to `<body>` that sets `window.location.href`.
pub fn render_redirect_script(url: &str) -> Result<Response, AppError> {
let script = format!("<script>window.location.href='{url}'</script>");
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<T: Template>( pub fn render_fragment<T: Template>(
template: T, template: T,
selector: &'static str, selector: &'static str,

View file

@ -4,8 +4,8 @@ use super::views::{
BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView,
CafeDetailView, CafeOptionView, CafeView, CupDetailView, CupView, GearDetailView, CafeDetailView, CafeOptionView, CafeView, CupDetailView, CupView, GearDetailView,
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
RoastOptionView, RoastView, RoasterDetailView, RoasterOptionView, RoasterView, StatCard, RoastDetailView, RoastOptionView, RoastView, RoasterDetailView, RoasterOptionView, RoasterView,
StatsView, TimelineEventView, TimelineMonthView, StatCard, StatsView, TimelineEventView, TimelineMonthView,
}; };
use crate::domain::bags::BagSortKey; use crate::domain::bags::BagSortKey;
use crate::domain::brews::BrewSortKey; use crate::domain::brews::BrewSortKey;
@ -241,6 +241,16 @@ pub struct CupDetailTemplate {
pub cup: CupDetailView, 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)] #[derive(Template)]
#[template(path = "pages/roaster.html")] #[template(path = "pages/roaster.html")]
pub struct RoasterDetailTemplate { pub struct RoasterDetailTemplate {

View file

@ -196,6 +196,7 @@ impl Default for BrewDefaultsView {
} }
pub struct BrewDetailView { pub struct BrewDetailView {
pub id: String,
// Coffee info // Coffee info
pub roast_name: String, pub roast_name: String,
pub roaster_name: String, pub roaster_name: String,
@ -252,6 +253,7 @@ impl BrewDetailView {
.join(", "); .join(", ");
Self { Self {
id: brew.brew.id.to_string(),
roast_name: brew.roast_name, roast_name: brew.roast_name,
roaster_name: brew.roaster_name, roaster_name: brew.roaster_name,
origin: coffee.origin, origin: coffee.origin,

View file

@ -39,6 +39,7 @@ impl CupView {
} }
pub struct CupDetailView { pub struct CupDetailView {
pub id: String,
// Coffee info // Coffee info
pub roast_name: String, pub roast_name: String,
pub roaster_name: String, pub roaster_name: String,
@ -87,6 +88,7 @@ impl CupDetailView {
let (map_countries, map_max) = build_map_data(&map_entries); let (map_countries, map_max) = build_map_data(&map_entries);
Self { Self {
id: cup.cup.id.to_string(),
roast_name: cup.roast_name, roast_name: cup.roast_name,
roaster_name: cup.roaster_name, roaster_name: cup.roaster_name,
origin: coffee.origin, origin: coffee.origin,

View file

@ -14,7 +14,7 @@ pub use cafes::{CafeDetailView, CafeOptionView, CafeView, NearbyCafeView};
pub use cups::{CupDetailView, CupView}; pub use cups::{CupDetailView, CupView};
pub use gear::{GearDetailView, GearOptionView, GearView}; pub use gear::{GearDetailView, GearOptionView, GearView};
pub use roasters::{RoasterDetailView, RoasterOptionView, RoasterView}; pub use roasters::{RoasterDetailView, RoasterOptionView, RoasterView};
pub use roasts::{RoastOptionView, RoastView}; pub use roasts::{RoastDetailView, RoastOptionView, RoastView};
pub use tasting_notes::TastingNoteView; pub use tasting_notes::TastingNoteView;
pub use timeline::{ pub use timeline::{
TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView, TimelineBrewDataView, TimelineEventDetailView, TimelineEventView, TimelineMonthView,

View file

@ -1,6 +1,8 @@
use crate::domain::roasters::Roaster;
use crate::domain::roasts::{Roast, RoastWithRoaster}; use crate::domain::roasts::{Roast, RoastWithRoaster};
use super::tasting_notes::{self, TastingNoteView}; use super::tasting_notes::{self, TastingNoteView};
use super::{build_coffee_info, build_map_data, build_roaster_info};
pub struct RoastView { pub struct RoastView {
pub id: String, 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<TastingNoteView>,
// Roaster info
pub roaster_country: String,
pub roaster_country_flag: String,
pub roaster_city: Option<String>,
pub roaster_homepage: Option<String>,
// 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 struct RoastOptionView {
pub id: String, pub id: String,
pub label: String, pub label: String,

View file

@ -72,14 +72,14 @@ impl TimelineEventView {
let TimelineEvent { let TimelineEvent {
id, id,
entity_type, entity_type,
entity_id: _, entity_id,
action, action,
occurred_at, occurred_at,
title, title,
details, details,
tasting_notes, tasting_notes,
slug: _, slug,
roaster_slug: _, roaster_slug,
brew_data, brew_data,
} = event; } = event;
@ -96,11 +96,21 @@ impl TimelineEventView {
}; };
let link = match entity_type.as_str() { let link = match entity_type.as_str() {
"roaster" => "/data?type=roasters".to_string(), "brew" => format!("/brews/{entity_id}"),
"roast" | "bag" | "brew" => format!("/data?type={entity_type}s"), "cup" => format!("/cups/{entity_id}"),
"cafe" => "/data?type=cafes".to_string(), "bag" => format!("/bags/{entity_id}"),
"cup" => "/data?type=cups".to_string(), "gear" => format!("/gear/{entity_id}"),
"gear" => "/data?type=gear".to_string(), "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("#"), _ => String::from("#"),
}; };

View file

@ -14,11 +14,8 @@
{{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }} {{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }}
</p> </p>
</div> </div>
{{ detail::share_button() }}
</header> </header>
{{ detail::share_script() }}
{# ── Coffee + map ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }} {{ 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 @@
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2">
{% if !bag.closed %} {% if !bag.closed %}
<button type="button" onclick="closeBag('{{ bag.id }}')" <button type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"> class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt"
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&remaining=0')">
{{ icons::x_mark("h-4 w-4") }} Close Bag {{ icons::x_mark("h-4 w-4") }} Close Bag
</button> </button>
{% endif %} {% endif %}
<button type="button" onclick="deleteBag('{{ bag.id }}')" <button type="button"
class="inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-700"> class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this bag? This cannot be undone.') && @delete('/api/v1/bags/{{ bag.id }}')">
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>
</div> </div>
<script>
const closeBag = async (bagId) => {
if (!confirm('Close this bag? This will mark it as finished.')) return;
try {
const today = new Date().toISOString().split('T')[0];
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.reload();
} catch (e) {
alert(`Failed to close bag: ${e.message}`);
}
};
const deleteBag = async (bagId) => {
if (!confirm('Delete this bag? This cannot be undone.')) return;
try {
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.href = '/';
} catch (e) {
alert(`Failed to delete bag: ${e.message}`);
}
};
</script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %} {% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %} {% 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 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 %} {% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
@ -13,11 +14,8 @@
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }} {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}
</p> </p>
</div> </div>
{{ detail::share_button() }}
</header> </header>
{{ detail::share_script() }}
{# ── Coffee + map ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }} {{ 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 @@
</dl> </dl>
</div> </div>
</div> </div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this brew? This cannot be undone.') && @delete('/api/v1/brews/{{ brew.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -13,11 +13,8 @@
{{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }} {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }}
</p> </p>
</div> </div>
{{ detail::share_button() }}
</header> </header>
{{ detail::share_script() }}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2> <h2 class="text-lg font-semibold text-text mb-4">Details</h2>
@ -48,25 +45,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" onclick="deleteCafe('{{ cafe.id }}')" <button type="button"
class="inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-700"> class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this cafe? This cannot be undone.') && @delete('/api/v1/cafes/{{ cafe.id }}')">
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>
<script>
const deleteCafe = async (id) => {
if (!confirm('Delete this cafe? This cannot be undone.')) return;
try {
const resp = await fetch(`/api/v1/cafes/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.href = '/data?type=cafes';
} catch (e) {
alert(`Failed to delete cafe: ${e.message}`);
}
};
</script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %} {% 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 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 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 %} {% 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 }} {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}
</p> </p>
</div> </div>
{{ detail::share_button() }}
</header> </header>
{{ detail::share_script() }}
{# ── Coffee + map ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }} {{ 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 @@
</dl> </dl>
</div> </div>
</div> </div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this cup? This cannot be undone.') && @delete('/api/v1/cups/{{ cup.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,5 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %} {% block title %}Brewlog · {{ gear.make }} {{ gear.model }}{% endblock %}
{% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %} {% block og_title %}{{ gear.make }} {{ gear.model }} — Brewlog{% endblock %}
@ -13,11 +12,8 @@
{{ gear.category_label }} · Added {{ gear.created_date }} {{ gear.category_label }} · Added {{ gear.created_date }}
</p> </p>
</div> </div>
{{ detail::share_button() }}
</header> </header>
{{ detail::share_script() }}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2> <h2 class="text-lg font-semibold text-text mb-4">Details</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm"> <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
@ -38,25 +34,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" onclick="deleteGear('{{ gear.id }}')" <button type="button"
class="inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-700"> class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this gear? This cannot be undone.') && @delete('/api/v1/gear/{{ gear.id }}')">
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>
<script>
const deleteGear = async (id) => {
if (!confirm('Delete this gear? This cannot be undone.')) return;
try {
const resp = await fetch(`/api/v1/gear/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.href = '/data?type=gear';
} catch (e) {
alert(`Failed to delete gear: ${e.message}`);
}
};
</script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -197,22 +197,20 @@
{% if !recent_events.is_empty() %} {% if !recent_events.is_empty() %}
<div class="space-y-2"> <div class="space-y-2">
{% for event in recent_events %} {% for event in recent_events %}
<div class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3"> <a href="{{ event.link }}" class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3 transition hover:border-accent/40">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="text-text-muted shrink-0"> <span class="text-text-muted shrink-0">
{% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %} {% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %}
</span> </span>
<p class="min-w-0 truncate text-sm"> <p class="min-w-0 truncate text-sm">
<a href="{{ event.link }}" class="font-medium text-text hover:text-accent" <span class="font-medium text-text">{{ event.title }}</span>{% if let Some(sub) = event.subtitle %}
>{{ event.title }}</a
>{% if let Some(sub) = event.subtitle %}
<span class="text-xs text-text-muted">· {{ sub }}</span>{% endif %} <span class="text-xs text-text-muted">· {{ sub }}</span>{% endif %}
</p> </p>
</div> </div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0" <time class="text-xs text-text-muted whitespace-nowrap shrink-0"
>{{ event.relative_date_label }}</time >{{ event.relative_date_label }}</time
> >
</div> </a>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}

View file

@ -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 %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
{% block content %}
<header class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ roast.name }}</h1>
<p class="text-sm text-text-secondary">
{{ roast.roaster_name }} · {{ roast.origin_flag }} {{ roast.origin }} · Added {{ roast.created_date }}
</p>
</div>
</header>
<div class="grid gap-6 md:grid-cols-2">
{{ 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") }}
</div>
<div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage) }}
</div>
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this roast? This cannot be undone.') && @delete('/api/v1/roasts/{{ roast.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
{% endif %}
{% endblock %}

View file

@ -13,11 +13,8 @@
{{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %} · {{ c }}{% endif %} · Added {{ roaster.created_date }} {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %} · {{ c }}{% endif %} · Added {{ roaster.created_date }}
</p> </p>
</div> </div>
{{ detail::share_button() }}
</header> </header>
{{ detail::share_script() }}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2> <h2 class="text-lg font-semibold text-text mb-4">Details</h2>
@ -50,25 +47,11 @@
{% if is_authenticated %} {% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2"> <div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" onclick="deleteRoaster('{{ roaster.id }}')" <button type="button"
class="inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-700"> class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-surface-alt"
data-on:click="confirm('Delete this roaster? This cannot be undone.') && @delete('/api/v1/roasters/{{ roaster.id }}')">
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>
<script>
const deleteRoaster = async (id) => {
if (!confirm('Delete this roaster? This cannot be undone.')) return;
try {
const resp = await fetch(`/api/v1/roasters/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.href = '/data?type=roasters';
} catch (e) {
alert(`Failed to delete roaster: ${e.message}`);
}
};
</script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -28,15 +28,13 @@
{{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }} {{ table::sortable_header("Roast", "roast", navigator, "#bag-list") }}
{{ table::sortable_header("Status", "status", navigator, "#bag-list") }} {{ table::sortable_header("Status", "status", navigator, "#bag-list") }}
{{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }} {{ table::sortable_header("Finished", "finished-at", navigator, "#bag-list") }}
{% if is_authenticated %} <th scope="col" class="actions-col px-4 py-3 text-right"></th>
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
{% for bag in bags.items %} {% for bag in bags.items %}
<tr class="transition hover:bg-surface-alt" <tr class="transition hover:bg-surface-alt"
{% if is_authenticated %}onclick="toggleRow(event)"{% endif %} onclick="window.location.href='/bags/{{ bag.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ bag.created_date }}</div> <div>{{ bag.created_date }}</div>
@ -76,23 +74,6 @@
<div>{{ bag.finished_date }}</div> <div>{{ bag.finished_date }}</div>
</td> </td>
{% endif %} {% endif %}
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
{% if !bag.closed %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})">
{{ icons::x_circle("h-4 w-4") }} Close bag
</button>
{% endif %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="inline-flex items-center gap-2"> <div class="inline-flex items-center gap-2">
<span class="md:hidden"> <span class="md:hidden">
@ -100,36 +81,13 @@
<span class="text-text-muted">Closed</span> <span class="text-text-muted">Closed</span>
{% endif %} {% endif %}
</span> </span>
<button type="button" <a href="/bags/{{ bag.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
title="Actions"> {{ icons::chevron_right("h-5 w-5") }}
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span> </a>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
</div>
</td>
{% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="6" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
{% if !bag.closed %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})">
{{ icons::x_circle("h-4 w-4") }} Close bag
</button>
{% endif %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div> </div>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -1,20 +1,6 @@
{% import "partials/lists/table.html" as table %} {% import "partials/lists/table.html" as table %}
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro brew_actions(brew, navigator) %}
<div class="flex items-center gap-4">
<a href="{{ brew.brew_again_url() }}"
class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover">
{{ icons::plus_circle("h-4 w-4") }} Brew Again
</a>
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
{% endmacro %}
<div id="brew-list" class="mt-6" data-star-scope="brews"> <div id="brew-list" class="mt-6" data-star-scope="brews">
{% if brews.items.is_empty() && !navigator.has_search() %} {% if brews.items.is_empty() && !navigator.has_search() %}
<div <div
@ -44,15 +30,13 @@
<th scope="col" class="px-4 py-3">Recipe</th> <th scope="col" class="px-4 py-3">Recipe</th>
<th scope="col" class="px-4 py-3">Brewer</th> <th scope="col" class="px-4 py-3">Brewer</th>
<th scope="col" class="px-4 py-3">Notes</th> <th scope="col" class="px-4 py-3">Notes</th>
{% if is_authenticated %} <th scope="col" class="actions-col px-4 py-3 text-right"></th>
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
{% for brew in brews.items %} {% for brew in brews.items %}
<tr class="transition hover:bg-surface-alt" <tr class="transition hover:bg-surface-alt"
{% if is_authenticated %}onclick="toggleRow(event)"{% endif %} onclick="window.location.href='/brews/{{ brew.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ brew.created_date }}</div> <div>{{ brew.created_date }}</div>
@ -96,27 +80,13 @@
<span class="pill pill-muted">No Notes</span> <span class="pill pill-muted">No Notes</span>
{% endif %} {% endif %}
</td> </td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
{{ brew_actions(brew, navigator) }}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button" <a href="/brews/{{ brew.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
title="Actions"> {{ icons::chevron_right("h-5 w-5") }}
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span> </a>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
</td>
{% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="7" class="bg-surface-alt px-4 py-2">
{{ brew_actions(brew, navigator) }}
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -27,7 +27,7 @@
{{ table::sortable_header("Name", "name", navigator, "#cafe-list") }} {{ table::sortable_header("Name", "name", navigator, "#cafe-list") }}
{{ table::sortable_header("City", "city", navigator, "#cafe-list") }} {{ table::sortable_header("City", "city", navigator, "#cafe-list") }}
{{ table::sortable_header("Country", "country", navigator, "#cafe-list") }} {{ table::sortable_header("Country", "country", navigator, "#cafe-list") }}
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th> <th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
@ -39,7 +39,7 @@
data-sort-country="{{ cafe.country }}" data-sort-country="{{ cafe.country }}"
data-sort-city="{{ cafe.city }}" data-sort-city="{{ cafe.city }}"
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
onclick="toggleRow(event)" onclick="window.location.href='{{ cafe.detail_path }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ cafe.created_date }}</div> <div>{{ cafe.created_date }}</div>
@ -52,57 +52,11 @@
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td> <td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td>
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cafe.city }}</td> <td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cafe.city }}</td>
<td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cafe.country }}</td> <td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cafe.country }}</td>
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ cafe.map_url }}" target="_blank" rel="noreferrer noopener">
{{ icons::location("h-4 w-4") }} View Map
</a>
{% if cafe.has_website %}
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ cafe.website_url }}" target="_blank" rel="noreferrer noopener">
{{ icons::external_link("h-4 w-4") }} Homepage
</a>
{% endif %}
{% if is_authenticated %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this cafe?') && @delete('/api/v1/cafes/{{ cafe.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cafe-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
{% endif %}
</div>
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button" <a href="{{ cafe.detail_path }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
title="Actions"> {{ icons::chevron_right("h-5 w-5") }}
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
</td>
</tr>
<tr class="hidden detail-row">
<td colspan="5" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ cafe.map_url }}" target="_blank" rel="noreferrer noopener">
{{ icons::location("h-4 w-4") }} View Map
</a> </a>
{% if cafe.has_website %}
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ cafe.website_url }}" target="_blank" rel="noreferrer noopener">
{{ icons::external_link("h-4 w-4") }} Homepage
</a>
{% endif %}
{% if is_authenticated %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this cafe?') && @delete('/api/v1/cafes/{{ cafe.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cafe-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -28,9 +28,7 @@
{{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }} {{ table::sortable_header("Roaster", "roaster", navigator, "#cup-list") }}
{{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }} {{ table::sortable_header("Cafe", "cafe", navigator, "#cup-list") }}
{{ table::sortable_header("City", "city", navigator, "#cup-list") }} {{ table::sortable_header("City", "city", navigator, "#cup-list") }}
{% if is_authenticated %} <th scope="col" class="actions-col px-4 py-3 text-right"></th>
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
@ -38,7 +36,7 @@
<tr <tr
data-star-key="{{ cup.id }}" data-star-key="{{ cup.id }}"
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
{% if is_authenticated %}onclick="toggleRow(event)"{% endif %} onclick="window.location.href='/cups/{{ cup.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ cup.created_date }}</div> <div>{{ cup.created_date }}</div>
@ -55,39 +53,13 @@
</td> </td>
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.cafe_city }}</td> <td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cup.cafe_city }}</td>
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cup.cafe_city }}</td> <td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ cup.cafe_city }}</td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this cup?') && @delete('/api/v1/cups/{{ cup.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cup-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button" <a href="/cups/{{ cup.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
title="Actions"> {{ icons::chevron_right("h-5 w-5") }}
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span> </a>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
</td>
{% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="6" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this cup?') && @delete('/api/v1/cups/{{ cup.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cup-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -28,15 +28,13 @@
{{ table::sortable_header("Category", "category", navigator, "#gear-list") }} {{ table::sortable_header("Category", "category", navigator, "#gear-list") }}
{{ table::sortable_header("Make", "make", navigator, "#gear-list") }} {{ table::sortable_header("Make", "make", navigator, "#gear-list") }}
{{ table::sortable_header("Model", "model", navigator, "#gear-list") }} {{ table::sortable_header("Model", "model", navigator, "#gear-list") }}
{% if is_authenticated %} <th scope="col" class="actions-col px-4 py-3 text-right"></th>
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
{% for item in gear.items %} {% for item in gear.items %}
<tr class="transition hover:bg-surface-alt" <tr class="transition hover:bg-surface-alt"
{% if is_authenticated %}onclick="toggleRow(event)"{% endif %} onclick="window.location.href='/gear/{{ item.id }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ item.created_date }}</div> <div>{{ item.created_date }}</div>
@ -52,39 +50,13 @@
<td data-label="Model" class="mobile-hidden px-4 py-3 whitespace-nowrap"> <td data-label="Model" class="mobile-hidden px-4 py-3 whitespace-nowrap">
{{ item.model }} {{ item.model }}
</td> </td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button" <a href="/gear/{{ item.id }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
title="Actions"> {{ icons::chevron_right("h-5 w-5") }}
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span> </a>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
</td>
{% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="5" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -27,9 +27,7 @@
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }} {{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
{{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }} {{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }}
<th scope="col" class="px-4 py-3">Tasting Notes</th> <th scope="col" class="px-4 py-3">Tasting Notes</th>
{% if is_authenticated %} <th scope="col" class="actions-col px-4 py-3 text-right"></th>
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
@ -42,7 +40,7 @@
data-sort-origin="{{ roast.origin }}" data-sort-origin="{{ roast.origin }}"
data-sort-producer="{{ roast.producer }}" data-sort-producer="{{ roast.producer }}"
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
{% if is_authenticated %}onclick="toggleRow(event)"{% endif %} onclick="window.location.href='{{ roast.detail_path }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ roast.created_date }}</div> <div>{{ roast.created_date }}</div>
@ -73,39 +71,13 @@
<span class="pill pill-muted">No Notes</span> <span class="pill pill-muted">No Notes</span>
{% endif %} {% endif %}
</td> </td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button" <a href="{{ roast.detail_path }}"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
title="Actions"> {{ icons::chevron_right("h-5 w-5") }}
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span> </a>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
</td>
{% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="5" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -27,7 +27,7 @@
{{ table::sortable_header("Name", "name", navigator, "#roaster-list") }} {{ table::sortable_header("Name", "name", navigator, "#roaster-list") }}
{{ table::sortable_header("Country", "country", navigator, "#roaster-list") }} {{ table::sortable_header("Country", "country", navigator, "#roaster-list") }}
{{ table::sortable_header("City", "city", navigator, "#roaster-list") }} {{ table::sortable_header("City", "city", navigator, "#roaster-list") }}
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th> <th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
@ -39,7 +39,7 @@
data-sort-country="{{ roaster.country }}" data-sort-country="{{ roaster.country }}"
data-sort-city="{{ roaster.city }}" data-sort-city="{{ roaster.city }}"
class="transition hover:bg-surface-alt" class="transition hover:bg-surface-alt"
{% if roaster.has_homepage || is_authenticated %}onclick="toggleRow(event)"{% endif %} onclick="window.location.href='{{ roaster.detail_path }}'"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ roaster.created_date }}</div> <div>{{ roaster.created_date }}</div>
@ -54,57 +54,13 @@
{% endif %} {% endif %}
<td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ roaster.country }}</td> <td data-label="Country" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ roaster.country }}</td>
<td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ roaster.city }}</td> <td data-label="City" class="mobile-hidden px-4 py-3 whitespace-nowrap">{{ roaster.city }}</td>
{% if roaster.has_homepage || is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
{% if roaster.has_homepage %}
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ roaster.homepage_url }}" target="_blank" rel="noreferrer noopener">
{{ icons::external_link("h-4 w-4") }} Homepage
</a>
{% endif %}
{% if is_authenticated %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
{% endif %}
</div>
</td>
{% endif %}
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
{% if roaster.has_homepage || is_authenticated %} <a href="{{ roaster.detail_path }}"
<button type="button" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt">
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt" {{ icons::chevron_right("h-5 w-5") }}
title="Actions">
<span class="icon-expand">{{ icons::ellipsis_vertical("h-5 w-5") }}</span>
<span class="icon-collapse hidden">{{ icons::chevron_up("h-5 w-5") }}</span>
</button>
{% endif %}
</td>
</tr>
{% if roaster.has_homepage || is_authenticated %}
<tr class="hidden detail-row">
<td colspan="5" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
{% if roaster.has_homepage %}
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ roaster.homepage_url }}" target="_blank" rel="noreferrer noopener">
{{ icons::external_link("h-4 w-4") }} Homepage
</a> </a>
{% endif %}
{% if is_authenticated %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-text-muted hover:text-red-600"
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})">
{{ icons::delete("h-4 w-4") }} Delete
</button>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -203,6 +203,7 @@ async fn roasts_create_with_datastar_header_returns_fragment() {
.post(app.api_url("/roasts")) .post(app.api_url("/roasts"))
.bearer_auth(app.auth_token.as_ref().unwrap()) .bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true") .header("datastar-request", "true")
.header("referer", format!("{}/data?type=roasts", app.address))
.json(&new_roast) .json(&new_roast)
.send() .send()
.await .await
@ -314,6 +315,7 @@ async fn bags_update_with_datastar_header_returns_fragment() {
.put(app.api_url(&format!("/bags/{}", bag.id))) .put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap()) .bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true") .header("datastar-request", "true")
.header("referer", format!("{}/data?type=bags", app.address))
.json(&update) .json(&update)
.send() .send()
.await .await

View file

@ -169,6 +169,7 @@ macro_rules! define_datastar_entity_tests {
.delete(app.api_url(&format!("{}/{}", $api_path, entity_id))) .delete(app.api_url(&format!("{}/{}", $api_path, entity_id)))
.bearer_auth(app.auth_token.as_ref().unwrap()) .bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true") .header("datastar-request", "true")
.header("referer", format!("{}/data?type={}", app.address, $type_param))
.send() .send()
.await .await
.expect(concat!("failed to delete ", stringify!($entity))); .expect(concat!("failed to delete ", stringify!($entity)));

View file

@ -124,8 +124,8 @@ async fn creating_a_roaster_surfaces_on_the_timeline() {
"Expected roaster name to appear in timeline HTML, got: {body}" "Expected roaster name to appear in timeline HTML, got: {body}"
); );
assert!( assert!(
body.contains("/data?type=roasters"), body.contains("/roasters/"),
"Expected roaster link in timeline HTML, got: {body}" "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}" "Expected cafe name to appear in timeline HTML, got: {body}"
); );
assert!( assert!(
body.contains("/data?type=cafes"), body.contains("/cafes/"),
"Expected cafe link in timeline HTML, got: {body}" "Expected cafe detail link in timeline HTML, got: {body}"
); );
} }