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:
parent
ccbd229d75
commit
958c8de6cc
19 changed files with 579 additions and 31 deletions
|
|
@ -72,14 +72,32 @@ pub(crate) async fn create_bag(
|
|||
info!(bag_id = %bag.id, "bag created");
|
||||
state.stats_invalidator.invalidate();
|
||||
|
||||
let detail_url = format!("/bags/{}", bag.id);
|
||||
|
||||
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 {
|
||||
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(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
Ok(Redirect::to(&detail_url).into_response())
|
||||
} else {
|
||||
let enriched = state
|
||||
.bag_repo
|
||||
|
|
|
|||
|
|
@ -74,14 +74,32 @@ pub(crate) async fn create_cafe(
|
|||
info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created");
|
||||
state.stats_invalidator.invalidate();
|
||||
|
||||
let detail_url = format!("/cafes/{}", cafe.slug);
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_cafe_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=cafes"));
|
||||
|
||||
if from_data_page {
|
||||
render_cafe_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(CAFE_PAGE_PATH, CAFE_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
Ok(Redirect::to(&detail_url).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(cafe)).into_response())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,14 +67,32 @@ pub(crate) async fn create_gear(
|
|||
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created");
|
||||
state.stats_invalidator.invalidate();
|
||||
|
||||
let detail_url = format!("/gear/{}", gear.id);
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_gear_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=gear"));
|
||||
|
||||
if from_data_page {
|
||||
render_gear_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(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
Ok(Redirect::to(&detail_url).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(gear)).into_response())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,14 +75,32 @@ pub(crate) async fn create_roaster(
|
|||
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
|
||||
state.stats_invalidator.invalidate();
|
||||
|
||||
let detail_url = format!("/roasters/{}", roaster.slug);
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_roaster_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=roasters"));
|
||||
|
||||
if from_data_page {
|
||||
render_roaster_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(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request, search)
|
||||
.page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
Ok(Redirect::to(&detail_url).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(roaster)).into_response())
|
||||
}
|
||||
|
|
|
|||
37
src/application/routes/app/cafes.rs
Normal file
37
src/application/routes/app/cafes.rs
Normal 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)
|
||||
}
|
||||
38
src/application/routes/app/gear.rs
Normal file
38
src/application/routes/app/gear.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -3,10 +3,13 @@ mod admin;
|
|||
pub(super) mod auth;
|
||||
mod bags;
|
||||
mod brews;
|
||||
mod cafes;
|
||||
mod checkin;
|
||||
mod cups;
|
||||
mod data;
|
||||
mod gear;
|
||||
mod home;
|
||||
mod roasters;
|
||||
mod stats;
|
||||
mod timeline;
|
||||
mod webauthn;
|
||||
|
|
@ -32,7 +35,10 @@ pub(super) fn router() -> axum::Router<AppState> {
|
|||
.route("/stats", get(stats::stats_page))
|
||||
.route("/bags/{id}", get(bags::bag_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("/gear/{id}", get(gear::gear_detail_page))
|
||||
.route("/roasters/{slug}", get(roasters::roaster_detail_page))
|
||||
.route("/styles.css", get(styles))
|
||||
.route("/webauthn.js", get(webauthn_js))
|
||||
.route("/components/photo-capture.js", get(photo_capture_js))
|
||||
|
|
|
|||
37
src/application/routes/app/roasters.rs
Normal file
37
src/application/routes/app/roasters.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ use askama::Template;
|
|||
|
||||
use super::views::{
|
||||
BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView,
|
||||
CafeOptionView, CafeView, CupDetailView, CupView, GearOptionView, GearView, ListNavigator,
|
||||
NearbyCafeView, Paginated, QuickNoteView, RoastOptionView, RoastView, RoasterOptionView,
|
||||
RoasterView, StatCard, StatsView, TimelineEventView, TimelineMonthView,
|
||||
CafeDetailView, CafeOptionView, CafeView, CupDetailView, CupView, GearDetailView,
|
||||
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
|
||||
RoastOptionView, RoastView, RoasterDetailView, RoasterOptionView, RoasterView, StatCard,
|
||||
StatsView, TimelineEventView, TimelineMonthView,
|
||||
};
|
||||
use crate::domain::bags::BagSortKey;
|
||||
use crate::domain::brews::BrewSortKey;
|
||||
|
|
@ -240,6 +241,36 @@ pub struct CupDetailTemplate {
|
|||
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> {
|
||||
template.render()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,44 @@
|
|||
use crate::domain::cafes::Cafe;
|
||||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||
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 id: String,
|
||||
pub detail_path: String,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,27 @@
|
|||
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)]
|
||||
pub struct GearView {
|
||||
pub id: String,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ mod timeline;
|
|||
|
||||
pub use bags::{BagDetailView, BagOptionView, BagView};
|
||||
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 gear::{GearOptionView, GearView};
|
||||
pub use roasters::{RoasterOptionView, RoasterView};
|
||||
pub use gear::{GearDetailView, GearOptionView, GearView};
|
||||
pub use roasters::{RoasterDetailView, RoasterOptionView, RoasterView};
|
||||
pub use roasts::{RoastOptionView, RoastView};
|
||||
pub use tasting_notes::TastingNoteView;
|
||||
pub use timeline::{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,43 @@
|
|||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||
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 id: String,
|
||||
pub name: String,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
</button>
|
||||
{% endif %}
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
72
templates/pages/cafe.html
Normal file
72
templates/pages/cafe.html
Normal 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
62
templates/pages/gear.html
Normal 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 %}
|
||||
74
templates/pages/roaster.html
Normal file
74
templates/pages/roaster.html
Normal 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 %}
|
||||
|
|
@ -74,6 +74,23 @@ const shareLink = (btn) => {
|
|||
</div>
|
||||
{% 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) %}
|
||||
{% if !map_countries.is_empty() %}
|
||||
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ async fn roasters_create_with_datastar_header_returns_fragment() {
|
|||
.post(app.api_url("/roasters"))
|
||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||
.header("datastar-request", "true")
|
||||
.header("referer", format!("{}/data?type=roasters", app.address))
|
||||
.json(&new_roaster)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -281,6 +282,7 @@ async fn bags_create_with_datastar_header_returns_fragment() {
|
|||
.post(app.api_url("/bags"))
|
||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||
.header("datastar-request", "true")
|
||||
.header("referer", format!("{}/data?type=bags", app.address))
|
||||
.json(&new_bag)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -415,6 +417,7 @@ async fn gear_create_with_datastar_header_returns_fragment() {
|
|||
.post(app.api_url("/gear"))
|
||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||
.header("datastar-request", "true")
|
||||
.header("referer", format!("{}/data?type=gear", app.address))
|
||||
.json(&new_gear)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -566,6 +569,7 @@ async fn cafes_create_with_datastar_header_returns_fragment() {
|
|||
.post(app.api_url("/cafes"))
|
||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||
.header("datastar-request", "true")
|
||||
.header("referer", format!("{}/data?type=cafes", app.address))
|
||||
.json(&new_cafe)
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
Loading…
Reference in a new issue