feat(ui): add horizontal scrolling recent brews on homepage

- Load last 10 brews instead of 3, display in horizontal scroll
- Extract brew card into reusable partial with portrait layout
- Add grinder_model field to BrewView for compact card display
- Move brew-again button to top-right icon, truncate long names
This commit is contained in:
Jon Seager 2026-02-07 10:40:20 +00:00
parent 55e549ceac
commit 577c0e39d3
No known key found for this signature in database
5 changed files with 63 additions and 72 deletions

View file

@ -57,16 +57,12 @@ fn count_request<K: SortKey>() -> ListRequest<K> {
async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
let recent_brews_req = ListRequest::new(
1,
PageSize::limited(3),
PageSize::limited(10),
BrewSortKey::CreatedAt,
SortDirection::Desc,
);
let open_bags_req = ListRequest::new(
1,
PageSize::All,
BagSortKey::UpdatedAt,
SortDirection::Desc,
);
let open_bags_req =
ListRequest::new(1, PageSize::All, BagSortKey::UpdatedAt, SortDirection::Desc);
let recent_events_req = ListRequest::new(
1,
PageSize::limited(5),

View file

@ -34,6 +34,7 @@ pub struct BrewView {
pub coffee_weight: String,
pub grinder_id: i64,
pub grinder_name: String,
pub grinder_model: String,
pub grind_setting: String,
pub brewer_id: i64,
pub brewer_name: String,
@ -93,6 +94,10 @@ impl BrewView {
roaster_slug: brew.roaster_slug,
coffee_weight: format!("{:.1}g", brew.brew.coffee_weight),
grinder_id: brew.brew.grinder_id.into_inner(),
grinder_model: brew
.grinder_name
.split_once(' ')
.map_or_else(|| brew.grinder_name.clone(), |(_, model)| model.to_string()),
grinder_name: brew.grinder_name,
grind_setting: format!("{:.1}", brew.brew.grind_setting),
brewer_id: brew.brew.brewer_id.into_inner(),

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import
"partials/brew_card.html" as brew_card %} {% import
"partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% block title
%}Brewlog{% endblock %} {% block head %} {% if is_authenticated %}
<script>
@ -81,70 +82,9 @@
>View all brews &rarr;</a
>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
<div class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory scrollbar-hide">
{% for brew in recent_brews %}
<div class="rounded-lg border bg-surface p-4 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="block truncate font-semibold text-text">{{ brew.roast_name }}</span>
<p class="truncate text-sm text-text-muted">{{ brew.roaster_name }}</p>
</div>
<time class="text-xs text-text-muted whitespace-nowrap shrink-0"
>{{ brew.relative_date_label }}</time
>
</div>
<div class="mt-2 space-y-0.5 text-sm">
<p class="truncate">
<span class="font-medium text-text"
>{{ brew.coffee_weight }} · {{ brew.water_volume }} · {{ brew.water_temp }}</span
>
<span class="text-text-muted">· {{ brew.ratio }}</span>
</p>
<p class="truncate">
<span class="font-medium text-text">{{ brew.grind_setting }}</span>
<span class="text-text-muted">· {{ brew.grinder_name }}</span>
</p>
<p class="truncate">
<span class="font-medium text-text">{{ brew.brewer_name }}</span>{% if let Some(fp) =
brew.filter_paper_name %} <span class="text-text-muted">· {{ fp }}</span>{% endif %}
</p>
</div>
<div class="mt-3 pt-3 border-t flex items-center justify-between gap-3">
<div class="flex flex-wrap gap-1">
{% if !brew.quick_notes.is_empty() %} {% for qn in brew.quick_notes %}
<span class="{{ qn.pill_class }}">{{ qn.label }}</span>
{% endfor %} {% else %}
<span class="pill pill-muted">No Notes</span>
{% endif %}
</div>
{% if is_authenticated %}
<form
class="inline shrink-0"
data-on:submit="$_brewing = true; @post('/api/v1/brews', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_brewing) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_brewing = false }"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
{% if let Some(fp_id) = brew.filter_paper_id %}
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<input type="hidden" name="quick_notes" value="{{ brew.quick_notes_raw }}" />
<button
type="submit"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-accent transition hover:text-accent-hover hover:bg-surface-alt"
title="Brew Again"
>
{{ icons::refresh("h-4 w-4") }}
</button>
</form>
{% endif %}
</div>
</div>
{{ brew_card::card(brew, is_authenticated) }}
{% endfor %}
</div>
</section>

View file

@ -12,8 +12,8 @@
</button>
{% endif %}
<div class="flex-1 pr-5">
<span class="block font-semibold text-text">{{ bag.roast_name }}</span>
<p class="mt-1 text-sm text-text-muted">{{ bag.roaster_name }}</p>
<span class="block font-semibold text-text truncate">{{ bag.roast_name }}</span>
<p class="mt-1 text-sm text-text-muted truncate">{{ bag.roaster_name }}</p>
</div>
{% if is_authenticated %}
<div class="mb-4 flex items-center">

View file

@ -0,0 +1,50 @@
{% import "partials/icons.html" as icons %}
{% macro card(brew, is_authenticated) %}
<div class="relative w-[45vw] md:w-[200px] h-[280px] shrink-0 snap-start rounded-lg border bg-surface p-4 flex flex-col">
{% if is_authenticated %}
<form
class="absolute top-4 right-3"
data-on:submit="$_brewing = true; @post('/api/v1/brews', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_brewing) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_brewing = false }"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
{% if let Some(fp_id) = brew.filter_paper_id %}
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<input type="hidden" name="quick_notes" value="{{ brew.quick_notes_raw }}" />
<button
type="submit"
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition hover:text-accent hover:bg-surface-alt"
title="Brew Again"
>
{{ icons::refresh("h-4.5 w-4.5") }}
</button>
</form>
{% endif %}
<div class="h-[4.5rem] overflow-hidden pr-5">
<span class="block font-semibold text-text truncate">{{ brew.roast_name }}</span>
<p class="mt-0.5 text-sm text-text-muted truncate">{{ brew.roaster_name }}</p>
</div>
<div class="mt-2 flex-1 space-y-1 text-text-secondary" style="font-size: 0.8125rem">
<p>{{ brew.coffee_weight }} &middot; {{ brew.water_volume }}</p>
<p>{{ brew.grinder_model }} &middot; {{ brew.grind_setting }}</p>
<p>{{ brew.brewer_name }}</p>
{% if let Some(fp) = brew.filter_paper_name %}<p>{{ fp }}</p>{% endif %}
</div>
<div class="flex flex-wrap gap-1">
{% if !brew.quick_notes.is_empty() %}
{% for qn in brew.quick_notes %}
<span class="{{ qn.pill_class }}" style="font-size: 0.65rem">{{ qn.label }}</span>
{% endfor %}
{% else %}
<span class="pill pill-muted" style="font-size: 0.65rem">No Notes</span>
{% endif %}
</div>
</div>
{% endmacro %}