From 6e2c59814c167ae42bba2a844bc5c33ca11a193f Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 16:36:35 +0000 Subject: [PATCH] feat(cups): add web views, templates, and navigation - Add CupView, CafeOptionView, RoastOptionView view models - Add CupsTemplate (full page) and CupListTemplate (fragment) - Create cups.html with form and cup_list.html with responsive table - Add Cups link to desktop and mobile navigation - Add cup timeline event link and label support --- src/presentation/web/templates.rs | 26 +++++++- src/presentation/web/views.rs | 78 +++++++++++++++++++++- templates/cups.html | 103 ++++++++++++++++++++++++++++++ templates/nav.html | 2 + templates/partials/cup_list.html | 93 +++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 templates/cups.html create mode 100644 templates/partials/cup_list.html diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 590d448..2346490 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,13 +1,14 @@ use askama::Template; use super::views::{ - BagOptionView, BagView, BrewDefaultsView, BrewView, CafeView, GearOptionView, GearView, - ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, - TimelineMonthView, + BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView, + GearOptionView, GearView, ListNavigator, Paginated, RoastOptionView, RoastView, + RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; use crate::domain::brews::BrewSortKey; use crate::domain::cafes::CafeSortKey; +use crate::domain::cups::CupSortKey; use crate::domain::gear::GearSortKey; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; @@ -175,6 +176,25 @@ pub struct CafeDetailTemplate { pub cafe: CafeView, } +#[derive(Template)] +#[template(path = "cups.html")] +pub struct CupsTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub cups: Paginated, + pub roast_options: Vec, + pub cafe_options: Vec, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/cup_list.html")] +pub struct CupListTemplate { + pub is_authenticated: bool, + pub cups: 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 b4fcc6b..3d3b1df 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -1,6 +1,7 @@ use crate::domain::bags::BagWithRoast; use crate::domain::brews::{Brew, BrewWithDetails}; use crate::domain::cafes::Cafe; +use crate::domain::cups::CupWithDetails; use crate::domain::gear::Gear; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::roasters::Roaster; @@ -522,16 +523,18 @@ impl TimelineEventView { ("gear", "added") => "Gear Added", ("brew", "brewed") => "Brewed", ("cafe", "added") => "Cafe Added", + ("cup", "added") => "Cup", _ => "Event", }; let link = match (entity_type.as_str(), slug, roaster_slug) { ("roaster", Some(slug), _) => format!("/roasters/{slug}"), - // Roasts, bags, and brews link to the roast page when we have slug info - ("roast" | "bag" | "brew", Some(slug), Some(roaster_slug)) => { + // Roasts, bags, brews, and cups link to the roast page when we have slug info + ("roast" | "bag" | "brew" | "cup", Some(slug), Some(roaster_slug)) => { format!("/roasters/{roaster_slug}/roasts/{slug}") } ("cafe", Some(slug), _) => format!("/cafes/{slug}"), + ("cup", _, _) => "/cups".to_string(), ("gear", _, _) => "/gear".to_string(), ("brew", _, _) => "/brews".to_string(), ("roaster", None, _) => format!("/roasters/{entity_id}"), @@ -879,3 +882,74 @@ impl From for CafeView { } } } + +pub struct CafeOptionView { + pub id: String, + pub label: String, +} + +impl From for CafeOptionView { + fn from(cafe: Cafe) -> Self { + Self { + id: cafe.id.to_string(), + label: format!("{} ({})", cafe.name, cafe.city), + } + } +} + +pub struct RoastOptionView { + pub id: String, + pub label: String, +} + +impl From for RoastOptionView { + fn from(roast: RoastWithRoaster) -> Self { + Self { + id: roast.roast.id.to_string(), + label: format!("{} - {}", roast.roaster_name, roast.roast.name), + } + } +} + +#[derive(Clone)] +pub struct CupView { + pub id: String, + pub roast_name: String, + pub roaster_name: String, + pub roast_slug: String, + pub roaster_slug: String, + pub cafe_name: String, + pub cafe_slug: String, + pub notes: String, + pub has_notes: bool, + pub rating: String, + pub has_rating: bool, + pub created_at: String, +} + +impl CupView { + pub fn from_domain(cup: CupWithDetails) -> Self { + let notes = cup.cup.notes.clone().unwrap_or_default(); + let has_notes = !notes.is_empty(); + let rating = cup + .cup + .rating + .map_or_else(|| "—".to_string(), |r| format!("{r}/5")); + let has_rating = cup.cup.rating.is_some(); + + Self { + id: cup.cup.id.to_string(), + roast_name: cup.roast_name, + roaster_name: cup.roaster_name, + roast_slug: cup.roast_slug, + roaster_slug: cup.roaster_slug, + cafe_name: cup.cafe_name, + cafe_slug: cup.cafe_slug, + notes, + has_notes, + rating, + has_rating, + created_at: cup.cup.created_at.format("%Y-%m-%d %H:%M").to_string(), + } + } +} diff --git a/templates/cups.html b/templates/cups.html new file mode 100644 index 0000000..2dbbd3f --- /dev/null +++ b/templates/cups.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} {% block title %}Brewlog · Cups{% endblock %} + +{% block content %} +
+
+
+

Cups

+

Track coffees you've enjoyed at cafes.

+
+ {% if is_authenticated %} + + {% endif %} +
+ + {% if is_authenticated %} + + {% endif %} +
+ +{% include "partials/cup_list.html" %} {% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 52759a0..a2cf4f2 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -8,6 +8,7 @@ Bags Brews Cafes + Cups Gear Timeline {% if is_authenticated %} @@ -35,6 +36,7 @@ Bags Brews Cafes + Cups Gear Timeline {% if is_authenticated %} diff --git a/templates/partials/cup_list.html b/templates/partials/cup_list.html new file mode 100644 index 0000000..3a828d9 --- /dev/null +++ b/templates/partials/cup_list.html @@ -0,0 +1,93 @@ +{% import "partials/table.html" as table %} + +
+ {% if cups.items.is_empty() && !navigator.has_search() %} +
+

+ No cups recorded yet. Use the form above to add your first cup. +

+
+ {% else %} +
+ {% call table::search_header(navigator, "#cup-list") %} +
+ + + + {% call table::sortable_header("Added", "created-at", navigator, "#cup-list") %} + {% call table::sortable_header("Coffee", "roast", navigator, "#cup-list") %} + {% call table::sortable_header("Cafe", "cafe", navigator, "#cup-list") %} + {% call table::sortable_header("Rating", "rating", navigator, "#cup-list") %} + + + + + + {% for cup in cups.items %} + + + + + + + + + + {% endfor %} + +
NotesActions
+ {{ cup.created_at }} + + {{ cup.roast_name }} + + {{ cup.roaster_name }} + {{ cup.cafe_name }} + {{ cup.rating }} + {% if cup.has_notes %}{{ cup.notes }}{% else %}—{% endif %} + + {% if is_authenticated %} +
+ +
+ {% endif %} +
+
+ {% if cups.items.is_empty() %} +
No cups match your search.
+ {% endif %} + {% call table::pagination_header(cups, navigator, "#cup-list") %} + {% if cups.has_next() %} + + {% endif %} +
+ {% endif %} +