feat: add bags web views and templates
This commit is contained in:
parent
f0cef61513
commit
d708a0e112
14 changed files with 693 additions and 343 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
114
templates/bags.html
Normal 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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
127
templates/partials/bag_list.html
Normal file
127
templates/partials/bag_list.html
Normal 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>
|
||||
|
|
@ -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() }}–{{ 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>
|
||||
|
|
|
|||
5
templates/partials/roast_options.html
Normal file
5
templates/partials/roast_options.html
Normal 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>
|
||||
|
|
@ -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() }}–{{ 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 %}
|
||||
|
|
|
|||
84
templates/partials/table.html
Normal file
84
templates/partials/table.html
Normal 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() }}–{{ 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 %}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue