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
This commit is contained in:
Jon Seager 2026-02-08 16:43:04 +00:00
parent 9e5dff7f5f
commit 4fd72d654a
No known key found for this signature in database
14 changed files with 610 additions and 394 deletions

View file

@ -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<String>`). Templates access it through the `base_url` field on their template struct, set with `crate::base_url()` in the handler. **Base URL** — `BREWLOG_RP_ORIGIN` is stored at startup via `set_base_url()` in `lib.rs` (a `OnceLock<String>`). 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:** **Adding OG tags to a new page:**
1. Add `pub base_url: &'static str` to the template struct 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 `<world-map>` |
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 ### Macros Reference
All macros have doc comments with usage examples. Check the source files for full documentation. 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-5` — form sections, admin sections, timeline cards
- `p-6` — auth pages (login, register) - `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 `<div class="relative z-10">` and use `event.preventDefault()` on clicks to prevent the card link from firing.
#### Typography #### Typography
| Level | Classes | Use | | 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 | | 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) | | 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) | | 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 | | 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 | | Nav icon | `rounded-md p-1.5 text-text-muted transition hover:text-text-secondary` | Theme toggle, user menu |

View file

@ -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<AppState>,
cookies: Cookies,
Path(id): Path<BagId>,
) -> Result<Response, StatusCode> {
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)
}

View file

@ -1,6 +1,7 @@
mod add; mod add;
mod admin; mod admin;
pub(super) mod auth; pub(super) mod auth;
mod bags;
mod brews; mod brews;
mod checkin; mod checkin;
mod cups; mod cups;
@ -29,6 +30,7 @@ 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("/bags/{id}", get(bags::bag_detail_page))
.route("/brews/{id}", get(brews::brew_detail_page)) .route("/brews/{id}", get(brews::brew_detail_page))
.route("/cups/{id}", get(cups::cup_detail_page)) .route("/cups/{id}", get(cups::cup_detail_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))

View file

