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.
**`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 `<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
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 `<div class="relative z-10">` 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 |

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 admin;
pub(super) mod auth;
mod bags;
mod brews;
mod checkin;
mod cups;
@ -29,6 +30,7 @@ pub(super) fn router() -> axum::Router<AppState> {
.route("/check-in", get(checkin::checkin_page))
.route("/timeline", get(timeline::timeline_page))
.route("/stats", get(stats::stats_page))
.route("/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))

View file

@ -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 {

View file

@ -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<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 {
fn from(bag: BagWithRoast) -> Self {
let remaining = format_weight(bag.bag.remaining);

View file

@ -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::<Vec<_>>()
})
.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),

View file

@ -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::<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(),
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(),

View file

@ -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<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.
///
/// 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" %}
{% 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 }}
</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>
{{ detail::share_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>
{{ detail::share_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 %}
{{ 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") }}
</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>
{{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }}
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2>

View file

@ -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 }}
</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>
{{ detail::share_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>
{{ detail::share_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 %}
{{ 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") }}
</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>
{{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }}
<div class="rounded-lg border bg-surface p-5">
<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>
<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>
<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 %}
<div>
<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
"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" />
{% if is_authenticated %}
<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 %}
{% endblock %} {% block content %}
<!-- Scan Bag -->
{% if is_authenticated %}
<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.' }"
class="hidden"></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"
>
{{ icons::camera("h-5 w-5") }}
<span class="text-sm font-medium">Scan</span>
</brew-photo-capture>
<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"
aria-label="Check in at a cafe"
>
@ -72,7 +49,7 @@
<span class="text-sm font-medium">Check In</span>
</a>
<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"
aria-label="Add manually"
>
@ -276,7 +253,7 @@
</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>
{% 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 %}
<span class="text-lg font-bold text-text whitespace-nowrap">{{ card.value }}</span>
<span class="text-sm font-medium text-accent">{{ card.label }}</span>
@ -294,7 +271,7 @@
<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">
{% 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-5 w-16 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>
<a
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") }}
<span class="text-lg font-bold text-text">{{ stats.brews }}</span>
@ -335,7 +312,7 @@
</a>
<a
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") }}
<span class="text-lg font-bold text-text">{{ stats.roasts }}</span>
@ -343,7 +320,7 @@
</a>
<a
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") }}
<span class="text-lg font-bold text-text">{{ stats.roasters }}</span>
@ -351,7 +328,7 @@
</a>
<a
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") }}
<span class="text-lg font-bold text-text">{{ stats.bags }}</span>
@ -359,7 +336,7 @@
</a>
<a
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") }}
<span class="text-lg font-bold text-text">{{ stats.cups }}</span>
@ -367,7 +344,7 @@
</a>
<a
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") }}
<span class="text-lg font-bold text-text">{{ stats.cafes }}</span>
@ -385,7 +362,7 @@
<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">
{% 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-5 w-8 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 %}
{% 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">
<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>
@ -14,23 +14,14 @@
</div>
</div>
{% if is_authenticated %}
<div class="@container mt-3 flex items-center gap-2">
<a href="/add?type=brew&bag_id={{ bag.id }}"
<div class="relative z-10 mt-3">
<span onclick="event.preventDefault(); window.location.href='/add?type=brew&bag_id={{ bag.id }}';"
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") }}
<span class="hidden @[10rem]:inline">Brew</span>
</a>
<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>
Brew
</span>
</div>
{% endif %}
</div>
</a>
{% 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 %}