From 3f83d74e20fa62462dcf9033519bbd20fa98e686 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 14:53:00 +0000 Subject: [PATCH] feat(cafes): add CLI commands, web views, and templates Add CLI subcommands (add, list, get, update, delete) with negative number support for coordinates. Add CafeView with map URL generation, Askama templates for list/detail pages, and nav link between Brews and Gear. --- src/main.rs | 6 +- src/presentation/cli/cafes.rs | 112 ++++++++++++++++++++++++ src/presentation/cli/mod.rs | 8 ++ src/presentation/web/templates.rs | 31 ++++++- src/presentation/web/views.rs | 63 ++++++++++++++ templates/cafe_detail.html | 73 ++++++++++++++++ templates/cafes.html | 133 ++++++++++++++++++++++++++++ templates/nav.html | 2 + templates/partials/cafe_list.html | 139 ++++++++++++++++++++++++++++++ 9 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 src/presentation/cli/cafes.rs create mode 100644 templates/cafe_detail.html create mode 100644 templates/cafes.html create mode 100644 templates/partials/cafe_list.html diff --git a/src/main.rs b/src/main.rs index 9ad9399..a710a8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use brewlog::infrastructure::backup::{BackupData, BackupService}; use brewlog::infrastructure::client::BrewlogClient; use brewlog::infrastructure::database::Database; use brewlog::presentation::cli::{ - Cli, Commands, ServeCommand, bags, brews, gear, roasters, roasts, tokens, + Cli, Commands, ServeCommand, bags, brews, cafes, gear, roasters, roasts, tokens, }; use clap::Parser; @@ -43,6 +43,10 @@ async fn main() -> Result<()> { let client = BrewlogClient::from_base_url(&cli.api_url)?; brews::run(&client, command).await } + Commands::Cafe { command } => { + let client = BrewlogClient::from_base_url(&cli.api_url)?; + cafes::run(&client, command).await + } Commands::Token { command } => { let client = BrewlogClient::from_base_url(&cli.api_url)?; tokens::run(&client, command).await diff --git a/src/presentation/cli/cafes.rs b/src/presentation/cli/cafes.rs new file mode 100644 index 0000000..59e342b --- /dev/null +++ b/src/presentation/cli/cafes.rs @@ -0,0 +1,112 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; + +use super::macros::{define_delete_command, define_get_command}; +use super::print_json; +use crate::domain::cafes::{NewCafe, UpdateCafe}; +use crate::domain::ids::CafeId; +use crate::infrastructure::client::BrewlogClient; + +#[derive(Debug, Subcommand)] +pub enum CafeCommands { + /// Add a new cafe + Add(AddCafeCommand), + /// List all cafes + List, + /// Get a cafe by ID + Get(GetCafeCommand), + /// Update a cafe + Update(UpdateCafeCommand), + /// Delete a cafe + Delete(DeleteCafeCommand), +} + +pub async fn run(client: &BrewlogClient, cmd: CafeCommands) -> Result<()> { + match cmd { + CafeCommands::Add(c) => add_cafe(client, c).await, + CafeCommands::List => list_cafes(client).await, + CafeCommands::Get(c) => get_cafe(client, c).await, + CafeCommands::Update(c) => update_cafe(client, c).await, + CafeCommands::Delete(c) => delete_cafe(client, c).await, + } +} + +#[derive(Debug, Args)] +pub struct AddCafeCommand { + #[arg(long)] + pub name: String, + #[arg(long)] + pub city: String, + #[arg(long)] + pub country: String, + #[arg(long, allow_negative_numbers = true)] + pub latitude: f64, + #[arg(long, allow_negative_numbers = true)] + pub longitude: f64, + #[arg(long)] + pub website: Option, + #[arg(long)] + pub notes: Option, +} + +pub async fn add_cafe(client: &BrewlogClient, command: AddCafeCommand) -> Result<()> { + let payload = NewCafe { + name: command.name, + city: command.city, + country: command.country, + latitude: command.latitude, + longitude: command.longitude, + website: command.website, + notes: command.notes, + }; + + let cafe = client.cafes().create(&payload).await?; + print_json(&cafe) +} + +pub async fn list_cafes(client: &BrewlogClient) -> Result<()> { + let cafes = client.cafes().list().await?; + print_json(&cafes) +} + +define_get_command!(GetCafeCommand, get_cafe, CafeId, cafes); + +#[derive(Debug, Args)] +pub struct UpdateCafeCommand { + #[arg(long)] + pub id: i64, + #[arg(long)] + pub name: Option, + #[arg(long)] + pub city: Option, + #[arg(long)] + pub country: Option, + #[arg(long, allow_negative_numbers = true)] + pub latitude: Option, + #[arg(long, allow_negative_numbers = true)] + pub longitude: Option, + #[arg(long)] + pub website: Option, + #[arg(long)] + pub notes: Option, +} + +pub async fn update_cafe(client: &BrewlogClient, command: UpdateCafeCommand) -> Result<()> { + let payload = UpdateCafe { + name: command.name, + city: command.city, + country: command.country, + latitude: command.latitude, + longitude: command.longitude, + website: command.website, + notes: command.notes, + }; + + let cafe = client + .cafes() + .update(CafeId::new(command.id), &payload) + .await?; + print_json(&cafe) +} + +define_delete_command!(DeleteCafeCommand, delete_cafe, CafeId, cafes, "cafe"); diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index c50c473..11e918e 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -1,6 +1,7 @@ pub mod backup; pub mod bags; pub mod brews; +pub mod cafes; pub mod gear; mod macros; pub mod roasters; @@ -12,6 +13,7 @@ use std::net::SocketAddr; use backup::{BackupCommand, RestoreCommand}; use bags::BagCommands; use brews::BrewCommands; +use cafes::CafeCommands; use clap::{Args, Parser, Subcommand}; use gear::GearCommands; use roasters::RoasterCommands; @@ -68,6 +70,12 @@ pub enum Commands { command: BrewCommands, }, + /// Manage cafes + Cafe { + #[command(subcommand)] + command: CafeCommands, + }, + /// Manage API tokens Token { #[command(subcommand)] diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 691f2ce..590d448 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,11 +1,13 @@ use askama::Template; use super::views::{ - BagOptionView, BagView, BrewDefaultsView, BrewView, GearOptionView, GearView, ListNavigator, - Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, + BagOptionView, BagView, BrewDefaultsView, BrewView, CafeView, GearOptionView, GearView, + ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, + TimelineMonthView, }; use crate::domain::bags::BagSortKey; use crate::domain::brews::BrewSortKey; +use crate::domain::cafes::CafeSortKey; use crate::domain::gear::GearSortKey; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; @@ -148,6 +150,31 @@ pub struct BrewListTemplate { pub navigator: ListNavigator, } +#[derive(Template)] +#[template(path = "cafes.html")] +pub struct CafesTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub cafes: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/cafe_list.html")] +pub struct CafeListTemplate { + pub is_authenticated: bool, + pub cafes: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "cafe_detail.html")] +pub struct CafeDetailTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub cafe: CafeView, +} + pub fn render_template(template: T) -> Result { template.render() } diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index c490ee4..8846caa 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -1,5 +1,6 @@ use crate::domain::bags::BagWithRoast; use crate::domain::brews::{Brew, BrewWithDetails}; +use crate::domain::cafes::Cafe; use crate::domain::gear::Gear; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::roasters::Roaster; @@ -519,6 +520,7 @@ impl TimelineEventView { ("bag", "finished") => "Bag Finished", ("gear", "added") => "Gear Added", ("brew", "brewed") => "Brewed", + ("cafe", "added") => "Cafe Added", _ => "Event", }; @@ -528,6 +530,7 @@ impl TimelineEventView { ("roast" | "bag" | "brew", Some(slug), Some(roaster_slug)) => { format!("/roasters/{roaster_slug}/roasts/{slug}") } + ("cafe", Some(slug), _) => format!("/cafes/{slug}"), ("gear", _, _) => "/gear".to_string(), ("brew", _, _) => "/brews".to_string(), ("roaster", None, _) => format!("/roasters/{entity_id}"), @@ -800,3 +803,63 @@ impl From for BrewDefaultsView { } } } + +pub struct CafeView { + pub id: String, + pub detail_path: String, + pub name: String, + pub city: String, + pub country: String, + pub latitude: f64, + pub longitude: f64, + pub map_url: String, + pub has_website: bool, + pub website_url: String, + pub website_label: String, + pub notes: String, + pub created_at: String, + pub created_at_sort_key: i64, +} + +impl From for CafeView { + fn from(cafe: Cafe) -> Self { + let Cafe { + id, + slug, + name, + city, + country, + latitude, + longitude, + website, + notes, + created_at, + updated_at: _, + } = cafe; + + let website = website.unwrap_or_default(); + let has_website = !website.is_empty(); + let detail_path = format!("/cafes/{slug}"); + let map_url = format!("https://www.google.com/maps?q={latitude},{longitude}"); + + let created_at_sort_key = created_at.timestamp(); + let created_at_label = created_at.format("%Y-%m-%d").to_string(); + + Self { + detail_path, + id: id.to_string(), + name, + city, + country, + latitude, + longitude, + map_url, + has_website, + website_url: website.clone(), + website_label: website, + notes: notes.unwrap_or_else(|| "This cafe has no notes yet.".to_string()), + created_at: created_at_label, + created_at_sort_key, + } + } +} diff --git a/templates/cafe_detail.html b/templates/cafe_detail.html new file mode 100644 index 0000000..ff9ee99 --- /dev/null +++ b/templates/cafe_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} {% block title %}Brewlog · Cafe · {{ cafe.name }}{% endblock %} {% +block content %} +
+

{{ cafe.name }}

+

Detailed view for {{ cafe.name }}.

+
+
+
+
+
City
+
{{ cafe.city }}
+
+
+
Country
+
{{ cafe.country }}
+
+
+
Coordinates
+
{{ cafe.latitude }}, {{ cafe.longitude }}
+
+ +
+
Website
+
+ {% if cafe.has_website %} + + + Visit + + {% else %} + + {% endif %} +
+
+
+
Created
+
{{ cafe.created_at }}
+
+
+

{{ cafe.notes }}

+
+{% endblock %} diff --git a/templates/cafes.html b/templates/cafes.html new file mode 100644 index 0000000..1ddfda4 --- /dev/null +++ b/templates/cafes.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} {% block title %}Brewlog · Cafes{% endblock %} {% block content %} +
+
+
+

