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:
Jon Seager 2026-02-08 16:10:01 +00:00
parent 9237a386e0
commit b17aaab5f4
No known key found for this signature in database
9 changed files with 791 additions and 9 deletions

View 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)
}

View 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)
}

View file

@ -1,7 +1,9 @@
mod add; mod add;
mod admin; mod admin;
pub(super) mod auth; pub(super) mod auth;
mod brews;
mod checkin; mod checkin;
mod cups;
mod data; mod data;
mod home; mod home;
mod stats; mod stats;
@ -27,6 +29,8 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/check-in", get(checkin::checkin_page)) .route("/check-in", get(checkin::checkin_page))
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/stats", get(stats::stats_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("/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))
@ -137,8 +141,5 @@ async fn favicon_dark() -> impl IntoResponse {
} }
async fn health() -> impl IntoResponse { async fn health() -> impl IntoResponse {
( ([("content-type", "application/json")], r#"{"status":"ok"}"#)
[("content-type", "application/json")],
r#"{"status":"ok"}"#,
)
} }

View file

@ -1,9 +1,9 @@
use askama::Template; use askama::Template;
use super::views::{ use super::views::{
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeOptionView, CafeView,
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView, CupDetailView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated,
RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView, QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView,
TimelineEventView, TimelineMonthView, TimelineEventView, TimelineMonthView,
}; };
use crate::domain::bags::BagSortKey; use crate::domain::bags::BagSortKey;
@ -199,6 +199,7 @@ pub struct StatsPageTemplate {
pub consumption_30d_weight: String, pub consumption_30d_weight: String,
pub consumption_all_time_weight: String, pub consumption_all_time_weight: String,
pub cache_age: String, pub cache_age: String,
pub has_data: bool,
} }
#[derive(Template)] #[derive(Template)]
@ -207,6 +208,24 @@ pub struct StatsMapFragment<'a> {
pub geo_stats: &'a crate::domain::country_stats::GeoStats, 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> { pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
template.render() template.render()
} }

View file

@ -1,9 +1,14 @@
use std::fmt::Write; use std::fmt::Write;
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time}; 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::formatting::format_weight;
use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast;
use super::build_map_data;
use super::relative_date; use super::relative_date;
use super::tasting_notes::{self, TastingNoteView};
#[derive(Clone)] #[derive(Clone)]
pub struct QuickNoteView { 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 { impl From<BrewWithDetails> for BrewDefaultsView {
fn from(brew: BrewWithDetails) -> Self { fn from(brew: BrewWithDetails) -> Self {
Self { Self {

View file

@ -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::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)] #[derive(Clone)]
pub struct CupView { 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(),
}
}
}

View file

@ -9,9 +9,9 @@ pub mod tasting_notes;
mod timeline; mod timeline;
pub use bags::{BagOptionView, BagView}; 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 cafes::{CafeOptionView, CafeView, NearbyCafeView};
pub use cups::CupView; pub use cups::{CupDetailView, CupView};
pub use gear::{GearOptionView, GearView}; pub use gear::{GearOptionView, GearView};
pub use roasters::{RoasterOptionView, RoasterView}; pub use roasters::{RoasterOptionView, RoasterView};
pub use roasts::{RoastOptionView, RoastView}; pub use roasts::{RoastOptionView, RoastView};
@ -29,6 +29,17 @@ pub struct StatsView {
pub bags: u64, 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 struct StatCard {
pub icon: &'static str, pub icon: &'static str,
pub value: String, pub value: String,
@ -349,3 +360,37 @@ fn page_size_from_text(value: &str) -> PageSize {
PageSize::limited(DEFAULT_PAGE_SIZE) 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
View 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
View 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 %}