feat(detail): add shareable brew and cup detail pages
Add /brews/:id and /cups/:id routes with full coffee, roaster, gear, and recipe information. Each page includes a world map highlighting relevant countries, tasting note pills, and a share button that copies the URL to the clipboard.
This commit is contained in:
parent
9237a386e0
commit
b17aaab5f4
9 changed files with 791 additions and 9 deletions
55
src/application/routes/app/brews.rs
Normal file
55
src/application/routes/app/brews.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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::BrewId;
|
||||
use crate::presentation::web::templates::BrewDetailTemplate;
|
||||
use crate::presentation::web::views::BrewDetailView;
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn brew_detail_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Path(id): Path<BrewId>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let brew_details = state
|
||||
.brew_repo
|
||||
.get_with_details(id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))?;
|
||||
|
||||
let bag = state
|
||||
.bag_repo
|
||||
.get(brew_details.brew.bag_id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))?;
|
||||
|
||||
let roast = state
|
||||
.roast_repo
|
||||
.get(bag.roast_id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))?;
|
||||
|
||||
let roaster = state
|
||||
.roaster_repo
|
||||
.get(roast.roaster_id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))?;
|
||||
|
||||
let view = BrewDetailView::from_parts(brew_details, &roast, &roaster);
|
||||
|
||||
let template = BrewDetailTemplate {
|
||||
nav_active: "",
|
||||
is_authenticated,
|
||||
version_info: &crate::VERSION_INFO,
|
||||
brew: view,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
60
src/application/routes/app/cups.rs
Normal file
60
src/application/routes/app/cups.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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::CupId;
|
||||
use crate::presentation::web::templates::CupDetailTemplate;
|
||||
use crate::presentation::web::views::CupDetailView;
|
||||
|
||||
#[tracing::instrument(skip(state, cookies))]
|
||||
pub(crate) async fn cup_detail_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Path(id): Path<CupId>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
|
||||
|
||||
let cup_details = state
|
||||
.cup_repo
|
||||
.get_with_details(id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))?;
|
||||
|
||||
let (roast, cafe) = tokio::try_join!(
|
||||
async {
|
||||
state
|
||||
.roast_repo
|
||||
.get(cup_details.cup.roast_id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))
|
||||
},
|
||||
async {
|
||||
state
|
||||
.cafe_repo
|
||||
.get(cup_details.cup.cafe_id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))
|
||||
},
|
||||
)?;
|
||||
|
||||
let roaster = state
|
||||
.roaster_repo
|
||||
.get(roast.roaster_id)
|
||||
.await
|
||||
.map_err(|e| map_app_error(e.into()))?;
|
||||
|
||||
let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
|
||||
|
||||
let template = CupDetailTemplate {
|
||||
nav_active: "",
|
||||
is_authenticated,
|
||||
version_info: &crate::VERSION_INFO,
|
||||
cup: view,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
mod add;
|
||||
mod admin;
|
||||
pub(super) mod auth;
|
||||
mod brews;
|
||||
mod checkin;
|
||||
mod cups;
|
||||
mod data;
|
||||
mod home;
|
||||
mod stats;
|
||||
|
|
@ -27,6 +29,8 @@ pub(super) fn router() -> axum::Router<AppState> {
|
|||
.route("/check-in", get(checkin::checkin_page))
|
||||
.route("/timeline", get(timeline::timeline_page))
|
||||
.route("/stats", get(stats::stats_page))
|
||||
.route("/brews/{id}", get(brews::brew_detail_page))
|
||||
.route("/cups/{id}", get(cups::cup_detail_page))
|
||||
.route("/styles.css", get(styles))
|
||||
.route("/webauthn.js", get(webauthn_js))
|
||||
.route("/components/photo-capture.js", get(photo_capture_js))
|
||||
|
|
@ -137,8 +141,5 @@ async fn favicon_dark() -> impl IntoResponse {
|
|||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "application/json")],
|
||||
r#"{"status":"ok"}"#,
|
||||
)
|
||||
([("content-type", "application/json")], r#"{"status":"ok"}"#)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use askama::Template;
|
||||
|
||||
use super::views::{
|
||||
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView,
|
||||
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
|
||||
RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView,
|
||||
BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeOptionView, CafeView,
|
||||
CupDetailView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated,
|
||||
QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView,
|
||||
TimelineEventView, TimelineMonthView,
|
||||
};
|
||||
use crate::domain::bags::BagSortKey;
|
||||
|
|
@ -199,6 +199,7 @@ pub struct StatsPageTemplate {
|
|||
pub consumption_30d_weight: String,
|
||||
pub consumption_all_time_weight: String,
|
||||
pub cache_age: String,
|
||||
pub has_data: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -207,6 +208,24 @@ pub struct StatsMapFragment<'a> {
|
|||
pub geo_stats: &'a crate::domain::country_stats::GeoStats,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/brew.html")]
|
||||
pub struct BrewDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub version_info: &'static crate::VersionInfo,
|
||||
pub brew: BrewDetailView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/cup.html")]
|
||||
pub struct CupDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub version_info: &'static crate::VersionInfo,
|
||||
pub cup: CupDetailView,
|
||||
}
|
||||
|
||||
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
||||
template.render()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
use std::fmt::Write;
|
||||
|
||||
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
|
||||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||
use crate::domain::formatting::format_weight;
|
||||
use crate::domain::roasters::Roaster;
|
||||
use crate::domain::roasts::Roast;
|
||||
|
||||
use super::build_map_data;
|
||||
use super::relative_date;
|
||||
use super::tasting_notes::{self, TastingNoteView};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QuickNoteView {
|
||||
|
|
@ -192,6 +197,128 @@ impl Default for BrewDefaultsView {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct BrewDetailView {
|
||||
// Coffee info
|
||||
pub roast_name: String,
|
||||
pub roaster_name: String,
|
||||
pub origin: String,
|
||||
pub origin_flag: String,
|
||||
pub region: String,
|
||||
pub producer: String,
|
||||
pub process: String,
|
||||
pub tasting_notes: Vec<TastingNoteView>,
|
||||
// Roaster info
|
||||
pub roaster_country: String,
|
||||
pub roaster_country_flag: String,
|
||||
pub roaster_city: Option<String>,
|
||||
pub roaster_homepage: Option<String>,
|
||||
// Recipe
|
||||
pub coffee_weight: String,
|
||||
pub water_volume: String,
|
||||
pub water_temp: String,
|
||||
pub grind_setting: String,
|
||||
pub brew_time: Option<String>,
|
||||
pub quick_notes_label: String,
|
||||
// Gear
|
||||
pub grinder_name: String,
|
||||
pub brewer_name: String,
|
||||
pub filter_paper_name: Option<String>,
|
||||
// Map
|
||||
pub map_countries: String,
|
||||
pub map_max: u32,
|
||||
// Dates
|
||||
pub created_date: String,
|
||||
pub created_time: String,
|
||||
}
|
||||
|
||||
impl BrewDetailView {
|
||||
pub fn from_parts(brew: BrewWithDetails, roast: &Roast, roaster: &Roaster) -> Self {
|
||||
let em_dash = "\u{2014}".to_string();
|
||||
|
||||
let origin = roast.origin.clone().unwrap_or_default();
|
||||
let origin_flag = country_to_iso(&origin)
|
||||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
||||
let roaster_country_flag = country_to_iso(&roaster.country)
|
||||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||
if let Some(ref o) = roast.origin
|
||||
&& !o.is_empty()
|
||||
{
|
||||
map_entries.push((o.as_str(), 2));
|
||||
}
|
||||
map_entries.push((roaster.country.as_str(), 1));
|
||||
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||
|
||||
let tasting_notes = roast
|
||||
.tasting_notes
|
||||
.iter()
|
||||
.flat_map(|note| {
|
||||
note.split([',', '\n'])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|n| tasting_notes::categorize(&n))
|
||||
.collect();
|
||||
|
||||
let quick_notes_label = brew
|
||||
.brew
|
||||
.quick_notes
|
||||
.iter()
|
||||
.map(|n| n.label())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
Self {
|
||||
roast_name: brew.roast_name,
|
||||
roaster_name: brew.roaster_name,
|
||||
origin: if origin.is_empty() {
|
||||
em_dash.clone()
|
||||
} else {
|
||||
origin
|
||||
},
|
||||
origin_flag,
|
||||
region: roast
|
||||
.region
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(em_dash.clone()),
|
||||
producer: roast
|
||||
.producer
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(em_dash.clone()),
|
||||
process: roast
|
||||
.process
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(em_dash),
|
||||
tasting_notes,
|
||||
roaster_country: roaster.country.clone(),
|
||||
roaster_country_flag,
|
||||
roaster_city: roaster.city.clone(),
|
||||
roaster_homepage: roaster.homepage.clone(),
|
||||
coffee_weight: format_weight(brew.brew.coffee_weight),
|
||||
water_volume: format!("{}ml", brew.brew.water_volume),
|
||||
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
|
||||
grind_setting: format!("{:.1}", brew.brew.grind_setting),
|
||||
brew_time: brew.brew.brew_time.map(format_brew_time),
|
||||
quick_notes_label,
|
||||
grinder_name: brew.grinder_name,
|
||||
brewer_name: brew.brewer_name,
|
||||
filter_paper_name: brew.filter_paper_name,
|
||||
map_countries,
|
||||
map_max,
|
||||
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: brew.brew.created_at.format("%H:%M").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BrewWithDetails> for BrewDefaultsView {
|
||||
fn from(brew: BrewWithDetails) -> Self {
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
use crate::domain::cafes::Cafe;
|
||||
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||
use crate::domain::cups::CupWithDetails;
|
||||
use crate::domain::roasters::Roaster;
|
||||
use crate::domain::roasts::Roast;
|
||||
|
||||
use super::build_map_data;
|
||||
use super::tasting_notes::{self, TastingNoteView};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CupView {
|
||||
|
|
@ -30,3 +37,113 @@ impl CupView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CupDetailView {
|
||||
// Coffee info
|
||||
pub roast_name: String,
|
||||
pub roaster_name: String,
|
||||
pub origin: String,
|
||||
pub origin_flag: String,
|
||||
pub region: String,
|
||||
pub producer: String,
|
||||
pub process: String,
|
||||
pub tasting_notes: Vec<TastingNoteView>,
|
||||
// Roaster info
|
||||
pub roaster_country: String,
|
||||
pub roaster_country_flag: String,
|
||||
pub roaster_city: Option<String>,
|
||||
pub roaster_homepage: Option<String>,
|
||||
// Cafe info
|
||||
pub cafe_name: String,
|
||||
pub cafe_city: String,
|
||||
pub cafe_country: String,
|
||||
pub cafe_country_flag: String,
|
||||
pub cafe_website: Option<String>,
|
||||
// Map
|
||||
pub map_countries: String,
|
||||
pub map_max: u32,
|
||||
// Dates
|
||||
pub created_date: String,
|
||||
pub created_time: String,
|
||||
}
|
||||
|
||||
impl CupDetailView {
|
||||
pub fn from_parts(cup: CupWithDetails, roast: &Roast, roaster: &Roaster, cafe: &Cafe) -> Self {
|
||||
let em_dash = "\u{2014}".to_string();
|
||||
|
||||
let origin = roast.origin.clone().unwrap_or_default();
|
||||
let origin_flag = country_to_iso(&origin)
|
||||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
||||
let roaster_country_flag = country_to_iso(&roaster.country)
|
||||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
||||
let cafe_country_flag = country_to_iso(&cafe.country)
|
||||
.map(iso_to_flag_emoji)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||
map_entries.push((cafe.country.as_str(), 3));
|
||||
if let Some(ref o) = roast.origin
|
||||
&& !o.is_empty()
|
||||
{
|
||||
map_entries.push((o.as_str(), 2));
|
||||
}
|
||||
map_entries.push((roaster.country.as_str(), 1));
|
||||
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||
|
||||
let tasting_notes = roast
|
||||
.tasting_notes
|
||||
.iter()
|
||||
.flat_map(|note| {
|
||||
note.split([',', '\n'])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|n| tasting_notes::categorize(&n))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
roast_name: cup.roast_name,
|
||||
roaster_name: cup.roaster_name,
|
||||
origin: if origin.is_empty() {
|
||||
em_dash.clone()
|
||||
} else {
|
||||
origin
|
||||
},
|
||||
origin_flag,
|
||||
region: roast
|
||||
.region
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(em_dash.clone()),
|
||||
producer: roast
|
||||
.producer
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(em_dash.clone()),
|
||||
process: roast
|
||||
.process
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(em_dash),
|
||||
tasting_notes,
|
||||
roaster_country: roaster.country.clone(),
|
||||
roaster_country_flag,
|
||||
roaster_city: roaster.city.clone(),
|
||||
roaster_homepage: roaster.homepage.clone(),
|
||||
cafe_name: cafe.name.clone(),
|
||||
cafe_city: cafe.city.clone(),
|
||||
cafe_country: cafe.country.clone(),
|
||||
cafe_country_flag,
|
||||
cafe_website: cafe.website.clone(),
|
||||
map_countries,
|
||||
map_max,
|
||||
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(),
|
||||
created_time: cup.cup.created_at.format("%H:%M").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ pub mod tasting_notes;
|
|||
mod timeline;
|
||||
|
||||
pub use bags::{BagOptionView, BagView};
|
||||
pub use brews::{BrewDefaultsView, BrewView, QuickNoteView};
|
||||
pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView};
|
||||
pub use cafes::{CafeOptionView, CafeView, NearbyCafeView};
|
||||
pub use cups::CupView;
|
||||
pub use cups::{CupDetailView, CupView};
|
||||
pub use gear::{GearOptionView, GearView};
|
||||
pub use roasters::{RoasterOptionView, RoasterView};
|
||||
pub use roasts::{RoastOptionView, RoastView};
|
||||
|
|
@ -29,6 +29,17 @@ pub struct StatsView {
|
|||
pub bags: u64,
|
||||
}
|
||||
|
||||
impl StatsView {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.brews == 0
|
||||
&& self.roasts == 0
|
||||
&& self.roasters == 0
|
||||
&& self.cups == 0
|
||||
&& self.cafes == 0
|
||||
&& self.bags == 0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StatCard {
|
||||
pub icon: &'static str,
|
||||
pub value: String,
|
||||
|
|
@ -349,3 +360,37 @@ fn page_size_from_text(value: &str) -> PageSize {
|
|||
PageSize::limited(DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `data-countries` and `data-max` values for the world-map component.
|
||||
///
|
||||
/// Accepts `(country_name, weight)` pairs where higher weights render darker.
|
||||
/// Resolves country names to ISO codes, deduplicates by keeping the highest weight
|
||||
/// per ISO code, and returns the attribute string and max value.
|
||||
pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::domain::countries::country_to_iso;
|
||||
|
||||
let mut iso_weights: HashMap<&str, u32> = HashMap::new();
|
||||
|
||||
for &(country_name, weight) in entries {
|
||||
if country_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(iso) = country_to_iso(country_name) {
|
||||
let entry = iso_weights.entry(iso).or_insert(0);
|
||||
*entry = (*entry).max(weight);
|
||||
}
|
||||
}
|
||||
|
||||
let mut max = 0u32;
|
||||
let parts: Vec<String> = iso_weights
|
||||
.iter()
|
||||
.map(|(iso, &w)| {
|
||||
max = max.max(w);
|
||||
format!("{iso}:{w}")
|
||||
})
|
||||
.collect();
|
||||
|
||||
(parts.join(","), max)
|
||||
}
|
||||
|
|
|
|||
192
templates/pages/brew.html
Normal file
192
templates/pages/brew.html
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
||||
{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="shareLink(this)" class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0">
|
||||
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
|
||||
<span class="share-icon-check hidden">{{ icons::check("h-4 w-4 shrink-0") }}</span>
|
||||
<span class="share-label">Share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const shareLink = (btn) => {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
btn.querySelector('.share-icon').classList.add('hidden');
|
||||
btn.querySelector('.share-icon-check').classList.remove('hidden');
|
||||
btn.querySelector('.share-label').textContent = 'Copied';
|
||||
setTimeout(() => {
|
||||
btn.querySelector('.share-icon').classList.remove('hidden');
|
||||
btn.querySelector('.share-icon-check').classList.add('hidden');
|
||||
btn.querySelector('.share-label').textContent = 'Share';
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
{# ── Coffee + map ── #}
|
||||
<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">Coffee</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Roast</dt>
|
||||
<dd class="font-medium text-text">{{ brew.roast_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Roaster</dt>
|
||||
<dd class="font-medium text-text">{{ brew.roaster_name }}</dd>
|
||||
</div>
|
||||
{% if brew.origin != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Origin</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !brew.origin_flag.is_empty() %}{{ brew.origin_flag }} {% endif %}{{ brew.origin }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if brew.region != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Region</dt>
|
||||
<dd class="font-medium text-text">{{ brew.region }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if brew.producer != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Producer</dt>
|
||||
<dd class="font-medium text-text">{{ brew.producer }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if brew.process != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Process</dt>
|
||||
<dd class="font-medium text-text">{{ brew.process }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if !brew.tasting_notes.is_empty() %}
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
{% for note in brew.tasting_notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if !brew.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="{{ brew.map_countries }}"
|
||||
data-max="{{ brew.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"></span> Origin
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent opacity-50"></span> Roaster
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Roaster & gear ── #}
|
||||
<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">Roaster</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ brew.roaster_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Country</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !brew.roaster_country_flag.is_empty() %}{{ brew.roaster_country_flag }} {% endif %}{{ brew.roaster_country }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(city) = brew.roaster_city %}
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ city }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = brew.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>
|
||||
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Grinder</dt>
|
||||
<dd class="font-medium text-text">{{ brew.grinder_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Brewer</dt>
|
||||
<dd class="font-medium text-text">{{ brew.brewer_name }}</dd>
|
||||
</div>
|
||||
{% if let Some(fp) = brew.filter_paper_name %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Filter Paper</dt>
|
||||
<dd class="font-medium text-text">{{ fp }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Recipe ── #}
|
||||
<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">Recipe</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Coffee</dt>
|
||||
<dd class="font-medium text-text">{{ brew.coffee_weight }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Water</dt>
|
||||
<dd class="font-medium text-text">{{ brew.water_volume }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Temperature</dt>
|
||||
<dd class="font-medium text-text">{{ brew.water_temp }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Grind Setting</dt>
|
||||
<dd class="font-medium text-text">{{ brew.grind_setting }}</dd>
|
||||
</div>
|
||||
{% if let Some(time) = brew.brew_time %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Brew Time</dt>
|
||||
<dd class="font-medium text-text">{{ time }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if !brew.quick_notes_label.is_empty() %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Notes</dt>
|
||||
<dd class="font-medium text-text">{{ brew.quick_notes_label }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
166
templates/pages/cup.html
Normal file
166
templates/pages/cup.html
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
{% extends "base.html" %}
|
||||
{% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
|
||||
{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
{{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="shareLink(this)" class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0">
|
||||
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
|
||||
<span class="share-icon-check hidden">{{ icons::check("h-4 w-4 shrink-0") }}</span>
|
||||
<span class="share-label">Share</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const shareLink = (btn) => {
|
||||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||
btn.querySelector('.share-icon').classList.add('hidden');
|
||||
btn.querySelector('.share-icon-check').classList.remove('hidden');
|
||||
btn.querySelector('.share-label').textContent = 'Copied';
|
||||
setTimeout(() => {
|
||||
btn.querySelector('.share-icon').classList.remove('hidden');
|
||||
btn.querySelector('.share-icon-check').classList.add('hidden');
|
||||
btn.querySelector('.share-label').textContent = 'Share';
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
{# ── Coffee + map ── #}
|
||||
<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">Coffee</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Roast</dt>
|
||||
<dd class="font-medium text-text">{{ cup.roast_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Roaster</dt>
|
||||
<dd class="font-medium text-text">{{ cup.roaster_name }}</dd>
|
||||
</div>
|
||||
{% if cup.origin != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Origin</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !cup.origin_flag.is_empty() %}{{ cup.origin_flag }} {% endif %}{{ cup.origin }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cup.region != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Region</dt>
|
||||
<dd class="font-medium text-text">{{ cup.region }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cup.producer != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Producer</dt>
|
||||
<dd class="font-medium text-text">{{ cup.producer }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cup.process != "\u{2014}" %}
|
||||
<div>
|
||||
<dt class="text-text-muted">Process</dt>
|
||||
<dd class="font-medium text-text">{{ cup.process }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if !cup.tasting_notes.is_empty() %}
|
||||
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||
{% for note in cup.tasting_notes %}
|
||||
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if !cup.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="{{ cup.map_countries }}"
|
||||
data-max="{{ cup.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"></span> Cafe
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent opacity-65"></span> Origin
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent opacity-35"></span> Roaster
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Roaster & cafe ── #}
|
||||
<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">Roaster</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ cup.roaster_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Country</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !cup.roaster_country_flag.is_empty() %}{{ cup.roaster_country_flag }} {% endif %}{{ cup.roaster_country }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(city) = cup.roaster_city %}
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ city }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = cup.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>
|
||||
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Cafe</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-text-muted">Name</dt>
|
||||
<dd class="font-medium text-text">{{ cup.cafe_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">City</dt>
|
||||
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-text-muted">Country</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if let Some(url) = cup.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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue