feat: add bags web views and templates

This commit is contained in:
Jon Seager 2025-11-27 14:02:55 +00:00
parent f0cef61513
commit d708a0e112
No known key found for this signature in database
14 changed files with 693 additions and 343 deletions

View file

@ -16,6 +16,84 @@ use crate::domain::ids::{BagId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::presentation::web::templates::{BagListTemplate, BagsTemplate};
use crate::presentation::web::views::{BagView, ListNavigator, Paginated, RoasterOptionView};
const BAG_PAGE_PATH: &str = "/bags";
const BAG_FRAGMENT_PATH: &str = "/bags#bag-list";
#[tracing::instrument(skip(state))]
async fn load_bag_page(
state: &AppState,
request: ListRequest<BagSortKey>,
) -> Result<(Vec<BagView>, Paginated<BagView>, ListNavigator<BagSortKey>), AppError> {
let open_bags = state.bag_repo.list_open().await.map_err(AppError::from)?;
let open_bags_view = open_bags
.into_iter()
.map(BagView::from_with_roast)
.collect();
let page = state
.bag_repo
.list_closed(&request)
.await
.map_err(AppError::from)?;
let (bags, navigator) = crate::application::routes::support::build_page_view(
page,
request,
BagView::from_with_roast,
BAG_PAGE_PATH,
BAG_FRAGMENT_PATH,
);
Ok((open_bags_view, bags, navigator))
}
#[tracing::instrument(skip(state, cookies, headers, query))]
pub(crate) async fn bags_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
headers: HeaderMap,
Query(query): Query<ListQuery>,
) -> Result<Response, StatusCode> {
let request = query.into_request::<BagSortKey>();
if is_datastar_request(&headers) {
let is_authenticated =
crate::application::routes::auth::is_authenticated(&state, &cookies).await;
return render_bag_list_fragment(state, request, is_authenticated)
.await
.map_err(map_app_error);
}
let roasters = state
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
let (open_bags, bags, navigator) = load_bag_page(&state, request)
.await
.map_err(map_app_error)?;
let is_authenticated =
crate::application::routes::auth::is_authenticated(&state, &cookies).await;
let template = BagsTemplate {
nav_active: "bags",
is_authenticated,
open_bags,
bags,
roaster_options,
navigator,
};
render_html(template).map(IntoResponse::into_response)
}
#[tracing::instrument(skip(state, _auth_user, headers, query))]
pub(crate) async fn create_bag(
State(state): State<AppState>,
@ -229,3 +307,59 @@ pub(crate) async fn finish_bag(
Ok(Redirect::to(BAG_PAGE_PATH).into_response())
}
}
#[derive(Debug, Deserialize)]
pub struct BagsQuery {
pub roast_id: Option<RoastId>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct NewBagSubmission {
roast_id: RoastId,
roast_date: Option<String>,
amount: f64,
}
impl NewBagSubmission {
fn into_new_bag(self) -> Result<NewBag, AppError> {
let roast_id = self.roast_id;
if roast_id.into_inner() <= 0 {
return Err(AppError::validation("invalid roast id"));
}
let roast_date = match self.roast_date {
Some(date_str) if !date_str.is_empty() => Some(
chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
.map_err(|_| AppError::validation("invalid roast date format"))?,
),
_ => None,
};
if self.amount <= 0.0 {
return Err(AppError::validation("amount must be positive"));
}
Ok(NewBag {
roast_id,
roast_date,
amount: self.amount,
})
}
}
async fn render_bag_list_fragment(
state: AppState,
request: ListRequest<BagSortKey>,
is_authenticated: bool,
) -> Result<Response, AppError> {
let (open_bags, bags, navigator) = load_bag_page(&state, request).await?;
let template = BagListTemplate {
is_authenticated,
open_bags,
bags,
navigator,
};
crate::application::routes::support::render_fragment(template, "#bag-list")
}

View file

@ -63,6 +63,8 @@ pub fn app_router(state: AppState) -> axum::Router {
"/roasters/:roaster_slug/roasts/:roast_slug",
get(roasts::roast_page),
)
.route("/bags", get(bags::bags_page))
.route("/bags/:id/finish", post(bags::finish_bag))
.route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles))
.route("/favicon.ico", get(favicon))

View file

@ -14,8 +14,10 @@ use crate::application::server::AppState;
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster};
use crate::presentation::web::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate};
use crate::domain::roasts::{NewRoast, Roast, RoastSortKey};
use crate::presentation::web::templates::{
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
};
use crate::presentation::web::views::{ListNavigator, Paginated, RoastView, RoasterOptionView};
const ROAST_PAGE_PATH: &str = "/roasts";
@ -102,6 +104,17 @@ pub(crate) async fn roast_page(
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let bags = state
.bag_repo
.list_by_roast(roast.id)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let bag_views = bags
.into_iter()
.map(crate::presentation::web::views::BagView::from_with_roast)
.collect();
let is_authenticated =
crate::application::routes::auth::is_authenticated(&state, &cookies).await;
@ -109,6 +122,7 @@ pub(crate) async fn roast_page(
nav_active: "roasts",
is_authenticated,
roast: RoastView::from_domain(roast, &roaster.name, &roaster.slug),
bags: bag_views,
};
render_html(template)
@ -150,20 +164,45 @@ pub(crate) async fn create_roast(
}
}
#[tracing::instrument(skip(state))]
#[tracing::instrument(skip(state, headers))]
pub(crate) async fn list_roasts(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<RoastsQuery>,
) -> Result<Json<Vec<RoastWithRoaster>>, ApiError> {
let roasts = match params.roaster_id {
) -> Result<Response, ApiError> {
let roaster_id = match params.roaster_id.as_deref() {
Some(s) if !s.is_empty() => {
let s = s.trim();
Some(s.parse::<RoasterId>().map_err(|_| {
tracing::warn!("Invalid roaster_id: '{}'", s);
ApiError::from(AppError::validation(format!("Invalid roaster_id: '{}'", s)))
})?)
}
_ => None,
};
let roasts = match roaster_id {
Some(roaster_id) => state
.roast_repo
.list_by_roaster(roaster_id)
.await
.map_err(AppError::from)?,
None => state.roast_repo.list_all().await.map_err(AppError::from)?,
None => {
if is_datastar_request(&headers) {
vec![]
} else {
state.roast_repo.list_all().await.map_err(AppError::from)?
}
}
};
Ok(Json(roasts))
if is_datastar_request(&headers) {
let template = RoastOptionsTemplate { roasts };
crate::application::routes::support::render_fragment(template, "#roast-select-options")
.map_err(ApiError::from)
} else {
Ok(Json(roasts).into_response())
}
}
#[tracing::instrument(skip(state))]
@ -197,7 +236,7 @@ pub(crate) async fn delete_roast(
#[derive(Debug, Deserialize)]
pub struct RoastsQuery {
pub roaster_id: Option<RoasterId>,
pub roaster_id: Option<String>,
}
#[derive(Debug, Deserialize)]

View file

@ -1,11 +1,12 @@
use askama::Template;
use super::views::{
ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView,
TimelineMonthView,
BagView, ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView,
TimelineEventView, TimelineMonthView,
};
use crate::domain::bags::BagSortKey;
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::RoastSortKey;
use crate::domain::roasts::{RoastSortKey, RoastWithRoaster};
use crate::domain::timeline::TimelineSortKey;
#[derive(Template)]
@ -50,6 +51,7 @@ pub struct RoastDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub roast: RoastView,
pub bags: Vec<BagView>,
}
#[derive(Template)]
@ -78,6 +80,32 @@ pub struct TimelineChunkTemplate {
pub months: Vec<TimelineMonthView>,
}
#[derive(Template)]
#[template(path = "bags.html")]
pub struct BagsTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub open_bags: Vec<BagView>,
pub bags: Paginated<BagView>,
pub roaster_options: Vec<RoasterOptionView>,
pub navigator: ListNavigator<BagSortKey>,
}
#[derive(Template)]
#[template(path = "partials/bag_list.html")]
pub struct BagListTemplate {
pub is_authenticated: bool,
pub open_bags: Vec<BagView>,
pub bags: Paginated<BagView>,
pub navigator: ListNavigator<BagSortKey>,
}
#[derive(Template)]
#[template(path = "partials/roast_options.html")]
pub struct RoastOptionsTemplate {
pub roasts: Vec<RoastWithRoaster>,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
template.render()
}

