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
.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)
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)]

View file

@ -269,16 +269,8 @@ pub(crate) async fn create_brew(
.await
.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)
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(

View file

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

View file

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

View file

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

View file

@ -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<crate::application::state::AppState>,
@ -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())
}

View file

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

View file

@ -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!("<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) {
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))]

View file

@ -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<AppState> {
.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))

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)
}
/// 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>(
template: T,
selector: &'static str,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<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 id: String,
pub label: String,

View file

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

View file

@ -14,11 +14,8 @@
{{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
{# ── Coffee + map ── #}
<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) }}
@ -76,47 +73,18 @@
<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">
{% if !bag.closed %}
<button type="button" onclick="closeBag('{{ bag.id }}')"
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">
<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"
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
</button>
{% endif %}
<button type="button" onclick="deleteBag('{{ bag.id }}')"
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">
<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 bag? This cannot be undone.') && @delete('/api/v1/bags/{{ bag.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</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 %}
{% endblock %}

View file

@ -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 }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
{# ── Coffee + map ── #}
<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) }}
@ -85,4 +83,14 @@
</dl>
</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 %}

View file

@ -13,11 +13,8 @@
{{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }} · Added {{ cafe.created_date }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
@ -48,25 +45,11 @@
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" onclick="deleteCafe('{{ cafe.id }}')"
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">
<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 cafe? This cannot be undone.') && @delete('/api/v1/cafes/{{ cafe.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</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 %}
{% endblock %}

View file

@ -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 }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
{# ── Coffee + map ── #}
<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) }}
@ -56,4 +54,14 @@
</dl>
</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 %}

View file

@ -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 }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
<div class="rounded-lg border bg-surface p-5">
<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">
@ -38,25 +34,11 @@
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" onclick="deleteGear('{{ gear.id }}')"
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">
<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 gear? This cannot be undone.') && @delete('/api/v1/gear/{{ gear.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</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 %}
{% endblock %}

View file

@ -197,22 +197,20 @@
{% if !recent_events.is_empty() %}
<div class="space-y-2">
{% 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">
<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 %}
</span>
<p class="min-w-0 truncate text-sm">
<a href="{{ event.link }}" class="font-medium text-text hover:text-accent"
>{{ event.title }}</a
>{% if let Some(sub) = event.subtitle %}
<span class="font-medium text-text">{{ event.title }}</span>{% if let Some(sub) = event.subtitle %}
<span class="text-xs text-text-muted">· {{ sub }}</span>{% endif %}
</p>
</div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
>{{ event.relative_date_label }}</time
>
</div>
</a>
{% endfor %}
</div>
{% 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 }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
@ -50,25 +47,11 @@
{% if is_authenticated %}
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
<button type="button" onclick="deleteRoaster('{{ roaster.id }}')"
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">
<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 roaster? This cannot be undone.') && @delete('/api/v1/roasters/{{ roaster.id }}')">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</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 %}
{% endblock %}

View file

@ -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 %}
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody class="divide-y/70">
{% for bag in bags.items %}
<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">
<div>{{ bag.created_date }}</div>
@ -76,23 +74,6 @@
<div>{{ bag.finished_date }}</div>
</td>
{% 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">
<div class="inline-flex items-center gap-2">
<span class="md:hidden">
@ -100,36 +81,13 @@
<span class="text-text-muted">Closed</span>
{% endif %}
</span>
<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"
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>
</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>
<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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -1,20 +1,6 @@
{% import "partials/lists/table.html" as table %}
{% 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">
{% if brews.items.is_empty() && !navigator.has_search() %}
<div
@ -44,15 +30,13 @@
<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">Notes</th>
{% if is_authenticated %}
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody class="divide-y/70">
{% for brew in brews.items %}
<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">
<div>{{ brew.created_date }}</div>
@ -96,27 +80,13 @@
<span class="pill pill-muted">No Notes</span>
{% endif %}
</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">
<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"
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>
</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) }}
<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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -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") }}
<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>
</thead>
<tbody class="divide-y/70">
@ -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 }}'"
>
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<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="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="" 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">
<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"
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>
</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>
{% 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>
<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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</td>
</tr>
{% endfor %}

View file

@ -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 %}
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody class="divide-y/70">
@ -38,7 +36,7 @@
<tr
data-star-key="{{ cup.id }}"
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">
<div>{{ cup.created_date }}</div>
@ -55,39 +53,13 @@
</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>
{% 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">
<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"
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>
</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>
<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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -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 %}
<th scope="col" class="actions-col px-4 py-3 text-right">Actions</th>
{% endif %}
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody class="divide-y/70">
{% for item in gear.items %}
<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">
<div>{{ item.created_date }}</div>
@ -52,39 +50,13 @@
<td data-label="Model" class="mobile-hidden px-4 py-3 whitespace-nowrap">
{{ item.model }}
</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">
<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"
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>
</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>
<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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -27,9 +27,7 @@
{{ table::sortable_header("Roast", "name", navigator, "#roast-list") }}
{{ table::sortable_header("Origin", "origin", navigator, "#roast-list") }}
<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">Actions</th>
{% endif %}
<th scope="col" class="actions-col px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody class="divide-y/70">
@ -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 }}'"
>
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ roast.created_date }}</div>
@ -73,39 +71,13 @@
<span class="pill pill-muted">No Notes</span>
{% endif %}
</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">
<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"
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>
</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>
<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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -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") }}
<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>
</thead>
<tbody class="divide-y/70">
@ -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 }}'"
>
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 font-medium text-text-secondary">
<div>{{ roaster.created_date }}</div>
@ -54,57 +54,13 @@
{% endif %}
<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>
{% 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">
{% if roaster.has_homepage || is_authenticated %}
<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"
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 %}
<a href="{{ roaster.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">
{{ icons::chevron_right("h-5 w-5") }}
</a>
</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>
{% 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>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

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

View file

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

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