feat(home): redesign home page with recent brews cards and inline scan

- Replace single "Last Brew" with grid of 3 recent brew cards
- Show brew details with emphasis on weight/volume, grind, and brewer
- Add "Brew Again" action matching bag card button style
- Replace quick-action buttons with always-visible "Scan Bag" section
- Fix activity chip wrapping by widening to w-28 with whitespace-nowrap
- Increase section gap from gap-6 to gap-8 for better visual spacing
This commit is contained in:
Jon Seager 2026-02-04 17:14:46 +00:00
parent 62740c4c68
commit ebd2698b42
No known key found for this signature in database
4 changed files with 62 additions and 82 deletions

View file

@ -31,7 +31,7 @@ pub(crate) async fn home_page(
let template = HomeTemplate { let template = HomeTemplate {
nav_active: "home", nav_active: "home",
is_authenticated, is_authenticated,
last_brew: content.last_brew, recent_brews: content.recent_brews,
open_bags: content.open_bags, open_bags: content.open_bags,
recent_events: content.recent_events, recent_events: content.recent_events,
stats, stats,
@ -41,7 +41,7 @@ pub(crate) async fn home_page(
} }
struct HomeContent { struct HomeContent {
last_brew: Option<BrewView>, recent_brews: Vec<BrewView>,
open_bags: Vec<BagView>, open_bags: Vec<BagView>,
recent_events: Vec<TimelineEventView>, recent_events: Vec<TimelineEventView>,
} }
@ -54,9 +54,9 @@ fn count_request<K: SortKey>() -> ListRequest<K> {
} }
async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> { async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
let last_brew_req = ListRequest::new( let recent_brews_req = ListRequest::new(
1, 1,
PageSize::limited(1), PageSize::limited(3),
BrewSortKey::CreatedAt, BrewSortKey::CreatedAt,
SortDirection::Desc, SortDirection::Desc,
); );
@ -68,11 +68,11 @@ async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
TimelineSortKey::default().default_direction(), TimelineSortKey::default().default_direction(),
); );
let (last_brew_page, open_bags_page, recent_events_page) = tokio::try_join!( let (recent_brews_page, open_bags_page, recent_events_page) = tokio::try_join!(
async { async {
state state
.brew_repo .brew_repo
.list(BrewFilter::all(), &last_brew_req, None) .list(BrewFilter::all(), &recent_brews_req, None)
.await .await
.map_err(AppError::from) .map_err(AppError::from)
}, },
@ -92,11 +92,11 @@ async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
}, },
)?; )?;
let last_brew = last_brew_page let recent_brews: Vec<BrewView> = recent_brews_page
.items .items
.into_iter() .into_iter()
.next() .map(BrewView::from_domain)
.map(BrewView::from_domain); .collect();
let open_bags = open_bags_page let open_bags = open_bags_page
.items .items
@ -111,7 +111,7 @@ async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
.collect(); .collect();
Ok(HomeContent { Ok(HomeContent {
last_brew, recent_brews,
open_bags, open_bags,
recent_events, recent_events,
}) })

View file

@ -212,7 +212,7 @@ pub struct HomeTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub last_brew: Option<BrewView>, pub recent_brews: Vec<BrewView>,
pub open_bags: Vec<BagView>, pub open_bags: Vec<BagView>,
pub recent_events: Vec<TimelineEventView>, pub recent_events: Vec<TimelineEventView>,
pub stats: StatsView, pub stats: StatsView,

View file

@ -89,7 +89,7 @@
</script> </script>
</head> </head>
<body class="min-h-screen bg-amber-50 text-stone-900"> <body class="min-h-screen bg-amber-50 text-stone-900">
<main class="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 py-10"> <main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-10">
{% include "nav.html" %} {% block content %}{% endblock %} {% include "nav.html" %} {% block content %}{% endblock %}
<footer class="mt-8 text-xs text-stone-500"> <footer class="mt-8 text-xs text-stone-500">
<p>Built with Axum, Tailwind CSS, and Datastar.</p> <p>Built with Axum, Tailwind CSS, and Datastar.</p>

View file

