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 %} +