diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 06b5f2d..bd8e3a0 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,10 +1,11 @@ use askama::Template; use super::views::{ - BagView, GearView, ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, - TimelineEventView, TimelineMonthView, + BagOptionView, BagView, BrewView, GearOptionView, GearView, ListNavigator, Paginated, + RoastView, RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; +use crate::domain::brews::BrewSortKey; use crate::domain::gear::GearSortKey; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; @@ -124,6 +125,26 @@ pub struct RoastOptionsTemplate { pub roasts: Vec, } +#[derive(Template)] +#[template(path = "brews.html")] +pub struct BrewsTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub brews: Paginated, + pub bag_options: Vec, + pub grinder_options: Vec, + pub brewer_options: Vec, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/brew_list.html")] +pub struct BrewListTemplate { + pub is_authenticated: bool, + pub brews: Paginated, + pub navigator: ListNavigator, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index 5fad68d..eb79cad 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -1,4 +1,6 @@ use crate::domain::bags::BagWithRoast; +use crate::domain::brews::BrewWithDetails; +use crate::domain::gear::Gear; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::roasters::Roaster; use crate::domain::roasts::{Roast, RoastWithRoaster}; @@ -454,16 +456,18 @@ impl TimelineEventView { ("bag", "added") => "Bag Added", ("bag", "finished") => "Bag Finished", ("gear", "added") => "Gear Added", + ("brew", "brewed") => "Brewed", _ => "Event", }; let link = match (entity_type.as_str(), slug, roaster_slug) { ("roaster", Some(slug), _) => format!("/roasters/{slug}"), - // Both roasts and bags link to the roast page - ("roast" | "bag", Some(slug), Some(roaster_slug)) => { + // Roasts, bags, and brews link to the roast page when we have slug info + ("roast" | "bag" | "brew", Some(slug), Some(roaster_slug)) => { format!("/roasters/{roaster_slug}/roasts/{slug}") } ("gear", _, _) => "/gear".to_string(), + ("brew", _, _) => "/brews".to_string(), ("roaster", None, _) => format!("/roasters/{entity_id}"), ("roast", None, _) => format!("/roasts/{entity_id}"), _ => String::from("#"), @@ -577,3 +581,84 @@ impl GearView { } } } + +#[derive(Clone)] +pub struct BrewView { + pub id: String, + pub bag_id: String, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, + pub coffee_weight: String, + pub grinder_name: String, + pub grind_setting: String, + pub brewer_name: String, + pub water_volume: String, + pub water_temp: String, + pub ratio: String, + pub created_at: String, +} + +impl BrewView { + pub fn from_domain(brew: BrewWithDetails) -> Self { + let ratio = if brew.brew.coffee_weight > 0.0 { + format!( + "1:{:.1}", + f64::from(brew.brew.water_volume) / brew.brew.coffee_weight + ) + } else { + "\u{2014}".to_string() + }; + + Self { + id: brew.brew.id.to_string(), + bag_id: brew.brew.bag_id.to_string(), + roast_name: brew.roast_name, + roaster_name: brew.roaster_name, + roast_slug: brew.roast_slug, + roaster_slug: brew.roaster_slug, + coffee_weight: format!("{:.1}g", brew.brew.coffee_weight), + grinder_name: brew.grinder_name, + grind_setting: format!("{:.1}", brew.brew.grind_setting), + brewer_name: brew.brewer_name, + water_volume: format!("{}ml", brew.brew.water_volume), + water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp), + ratio, + created_at: brew.brew.created_at.format("%Y-%m-%d %H:%M").to_string(), + } + } +} + +#[derive(Clone)] +pub struct BagOptionView { + pub id: String, + pub label: String, +} + +impl From for BagOptionView { + fn from(bag: BagWithRoast) -> Self { + Self { + id: bag.bag.id.to_string(), + label: format!( + "{} - {} ({:.0}g remaining)", + bag.roaster_name, bag.roast_name, bag.bag.remaining + ), + } + } +} + +#[derive(Clone)] +pub struct GearOptionView { + pub id: String, + pub label: String, +} + +impl From for GearOptionView { + fn from(gear: Gear) -> Self { + Self { + id: gear.id.to_string(), + label: format!("{} {}", gear.make, gear.model), + } + } +} diff --git a/templates/brews.html b/templates/brews.html new file mode 100644 index 0000000..17f7efb --- /dev/null +++ b/templates/brews.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} {% block title %}Brewlog ยท Brews{% endblock %} {% block content %} +
+
+
+

Brews

+

Log your coffee brews.

+
+ {% if is_authenticated && !bag_options.is_empty() && !grinder_options.is_empty() && !brewer_options.is_empty() %} + + {% endif %} +
+ + {% if is_authenticated %} + {% if bag_options.is_empty() %} +
+

Open a bag first

+

+ Brews need an open bag of coffee. + Add a bag + to enable this form. +

+
+ {% else if grinder_options.is_empty() || brewer_options.is_empty() %} +
+

Add your gear first

+

+ Brews need a grinder and brewer. + Add your gear + to enable this form. +

+
+ {% else %} + + {% endif %} + {% endif %} + +
{% include "partials/brew_list.html" %}
+
+{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 7aa3ad4..c3e15b9 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -4,6 +4,7 @@ Roasters Roasts Bags + Brews Gear Timeline {% if is_authenticated %} diff --git a/templates/partials/brew_list.html b/templates/partials/brew_list.html new file mode 100644 index 0000000..9c5beb8 --- /dev/null +++ b/templates/partials/brew_list.html @@ -0,0 +1,71 @@ +{% import "partials/table.html" as table %} + +
+
+ {% call table::pagination_header(brews, navigator, "#brew-list") %} + +
+ + + + + {% call table::sortable_header("Weight", "coffee-weight", navigator, "#brew-list") %} + + + {% call table::sortable_header("Water", "water-volume", navigator, "#brew-list") %} + + + {% call table::sortable_header("Date", "created-at", navigator, "#brew-list") %} + {% if is_authenticated %} + + {% endif %} + + + + {% for brew in brews.items %} + + + + + + + + + + {% if is_authenticated %} + + {% endif %} + + {% endfor %} + +
CoffeeGrindBrewerTempRatio
+ + + {{ brew.coffee_weight }} +
{{ brew.grind_setting }}
+
{{ brew.grinder_name }}
+
{{ brew.brewer_name }}{{ brew.water_volume }}{{ brew.water_temp }}{{ brew.ratio }}{{ brew.created_at }} + +
+
+ + {% if brews.items.is_empty() %} +
+ No brews logged yet. Start by logging your first cup! +
+ {% endif %} +
+
diff --git a/templates/styles.css b/templates/styles.css index cd31a82..2475bc9 100644 --- a/templates/styles.css +++ b/templates/styles.css @@ -24,3 +24,21 @@ a { border-color: rgba(180, 83, 9, 0.6); box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.2); } + +.btn-adjust { + display: flex; + height: 2rem; + width: 2rem; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + border: 1px solid rgba(120, 113, 108, 0.3); + background-color: white; + color: rgba(87, 83, 78, 1); + font-weight: 700; + transition: background-color 150ms ease; +} + +.btn-adjust:hover { + background-color: rgba(245, 245, 244, 1); +}