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");
|
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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
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))
|
||||||
|
|
|
||||||
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::{
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
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>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue