meal categories
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline failed
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
2026-08-03 21:29:33 -04:00
parent cf6853d71e
commit 3695fc68d6
11 changed files with 680 additions and 67 deletions
+175 -37
View File
@@ -3,7 +3,7 @@ use pulldown_cmark::{Options, Parser, html as cmark_html};
use crate::{
domain::PresenceUser,
domain::{Category, GroceryList, Item, Meal, MealIngredient, Passkey, User},
domain::{Category, GroceryList, Item, Meal, MealCategory, MealIngredient, Passkey, User},
};
pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
@@ -234,7 +234,12 @@ pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Marku
)
}
pub fn meals_page(user: &User, meals: &[Meal]) -> Markup {
pub fn meals_page(
user: &User,
meals: &[Meal],
meal_categories: &[MealCategory],
csrf_token: &str,
) -> Markup {
page(
"Meals",
Some(user),
@@ -247,6 +252,13 @@ pub fn meals_page(user: &User, meals: &[Meal]) -> Markup {
}
a class="button button-primary" href="/meals/new" { "New meal" }
}
@if meals.is_empty() {
div class="empty-state" {
div class="empty-mark" { "🍽" }
h3 { "No meals yet" }
p { "Create a meal to reuse its ingredients across your lists." }
}
}
div class="dashboard-grid" {
section class="panel" {
div class="panel-heading" {
@@ -254,21 +266,52 @@ pub fn meals_page(user: &User, meals: &[Meal]) -> Markup {
span class="count-badge" { (meals.len()) }
}
@if meals.is_empty() {
div class="empty-state" {
div class="empty-mark" { "🍽" }
h3 { "No meals yet" }
p { "Create a meal to reuse its ingredients across your lists." }
}
p class="muted" { "Create a meal to get started." }
} @else {
div class="list-cards" {
@for meal in meals {
a class="list-card" href=(format!("/meals/{}", meal.id)) {
span class="list-card-icon" { "🍽" }
span class="list-card-copy" {
strong { (meal.name) }
small { (meal.ingredients.len()) " ingredients" }
@for (category_name, category_meals) in meal_groups(meals, meal_categories) {
div class="category-group" {
div class="category-heading" {
h3 { (category_name) " (" (category_meals.len()) ")" }
}
div class="list-cards" {
@for meal in category_meals {
a class="list-card" href=(format!("/meals/{}", meal.id)) {
span class="list-card-icon" { "🍽" }
span class="list-card-copy" {
strong { (meal.name) }
small { (meal.ingredients.len()) " ingredients" }
}
span class="list-card-arrow" { "" }
}
}
}
}
}
}
}
aside class="side-column" {
section class="panel categories-panel" {
div class="panel-heading" {
h2 { "Meal categories" }
}
p { "Organize meals by type." }
form method="post" action="/meals/categories" class="category-form" {
input type="hidden" name="csrf" value=(csrf_token);
input name="name" type="text" maxlength="60" placeholder="New category" required;
button class="button button-small button-secondary" type="submit" { "Add" }
}
@if meal_categories.is_empty() {
p class="muted category-empty" { "No categories yet." }
} @else {
div class="meal-category-list" {
@for category in meal_categories {
div class="meal-category-row" {
span class="meal-category-name" { (category.name) }
form method="post" action=(format!("/meals/categories/{}/delete", category.id)) {
input type="hidden" name="csrf" value=(csrf_token);
button class="meal-category-delete" type="submit" aria-label=(format!("Delete {}", category.name)) { "" }
}
}
span class="list-card-arrow" { "" }
}
}
}
@@ -279,7 +322,47 @@ pub fn meals_page(user: &User, meals: &[Meal]) -> Markup {
)
}
pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup {
fn meal_groups<'a>(
meals: &'a [Meal],
meal_categories: &[MealCategory],
) -> Vec<(String, Vec<&'a Meal>)> {
let mut groups = Vec::new();
for category in meal_categories {
let in_category = meals
.iter()
.filter(|meal| meal.category_id == Some(category.id))
.collect::<Vec<_>>();
if !in_category.is_empty() {
groups.push((category.name.clone(), in_category));
}
}
let uncategorized = meals
.iter()
.filter(|meal| meal.category_id.is_none())
.collect::<Vec<_>>();
if !uncategorized.is_empty() {
groups.push(("Uncategorized".into(), uncategorized));
}
groups
}
/// Returns the display name of a meal's category, if it has one.
fn meal_category_name(meal: &Meal, meal_categories: &[MealCategory]) -> Option<String> {
meal.category_id.and_then(|id| {
meal_categories
.iter()
.find(|category| category.id == id)
.map(|category| category.name.clone())
})
}
pub fn meal_picker(
meals: &[Meal],
meal_categories: &[MealCategory],
list_id: i64,
csrf_token: &str,
) -> Markup {
html! {
div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" {
div class="meal-picker-modal" role="dialog" aria-modal="true" aria-label="Add a meal" {
@@ -299,23 +382,30 @@ pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup {
}
} @else {
div class="meal-picker-list" {
@for meal in meals {
form
hx-post=(format!("/lists/{}/add-meal", list_id))
hx-target="#list-items"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
class="meal-picker-row"
{
input type="hidden" name="csrf" value=(csrf_token);
input type="hidden" name="meal_id" value=(meal.id);
button class="meal-picker-button" type="submit" {
span class="meal-picker-icon" { "🍽" }
span class="meal-picker-copy" {
strong { (meal.name) }
small { (meal.ingredients.len()) " ingredients" }
@for (category_name, category_meals) in meal_groups(meals, meal_categories) {
div class="category-group" {
div class="category-heading" {
h3 { (category_name) }
}
@for meal in category_meals {
form
hx-post=(format!("/lists/{}/add-meal", list_id))
hx-target="#list-items"
hx-swap="outerHTML"
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
class="meal-picker-row"
{
input type="hidden" name="csrf" value=(csrf_token);
input type="hidden" name="meal_id" value=(meal.id);
button class="meal-picker-button" type="submit" {
span class="meal-picker-icon" { "🍽" }
span class="meal-picker-copy" {
strong { (meal.name) }
small { (meal.ingredients.len()) " ingredients" }
}
span class="meal-picker-add" { "Add" }
}
}
span class="meal-picker-add" { "Add" }
}
}
}
@@ -326,15 +416,27 @@ pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup {
}
}
pub fn meal_form_page(user: &User, meal: Option<&Meal>, csrf_token: &str) -> Markup {
let (title, action, name, description) = match meal {
pub fn meal_form_page(
user: &User,
meal: Option<&Meal>,
meal_categories: &[MealCategory],
csrf_token: &str,
) -> Markup {
let (title, action, name, description, category_id) = match meal {
Some(meal) => (
"Edit meal",
format!("/meals/{}/edit", meal.id),
meal.name.clone(),
meal.description.clone(),
meal.category_id,
),
None => (
"New meal",
"/meals".into(),
String::new(),
String::new(),
None,
),
None => ("New meal", "/meals".into(), String::new(), String::new()),
};
page(
title,
@@ -349,6 +451,21 @@ pub fn meal_form_page(user: &User, meal: Option<&Meal>, csrf_token: &str) -> Mar
input type="hidden" name="csrf" value=(csrf_token);
label for="meal-name" { "Name" }
input id="meal-name" name="name" type="text" maxlength="120" value=(name) required;
label for="meal-category" { "Category" }
select id="meal-category" name="category_id" {
@if category_id.is_none() {
option value="" selected { "Uncategorized" }
} @else {
option value="" { "Uncategorized" }
}
@for category in meal_categories {
@if category_id == Some(category.id) {
option value=(category.id) selected { (category.name) }
} @else {
option value=(category.id) { (category.name) }
}
}
}
label for="meal-description" { "Description (markdown)" }
textarea id="meal-description" name="description" rows="8" { (description) }
button class="button button-primary" type="submit" { "Save meal" }
@@ -361,7 +478,13 @@ pub fn meal_form_page(user: &User, meal: Option<&Meal>, csrf_token: &str) -> Mar
)
}
pub fn meal_page(user: &User, meal: &Meal, categories: &[Category], csrf_token: &str) -> Markup {
pub fn meal_page(
user: &User,
meal: &Meal,
categories: &[Category],
meal_categories: &[MealCategory],
csrf_token: &str,
) -> Markup {
page(
&meal.name,
Some(user),
@@ -386,6 +509,21 @@ pub fn meal_page(user: &User, meal: &Meal, categories: &[Category], csrf_token:
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input id="meal-edit-name" name="name" value=(meal.name) maxlength="120" required;
label { "Category" }
select id="meal-edit-category" name="category_id" {
@if meal.category_id.is_none() {
option value="" selected { "Uncategorized" }
} @else {
option value="" { "Uncategorized" }
}
@for category in meal_categories {
@if meal.category_id == Some(category.id) {
option value=(category.id) selected { (category.name) }
} @else {
option value=(category.id) { (category.name) }
}
}
}
label { "Description (markdown)" }
textarea id="meal-edit-description" name="description" rows="8" { (meal.description) }
button id="meal-edit-save" class="button button-primary" type="submit" { "Save meal" }
@@ -397,7 +535,7 @@ pub fn meal_page(user: &User, meal: &Meal, categories: &[Category], csrf_token:
div class="list-heading" {
div {
p class="eyebrow" { "MEAL" }
h1 { (meal.name) }
h1 { (meal.name) @if let Some(category_name) = meal_category_name(meal, meal_categories) { span class="meal-category-label" { "(" (category_name) ")" } } }
}
}
@if meal.description.is_empty() {