Cafes

+

Discover and track your favourite cafes.

+
+ {% if is_authenticated %} + + {% endif %} +
+ + {% if is_authenticated %} + + {% endif %} +
+ +{% include "partials/cafe_list.html" %} {% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 0e258d1..52759a0 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -7,6 +7,7 @@ Roasts Bags Brews + Cafes Gear Timeline {% if is_authenticated %} @@ -33,6 +34,7 @@ Roasts Bags Brews + Cafes Gear Timeline {% if is_authenticated %} diff --git a/templates/partials/cafe_list.html b/templates/partials/cafe_list.html new file mode 100644 index 0000000..a3d8220 --- /dev/null +++ b/templates/partials/cafe_list.html @@ -0,0 +1,139 @@ +{% import "partials/table.html" as table %} + +
+ {% if cafes.items.is_empty() && !navigator.has_search() %} +
+

+ No cafes recorded yet. Use the form above to add your first cafe. +

+
+ {% else %} +
+ {% call table::search_header(navigator, "#cafe-list") %} +
+ + + + {% call table::sortable_header("Added", "created-at", navigator, "#cafe-list") %} + {% call table::sortable_header("Name", "name", navigator, "#cafe-list") %} + {% call table::sortable_header("Location", "country", navigator, "#cafe-list") %} + + + + + + {% for cafe in cafes.items %} + + + + + + + + + {% endfor %} + +
NotesActions
+ {{ cafe.created_at }} + + {{ cafe.name }} + + {{ cafe.country }} + + {{ cafe.city }}{{ cafe.notes }} +
+ + Open {{ cafe.name }} in Google Maps + + + {% if cafe.has_website %} + + {% else %} + + {% endif %} + {% if is_authenticated %} + + {% endif %} +
+
+
+ {% if cafes.items.is_empty() %} +
No cafes match your search.
+ {% endif %} + {% call table::pagination_header(cafes, navigator, "#cafe-list") %} + {% if cafes.has_next() %} + + {% endif %} +
+ {% endif %} +