From 74a8bef4757175dbdf7fc3b58e123c0ccd3eedad Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 2 Feb 2026 15:35:57 +0000 Subject: [PATCH] feat(web): add Gear web UI with reactive updates Implement complete web interface for the Gear entity: - Add GearView model with category badges and formatted display - Create GearTemplate and GearListTemplate for Askama rendering - Build main gear page with collapsible add form (Datastar-powered) - Implement gear list table with sortable columns and pagination - Use trash icon for delete actions matching roasts table design - Add Gear navigation link in main menu between Bags and Timeline - Integrate gear events into timeline view with proper labels and links The web UI follows the established patterns from other entities with Datastar for reactive fragment updates and proper authentication gating. Apply code formatting fixes across all gear-related modules. --- src/application/routes/gear.rs | 6 +- src/infrastructure/client/gear.rs | 9 +- src/infrastructure/repositories/gear.rs | 18 ++-- src/main.rs | 4 +- src/presentation/cli/gear.rs | 7 +- src/presentation/cli/mod.rs | 4 +- src/presentation/web/templates.rs | 20 ++++- src/presentation/web/views.rs | 29 +++++++ templates/gear.html | 104 ++++++++++++++++++++++++ templates/nav.html | 1 + templates/partials/gear_list.html | 72 ++++++++++++++++ 11 files changed, 247 insertions(+), 27 deletions(-) create mode 100644 templates/gear.html create mode 100644 templates/partials/gear_list.html 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 %} +