@ -28,35 +28,14 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Quick Actions --> <!-- Scan Bag -->
{% if is_authenticated %} {% if is_authenticated %}
<section class="grid grid-cols-1 sm:grid-cols-2 gap-4" <section
data-signals:_show-scan="false"
data-signals:_extracting="false" data-signals:_extracting="false"
data-signals:_extract-error="''" data-signals:_extract-error="''"
data-signals:_scan-extracted="false" data-signals:_scan-extracted="false"
data-signals:_scan-submitting="false" data-signals:_scan-submitting="false"
data-signals:_scan-error="''" data-signals:_scan-error="''"
>
<button
type="button"
data-on:click="$_showScan = !$_showScan; !$_showScan && ($_scanExtracted = false, $_extracting = false, $_extractError = '', $_scanError = '')"
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-500 px-6 py-5 text-lg font-semibold text-amber-700 shadow-sm transition hover:bg-amber-50"
>
{% call icons::camera("h-6 w-6") %}
Scan Bag
</button>
<a
href="/check-in"
class="flex items-center justify-center gap-3 rounded-lg border-2 border-amber-500 px-6 py-5 text-lg font-semibold text-amber-700 shadow-sm transition hover:bg-amber-50"
>
{% call icons::pen("h-6 w-6") %}
Check In
</a>
</section>
<!-- Inline Scan Section (hidden by default) -->
<section data-show="$_showScan" style="display: none"
data-signals:_roaster-name="''" data-signals:_roaster-name="''"
data-signals:_roaster-country="''" data-signals:_roaster-country="''"
data-signals:_roaster-city="''" data-signals:_roaster-city="''"
@ -68,6 +47,8 @@
data-signals:_process="''" data-signals:_process="''"
data-signals:_tasting-notes="''" data-signals:_tasting-notes="''"
> >
<h2 class="text-lg font-semibold text-amber-700 mb-3">Scan Bag</h2>
<!-- Hidden file input — minimal JS for FileReader API --> <!-- Hidden file input — minimal JS for FileReader API -->
<input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden" <input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('scan-image').value=r.result;document.getElementById('scan-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" /> onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('scan-image').value=r.result;document.getElementById('scan-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
@ -115,12 +96,12 @@
<!-- Form: pre-filled roaster + roast (shown after extraction) --> <!-- Form: pre-filled roaster + roast (shown after extraction) -->
<div data-show="$_scanExtracted" style="display: none"> <div data-show="$_scanExtracted" style="display: none">
<form <form
class="mt-4 flex flex-col gap-4" class="flex flex-col gap-4"
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})" data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; window.location.href = '/roasts' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }" data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; window.location.href = '/roasts' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
> >
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2> <h3 class="text-base font-semibold text-amber-700">Roaster</h3>
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p> <p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -142,7 +123,7 @@
</div> </div>
</div> </div>
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<h2 class="text-lg font-semibold text-amber-700">Roast</h2> <h3 class="text-base font-semibold text-amber-700">Roast</h3>
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p> <p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -196,54 +177,53 @@
</section> </section>
{% endif %} {% endif %}
<!-- Last Brew --> <!-- Recent Brews -->
{% if let Some(brew) = last_brew %} {% if !recent_brews.is_empty() %}
<section> <section>
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Last Brew</h2> <h2 class="text-lg font-semibold text-amber-700">Recent Brews</h2>
<span class="text-xs text-stone-500">{{ brew.created_at }}</span> <a href="/brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews &rarr;</a>
</div> </div>
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
<div class="flex flex-wrap items-baseline gap-2"> {% for brew in recent_brews %}
<a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="font-semibold text-amber-800 hover:text-amber-600">{{ brew.roast_name }}</a> <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm">
<span class="text-sm text-stone-500">{{ brew.roaster_name }}</span> <div>
</div> <a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="font-semibold text-amber-800 hover:text-amber-600">{{ brew.roast_name }}</a>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-stone-600"> <p class="text-sm text-stone-500">{{ brew.roaster_name }}</p>
<span>{{ brew.coffee_weight }}</span> </div>
<span>{{ brew.water_volume }} @ {{ brew.water_temp }}</span> <div class="mt-2 space-y-0.5 text-sm">
<span>{{ brew.ratio }}</span> <p><span class="font-medium text-stone-800">{{ brew.coffee_weight }} / {{ brew.water_volume }}</span> <span class="text-stone-500">· {{ brew.ratio }} · {{ brew.water_temp }}</span></p>
<span>{{ brew.grinder_name }} @ {{ brew.grind_setting }}</span> <p><span class="font-medium text-stone-800">{{ brew.grind_setting }}</span> <span class="text-stone-500">· {{ brew.grinder_name }}</span></p>
<span>{{ brew.brewer_name }}</span> <p><span class="font-medium text-stone-800">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-stone-500">· {{ fp }}</span>{% endif %}</p>
{% if let Some(fp) = brew.filter_paper_name %} </div>
<span>{{ fp }}</span> {% if is_authenticated %}
<div class="mt-3 pt-3 border-t border-amber-100 flex gap-3">
<form
class="inline"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
>
<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 }}" />
<button
type="submit"
class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500"
>
{% call icons::plus_circle("h-4 w-4") %}
Brew Again
</button>
</form>
</div>
{% endif %} {% endif %}
</div> </div>
{% if is_authenticated %} {% endfor %}
<div class="mt-3 pt-3 border-t border-amber-200">
<form
class="inline"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
>
<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 }}" />
<button
type="submit"
class="inline-flex items-center gap-2 rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
{% call icons::refresh("h-4 w-4") %}
Brew Again
</button>
</form>
</div>
{% endif %}
</div> </div>
</section> </section>
{% endif %} {% endif %}
@ -274,7 +254,7 @@
{% for event in recent_events %} {% for event in recent_events %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3"> <div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="inline-flex w-24 shrink-0 items-center justify-center rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span> <span class="inline-flex w-28 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span>
<a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a> <a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a>
</div> </div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.date_label }}</time> <time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.date_label }}</time>