View file

@ -1,3 +1,4 @@
use crate::domain::bags::{Bag, BagWithRoast};
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
use crate::domain::roasters::Roaster;
use crate::domain::roasts::{Roast, RoastWithRoaster};
@ -417,10 +418,6 @@ pub struct TimelineEventDetailView {
pub struct TimelineEventView {
pub id: String,
pub kind_label: &'static str,
pub badge_class: &'static str,
pub accent_class: &'static str,
pub card_border_class: &'static str,
pub title_class: &'static str,
pub date_label: String,
pub time_label: Option<String>,
pub iso_timestamp: String,
@ -454,6 +451,13 @@ impl TimelineEventView {
let kind_label = match entity_type.as_str() {
"roaster" => "Roaster Added",
"roast" => "Roast Added",
"bag" => {
if title.starts_with("Finished") {
"Bag Finished"
} else {
"Bag Added"
}
}
_ => "Event",
};
@ -462,6 +466,9 @@ impl TimelineEventView {
("roast", Some(slug), Some(roaster_slug)) => {
format!("/roasters/{roaster_slug}/roasts/{slug}")
}
("bag", Some(slug), Some(roaster_slug)) => {
format!("/roasters/{roaster_slug}/roasts/{slug}")
}
("roaster", None, _) => format!("/roasters/{entity_id}"),
("roast", None, _) => format!("/roasts/{entity_id}"),
_ => String::from("#"),
@ -501,10 +508,6 @@ impl TimelineEventView {
Self {
id: id.to_string(),
kind_label,
badge_class: "bg-amber-200 text-amber-800",
accent_class: "bg-amber-600",
card_border_class: "border-amber-200 bg-amber-50/80",
title_class: "text-amber-800",
date_label: occurred_at.format("%B %d, %Y").to_string(),
time_label: Some(occurred_at.format("%H:%M UTC").to_string()),
iso_timestamp: occurred_at.to_rfc3339(),
@ -516,3 +519,68 @@ impl TimelineEventView {
}
}
}
#[derive(Debug, Clone)]
pub struct BagView {
pub id: String,
pub roast_id: String,
pub roast_date: Option<String>,
pub amount: String,
pub remaining: String,
pub closed: bool,
pub finished_at: String,
pub created_at: String,
pub roast_name: String,
pub roaster_name: String,
pub roast_slug: String,
pub roaster_slug: String,
}
impl BagView {
pub fn from_domain(
bag: Bag,
roast_name: &str,
roaster_name: &str,
roast_slug: &str,
roaster_slug: &str,
) -> Self {
Self {
id: bag.id.to_string(),
roast_id: bag.roast_id.to_string(),
roast_date: bag.roast_date.map(|d| d.to_string()),
amount: format!("{:.1}", bag.amount),
remaining: format!("{:.1}", bag.remaining),
closed: bag.closed,
finished_at: bag
.finished_at
.map(|d| d.to_string())
.unwrap_or_else(|| "".to_string()),
created_at: bag.created_at.format("%Y-%m-%d").to_string(),
roast_name: roast_name.to_string(),
roaster_name: roaster_name.to_string(),
roast_slug: roast_slug.to_string(),
roaster_slug: roaster_slug.to_string(),
}
}
pub fn from_with_roast(bag: BagWithRoast) -> Self {
Self {
id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(),
roast_date: bag.bag.roast_date.map(|d| d.to_string()),
amount: format!("{:.1}", bag.bag.amount),
remaining: format!("{:.1}", bag.bag.remaining),
closed: bag.bag.closed,
finished_at: bag
.bag
.finished_at
.map(|d| d.to_string())
.unwrap_or_else(|| "".to_string()),
created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(),
roast_name: bag.roast_name,
roaster_name: bag.roaster_name,
roast_slug: bag.roast_slug,
roaster_slug: bag.roaster_slug,
}
}
}

114
templates/bags.html Normal file
View file

@ -0,0 +1,114 @@
{% extends "base.html" %} {% block title %}Brewlog · Bags{% 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">Bags</h1>
<p class="max-w-2xl text-sm text-stone-600">Track your coffee bags.</p>
</div>
{% if is_authenticated && !roaster_options.is_empty() %}
<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 bag"
>
<span aria-hidden="true">+</span>
</button>
{% endif %}
</header>
{% if is_authenticated %} {% if roaster_options.is_empty() %}
<div
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
>
<h2 class="text-lg font-semibold text-amber-700">Add a roaster first</h2>
<p class="mt-2">
Bags need a roaster and a roast.
<a class="text-amber-700 hover:text-amber-600 underline" href="/roasters"
>Create a roaster</a
>
to enable this form.
</p>
</div>
{% else %}
<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 Bag</h2>
<p class="mt-1 text-sm text-stone-600">
Select a roaster, then a roast, and enter the details.
</p>
</div>
<form
method="post"
action="/api/v1/bags"
class="mt-4 flex flex-col gap-4"
data-on:submit="$isSubmitting = true; @post('/api/v1/bags?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#bag-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">Roaster *</span>
<select
name="roaster_id"
required
class="input-field"
data-on:change="@get('/api/v1/roasts?roaster_id=' + evt.target.value, {responseOverrides: {selector: '#roast-select-options', mode: 'replace'}})"
>
<option value="">Select a roaster</option>
{% for roaster in roaster_options %}
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
{% endfor %}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast *</span>
<select name="roast_id" required class="input-field" id="roast-select">
<option value="">Select a roast</option>
<optgroup id="roast-select-options"></optgroup>
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast Date</span>
<input type="date" name="roast_date" class="input-field" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Amount (g) *</span>
<input
type="number"
name="amount"
step="0.1"
required
class="input-field"
placeholder="250"
/>
</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 Bag
</button>
</div>
</form>
</div>
{% endif %} {% endif %}
<div class="mt-8">{% include "partials/bag_list.html" %}</div>
</section>
{% endblock %}

View file

@ -1,8 +1,9 @@
<nav class="flex items-center justify-between gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-3 text-sm text-stone-600">
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/timeline">Brewlog</a></div>
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/timeline">B{rew}log</a></div>
<div class="flex items-center gap-3">
<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 == "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">

View file

@ -0,0 +1,127 @@
{% import "partials/table.html" as table %}
<div id="bag-list" class="space-y-8" data-star-scope="bags">
{% if !open_bags.is_empty() %}
<section>
<h2 class="text-2xl font-bold mb-4 text-stone-800">Open Bags</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for bag in open_bags %}
<div class="bg-white rounded-lg shadow-md overflow-hidden border border-stone-200">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-xl font-semibold text-stone-900">
<a
href="/roasters/{{ bag.roaster_slug }}"
class="hover:text-amber-700 hover:underline"
>
{{ bag.roaster_name }}
</a>
</h3>
<p class="text-stone-600">
<a
href="/roasters/{{ bag.roaster_slug }}/roasts/{{ bag.roast_slug }}"
class="hover:text-amber-700 hover:underline"
>
{{ bag.roast_name }}
</a>
</p>
</div>
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Open
</span>
</div>
<div class="space-y-2 text-sm text-stone-500 mb-4">
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"
/>
</svg>
<span>{{ bag.remaining }} / {{ bag.amount }}g</span>
</div>
<div class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{% if let Some(roast_date) = bag.roast_date %}
<span>Roasted {{ roast_date }}</span>
{% else %}
<span>Added {{ bag.created_at }}</span>
{% endif %}
</div>
</div>
{% if is_authenticated %}
<div class="pt-4 border-t border-stone-100 flex justify-end">
<button
class="text-sm text-stone-600 hover:text-stone-900 font-medium"
data-on:click="@post('/bags/{{ bag.id }}/finish?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})"
>
Finish Bag
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<section>
<h2 class="text-2xl font-bold mb-4 text-stone-800">History</h2>
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
{% call table::pagination_header(bags, navigator, "#bag-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("Roaster", "roaster", navigator, "#bag-list") %} {%
call table::sortable_header("Roast", "roast", navigator, "#bag-list") %}
<th scope="col" class="px-4 py-3">Weight</th>
{% call table::sortable_header("Added", "created-at", navigator, "#bag-list") %} {%
call table::sortable_header("Finished", "finished-at", navigator, "#bag-list") %}
</tr>
</thead>
<tbody class="divide-y divide-amber-200/70">
{% for bag in bags.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50">
<td class="px-4 py-3 whitespace-nowrap font-medium text-stone-600">
{{ bag.roaster_name }}
</td>
<td class="px-4 py-3 whitespace-nowrap">{{ bag.roast_name }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td>
<td class="px-4 py-3 whitespace-nowrap">{{ bag.created_at }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
</div>

View file

@ -1,3 +1,5 @@
{% import "partials/table.html" as table %}
<div id="roast-list" class="mt-6" data-star-scope="roasts">
{% if roasts.items.is_empty() %}
<div
@ -7,181 +9,16 @@
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
<div
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
>
<p>
Showing {{ roasts.start_index() }}&ndash;{{ roasts.end_index() }} of {{ roasts.total }}
roasts
</p>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasts.has_previous() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(roasts.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(roasts.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span aria-hidden="true"></span>
<span>Previous</span>
</button>
{% if roasts.is_showing_all() %}
<span class="font-semibold text-amber-800">Showing all results</span>
{% else %}
<span class="font-semibold text-amber-800"
>Page {{ roasts.page }} of {{ roasts.total_pages() }}</span
>
{% endif %}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasts.has_next() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(roasts.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span>Next</span>
<span aria-hidden="true"></span>
</button>
</div>
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
<span>Rows</span>
<select
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if roasts.is_page_size(5) %}selected{% endif %}>5</option>
<option value="10" data-href="{{ navigator.fragment_rows_href("10")|safe }}" data-url="{{ navigator.rows_href("10")|safe }}" {% if roasts.is_page_size(10) %}selected{% endif %}>10</option>
<option value="20" data-href="{{ navigator.fragment_rows_href("20")|safe }}" data-url="{{ navigator.rows_href("20")|safe }}" {% if roasts.is_page_size(20) %}selected{% endif %}>20</option>
<option value="50" data-href="{{ navigator.fragment_rows_href("50")|safe }}" data-url="{{ navigator.rows_href("50")|safe }}" {% if roasts.is_page_size(50) %}selected{% endif %}>50</option>
<option value="all" data-href="{{ navigator.fragment_rows_href("all")|safe }}" data-url="{{ navigator.rows_href("all")|safe }}" {% if roasts.is_showing_all() %}selected{% endif %}>All</option>
</select>
</label>
</div>
</div>
{% call table::pagination_header(roasts, navigator, "#roast-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>
{% set created_sorted = navigator.is_sorted_by("created-at") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if created_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("created-at")|safe }}'); @get('{{ navigator.fragment_sort_href("created-at")|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Added</span>
{% if created_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set name_sorted = navigator.is_sorted_by("name") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if name_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("name")|safe }}'); @get('{{ navigator.fragment_sort_href("name")|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Roast</span>
{% if name_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set roaster_sorted = navigator.is_sorted_by("roaster") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if roaster_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("roaster")|safe }}'); @get('{{ navigator.fragment_sort_href("roaster")|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Roaster</span>
{% if roaster_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set origin_sorted = navigator.is_sorted_by("origin") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if origin_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("origin")|safe }}'); @get('{{ navigator.fragment_sort_href("origin")|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Origin</span>
{% if origin_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set producer_sorted = navigator.is_sorted_by("producer") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if producer_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("producer")|safe }}'); @get('{{ navigator.fragment_sort_href("producer")|safe }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span>Producer</span>
{% if producer_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% call table::sortable_header("Added", "created-at", navigator, "#roast-list") %} {%
call table::sortable_header("Roast", "name", navigator, "#roast-list") %} {% call
table::sortable_header("Roaster", "roaster", navigator, "#roast-list") %} {% call
table::sortable_header("Origin", "origin", navigator, "#roast-list") %} {% call
table::sortable_header("Producer", "producer", navigator, "#roast-list") %}
<th scope="col" class="px-4 py-3">Notes</th>
{% if is_authenticated %}
<th scope="col" class="px-4 py-3 text-right">Actions</th>

View file

@ -0,0 +1,5 @@
<optgroup id="roast-select-options">
{% for roast in roasts %}
<option value="{{ roast.roast.id }}">{{ roast.roast.name }}</option>
{% endfor %}
</optgroup>

View file

@ -1,3 +1,5 @@
{% import "partials/table.html" as table %}
<div id="roaster-list" class="mt-6" data-star-scope="roasters">
{% if roasters.items.is_empty() %}
<div
@ -9,158 +11,15 @@
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
<div
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
>
<p>
Showing {{ roasters.start_index() }}&ndash;{{ roasters.end_index() }} of {{ roasters.total
}} roasters
</p>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasters.has_previous() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(roasters.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(roasters.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span aria-hidden="true"></span>
<span>Previous</span>
</button>
{% if roasters.is_showing_all() %}
<span class="font-semibold text-amber-800">Showing all results</span>
{% else %}
<span class="font-semibold text-amber-800"
>Page {{ roasters.page }} of {{ roasters.total_pages() }}</span
>
{% endif %}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if roasters.has_next() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(roasters.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span>Next</span>
<span aria-hidden="true"></span>
</button>
</div>
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
<span>Rows</span>
<select
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if roasters.is_page_size(5) %}selected{% endif %}>5</option>
<option value="10" data-href="{{ navigator.fragment_rows_href("10")|safe }}" data-url="{{ navigator.rows_href("10")|safe }}" {% if roasters.is_page_size(10) %}selected{% endif %}>10</option>
<option value="20" data-href="{{ navigator.fragment_rows_href("20")|safe }}" data-url="{{ navigator.rows_href("20")|safe }}" {% if roasters.is_page_size(20) %}selected{% endif %}>20</option>
<option value="50" data-href="{{ navigator.fragment_rows_href("50")|safe }}" data-url="{{ navigator.rows_href("50")|safe }}" {% if roasters.is_page_size(50) %}selected{% endif %}>50</option>
<option value="all" data-href="{{ navigator.fragment_rows_href("all")|safe }}" data-url="{{ navigator.rows_href("all")|safe }}" {% if roasters.is_showing_all() %}selected{% endif %}>All</option>
</select>
</label>
</div>
</div>
{% call table::pagination_header(roasters, navigator, "#roaster-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>
{% set created_sorted = navigator.is_sorted_by("created-at") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if created_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("created-at")|safe }}'); @get('{{ navigator.fragment_sort_href("created-at")|safe }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>Added</span>
{% if created_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set name_sorted = navigator.is_sorted_by("name") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if name_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("name")|safe }}'); @get('{{ navigator.fragment_sort_href("name")|safe }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>Name</span>
{% if name_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set country_sorted = navigator.is_sorted_by("country") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if country_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("country")|safe }}'); @get('{{ navigator.fragment_sort_href("country")|safe }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>Country</span>
{% if country_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% set city_sorted = navigator.is_sorted_by("city") %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if city_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href("city")|safe }}'); @get('{{ navigator.fragment_sort_href("city")|safe }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span>City</span>
{% if city_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% call table::sortable_header("Added", "created-at", navigator, "#roaster-list") %} {%
call table::sortable_header("Name", "name", navigator, "#roaster-list") %} {% call
table::sortable_header("Country", "country", navigator, "#roaster-list") %} {% call
table::sortable_header("City", "city", navigator, "#roaster-list") %}
<th scope="col" class="px-4 py-3">Homepage</th>
<th scope="col" class="px-4 py-3">Notes</th>
{% if is_authenticated %}

View file

@ -0,0 +1,84 @@
{% macro pagination_header(items, navigator, target_selector) %}
<div
class="flex flex-col gap-3 border-b border-amber-200/70 px-4 py-3 text-xs text-stone-600 sm:flex-row sm:items-center sm:justify-between"
>
<p>
Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of {{ items.total }}
results
</p>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if items.has_previous() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span aria-hidden="true"></span>
<span>Previous</span>
</button>
{% if items.is_showing_all() %}
<span class="font-semibold text-amber-800">Showing all results</span>
{% else %}
<span class="font-semibold text-amber-800"
>Page {{ items.page }} of {{ items.total_pages() }}</span
>
{% endif %}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300"
{% if items.has_next() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
{% else %}
disabled
{% endif %}
>
<span>Next</span>
<span aria-hidden="true"></span>
</button>
</div>
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
<span>Rows</span>
<select
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400"
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
>
<option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if items.is_page_size(5) %}selected{% endif %}>5</option>
<option value="10" data-href="{{ navigator.fragment_rows_href("10")|safe }}" data-url="{{ navigator.rows_href("10")|safe }}" {% if items.is_page_size(10) %}selected{% endif %}>10</option>
<option value="20" data-href="{{ navigator.fragment_rows_href("20")|safe }}" data-url="{{ navigator.rows_href("20")|safe }}" {% if items.is_page_size(20) %}selected{% endif %}>20</option>
<option value="50" data-href="{{ navigator.fragment_rows_href("50")|safe }}" data-url="{{ navigator.rows_href("50")|safe }}" {% if items.is_page_size(50) %}selected{% endif %}>50</option>
<option value="all" data-href="{{ navigator.fragment_rows_href("all")|safe }}" data-url="{{ navigator.rows_href("all")|safe }}" {% if items.is_showing_all() %}selected{% endif %}>All</option>
</select>
</label>
</div>
</div>
{% endmacro %}
{% macro sortable_header(label, key, navigator, target_selector) %}
{% set is_sorted = navigator.is_sorted_by(key) %}
<th
scope="col"
class="px-4 py-3"
aria-sort="{% if is_sorted %}{% if navigator.sort_direction() == "asc" %}ascending{% else %}descending{% endif %}{% else %}none{% endif %}"
>
<button
type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href(key)|safe }}'); @get('{{ navigator.fragment_sort_href(key)|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
>
<span>{{ label }}</span>
{% if is_sorted %}
{% if navigator.sort_direction() == "asc" %}
<span aria-hidden="true"></span>
{% else %}
<span aria-hidden="true"></span>
{% endif %}
{% else %}
<span aria-hidden="true"></span>
{% endif %}
</button>
</th>
{% endmacro %}

View file

@ -4,12 +4,12 @@
{% for event in month.events %}
<li class="relative pl-6 sm:pl-10" data-timeline-event>
<span
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 {{ event.accent_class }} sm:left-[-14px]"
class="absolute left-[-10px] top-2 h-5 w-5 rounded-full border-[6px] border-amber-50 bg-amber-600 sm:left-[-14px]"
></span>
<div class="rounded-lg border {{ event.card_border_class }} p-5 shadow-sm">
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-5 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {{ event.badge_class }}"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
>{{ event.kind_label }}</span
>
<time
@ -20,7 +20,7 @@
%}
</time>
</div>
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold {{ event.title_class }}">
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-amber-800">
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
{% if let Some(url) = event.external_link %}
<a

View file

@ -47,4 +47,56 @@ block content %}
</ul>
{% endif %}
</section>
<section class="mt-8">
<h2 class="mb-4 text-xl font-semibold text-amber-800">Bags</h2>
{% if bags.is_empty() %}
<p class="text-sm text-stone-600">No bags recorded for this roast.</p>
{% else %}
<div class="overflow-hidden rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm">
<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>
<th scope="col" class="px-4 py-3">Created</th>
<th scope="col" class="px-4 py-3">Amount</th>
<th scope="col" class="px-4 py-3">Remaining</th>
<th scope="col" class="px-4 py-3">Roast Date</th>
<th scope="col" class="px-4 py-3">Status</th>
<th scope="col" class="px-4 py-3">Finished</th>
</tr>
</thead>
<tbody class="divide-y divide-amber-200/70">
{% for bag in bags %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50">
<td class="px-4 py-3 whitespace-nowrap text-xs font-medium text-stone-600">
{{ bag.created_at }}
</td>
<td class="px-4 py-3">{{ bag.amount }}g</td>
<td class="px-4 py-3">{{ bag.remaining }}g</td>
<td class="px-4 py-3">
{% if let Some(date) = bag.roast_date %}{{ date }}{% else %}—{% endif %}
</td>
<td class="px-4 py-3">
{% if bag.closed %}
<span
class="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-800"
>
Closed
</span>
{% else %}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
>
Open
</span>
{% endif %}
</td>
<td class="px-4 py-3">{{ bag.finished_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
{% endblock %}