From 4fd72d654a55050e409d31ec9d74dc5572a0eb26 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sun, 8 Feb 2026 16:43:04 +0000 Subject: [PATCH] feat(bags): add bag detail page, extract shared detail page macros - Create /bags/:id detail page with coffee, roaster, map, and bag info cards - Extract shared template macros into detail_cards.html (coffee_card, roaster_card, map_with_legend, share_button) - Extract build_coffee_info() and build_roaster_info() view model helpers - Refactor brew.html and cup.html to use shared macros - Make bag cards on homepage clickable, linking to detail page - Add Close Bag and Delete actions on bag detail page - Unify homepage card styling (bg-surface, hover:border-accent/40) - Update CLAUDE.md with detail page patterns --- CLAUDE.md | 41 +++++++- src/application/routes/app/bags.rs | 50 +++++++++ src/application/routes/app/mod.rs | 2 + src/presentation/web/templates.rs | 18 +++- src/presentation/web/views/bags.rs | 86 +++++++++++++++ src/presentation/web/views/brews.rs | 66 +++--------- src/presentation/web/views/cups.rs | 64 +++--------- src/presentation/web/views/mod.rs | 84 ++++++++++++++- templates/pages/bag.html | 122 ++++++++++++++++++++++ templates/pages/brew.html | 119 ++------------------- templates/pages/cup.html | 130 ++--------------------- templates/pages/home.html | 49 +++------ templates/partials/bag_card.html | 23 ++-- templates/partials/detail_cards.html | 150 +++++++++++++++++++++++++++ 14 files changed, 610 insertions(+), 394 deletions(-) create mode 100644 src/application/routes/app/bags.rs create mode 100644 templates/pages/bag.html create mode 100644 templates/partials/detail_cards.html diff --git a/CLAUDE.md b/CLAUDE.md index a51ea40..b261ef7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ Social media preview cards are powered by Open Graph and Twitter Card meta tags **Base URL** — `BREWLOG_RP_ORIGIN` is stored at startup via `set_base_url()` in `lib.rs` (a `OnceLock`). Templates access it through the `base_url` field on their template struct, set with `crate::base_url()` in the handler. -**`og:image`** — a static 1200x630 PNG (`static/og-image.png`) served at `/og-image.png` via `include_bytes!()`. Only public shareable pages (home, brew detail, cup detail, stats) include it via `{% block head %}`, since those are the pages social crawlers can access. +**`og:image`** — a static 1200x630 PNG (`static/og-image.png`) served at `/og-image.png` via `include_bytes!()`. Only public shareable pages (home, bag detail, brew detail, cup detail, stats) include it via `{% block head %}`, since those are the pages social crawlers can access. **Adding OG tags to a new page:** 1. Add `pub base_url: &'static str` to the template struct @@ -207,6 +207,41 @@ if is_datastar_request(&headers) { } ``` +### Detail Pages + +Three shareable detail pages (`/brews/:id`, `/cups/:id`, `/bags/:id`) share layout and logic via extracted template macros and Rust helpers. + +**Template macros** — `templates/partials/detail_cards.html` provides: + +| Macro | Parameters | Used by | +|-------|-----------|---------| +| `share_button()` | — | All detail pages | +| `share_script()` | — | All detail pages | +| `coffee_card(...)` | roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes | All detail pages | +| `roaster_card(...)` | name, country, country_flag, city, homepage | All detail pages | +| `map_with_legend_2(...)` | map_countries, map_max, label1, opacity1, label2, opacity2 | Brew, Bag | +| `map_with_legend_3(...)` | map_countries, map_max, label1-3, opacity1-3 | Cup | + +Detail page templates import with `{% import "partials/detail_cards.html" as detail %}` and call macros as `{{ detail::coffee_card(...) }}`. + +**View model helpers** — `presentation/web/views/mod.rs` provides: + +| Helper | Input | Purpose | +|--------|-------|---------| +| `build_coffee_info(roast)` | `&Roast` | Extracts origin, flag, region, producer, process, tasting notes | +| `build_roaster_info(roaster)` | `&Roaster` | Extracts country, flag, city, homepage | +| `build_map_data(entries)` | `&[(&str, u32)]` | Builds `data-countries` + `data-max` for `` | + +Each `*DetailView::from_parts()` calls these helpers and flattens the results into its own struct (Askama needs direct field access). + +**Detail page layout** — all three follow the same grid structure: +1. Header: page title + subtitle + share button +2. Row 1 (2-col): Coffee card + Map with legend +3. Row 2 (2-col): Roaster card + page-specific card (Gear/Recipe for brew, Cafe for cup, Bag Info for bag) +4. Actions card (authenticated, full-width): page-specific buttons (e.g., Close Bag + Delete on bag page) + +**Route handlers** — each handler fetches the entity with related data (bag → roast → roaster), builds the `*DetailView`, and renders the template. Handlers live in `application/routes/app/{entity}.rs`. + ### Macros Reference All macros have doc comments with usage examples. Check the source files for full documentation. @@ -504,6 +539,8 @@ Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by conte - `p-5` — form sections, admin sections, timeline cards - `p-6` — auth pages (login, register) +**Clickable cards** (homepage cards, bag cards, brew cards, stats/data links) use `hover:border-accent/40` for hover state — never `hover:bg-surface-alt`. When a clickable card contains action buttons, wrap the buttons in `
` and use `event.preventDefault()` on clicks to prevent the card link from firing. + #### Typography | Level | Classes | Use | @@ -563,7 +600,7 @@ When an icon appears alongside text in a button or label, use `inline-flex items |---------|---------|-----| | Primary | `inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover` | Form submits (Save, Log Brew, Check In), New Backup | | Outlined | `inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition hover:bg-surface-alt` | Bordered secondary actions. Colour variants: `text-text` for Cancel/Back, `text-accent hover:text-text` for actions (Restore, Reset, Sign Out, Delete, Revoke) | -| Card action | `inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium transition hover:bg-surface-alt` | Compact inline card buttons. Colour variants: `text-accent hover:text-accent-hover` for positive actions (Brew), `text-text-muted hover:text-text` for neutral (Close Bag) | +| Card action | `inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt` | Compact inline card buttons (e.g., Brew on bag card) | | Link | `inline-flex items-center gap-1 text-sm font-medium` | Borderless inline actions. Colour variants: `text-accent hover:text-accent-hover` for actions (View all, Brew Again, Homepage), `text-text-muted hover:text-red-600` for destructive (Delete) | | Text-only | `text-xs text-text-muted hover:text-text` | Minimal buttons: Change, Back in summary bars | | Nav icon | `rounded-md p-1.5 text-text-muted transition hover:text-text-secondary` | Theme toggle, user menu | diff --git a/src/application/routes/app/bags.rs b/src/application/routes/app/bags.rs new file mode 100644 index 0000000..9cb60ba --- /dev/null +++ b/src/application/routes/app/bags.rs @@ -0,0 +1,50 @@ +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::BagId; +use crate::presentation::web::templates::BagDetailTemplate; +use crate::presentation::web::views::BagDetailView; + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn bag_detail_page( + State(state): State, + cookies: Cookies, + Path(id): Path, +) -> Result { + let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; + + let bag = state + .bag_repo + .get_with_roast(id) + .await + .map_err(|e| map_app_error(e.into()))?; + + let roast = state + .roast_repo + .get(bag.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 = BagDetailView::from_parts(bag, &roast, &roaster); + + let template = BagDetailTemplate { + nav_active: "", + is_authenticated, + version_info: &crate::VERSION_INFO, + base_url: crate::base_url(), + bag: view, + }; + + render_html(template).map(IntoResponse::into_response) +} diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index bad1adb..304145d 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -1,6 +1,7 @@ mod add; mod admin; pub(super) mod auth; +mod bags; mod brews; mod checkin; mod cups; @@ -29,6 +30,7 @@ pub(super) fn router() -> axum::Router { .route("/check-in", get(checkin::checkin_page)) .route("/timeline", get(timeline::timeline_page)) .route("/stats", get(stats::stats_page)) + .route("/bags/{id}", get(bags::bag_detail_page)) .route("/brews/{id}", get(brews::brew_detail_page)) .route("/cups/{id}", get(cups::cup_detail_page)) .route("/styles.css", get(styles)) diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index da5d3be..a28f8d1 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,10 +1,10 @@ use askama::Template; use super::views::{ - BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeOptionView, CafeView, - CupDetailView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, - QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView, - TimelineEventView, TimelineMonthView, + BagDetailView, 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; use crate::domain::brews::BrewSortKey; @@ -210,6 +210,16 @@ pub struct StatsMapFragment<'a> { pub geo_stats: &'a crate::domain::country_stats::GeoStats, } +#[derive(Template)] +#[template(path = "pages/bag.html")] +pub struct BagDetailTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub version_info: &'static crate::VersionInfo, + pub base_url: &'static str, + pub bag: BagDetailView, +} + #[derive(Template)] #[template(path = "pages/brew.html")] pub struct BrewDetailTemplate { diff --git a/src/presentation/web/views/bags.rs b/src/presentation/web/views/bags.rs index 0b0dd41..6c6128c 100644 --- a/src/presentation/web/views/bags.rs +++ b/src/presentation/web/views/bags.rs @@ -1,5 +1,10 @@ use crate::domain::bags::BagWithRoast; use crate::domain::formatting::format_weight; +use crate::domain::roasters::Roaster; +use crate::domain::roasts::Roast; + +use super::tasting_notes::TastingNoteView; +use super::{build_coffee_info, build_map_data, build_roaster_info}; #[derive(Debug, Clone)] pub struct BagView { @@ -58,6 +63,87 @@ pub struct BagOptionView { pub remaining: String, } +pub struct BagDetailView { + pub id: String, + // 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, + // Roaster info + pub roaster_country: String, + pub roaster_country_flag: String, + pub roaster_city: Option, + pub roaster_homepage: Option, + // Bag-specific + pub amount: String, + pub remaining: String, + pub used_percent: u8, + pub closed: bool, + pub roast_date: Option, + pub finished_date: Option, + // Map + pub map_countries: String, + pub map_max: u32, + // Dates + pub created_date: String, + pub created_time: String, +} + +impl BagDetailView { + pub fn from_parts(bag: BagWithRoast, roast: &Roast, roaster: &Roaster) -> Self { + let coffee = build_coffee_info(roast); + let roaster_info = build_roaster_info(roaster); + + 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); + + Self { + id: bag.bag.id.to_string(), + roast_name: bag.roast_name, + roaster_name: bag.roaster_name, + origin: coffee.origin, + origin_flag: coffee.origin_flag, + region: coffee.region, + producer: coffee.producer, + process: coffee.process, + tasting_notes: coffee.tasting_notes, + roaster_country: roaster_info.country, + roaster_country_flag: roaster_info.country_flag, + roaster_city: roaster_info.city, + roaster_homepage: roaster_info.homepage, + amount: format_weight(bag.bag.amount), + remaining: format_weight(bag.bag.remaining), + used_percent: if bag.bag.amount > 0.0 { + (((bag.bag.amount - bag.bag.remaining) / bag.bag.amount) * 100.0).clamp(0.0, 100.0) + as u8 + } else { + 0 + }, + closed: bag.bag.closed, + roast_date: bag.bag.roast_date.map(|d| d.to_string()), + finished_date: bag + .bag + .finished_at + .map(|d| d.format("%Y-%m-%d").to_string()), + map_countries, + map_max, + created_date: bag.bag.created_at.format("%Y-%m-%d").to_string(), + created_time: bag.bag.created_at.format("%H:%M").to_string(), + } + } +} + impl From for BagOptionView { fn from(bag: BagWithRoast) -> Self { let remaining = format_weight(bag.bag.remaining); diff --git a/src/presentation/web/views/brews.rs b/src/presentation/web/views/brews.rs index aa995e2..f323a3a 100644 --- a/src/presentation/web/views/brews.rs +++ b/src/presentation/web/views/brews.rs @@ -1,14 +1,12 @@ 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}; +use super::tasting_notes::TastingNoteView; +use super::{build_coffee_info, build_map_data, build_roaster_info, relative_date}; #[derive(Clone)] pub struct QuickNoteView { @@ -233,16 +231,8 @@ pub struct BrewDetailView { 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 coffee = build_coffee_info(roast); + let roaster_info = build_roaster_info(roaster); let mut map_entries: Vec<(&str, u32)> = Vec::new(); if let Some(ref o) = roast.origin @@ -253,18 +243,6 @@ impl BrewDetailView { 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::>() - }) - .map(|n| tasting_notes::categorize(&n)) - .collect(); - let quick_notes_label = brew .brew .quick_notes @@ -276,32 +254,16 @@ impl BrewDetailView { 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(), + origin: coffee.origin, + origin_flag: coffee.origin_flag, + region: coffee.region, + producer: coffee.producer, + process: coffee.process, + tasting_notes: coffee.tasting_notes, + roaster_country: roaster_info.country, + roaster_country_flag: roaster_info.country_flag, + roaster_city: roaster_info.city, + roaster_homepage: roaster_info.homepage, 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), diff --git a/src/presentation/web/views/cups.rs b/src/presentation/web/views/cups.rs index fdfed75..78e52ca 100644 --- a/src/presentation/web/views/cups.rs +++ b/src/presentation/web/views/cups.rs @@ -4,8 +4,8 @@ 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}; +use super::tasting_notes::TastingNoteView; +use super::{build_coffee_info, build_map_data, build_roaster_info}; #[derive(Clone)] pub struct CupView { @@ -69,16 +69,8 @@ pub struct CupDetailView { 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 coffee = build_coffee_info(roast); + let roaster_info = build_roaster_info(roaster); let cafe_country_flag = country_to_iso(&cafe.country) .map(iso_to_flag_emoji) @@ -94,47 +86,19 @@ impl CupDetailView { 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::>() - }) - .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(), + origin: coffee.origin, + origin_flag: coffee.origin_flag, + region: coffee.region, + producer: coffee.producer, + process: coffee.process, + tasting_notes: coffee.tasting_notes, + roaster_country: roaster_info.country, + roaster_country_flag: roaster_info.country_flag, + roaster_city: roaster_info.city, + roaster_homepage: roaster_info.homepage, cafe_name: cafe.name.clone(), cafe_city: cafe.city.clone(), cafe_country: cafe.country.clone(), diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 47f6dcf..4624cc4 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -8,7 +8,7 @@ mod roasts; pub mod tasting_notes; mod timeline; -pub use bags::{BagOptionView, BagView}; +pub use bags::{BagDetailView, BagOptionView, BagView}; pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView}; pub use cafes::{CafeOptionView, CafeView, NearbyCafeView}; pub use cups::{CupDetailView, CupView}; @@ -361,6 +361,88 @@ fn page_size_from_text(value: &str) -> PageSize { } } +/// Shared coffee info fields extracted from a `Roast` for detail pages. +pub(crate) struct CoffeeInfo { + pub origin: String, + pub origin_flag: String, + pub region: String, + pub producer: String, + pub process: String, + pub tasting_notes: Vec, +} + +/// Build coffee info fields from a roast, using em dash for empty/missing values. +pub(crate) fn build_coffee_info(roast: &crate::domain::roasts::Roast) -> CoffeeInfo { + use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; + + 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 notes = roast + .tasting_notes + .iter() + .flat_map(|note| { + note.split([',', '\n']) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .map(|n| tasting_notes::categorize(&n)) + .collect(); + + CoffeeInfo { + 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: notes, + } +} + +/// Shared roaster info fields extracted from a `Roaster` for detail pages. +pub(crate) struct RoasterInfo { + pub country: String, + pub country_flag: String, + pub city: Option, + pub homepage: Option, +} + +/// Build roaster info fields from a roaster. +pub(crate) fn build_roaster_info(roaster: &crate::domain::roasters::Roaster) -> RoasterInfo { + use crate::domain::countries::{country_to_iso, iso_to_flag_emoji}; + + let country_flag = country_to_iso(&roaster.country) + .map(iso_to_flag_emoji) + .unwrap_or_default(); + + RoasterInfo { + country: roaster.country.clone(), + country_flag, + city: roaster.city.clone(), + homepage: roaster.homepage.clone(), + } +} + /// Build `data-countries` and `data-max` values for the world-map component. /// /// Accepts `(country_name, weight)` pairs where higher weights render darker. diff --git a/templates/pages/bag.html b/templates/pages/bag.html new file mode 100644 index 0000000..ef95dfb --- /dev/null +++ b/templates/pages/bag.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% import "partials/detail_cards.html" as detail %} +{% import "partials/icons.html" as icons %} +{% block title %}Brewlog · {{ bag.roast_name }}{% endblock %} +{% block description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %} +{% block og_title %}{{ bag.roast_name }} — Brewlog{% endblock %} +{% block og_description %}{{ bag.roast_name }} by {{ bag.roaster_name }} — {{ bag.amount }} bag.{% endblock %} +{% block head %}{% endblock %} +{% block content %} +
+
+

{{ bag.roast_name }}

+

+ {{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }} +

+
+ {{ detail::share_button() }} +
+ +{{ detail::share_script() }} + +{# ── Coffee + map ── #} +
+ {{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }} + {{ detail::map_with_legend_2(bag.map_countries, bag.map_max, "Origin", "", "Roaster", "opacity-50") }} +
+ +{# ── Roaster & bag info ── #} +
+ {{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage) }} + +
+

Bag

+
+
+
Amount
+
{{ bag.amount }}
+
+
+
Status
+
+ {% if bag.closed %} + Closed + {% else %} +
+
+
+
+

{{ bag.remaining }} / {{ bag.amount }}

+
+ {% endif %} +
+
+ {% if let Some(rd) = bag.roast_date %} +
+
Roast Date
+
{{ rd }}
+
+ {% endif %} +
+
Opened
+
{{ bag.created_date }}
+
+ {% if let Some(fd) = bag.finished_date %} +
+
Finished
+
{{ fd }}
+
+ {% endif %} +
+
+
+ +{% if is_authenticated %} +{# ── Actions ── #} +
+
+ {% if !bag.closed %} + + {% endif %} + +
+
+ +{% endif %} +{% endblock %} diff --git a/templates/pages/brew.html b/templates/pages/brew.html index 84504c9..26cca95 100644 --- a/templates/pages/brew.html +++ b/templates/pages/brew.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% import "partials/icons.html" as icons %} +{% import "partials/detail_cards.html" as detail %} {% 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 og_title %}{{ brew.roast_name }} — Brewlog{% endblock %} @@ -13,127 +13,20 @@ {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}

- + {{ detail::share_button() }} - +{{ detail::share_script() }} {# ── Coffee + map ── #}
-
-

Coffee

-
-
-
Roast
-
{{ brew.roast_name }}
-
-
-
Roaster
-
{{ brew.roaster_name }}
-
- {% if brew.origin != "\u{2014}" %} -
-
Origin
-
- {% if !brew.origin_flag.is_empty() %}{{ brew.origin_flag }} {% endif %}{{ brew.origin }} -
-
- {% endif %} - {% if brew.region != "\u{2014}" %} -
-
Region
-
{{ brew.region }}
-
- {% endif %} - {% if brew.producer != "\u{2014}" %} -
-
Producer
-
{{ brew.producer }}
-
- {% endif %} - {% if brew.process != "\u{2014}" %} -
-
Process
-
{{ brew.process }}
-
- {% endif %} -
- {% if !brew.tasting_notes.is_empty() %} -
- {% for note in brew.tasting_notes %} - {{ note.label }} - {% endfor %} -
- {% endif %} -
- - {% if !brew.map_countries.is_empty() %} -
- -
- - Origin - - - Roaster - -
-
- {% endif %} + {{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }} + {{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }}
{# ── Roaster & gear ── #}
-
-

Roaster

-
-
-
Name
-
{{ brew.roaster_name }}
-
-
-
Country
-
- {% if !brew.roaster_country_flag.is_empty() %}{{ brew.roaster_country_flag }} {% endif %}{{ brew.roaster_country }} -
-
- {% if let Some(city) = brew.roaster_city %} -
-
City
-
{{ city }}
-
- {% endif %} - {% if let Some(url) = brew.roaster_homepage %} -
-
Website
-
- Visit Website -
-
- {% endif %} -
-
+ {{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }}

Gear

diff --git a/templates/pages/cup.html b/templates/pages/cup.html index b147a03..eeb42a5 100644 --- a/templates/pages/cup.html +++ b/templates/pages/cup.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% import "partials/icons.html" as icons %} +{% import "partials/detail_cards.html" as detail %} {% 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 og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %} @@ -13,130 +13,20 @@ {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}

- + {{ detail::share_button() }} - +{{ detail::share_script() }} {# ── Coffee + map ── #}
-
-

Coffee

-
-
-
Roast
-
{{ cup.roast_name }}
-
-
-
Roaster
-
{{ cup.roaster_name }}
-
- {% if cup.origin != "\u{2014}" %} -
-
Origin
-
- {% if !cup.origin_flag.is_empty() %}{{ cup.origin_flag }} {% endif %}{{ cup.origin }} -
-
- {% endif %} - {% if cup.region != "\u{2014}" %} -
-
Region
-
{{ cup.region }}
-
- {% endif %} - {% if cup.producer != "\u{2014}" %} -
-
Producer
-
{{ cup.producer }}
-
- {% endif %} - {% if cup.process != "\u{2014}" %} -
-
Process
-
{{ cup.process }}
-
- {% endif %} -
- {% if !cup.tasting_notes.is_empty() %} -
- {% for note in cup.tasting_notes %} - {{ note.label }} - {% endfor %} -
- {% endif %} -
- - {% if !cup.map_countries.is_empty() %} -
- -
- - Cafe - - - Origin - - - Roaster - -
-
- {% endif %} + {{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }} + {{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }}
{# ── Roaster & cafe ── #}
-
-

Roaster

-
-
-
Name
-
{{ cup.roaster_name }}
-
-
-
Country
-
- {% if !cup.roaster_country_flag.is_empty() %}{{ cup.roaster_country_flag }} {% endif %}{{ cup.roaster_country }} -
-
- {% if let Some(city) = cup.roaster_city %} -
-
City
-
{{ city }}
-
- {% endif %} - {% if let Some(url) = cup.roaster_homepage %} -
-
Website
-
- Visit Website -
-
- {% endif %} -
-
+ {{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }}

Cafe

@@ -145,16 +35,16 @@ const shareLink = (btn) => {
Name
{{ cup.cafe_name }}
-
-
City
-
{{ cup.cafe_city }}
-
Country
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }}
+
+
City
+
{{ cup.cafe_city }}
+
{% if let Some(url) = cup.cafe_website %}
Website
diff --git a/templates/pages/home.html b/templates/pages/home.html index ef51c67..3833a98 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -1,30 +1,7 @@ {% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import "partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %} -{% if is_authenticated %} - -{% endif %} {% endblock %} {% block content %} +{% endblock %} {% block content %} {% if is_authenticated %}