feat(detail): add roaster, cafe, and gear detail pages

- Add detail page routes, templates, and view models for roasters, cafes,
  and gear, following the existing pattern from bags/brews/cups
- Add map_with_legend_1 macro for single-country detail maps
- Redirect to detail page after entity creation instead of list page
- Check referer in create handlers to return list fragment only from data page
- Update bag delete button to use red background style
- Update datastar tests to include referer header for create assertions
This commit is contained in:
Jon Seager 2026-02-08 17:20:46 +00:00
parent ccbd229d75
commit 958c8de6cc
No known key found for this signature in database
19 changed files with 579 additions and 31 deletions

View file

@ -72,14 +72,32 @@ pub(crate) async fn create_bag(
info!(bag_id = %bag.id, "bag created"); info!(bag_id = %bag.id, "bag created");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
let detail_url = format!("/bags/{}", bag.id);
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 {
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(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request, search).page_href(1);
Ok(Redirect::to(&target).into_response())
} else { } else {
let enriched = state let enriched = state
.bag_repo .bag_repo

View file

@ -74,14 +74,32 @@ pub(crate) async fn create_cafe(
info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created"); info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
let detail_url = format!("/cafes/{}", cafe.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=cafes"));
if from_data_page {
render_cafe_list_fragment(state, request, search, true) render_cafe_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(CAFE_PAGE_PATH, CAFE_FRAGMENT_PATH, request, search).page_href(1);
Ok(Redirect::to(&target).into_response())
} else { } else {
Ok((StatusCode::CREATED, Json(cafe)).into_response()) Ok((StatusCode::CREATED, Json(cafe)).into_response())
} }

View file

@ -67,14 +67,32 @@ pub(crate) async fn create_gear(
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created"); info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
let detail_url = format!("/gear/{}", gear.id);
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=gear"));
if from_data_page {
render_gear_list_fragment(state, request, search, true) render_gear_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(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request, search).page_href(1);
Ok(Redirect::to(&target).into_response())
} else { } else {
Ok((StatusCode::CREATED, Json(gear)).into_response()) Ok((StatusCode::CREATED, Json(gear)).into_response())
} }

View file

@ -75,14 +75,32 @@ pub(crate) async fn create_roaster(
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created"); info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
state.stats_invalidator.invalidate(); state.stats_invalidator.invalidate();
let detail_url = format!("/roasters/{}", roaster.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=roasters"));
if from_data_page {
render_roaster_list_fragment(state, request, search, true) render_roaster_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 = ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request, search) Ok(Redirect::to(&detail_url).into_response())
.page_href(1);
Ok(Redirect::to(&target).into_response())
} else { } else {
Ok((StatusCode::CREATED, Json(roaster)).into_response()) Ok((StatusCode::CREATED, Json(roaster)).into_response())
} }

View file

@ -0,0 +1,37 @@
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::CafeDetailTemplate;
use crate::presentation::web::views::CafeDetailView;
#[tracing::instrument(skip(state, cookies))]
pub(crate) async fn cafe_detail_page(
State(state): State<AppState>,
cookies: Cookies,
Path(slug): Path<String>,
) -> Result<Response, StatusCode> {
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
let cafe = state
.cafe_repo
.get_by_slug(&slug)
.await
.map_err(|e| map_app_error(e.into()))?;
let view = CafeDetailView::from_domain(cafe);
let template = CafeDetailTemplate {
nav_active: "",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
cafe: view,
};
render_html(template).map(IntoResponse::into_response)
}

View file

@ -0,0 +1,38 @@
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::domain::ids::GearId;
use crate::presentation::web::templates::GearDetailTemplate;
use crate::presentation::web::views::GearDetailView;
#[tracing::instrument(skip(state, cookies))]
pub(crate) async fn gear_detail_page(
State(state): State<AppState>,
cookies: Cookies,
Path(id): Path<GearId>,
) -> Result<Response, StatusCode> {
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
let gear = state
.gear_repo
.get(id)
.await
.map_err(|e| map_app_error(e.into()))?;
let view = GearDetailView::from_domain(gear);
let template = GearDetailTemplate {
nav_active: "",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
gear: view,
};
render_html(template).map(IntoResponse::into_response)
}

View file

@ -3,10 +3,13 @@ mod admin;
pub(super) mod auth; pub(super) mod auth;
mod bags; mod bags;
mod brews; mod brews;
mod cafes;
mod checkin; mod checkin;
mod cups; mod cups;
mod data; mod data;
mod gear;
mod home; mod home;
mod roasters;
mod stats; mod stats;
mod timeline; mod timeline;
mod webauthn; mod webauthn;
@ -32,7 +35,10 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/stats", get(stats::stats_page)) .route("/stats", get(stats::stats_page))
.route("/bags/{id}", get(bags::bag_detail_page)) .route("/bags/{id}", get(bags::bag_detail_page))
.route("/brews/{id}", get(brews::brew_detail_page)) .route("/brews/{id}", get(brews::brew_detail_page))
.route("/cafes/{slug}", get(cafes::cafe_detail_page))
.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("/roasters/{slug}", get(roasters::roaster_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,37 @@
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::RoasterDetailTemplate;
use crate::presentation::web::views::RoasterDetailView;
#[tracing::instrument(skip(state, cookies))]
pub(crate) async fn roaster_detail_page(
State(state): State<AppState>,
cookies: Cookies,
Path(slug): Path<String>,
) -> Result<Response, StatusCode> {
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
let roaster = state
.roaster_repo
.get_by_slug(&slug)
.await
.map_err(|e| map_app_error(e.into()))?;
let view = RoasterDetailView::from_domain(roaster);
let template = RoasterDetailTemplate {
nav_active: "",
is_authenticated,
version_info: &crate::VERSION_INFO,
base_url: crate::base_url(),
roaster: view,
};
render_html(template).map(IntoResponse::into_response)
}

View file

@ -2,9 +2,10 @@ use askama::Template;
use super::views::{ use super::views::{
BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView,
CafeOptionView, CafeView, CupDetailView, CupView, GearOptionView, GearView, ListNavigator, CafeDetailView, CafeOptionView, CafeView, CupDetailView, CupView, GearDetailView,
NearbyCafeView, Paginated, QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
RoasterView, StatCard, StatsView, TimelineEventView, TimelineMonthView, RoastOptionView, RoastView, RoasterDetailView, RoasterOptionView, RoasterView, StatCard,
StatsView, TimelineEventView, TimelineMonthView,
}; };
use crate::domain::bags::BagSortKey; use crate::domain::bags::BagSortKey;
use crate::domain::brews::BrewSortKey; use crate::domain::brews::BrewSortKey;
@ -240,6 +241,36 @@ pub struct CupDetailTemplate {
pub cup: CupDetailView, pub cup: CupDetailView,
} }
#[derive(Template)]
#[template(path = "pages/roaster.html")]
pub struct RoasterDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub roaster: RoasterDetailView,
}
#[derive(Template)]
#[template(path = "pages/cafe.html")]
pub struct CafeDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub cafe: CafeDetailView,
}
#[derive(Template)]
#[template(path = "pages/gear.html")]
pub struct GearDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str,
pub gear: GearDetailView,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> { pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
template.render() template.render()
} }

View file

@ -1,6 +1,44 @@
use crate::domain::cafes::Cafe; use crate::domain::cafes::Cafe;
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
use crate::infrastructure::foursquare::NearbyCafe; use crate::infrastructure::foursquare::NearbyCafe;
use super::build_map_data;
pub struct CafeDetailView {
pub id: String,
pub name: String,
pub city: String,
pub country: String,
pub country_flag: String,
pub website: Option<String>,
pub map_countries: String,
pub map_max: u32,
pub created_date: String,
pub created_time: String,
}
impl CafeDetailView {
pub fn from_domain(cafe: Cafe) -> Self {
let country_flag = country_to_iso(&cafe.country)
.map(iso_to_flag_emoji)
.unwrap_or_default();
let (map_countries, map_max) = build_map_data(&[(&cafe.country, 1)]);
Self {
id: cafe.id.to_string(),
name: cafe.name,
city: cafe.city,
country_flag,
country: cafe.country,
website: cafe.website,
map_countries,
map_max,
created_date: cafe.created_at.format("%Y-%m-%d").to_string(),
created_time: cafe.created_at.format("%H:%M").to_string(),
}
}
}
pub struct CafeView { pub struct CafeView {
pub id: String, pub id: String,
pub detail_path: String, pub detail_path: String,

View file

@ -1,5 +1,27 @@
use crate::domain::gear::Gear; use crate::domain::gear::Gear;
pub struct GearDetailView {
pub id: String,
pub category_label: String,
pub make: String,
pub model: String,
pub created_date: String,
pub created_time: String,
}
impl GearDetailView {
pub fn from_domain(gear: Gear) -> Self {
Self {
id: gear.id.to_string(),
category_label: gear.category.display_label().to_string(),
make: gear.make,
model: gear.model,
created_date: gear.created_at.format("%Y-%m-%d").to_string(),
created_time: gear.created_at.format("%H:%M").to_string(),
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct GearView { pub struct GearView {
pub id: String, pub id: String,

View file

@ -10,10 +10,10 @@ mod timeline;
pub use bags::{BagDetailView, BagOptionView, BagView}; pub use bags::{BagDetailView, BagOptionView, BagView};
pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView}; pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView};
pub use cafes::{CafeOptionView, CafeView, NearbyCafeView}; pub use cafes::{CafeDetailView, CafeOptionView, CafeView, NearbyCafeView};
pub use cups::{CupDetailView, CupView}; pub use cups::{CupDetailView, CupView};
pub use gear::{GearOptionView, GearView}; pub use gear::{GearDetailView, GearOptionView, GearView};
pub use roasters::{RoasterOptionView, RoasterView}; pub use roasters::{RoasterDetailView, RoasterOptionView, RoasterView};
pub use roasts::{RoastOptionView, RoastView}; pub use roasts::{RoastOptionView, RoastView};
pub use tasting_notes::TastingNoteView; pub use tasting_notes::TastingNoteView;
pub use timeline::{ pub use timeline::{

View file

@ -1,5 +1,43 @@
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
use super::build_map_data;
pub struct RoasterDetailView {
pub id: String,
pub name: String,
pub country: String,
pub country_flag: String,
pub city: Option<String>,
pub homepage: Option<String>,
pub map_countries: String,
pub map_max: u32,
pub created_date: String,
pub created_time: String,
}
impl RoasterDetailView {
pub fn from_domain(roaster: Roaster) -> Self {
let country_flag = country_to_iso(&roaster.country)
.map(iso_to_flag_emoji)
.unwrap_or_default();
let (map_countries, map_max) = build_map_data(&[(&roaster.country, 1)]);
Self {
id: roaster.id.to_string(),
name: roaster.name,
country_flag,
country: roaster.country,
city: roaster.city,
homepage: roaster.homepage,
map_countries,
map_max,
created_date: roaster.created_at.format("%Y-%m-%d").to_string(),
created_time: roaster.created_at.format("%H:%M").to_string(),
}
}
}
pub struct RoasterOptionView { pub struct RoasterOptionView {
pub id: String, pub id: String,
pub name: String, pub name: String,

View file

@ -82,7 +82,7 @@
</button> </button>
{% endif %} {% endif %}
<button type="button" onclick="deleteBag('{{ bag.id }}')" <button type="button" onclick="deleteBag('{{ 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"> 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">
{{ icons::delete("h-4 w-4") }} Delete {{ icons::delete("h-4 w-4") }} Delete
</button> </button>
</div> </div>

72
templates/pages/cafe.html Normal file
View file

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ cafe.name }}{% endblock %}
{% block og_title %}{{ cafe.name }} — Brewlog{% endblock %}
{% block og_description %}{{ cafe.name }} — {{ cafe.city }}, {{ cafe.country_flag }} {{ cafe.country }}{% 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">{{ cafe.name }}</h1>
<p class="text-sm text-text-secondary">
{{ 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>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-text-muted">City</dt>
<dd class="font-medium text-text">{{ cafe.city }}</dd>
</div>
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }} {% endif %}{{ cafe.country }}
</dd>
</div>
{% if let Some(url) = cafe.website %}
<div>
<dt class="text-text-muted">Website</dt>
<dd class="font-medium">
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
</dd>
</div>
{% endif %}
</dl>
</div>
{{ detail::map_with_legend_1(cafe.map_countries, cafe.map_max, "Cafe", "") }}
</div>
{% 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">
{{ 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 %}

62
templates/pages/gear.html Normal file
View file

@ -0,0 +1,62 @@
{% 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 %}
{% block og_description %}{{ gear.make }} {{ gear.model }} — {{ gear.category_label }}{% 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">{{ gear.make }} {{ gear.model }}</h1>
<p class="text-sm text-text-secondary">
{{ 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">
<div>
<dt class="text-text-muted">Category</dt>
<dd class="font-medium text-text">{{ gear.category_label }}</dd>
</div>
<div>
<dt class="text-text-muted">Make</dt>
<dd class="font-medium text-text">{{ gear.make }}</dd>
</div>
<div>
<dt class="text-text-muted">Model</dt>
<dd class="font-medium text-text">{{ gear.model }}</dd>
</div>
</dl>
</div>
{% 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">
{{ 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

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% import "partials/detail_cards.html" as detail %}
{% import "partials/icons.html" as icons %}
{% block title %}Brewlog · {{ roaster.name }}{% endblock %}
{% block og_title %}{{ roaster.name }} — Brewlog{% endblock %}
{% block og_description %}{{ roaster.name }} — {{ roaster.country_flag }} {{ roaster.country }}{% if let Some(c) = roaster.city %}, {{ c }}{% endif %}{% 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">{{ roaster.name }}</h1>
<p class="text-sm text-text-secondary">
{{ 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>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !roaster.country_flag.is_empty() %}{{ roaster.country_flag }} {% endif %}{{ roaster.country }}
</dd>
</div>
{% if let Some(c) = roaster.city %}
<div>
<dt class="text-text-muted">City</dt>
<dd class="font-medium text-text">{{ c }}</dd>
</div>
{% endif %}
{% if let Some(url) = roaster.homepage %}
<div>
<dt class="text-text-muted">Website</dt>
<dd class="font-medium">
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
</dd>
</div>
{% endif %}
</dl>
</div>
{{ detail::map_with_legend_1(roaster.map_countries, roaster.map_max, "Roaster", "") }}
</div>
{% 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">
{{ 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

@ -74,6 +74,23 @@ const shareLink = (btn) => {
</div> </div>
{% endmacro %} {% endmacro %}
{% macro map_with_legend_1(map_countries, map_max, label1, opacity1) %}
{% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
<world-map
class="block w-full"
data-countries="{{ map_countries }}"
data-max="{{ map_max }}"
></world-map>
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted">
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity1 }}"></span> {{ label1 }}
</span>
</div>
</div>
{% endif %}
{% endmacro %}
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %} {% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
{% if !map_countries.is_empty() %} {% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center"> <div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">

View file

@ -115,6 +115,7 @@ async fn roasters_create_with_datastar_header_returns_fragment() {
.post(app.api_url("/roasters")) .post(app.api_url("/roasters"))
.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=roasters", app.address))
.json(&new_roaster) .json(&new_roaster)
.send() .send()
.await .await
@ -281,6 +282,7 @@ async fn bags_create_with_datastar_header_returns_fragment() {
.post(app.api_url("/bags")) .post(app.api_url("/bags"))
.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(&new_bag) .json(&new_bag)
.send() .send()
.await .await
@ -415,6 +417,7 @@ async fn gear_create_with_datastar_header_returns_fragment() {
.post(app.api_url("/gear")) .post(app.api_url("/gear"))
.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=gear", app.address))
.json(&new_gear) .json(&new_gear)
.send() .send()
.await .await
@ -566,6 +569,7 @@ async fn cafes_create_with_datastar_header_returns_fragment() {
.post(app.api_url("/cafes")) .post(app.api_url("/cafes"))
.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=cafes", app.address))
.json(&new_cafe) .json(&new_cafe)
.send() .send()
.await .await