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.
This commit is contained in:
parent
1f5561d7ab
commit
74a8bef475
11 changed files with 247 additions and 27 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@ impl<'a> GearClient<'a> {
|
|||
pub async fn list(&self, category: Option<String>) -> Result<Vec<Gear>> {
|
||||
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<String>,
|
||||
) -> Result<Gear> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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::<GearRecord>()
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<BagSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "gear.html")]
|
||||
pub struct GearTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub gear: Paginated<GearView>,
|
||||
pub navigator: ListNavigator<GearSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/gear_list.html")]
|
||||
pub struct GearListTemplate {
|
||||
pub is_authenticated: bool,
|
||||
pub gear: Paginated<GearView>,
|
||||
pub navigator: ListNavigator<GearSortKey>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "partials/roast_options.html")]
|
||||
pub struct RoastOptionsTemplate {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
templates/gear.html
Normal file
104
templates/gear.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Brewlog · Gear{% endblock %}
|
||||
{% block content %}
|
||||
<section data-signals:show-form="false" data-signals:is-submitting="false">
|
||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-semibold">Gear</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Track your brewing equipment.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
data-class:hidden="$showForm"
|
||||
data-on:click="$showForm = true"
|
||||
aria-label="Add new gear"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$showForm"
|
||||
style="display: none"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-amber-700">New Gear</h2>
|
||||
<p class="mt-1 text-sm text-stone-600">
|
||||
Add brewing equipment to your collection.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="/api/v1/gear"
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
data-on:submit="$isSubmitting = true; @post('/api/v1/gear?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#gear-list', mode: 'replace'}})"
|
||||
data-ref="form"
|
||||
data-on:datastar-fetch="evt.detail.type === 'finished' && $isSubmitting && ($showForm = false, $form && $form.reset(), $isSubmitting = false)"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Category *</span>
|
||||
<select name="category" required class="input-field">
|
||||
<option value="">Select a category</option>
|
||||
<option value="grinder">Grinder</option>
|
||||
<option value="brewer">Brewer</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Make *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="make"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Baratza"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Model *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="model"
|
||||
required
|
||||
class="input-field"
|
||||
placeholder="Encore"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="text-stone-700">Notes</span>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows="3"
|
||||
class="input-field"
|
||||
placeholder="Additional notes about this equipment..."
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-200/50"
|
||||
data-on:click="$showForm = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
>
|
||||
Save Gear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-8">{% include "partials/gear_list.html" %}</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "bags" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/bags">Bags</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
|
||||
{% if is_authenticated %}
|
||||
<form method="post" action="/logout" class="inline">
|
||||
|
|
|
|||
72
templates/partials/gear_list.html
Normal file
72
templates/partials/gear_list.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{% import "partials/table.html" as table %}
|
||||
|
||||
<div id="gear-list">
|
||||
{% if gear.items.is_empty() %}
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 p-8 text-center">
|
||||
<p class="text-stone-500">No gear found.</p>
|
||||
{% if is_authenticated %}
|
||||
<p class="mt-2 text-sm text-stone-500">Add your first piece of brewing equipment above.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
|
||||
{% call table::pagination_header(gear, navigator, "#gear-list") %}
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
|
||||
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
|
||||
<tr>
|
||||
{% 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") %}
|
||||
<th scope="col" class="px-4 py-3">Notes</th>
|
||||
{% call table::sortable_header("Added", "created-at", navigator, "#gear-list") %}
|
||||
{% if is_authenticated %}
|
||||
<th scope="col" class="relative px-4 py-3">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-amber-200/70">
|
||||
{% for item in gear.items %}
|
||||
<tr class="bg-amber-50/40 transition hover:bg-amber-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">
|
||||
{{ item.category_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap font-medium text-stone-600">
|
||||
{{ item.make }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{{ item.model }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="max-w-xs truncate">{{ item.notes }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{{ item.created_at }}
|
||||
</td>
|
||||
{% if is_authenticated %}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
data-on:click="confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})"
|
||||
>
|
||||
<span class="sr-only">Delete</span>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
Loading…
Reference in a new issue