diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index b702e89..cd1357b 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -1,15 +1,15 @@ +use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; -use axum::Json; use serde::Deserialize; use super::macros::{define_delete_handler, define_get_handler}; use crate::application::auth::AuthenticatedUser; -use crate::application::errors::{map_app_error, ApiError, AppError}; +use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::routes::render_html; use crate::application::routes::support::{ - is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, + FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, }; use crate::application::server::AppState; use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; diff --git a/src/infrastructure/client/gear.rs b/src/infrastructure/client/gear.rs index df28382..b83263f 100644 --- a/src/infrastructure/client/gear.rs +++ b/src/infrastructure/client/gear.rs @@ -43,8 +43,7 @@ impl<'a> GearClient<'a> { pub async fn list(&self, category: Option) -> Result> { let mut url = self.inner.endpoint("api/v1/gear")?; if let Some(category) = category { - url.query_pairs_mut() - .append_pair("category", &category); + url.query_pairs_mut().append_pair("category", &category); } let response = self @@ -77,11 +76,7 @@ impl<'a> GearClient<'a> { notes: Option, ) -> Result { let url = self.inner.endpoint(&format!("api/v1/gear/{id}"))?; - let payload = UpdateGear { - make, - model, - notes, - }; + let payload = UpdateGear { make, model, notes }; let response = self .inner diff --git a/src/infrastructure/repositories/gear.rs b/src/infrastructure/repositories/gear.rs index 2999e46..6739080 100644 --- a/src/infrastructure/repositories/gear.rs +++ b/src/infrastructure/repositories/gear.rs @@ -1,13 +1,13 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::{query_as, QueryBuilder}; +use sqlx::{QueryBuilder, query_as}; use super::macros::push_update_field; +use crate::domain::RepositoryError; use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::ids::GearId; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::GearRepository; -use crate::domain::RepositoryError; use crate::infrastructure::database::DatabasePool; #[derive(Clone)] @@ -51,12 +51,10 @@ impl SqlGearRepository { } fn build_where_clause(filter: &GearFilter) -> Option { - if let Some(category) = &filter.category { - // SAFETY: category.as_str() returns a fixed static string literal ('grinder' or 'brewer') - Some(format!("category = '{}'", category.as_str())) - } else { - None - } + filter + .category + .as_ref() + .map(|category| format!("category = '{}'", category.as_str())) } } @@ -142,9 +140,7 @@ impl GearRepository for SqlGearRepository { builder.push(" WHERE id = "); builder.push_bind(id.into_inner()); - builder.push( - " RETURNING id, category, make, model, notes, created_at, updated_at", - ); + builder.push(" RETURNING id, category, make, model, notes, created_at, updated_at"); let record = builder .build_query_as::() diff --git a/src/main.rs b/src/main.rs index 34533be..bdd9b3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ use anyhow::Result; use brewlog::application::{ServerConfig, serve}; use brewlog::infrastructure::client::BrewlogClient; -use brewlog::presentation::cli::{Cli, Commands, ServeCommand, bags, gear, roasters, roasts, tokens}; +use brewlog::presentation::cli::{ + Cli, Commands, ServeCommand, bags, gear, roasters, roasts, tokens, +}; use clap::Parser; use tracing::{Subscriber, subscriber::set_global_default}; diff --git a/src/presentation/cli/gear.rs b/src/presentation/cli/gear.rs index b32afec..8f574aa 100644 --- a/src/presentation/cli/gear.rs +++ b/src/presentation/cli/gear.rs @@ -21,7 +21,12 @@ pub struct AddGearCommand { pub async fn add_gear(client: &BrewlogClient, command: AddGearCommand) -> Result<()> { let gear = client .gear() - .create(&command.category, command.make, command.model, command.notes) + .create( + &command.category, + command.make, + command.model, + command.notes, + ) .await?; print_json(&gear) } diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index d296322..ffc01fa 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -9,9 +9,7 @@ use std::net::SocketAddr; use bags::{AddBagCommand, DeleteBagCommand, GetBagCommand, ListBagsCommand, UpdateBagCommand}; use clap::{Args, Parser, Subcommand}; -use gear::{ - AddGearCommand, DeleteGearCommand, GetGearCommand, ListGearCommand, UpdateGearCommand, -}; +use gear::{AddGearCommand, DeleteGearCommand, GetGearCommand, ListGearCommand, UpdateGearCommand}; use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand}; use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand}; use tokens::{CreateTokenCommand, RevokeTokenCommand}; diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 1aa6f52..06b5f2d 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -1,10 +1,11 @@ use askama::Template; use super::views::{ - BagView, ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, + BagView, GearView, ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, }; use crate::domain::bags::BagSortKey; +use crate::domain::gear::GearSortKey; use crate::domain::roasters::RoasterSortKey; use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; use crate::domain::timeline::TimelineSortKey; @@ -100,6 +101,23 @@ pub struct BagListTemplate { pub navigator: ListNavigator, } +#[derive(Template)] +#[template(path = "gear.html")] +pub struct GearTemplate { + pub nav_active: &'static str, + pub is_authenticated: bool, + pub gear: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/gear_list.html")] +pub struct GearListTemplate { + pub is_authenticated: bool, + pub gear: Paginated, + pub navigator: ListNavigator, +} + #[derive(Template)] #[template(path = "partials/roast_options.html")] pub struct RoastOptionsTemplate { diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index 2efc92a..dac8949 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -454,6 +454,7 @@ impl TimelineEventView { ("roast", "added") => "Roast Added", ("bag", "added") => "Bag Added", ("bag", "finished") => "Bag Finished", + ("gear", "added") => "Gear Added", _ => "Event", }; @@ -465,6 +466,7 @@ impl TimelineEventView { ("bag", Some(slug), Some(roaster_slug)) => { format!("/roasters/{roaster_slug}/roasts/{slug}") } + ("gear", _, _) => "/gear".to_string(), ("roaster", None, _) => format!("/roasters/{entity_id}"), ("roast", None, _) => format!("/roasts/{entity_id}"), _ => String::from("#"), @@ -554,3 +556,30 @@ impl BagView { } } } + +#[derive(Clone)] +pub struct GearView { + pub id: String, + pub category: String, + pub category_label: String, + pub make: String, + pub model: String, + pub full_name: String, + pub notes: String, + pub created_at: String, +} + +impl GearView { + pub fn from_domain(gear: crate::domain::gear::Gear) -> Self { + Self { + id: gear.id.to_string(), + category: gear.category.as_str().to_string(), + category_label: gear.category.display_label().to_string(), + make: gear.make.clone(), + model: gear.model.clone(), + full_name: format!("{} {}", gear.make, gear.model), + notes: gear.notes.unwrap_or_else(|| "No notes.".to_string()), + created_at: gear.created_at.format("%Y-%m-%d").to_string(), + } + } +} diff --git a/templates/gear.html b/templates/gear.html new file mode 100644 index 0000000..d1cbefb --- /dev/null +++ b/templates/gear.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Brewlog ยท Gear{% endblock %} +{% block content %} +
+
+
+

Gear

+

Track your brewing equipment.

+
+ {% if is_authenticated %} + + {% endif %} +
+ + {% if is_authenticated %} + + {% endif %} + +
{% include "partials/gear_list.html" %}
+
+{% endblock %} diff --git a/templates/nav.html b/templates/nav.html index 373821e..7aa3ad4 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -4,6 +4,7 @@ Roasters Roasts Bags + Gear Timeline {% if is_authenticated %}
diff --git a/templates/partials/gear_list.html b/templates/partials/gear_list.html new file mode 100644 index 0000000..e444770 --- /dev/null +++ b/templates/partials/gear_list.html @@ -0,0 +1,72 @@ +{% import "partials/table.html" as table %} + +
+ {% if gear.items.is_empty() %} +
+

No gear found.

+ {% if is_authenticated %} +

Add your first piece of brewing equipment above.

+ {% endif %} +
+ {% else %} +
+ {% call table::pagination_header(gear, navigator, "#gear-list") %} + +
+ + + + {% call table::sortable_header("Category", "category", navigator, "#gear-list") %} + {% call table::sortable_header("Make", "make", navigator, "#gear-list") %} + {% call table::sortable_header("Model", "model", navigator, "#gear-list") %} + + {% call table::sortable_header("Added", "created-at", navigator, "#gear-list") %} + {% if is_authenticated %} + + {% endif %} + + + + {% for item in gear.items %} + + + + + + + {% if is_authenticated %} + + {% endif %} + + {% endfor %} + +
Notes + Actions +
+ + {{ item.category_label }} + + + {{ item.make }} + + {{ item.model }} + +
{{ item.notes }}
+
+ {{ item.created_at }} + + +
+
+
+ {% endif %} +