@ -1,10 +1,10 @@
use askama::Template; use askama::Template;
use super::views::{ use super::views::{
BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeOptionView, CafeView, BagDetailView, BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView,
CupDetailView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, CafeOptionView, CafeView, CupDetailView, CupView, GearOptionView, GearView, ListNavigator,
QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView, NearbyCafeView, Paginated, QuickNoteView, RoastOptionView, RoastView, RoasterOptionView,
TimelineEventView, TimelineMonthView, 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;
@ -210,6 +210,16 @@ 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/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)] #[derive(Template)]
#[template(path = "pages/brew.html")] #[template(path = "pages/brew.html")]
pub struct BrewDetailTemplate { pub struct BrewDetailTemplate {

View file

@ -1,5 +1,10 @@
use crate::domain::bags::BagWithRoast; use crate::domain::bags::BagWithRoast;
use crate::domain::formatting::format_weight; 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)] #[derive(Debug, Clone)]
pub struct BagView { pub struct BagView {
@ -58,6 +63,87 @@ pub struct BagOptionView {
pub remaining: String, 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<TastingNoteView>,
// Roaster info
pub roaster_country: String,
pub roaster_country_flag: String,
pub roaster_city: Option<String>,
pub roaster_homepage: Option<String>,
// Bag-specific
pub amount: String,
pub remaining: String,
pub used_percent: u8,
pub closed: bool,
pub roast_date: Option<String>,
pub finished_date: Option<String>,
// 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<BagWithRoast> for BagOptionView { impl From<BagWithRoast> for BagOptionView {
fn from(bag: BagWithRoast) -> Self { fn from(bag: BagWithRoast) -> Self {
let remaining = format_weight(bag.bag.remaining); let remaining = format_weight(bag.bag.remaining);

View file

@ -1,14 +1,12 @@
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::roasters::Roaster;
use crate::domain::roasts::Roast; use crate::domain::roasts::Roast;
use super::build_map_data; use super::tasting_notes::TastingNoteView;
use super::relative_date; use super::{build_coffee_info, build_map_data, build_roaster_info, relative_date};
use super::tasting_notes::{self, TastingNoteView};
#[derive(Clone)] #[derive(Clone)]
pub struct QuickNoteView { pub struct QuickNoteView {
@ -233,16 +231,8 @@ pub struct BrewDetailView {
impl BrewDetailView { impl BrewDetailView {
pub fn from_parts(brew: BrewWithDetails, roast: &Roast, roaster: &Roaster) -> Self { pub fn from_parts(brew: BrewWithDetails, roast: &Roast, roaster: &Roaster) -> Self {
let em_dash = "\u{2014}".to_string(); let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster);
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(); let mut map_entries: Vec<(&str, u32)> = Vec::new();
if let Some(ref o) = roast.origin if let Some(ref o) = roast.origin
@ -253,18 +243,6 @@ impl BrewDetailView {
map_entries.push((roaster.country.as_str(), 1)); map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries); 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 let quick_notes_label = brew
.brew .brew
.quick_notes .quick_notes
@ -276,32 +254,16 @@ impl BrewDetailView {
Self { Self {
roast_name: brew.roast_name, roast_name: brew.roast_name,
roaster_name: brew.roaster_name, roaster_name: brew.roaster_name,
origin: if origin.is_empty() { origin: coffee.origin,
em_dash.clone() origin_flag: coffee.origin_flag,
} else { region: coffee.region,
origin producer: coffee.producer,
}, process: coffee.process,
origin_flag, tasting_notes: coffee.tasting_notes,
region: roast roaster_country: roaster_info.country,
.region roaster_country_flag: roaster_info.country_flag,
.clone() roaster_city: roaster_info.city,
.filter(|s| !s.is_empty()) roaster_homepage: roaster_info.homepage,
.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), coffee_weight: format_weight(brew.brew.coffee_weight),
water_volume: format!("{}ml", brew.brew.water_volume), water_volume: format!("{}ml", brew.brew.water_volume),
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp), water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),

View file

@ -4,8 +4,8 @@ use crate::domain::cups::CupWithDetails;
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
use crate::domain::roasts::Roast; use crate::domain::roasts::Roast;
use super::build_map_data; use super::tasting_notes::TastingNoteView;
use super::tasting_notes::{self, TastingNoteView}; use super::{build_coffee_info, build_map_data, build_roaster_info};
#[derive(Clone)] #[derive(Clone)]
pub struct CupView { pub struct CupView {
@ -69,16 +69,8 @@ pub struct CupDetailView {
impl CupDetailView { impl CupDetailView {
pub fn from_parts(cup: CupWithDetails, roast: &Roast, roaster: &Roaster, cafe: &Cafe) -> Self { pub fn from_parts(cup: CupWithDetails, roast: &Roast, roaster: &Roaster, cafe: &Cafe) -> Self {
let em_dash = "\u{2014}".to_string(); let coffee = build_coffee_info(roast);
let roaster_info = build_roaster_info(roaster);
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) let cafe_country_flag = country_to_iso(&cafe.country)
.map(iso_to_flag_emoji) .map(iso_to_flag_emoji)
@ -94,47 +86,19 @@ impl CupDetailView {
map_entries.push((roaster.country.as_str(), 1)); map_entries.push((roaster.country.as_str(), 1));
let (map_countries, map_max) = build_map_data(&map_entries); 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 { Self {
roast_name: cup.roast_name, roast_name: cup.roast_name,
roaster_name: cup.roaster_name, roaster_name: cup.roaster_name,
origin: if origin.is_empty() { origin: coffee.origin,
em_dash.clone() origin_flag: coffee.origin_flag,
} else { region: coffee.region,
origin producer: coffee.producer,
}, process: coffee.process,
origin_flag, tasting_notes: coffee.tasting_notes,
region: roast roaster_country: roaster_info.country,
.region roaster_country_flag: roaster_info.country_flag,
.clone() roaster_city: roaster_info.city,
.filter(|s| !s.is_empty()) roaster_homepage: roaster_info.homepage,
.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_name: cafe.name.clone(),
cafe_city: cafe.city.clone(), cafe_city: cafe.city.clone(),
cafe_country: cafe.country.clone(), cafe_country: cafe.country.clone(),

View file

@ -8,7 +8,7 @@ mod roasts;
pub mod tasting_notes; pub mod tasting_notes;
mod timeline; mod timeline;
pub use bags::{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::{CafeOptionView, CafeView, NearbyCafeView};
pub use cups::{CupDetailView, CupView}; 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<tasting_notes::TastingNoteView>,
}
/// 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::<Vec<_>>()
})
.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<String>,
pub homepage: Option<String>,
}
/// 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. /// Build `data-countries` and `data-max` values for the world-map component.
/// ///
/// Accepts `(country_name, weight)` pairs where higher weights render darker. /// Accepts `(country_name, weight)` pairs where higher weights render darker.

122
templates/pages/bag.html Normal file
View file

@ -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 %}<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">{{ bag.roast_name }}</h1>
<p class="text-sm text-text-secondary">
{{ bag.roaster_name }} · {{ bag.amount }} · Opened {{ bag.created_date }}
</p>
</div>
{{ detail::share_button() }}
</header>
{{ detail::share_script() }}
{# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ 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") }}
</div>
{# ── Roaster & bag info ── #}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage) }}
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Bag</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-text-muted">Amount</dt>
<dd class="font-medium text-text">{{ bag.amount }}</dd>
</div>
<div>
<dt class="text-text-muted">Status</dt>
<dd>
{% if bag.closed %}
<span class="font-medium text-text">Closed</span>
{% else %}
<div class="mt-1">
<div class="h-2 rounded-full bg-surface-alt overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width: {{ bag.used_percent }}%"></div>
</div>
<p class="mt-1 text-text-muted" style="font-size: 0.7rem">{{ bag.remaining }} / {{ bag.amount }}</p>
</div>
{% endif %}
</dd>
</div>
{% if let Some(rd) = bag.roast_date %}
<div>
<dt class="text-text-muted">Roast Date</dt>
<dd class="font-medium text-text">{{ rd }}</dd>
</div>
{% endif %}
<div>
<dt class="text-text-muted">Opened</dt>
<dd class="font-medium text-text">{{ bag.created_date }}</dd>
</div>
{% if let Some(fd) = bag.finished_date %}
<div>
<dt class="text-text-muted">Finished</dt>
<dd class="font-medium text-text">{{ fd }}</dd>
</div>
{% endif %}
</dl>
</div>
</div>
{% if is_authenticated %}
{# ── Actions ── #}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5 md:col-span-2 flex items-center gap-2">
{% if !bag.closed %}
<button type="button" onclick="closeBag('{{ 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">
{{ icons::x_mark("h-4 w-4") }} Close Bag
</button>
{% endif %}
<button type="button" onclick="deleteBag('{{ bag.id }}')"
class="inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-accent transition hover:text-text hover:bg-surface-alt">
{{ icons::delete("h-4 w-4") }} Delete
</button>
</div>
</div>
<script>
const closeBag = async (bagId) => {
if (!confirm('Close this bag? This will mark it as finished.')) return;
try {
const today = new Date().toISOString().split('T')[0];
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.reload();
} catch (e) {
alert(`Failed to close bag: ${e.message}`);
}
};
const deleteBag = async (bagId) => {
if (!confirm('Delete this bag? This cannot be undone.')) return;
try {
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
window.location.href = '/';
} catch (e) {
alert(`Failed to delete bag: ${e.message}`);
}
};
</script>
{% endif %}
{% endblock %}

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "partials/icons.html" as icons %} {% import "partials/detail_cards.html" as detail %}
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %} {% 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 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 %} {% block og_title %}{{ brew.roast_name }} — Brewlog{% endblock %}
@ -13,127 +13,20 @@
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }} {{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}
</p> </p>
</div> </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"> {{ detail::share_button() }}
<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> </header>
<script> {{ detail::share_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 ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> {{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }}
<h2 class="text-lg font-semibold text-text mb-4">Coffee</h2> {{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }}
<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> </div>
{# ── Roaster & gear ── #} {# ── Roaster & gear ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> {{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }}
<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"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2> <h2 class="text-lg font-semibold text-text mb-4">Gear</h2>

View file

@ -1,5 +1,5 @@
{% extends "base.html" %} {% 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 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 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 %} {% 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 }} {{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}
</p> </p>
</div> </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"> {{ detail::share_button() }}
<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> </header>
<script> {{ detail::share_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 ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> {{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }}
<h2 class="text-lg font-semibold text-text mb-4">Coffee</h2> {{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }}
<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> </div>
{# ── Roaster & cafe ── #} {# ── Roaster & cafe ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> {{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }}
<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"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Cafe</h2> <h2 class="text-lg font-semibold text-text mb-4">Cafe</h2>
@ -145,16 +35,16 @@ const shareLink = (btn) => {
<dt class="text-text-muted">Name</dt> <dt class="text-text-muted">Name</dt>
<dd class="font-medium text-text">{{ cup.cafe_name }}</dd> <dd class="font-medium text-text">{{ cup.cafe_name }}</dd>
</div> </div>
<div>
<dt class="text-text-muted">City</dt>
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
</div>
<div> <div>
<dt class="text-text-muted">Country</dt> <dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text"> <dd class="font-medium text-text">
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }} {% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }}
</dd> </dd>
</div> </div>
<div>
<dt class="text-text-muted">City</dt>
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
</div>
{% if let Some(url) = cup.cafe_website %} {% if let Some(url) = cup.cafe_website %}
<div> <div>
<dt class="text-text-muted">Website</dt> <dt class="text-text-muted">Website</dt>

View file

@ -1,30 +1,7 @@
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import {% 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 %} "partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %}
<meta property="og:image" content="{{ base_url }}/og-image.png" /> <meta property="og:image" content="{{ base_url }}/og-image.png" />
{% if is_authenticated %} {% endblock %} {% block content %}
<script>
const closeBag = async (bagId, cardEl) => {
if (!confirm("Close this bag? This will mark it as finished.")) return
try {
const today = new Date().toISOString().split("T")[0]
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
})
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
cardEl.remove()
const grid = document.getElementById("open-bags-grid")
if (grid && grid.children.length === 0) {
document.getElementById("open-bags-section")?.remove()
}
} catch (e) {
alert(`Failed to close bag: ${e.message}`)
}
}
</script>
{% endif %} {% endblock %} {% block content %}
<!-- Scan Bag --> <!-- Scan Bag -->
{% if is_authenticated %} {% if is_authenticated %}
<section <section
@ -57,14 +34,14 @@
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
class="hidden"></form> class="hidden"></form>
<brew-photo-capture target-input="scan-image" target-form="scan-extract-form" <brew-photo-capture target-input="scan-image" target-form="scan-extract-form"
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border px-4 py-3 text-accent transition hover:bg-surface-alt cursor-pointer" class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40 cursor-pointer"
aria-label="Scan Bag" aria-label="Scan Bag"
> >
{{ icons::camera("h-5 w-5") }} {{ icons::camera("h-5 w-5") }}
<span class="text-sm font-medium">Scan</span> <span class="text-sm font-medium">Scan</span>
</brew-photo-capture> </brew-photo-capture>
<a href="/check-in" <a href="/check-in"
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border px-4 py-3 text-accent transition hover:bg-surface-alt" class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
title="Check in at a cafe" title="Check in at a cafe"
aria-label="Check in at a cafe" aria-label="Check in at a cafe"
> >
@ -72,7 +49,7 @@
<span class="text-sm font-medium">Check In</span> <span class="text-sm font-medium">Check In</span>
</a> </a>
<a href="/add" <a href="/add"
class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border px-4 py-3 text-accent transition hover:bg-surface-alt" class="inline-flex flex-col items-center justify-center gap-1.5 rounded-md border bg-surface px-4 py-3 text-accent transition hover:border-accent/40"
title="Add manually" title="Add manually"
aria-label="Add manually" aria-label="Add manually"
> >
@ -276,7 +253,7 @@
</button> </button>
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll> <div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll>
{% for card in stat_cards %} {% for card in stat_cards %}
<a href="/stats" class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt"> <a href="/stats" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40">
{% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %} {% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %}
<span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span> <span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span>
<span class="text-sm font-medium text-accent">{{ card.label }}</span> <span class="text-sm font-medium text-accent">{{ card.label }}</span>
@ -294,7 +271,7 @@
<div class="relative"> <div class="relative">
<div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div class="grid grid-flow-col auto-cols-[minmax(45vw,max-content)] md:auto-cols-[minmax(200px,max-content)] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
{% for _ in 0..6 %} {% for _ in 0..6 %}
<div class="flex flex-col items-center gap-1 rounded-lg border p-4"> <div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
<div class="h-6 w-6 rounded-full bg-surface-alt"></div> <div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-16 rounded bg-surface-alt"></div> <div class="h-5 w-16 rounded bg-surface-alt"></div>
<div class="h-4 w-20 rounded bg-surface-alt"></div> <div class="h-4 w-20 rounded bg-surface-alt"></div>
@ -327,7 +304,7 @@
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll> <div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-x-auto scroll-smooth scrollbar-hide" data-chip-scroll>
<a <a
href="/data?type=brews" href="/data?type=brews"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::beaker("h-6 w-6 text-accent") }} {{ icons::beaker("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.brews }}</span> <span class="text-lg font-bold text-text">{{ stats.brews }}</span>
@ -335,7 +312,7 @@
</a> </a>
<a <a
href="/data?type=roasts" href="/data?type=roasts"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::coffee_bean("h-6 w-6 text-accent") }} {{ icons::coffee_bean("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.roasts }}</span> <span class="text-lg font-bold text-text">{{ stats.roasts }}</span>
@ -343,7 +320,7 @@
</a> </a>
<a <a
href="/data?type=roasters" href="/data?type=roasters"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::fire("h-6 w-6 text-accent") }} {{ icons::fire("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.roasters }}</span> <span class="text-lg font-bold text-text">{{ stats.roasters }}</span>
@ -351,7 +328,7 @@
</a> </a>
<a <a
href="/data?type=bags" href="/data?type=bags"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::bag("h-6 w-6 text-accent") }} {{ icons::bag("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.bags }}</span> <span class="text-lg font-bold text-text">{{ stats.bags }}</span>
@ -359,7 +336,7 @@
</a> </a>
<a <a
href="/data?type=cups" href="/data?type=cups"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::cup("h-6 w-6 text-accent") }} {{ icons::cup("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.cups }}</span> <span class="text-lg font-bold text-text">{{ stats.cups }}</span>
@ -367,7 +344,7 @@
</a> </a>
<a <a
href="/data?type=cafes" href="/data?type=cafes"
class="group flex flex-col items-center gap-1 rounded-lg border p-4 transition hover:bg-surface-alt" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{{ icons::location("h-6 w-6 text-accent") }} {{ icons::location("h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text">{{ stats.cafes }}</span> <span class="text-lg font-bold text-text">{{ stats.cafes }}</span>
@ -385,7 +362,7 @@
<div class="relative"> <div class="relative">
<div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true"> <div class="grid grid-flow-col auto-cols-[45vw] md:auto-cols-[200px] gap-3 overflow-hidden blur-[2px] select-none pointer-events-none" aria-hidden="true">
{% for _ in 0..6 %} {% for _ in 0..6 %}
<div class="flex flex-col items-center gap-1 rounded-lg border p-4"> <div class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4">
<div class="h-6 w-6 rounded-full bg-surface-alt"></div> <div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-8 rounded bg-surface-alt"></div> <div class="h-5 w-8 rounded bg-surface-alt"></div>
<div class="h-4 w-16 rounded bg-surface-alt"></div> <div class="h-4 w-16 rounded bg-surface-alt"></div>

View file

@ -1,6 +1,6 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro card(bag, is_authenticated) %} {% macro card(bag, is_authenticated) %}
<div id="bag-card-{{ bag.id }}" class="relative w-[45vw] md:w-[200px] h-[200px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col"> <a href="/bags/{{ bag.id }}" id="bag-card-{{ bag.id }}" class="relative w-[45vw] md:w-[200px] h-[200px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col transition hover:border-accent/40">
<div class="flex-1"> <div class="flex-1">
<span class="block font-semibold text-text truncate">{{ bag.roast_name }}</span> <span class="block font-semibold text-text truncate">{{ bag.roast_name }}</span>
<p class="mt-1 text-sm text-text-muted truncate">{{ bag.roaster_name }}</p> <p class="mt-1 text-sm text-text-muted truncate">{{ bag.roaster_name }}</p>
@ -14,23 +14,14 @@
</div> </div>
</div> </div>
{% if is_authenticated %} {% if is_authenticated %}
<div class="@container mt-3 flex items-center gap-2"> <div class="relative z-10 mt-3">
<a href="/add?type=brew&bag_id={{ bag.id }}" <span onclick="event.preventDefault(); window.location.href='/add?type=brew&bag_id={{ bag.id }}';"
title="Brew" title="Brew"
class="flex-1 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"> class="inline-flex h-8 w-full 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 cursor-pointer">
{{ icons::beaker("h-4 w-4") }} {{ icons::beaker("h-4 w-4") }}
<span class="hidden @[10rem]:inline">Brew</span> Brew
</a> </span>
<button
type="button"
class="flex-1 inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-text-muted transition hover:text-text hover:bg-surface-alt"
title="Close Bag"
onclick="closeBag('{{ bag.id }}', document.getElementById('bag-card-{{ bag.id }}'))"
>
{{ icons::x_mark("h-4 w-4") }}
<span class="hidden @[10rem]:inline">Close</span>
</button>
</div> </div>
{% endif %} {% endif %}
</div> </a>
{% endmacro %} {% endmacro %}

View file

@ -0,0 +1,150 @@
{% import "partials/icons.html" as icons %}
{% macro share_button() %}
<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>
{% endmacro %}
{% macro share_script() %}
<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>
{% endmacro %}
{% macro coffee_card(roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes) %}
<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">{{ roast_name }}</dd>
</div>
<div>
<dt class="text-text-muted">Roaster</dt>
<dd class="font-medium text-text">{{ roaster_name }}</dd>
</div>
{% if origin != "\u{2014}" %}
<div>
<dt class="text-text-muted">Origin</dt>
<dd class="font-medium text-text">
{% if !origin_flag.is_empty() %}{{ origin_flag }} {% endif %}{{ origin }}
</dd>
</div>
{% endif %}
{% if region != "\u{2014}" %}
<div>
<dt class="text-text-muted">Region</dt>
<dd class="font-medium text-text">{{ region }}</dd>
</div>
{% endif %}
{% if producer != "\u{2014}" %}
<div>
<dt class="text-text-muted">Producer</dt>
<dd class="font-medium text-text">{{ producer }}</dd>
</div>
{% endif %}
{% if process != "\u{2014}" %}
<div>
<dt class="text-text-muted">Process</dt>
<dd class="font-medium text-text">{{ process }}</dd>
</div>
{% endif %}
</dl>
{% if !tasting_notes.is_empty() %}
<div class="mt-4 flex flex-wrap gap-1.5">
{% for note in tasting_notes %}
<span class="{{ note.pill_class }}">{{ note.label }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro map_with_legend_2(map_countries, map_max, label1, opacity1, label2, opacity2) %}
{% if !map_countries.is_empty() %}
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
<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>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"></span> {{ label2 }}
</span>
</div>
</div>
{% endif %}
{% endmacro %}
{% macro map_with_legend_3(map_countries, map_max, label1, opacity1, label2, opacity2, label3, opacity3) %}
{% 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>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity2 }}"></span> {{ label2 }}
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent {{ opacity3 }}"></span> {{ label3 }}
</span>
</div>
</div>
{% endif %}
{% endmacro %}
{% macro roaster_card(name, country, country_flag, city, homepage) %}
<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">{{ name }}</dd>
</div>
<div>
<dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text">
{% if !country_flag.is_empty() %}{{ country_flag }} {% endif %}{{ country }}
</dd>
</div>
{% if let Some(c) = city %}
<div>
<dt class="text-text-muted">City</dt>
<dd class="font-medium text-text">{{ c }}</dd>
</div>
{% endif %}
{% if let Some(url) = 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>
{% endmacro %}