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:
parent
62740c4c68
commit
ebd2698b42
4 changed files with 62 additions and 82 deletions
|
|
@ -31,7 +31,7 @@ pub(crate) async fn home_page(
|
|||
let template = HomeTemplate {
|
||||
nav_active: "home",
|
||||
is_authenticated,
|
||||
last_brew: content.last_brew,
|
||||
recent_brews: content.recent_brews,
|
||||
open_bags: content.open_bags,
|
||||
recent_events: content.recent_events,
|
||||
stats,
|
||||
|
|
@ -41,7 +41,7 @@ pub(crate) async fn home_page(
|
|||
}
|
||||
|
||||
struct HomeContent {
|
||||
last_brew: Option<BrewView>,
|
||||
recent_brews: Vec<BrewView>,
|
||||
open_bags: Vec<BagView>,
|
||||
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> {
|
||||
let last_brew_req = ListRequest::new(
|
||||
let recent_brews_req = ListRequest::new(
|
||||
1,
|
||||
PageSize::limited(1),
|
||||
PageSize::limited(3),
|
||||
BrewSortKey::CreatedAt,
|
||||
SortDirection::Desc,
|
||||
);
|
||||
|
|
@ -68,11 +68,11 @@ async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
|
|||
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 {
|
||||
state
|
||||
.brew_repo
|
||||
.list(BrewFilter::all(), &last_brew_req, None)
|
||||
.list(BrewFilter::all(), &recent_brews_req, None)
|
||||
.await
|
||||
.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
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(BrewView::from_domain);
|
||||
.map(BrewView::from_domain)
|
||||
.collect();
|
||||
|
||||
let open_bags = open_bags_page
|
||||
.items
|
||||
|
|
@ -111,7 +111,7 @@ async fn load_home_content(state: &AppState) -> Result<HomeContent, AppError> {
|
|||
.collect();
|
||||
|
||||
Ok(HomeContent {
|
||||
last_brew,
|
||||
recent_brews,
|
||||
open_bags,
|
||||
recent_events,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ pub struct HomeTemplate {
|
|||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
|
||||
pub last_brew: Option<BrewView>,
|
||||
pub recent_brews: Vec<BrewView>,
|
||||
pub open_bags: Vec<BagView>,
|
||||
pub recent_events: Vec<TimelineEventView>,
|
||||
pub stats: StatsView,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
</script>
|
||||
</head>
|
||||
<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 %}
|
||||
<footer class="mt-8 text-xs text-stone-500">
|
||||
<p>Built with Axum, Tailwind CSS, and Datastar.</p>
|
||||
|
|
|
|||
|
|
@ -28,35 +28,14 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Quick Actions -->
|
||||
<!-- Scan Bag -->
|
||||
{% if is_authenticated %}
|
||||
<section class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
data-signals:_show-scan="false"
|
||||
<section
|
||||
data-signals:_extracting="false"
|
||||
data-signals:_extract-error="''"
|
||||
data-signals:_scan-extracted="false"
|
||||
data-signals:_scan-submitting="false"
|
||||
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-country="''"
|
||||
data-signals:_roaster-city="''"
|
||||
|
|
@ -68,6 +47,8 @@
|
|||
data-signals:_process="''"
|
||||
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 -->
|
||||
<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=''}" />
|
||||
|
|
@ -115,12 +96,12 @@
|
|||
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
|
||||
<div data-show="$_scanExtracted" style="display: none">
|
||||
<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: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">
|
||||
<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>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
|
|
@ -142,7 +123,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
|
|
@ -196,30 +177,27 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last Brew -->
|
||||
{% if let Some(brew) = last_brew %}
|
||||
<!-- Recent Brews -->
|
||||
{% if !recent_brews.is_empty() %}
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-amber-700">Last Brew</h2>
|
||||
<span class="text-xs text-stone-500">{{ brew.created_at }}</span>
|
||||
<h2 class="text-lg font-semibold text-amber-700">Recent Brews</h2>
|
||||
<a href="/brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews →</a>
|
||||
</div>
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||
<div class="flex flex-wrap items-baseline gap-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
|
||||
{% for brew in recent_brews %}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm">
|
||||
<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>
|
||||
<span class="text-sm text-stone-500">{{ brew.roaster_name }}</span>
|
||||
<p class="text-sm text-stone-500">{{ brew.roaster_name }}</p>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-stone-600">
|
||||
<span>{{ brew.coffee_weight }}</span>
|
||||
<span>{{ brew.water_volume }} @ {{ brew.water_temp }}</span>
|
||||
<span>{{ brew.ratio }}</span>
|
||||
<span>{{ brew.grinder_name }} @ {{ brew.grind_setting }}</span>
|
||||
<span>{{ brew.brewer_name }}</span>
|
||||
{% if let Some(fp) = brew.filter_paper_name %}
|
||||
<span>{{ fp }}</span>
|
||||
{% endif %}
|
||||
<div class="mt-2 space-y-0.5 text-sm">
|
||||
<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>
|
||||
<p><span class="font-medium text-stone-800">{{ brew.grind_setting }}</span> <span class="text-stone-500">· {{ brew.grinder_name }}</span></p>
|
||||
<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>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<div class="mt-3 pt-3 border-t border-amber-200">
|
||||
<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'})"
|
||||
|
|
@ -236,15 +214,17 @@
|
|||
<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"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500"
|
||||
>
|
||||
{% call icons::refresh("h-4 w-4") %}
|
||||
{% call icons::plus_circle("h-4 w-4") %}
|
||||
Brew Again
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -274,7 +254,7 @@
|
|||
{% 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="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>
|
||||
</div>
|
||||
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.date_label }}</time>
|
||||
|
|
|
|||
Loading…
Reference in a new issue