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:
parent
9e5dff7f5f
commit
4fd72d654a
14 changed files with 610 additions and 394 deletions
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -146,7 +146,7 @@ Social media preview cards are powered by Open Graph and Twitter Card meta tags
|
|||
|
||||
**Base URL** — `BREWLOG_RP_ORIGIN` is stored at startup via `set_base_url()` in `lib.rs` (a `OnceLock<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 |
|
||||
|
|
|
|||
50
src/application/routes/app/bags.rs
Normal file
50
src/application/routes/app/bags.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
122
templates/pages/bag.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
150
templates/partials/detail_cards.html
Normal file
150
templates/partials/detail_cards.html
Normal 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 %}
|
||||
Loading…
Reference in a new issue