Add support for meals (#1)

Reviewed-on: #1
Co-authored-by: Simon Bernier St-Pierre <git.sbstp.ca@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-01 20:27:38 -04:00
committed by sbstp
parent 6a2cef2003
commit 9b32fd23a7
11 changed files with 1858 additions and 135 deletions
+369 -29
View File
@@ -1,8 +1,9 @@
use maud::{DOCTYPE, Markup, html};
use pulldown_cmark::{Options, Parser, html as cmark_html};
use crate::{
domain::PresenceUser,
domain::{Category, GroceryList, Item, User},
domain::{Category, GroceryList, Item, Meal, MealIngredient, User},
};
pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
@@ -144,6 +145,349 @@ pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Marku
)
}
pub fn meals_page(user: &User, meals: &[Meal]) -> Markup {
page(
"Meals",
Some(user),
html! {
div class="page-heading" {
div {
p class="eyebrow" { "MEAL LIBRARY" }
h1 { "Meals" }
p class="lede" { "Save a meal and add its ingredients to any list." }
}
a class="button button-primary" href="/meals/new" { "New meal" }
}
div class="dashboard-grid" {
section class="panel" {
div class="panel-heading" {
h2 { "All meals" }
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." }
}
} @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" }
}
span class="list-card-arrow" { "" }
}
}
}
}
}
}
},
)
}
pub fn meal_picker(meals: &[Meal], 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" {
div class="meal-picker-header" {
div {
p class="eyebrow" { "ADD TO LIST" }
h2 { "Add a meal" }
}
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "" }
}
@if meals.is_empty() {
div class="meal-picker-empty" {
span class="empty-mark" { "🍽" }
h3 { "No meals yet" }
p { "Create a meal first, then add it to any list." }
a class="button button-primary" href="/meals/new" { "Create a meal" }
}
} @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" }
}
span class="meal-picker-add" { "Add" }
}
}
}
}
}
}
}
}
}
pub fn meal_form_page(
user: &User,
meal: Option<&Meal>,
csrf_token: &str,
) -> Markup {
let (title, action, name, description) = match meal {
Some(meal) => (
"Edit meal",
format!("/meals/{}/edit", meal.id),
meal.name.clone(),
meal.description.clone(),
),
None => ("New meal", "/meals".into(), String::new(), String::new()),
};
page(
title,
Some(user),
html! {
div class="page-heading" {
a class="back-link" href="/meals" { "← All meals" }
h1 { (title) }
}
section class="panel" {
form method="post" action=(action) class="stack" {
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-description" { "Description (markdown)" }
textarea id="meal-description" name="description" rows="8" { (description) }
button class="button button-primary" type="submit" { "Save meal" }
}
}
@if meal.is_none() {
p class="muted" { "You can add ingredients after creating the meal." }
}
},
)
}
pub fn meal_page(
user: &User,
meal: &Meal,
categories: &[Category],
csrf_token: &str,
) -> Markup {
page(
&meal.name,
Some(user),
html! {
div class="page-heading" {
a class="back-link" href="/meals" { "← All meals" }
div class="list-topbar-actions" {
button type="button" class="button button-small button-quiet" onclick="document.getElementById('meal-edit-modal').showModal()" { "Edit" }
form method="post" action=(format!("/meals/{}/delete", meal.id)) {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link" type="submit" { "Delete" }
}
}
}
dialog id="meal-edit-modal" class="item-modal" {
div class="item-modal-card" {
div class="item-modal-header" {
h3 { "Edit meal" }
button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "" }
}
form method="post" action=(format!("/meals/{}/edit", meal.id)) class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input name="name" value=(meal.name) maxlength="120" required;
label { "Description (markdown)" }
textarea name="description" rows="8" { (meal.description) }
button class="button button-primary" type="submit" { "Save meal" }
}
}
}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
div {
p class="eyebrow" { "MEAL" }
h1 { (meal.name) }
}
}
@if meal.description.is_empty() {
p class="muted" { "No description." }
} @else {
div class="markdown" { (render_markdown(&meal.description)) }
}
h2 class="category-heading" { "Ingredients" }
@if meal.ingredients.is_empty() {
p class="muted" { "No ingredients yet." }
} @else {
div class="item-list" {
@for group in ingredient_groups(&meal.ingredients, categories) {
(ingredient_category_group(&group.0, &group.1, meal.id, categories, csrf_token))
}
}
}
}
aside class="side-column" {
section class="panel" {
div class="panel-heading" { h2 { "Add ingredient" } }
form method="post" action=(format!("/meals/{}/ingredients", meal.id)) class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input name="name" type="text" maxlength="120" required;
label { "Quantity" }
input name="quantity" type="text" maxlength="40";
label { "Note" }
input name="note" type="text" maxlength="120";
label { "Category" }
select name="category_id" {
(category_options(categories, None))
}
button class="button button-primary" type="submit" { "Add ingredient" }
}
}
}
}
},
)
}
fn ingredient_row(
ingredient: &MealIngredient,
meal_id: i64,
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
div class="item-copy" {
@if !ingredient.quantity.is_empty() {
span class="item-qty" { "(" (ingredient.quantity) ")" }
}
strong { (ingredient.name) }
@if !ingredient.note.is_empty() {
small { (ingredient.note) }
}
}
button type="button" class="item-actions-button" aria-label="Ingredient actions" onclick=(format!("document.getElementById('ingredient-edit-{}').showModal()", ingredient.id)) { "•••" }
dialog id=(format!("ingredient-edit-{}", ingredient.id)) class="item-modal" {
div class="item-modal-card" {
div class="item-modal-header" {
h3 { (ingredient.name) }
button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "" }
}
form
hx-post=(format!("/meals/{}/ingredients/{}/edit", meal_id, ingredient.id))
hx-target="body"
hx-swap="outerHTML"
class="stack"
{
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
input name="name" value=(ingredient.name) maxlength="120" required;
label { "Quantity" }
input name="quantity" value=(ingredient.quantity) maxlength="40";
label { "Note" }
input name="note" value=(ingredient.note) maxlength="120";
label { "Category" }
select name="category_id" {
(category_options(categories, ingredient.category_id))
}
button class="button button-primary" type="submit" { "Save" }
}
form method="post" action=(format!("/meals/{}/ingredients/{}/delete", meal_id, ingredient.id)) {
input type="hidden" name="csrf" value=(csrf_token);
button class="danger-link" type="submit" { "Remove" }
}
}
}
}
}
fn ingredient_groups<'a>(
ingredients: &'a [MealIngredient],
categories: &[Category],
) -> Vec<(String, Vec<&'a MealIngredient>)> {
let mut groups = Vec::new();
for category in categories {
let in_category = ingredients
.iter()
.filter(|ingredient| ingredient.category_id == Some(category.id))
.collect::<Vec<_>>();
if !in_category.is_empty() {
groups.push((category.name.clone(), in_category));
}
}
let uncategorized = ingredients
.iter()
.filter(|ingredient| ingredient.category_id.is_none())
.collect::<Vec<_>>();
if !uncategorized.is_empty() {
groups.push(("Uncategorized".into(), uncategorized));
}
groups
}
fn ingredient_category_group(
name: &str,
ingredients: &[&MealIngredient],
meal_id: i64,
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
section class="category-group" {
h2 class="category-heading" { (name) }
ul class="ingredient-list" {
@for ingredient in ingredients {
li {
(ingredient_row(ingredient, meal_id, categories, csrf_token))
}
}
}
}
}
}
fn render_markdown(source: &str) -> Markup {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(source, options);
let mut buffer = String::new();
cmark_html::push_html(&mut buffer, parser);
html! {
(maud::PreEscaped(buffer))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_markdown_turns_bullets_into_list_html() {
let html = render_markdown("- one\n- two\n").into_string();
assert!(html.contains("<ul>"), "expected <ul>, got: {html}");
assert!(html.contains("<li>"), "expected <li>, got: {html}");
assert!(!html.contains("* one"), "raw bullet leaked through: {html}");
}
#[test]
fn render_markdown_renders_emphasis() {
let html = render_markdown("**bold** and *italic*").into_string();
assert!(html.contains("<strong>"), "expected <strong>, got: {html}");
assert!(html.contains("<em>"), "expected <em>, got: {html}");
}
}
pub fn list_page(
user: &User,
list: &GroceryList,
@@ -160,8 +504,10 @@ pub fn list_page(
a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" }
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list.id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
}
}
div id="meal-picker" class="meal-picker" {}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
@@ -175,7 +521,7 @@ pub fn list_page(
}
aside class="side-column" {
(presence_panel(presence, false))
(categories_panel(list, categories, csrf_token, false))
(categories_panel(categories, csrf_token, false))
section class="panel tip-panel" {
span class="tip-label" { "TIP" }
p { "Check items off as you go. Everyone viewing this list will see it instantly." }
@@ -332,23 +678,26 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
}
}
div class="item-copy" {
@if !item.quantity.is_empty() {
span class="item-qty" { "(" (item.quantity) ")" }
}
strong { (item.name) }
@if !item.quantity.is_empty() || !item.note.is_empty() {
small {
@if !item.quantity.is_empty() { (item.quantity) }
@if !item.quantity.is_empty() && !item.note.is_empty() { " · " }
@if !item.note.is_empty() { (item.note) }
}
@if !item.note.is_empty() {
small { (item.note) }
}
}
details class="item-actions" {
summary aria-label="Item actions" { "•••" }
div class="item-menu" {
button type="button" class="item-actions-button" aria-label="Item actions" onclick=(format!("document.getElementById('item-edit-{}').showModal()", item.id)) { "•••" }
dialog id=(format!("item-edit-{}", item.id)) class="item-modal" {
div class="item-modal-card" {
div class="item-modal-header" {
h3 { (item.name) }
button type="button" class="meal-picker-close" aria-label="Close" onclick="this.closest('dialog').close()" { "" }
}
form
hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id))
hx-target="#list-items"
hx-swap="outerHTML"
class="edit-form stack"
class="stack"
{
input type="hidden" name="csrf" value=(csrf_token);
label { "Name" }
@@ -361,7 +710,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
select name="category_id" {
(category_options(categories, item.category_id))
}
button class="button button-small button-secondary" type="submit" { "Save" }
button class="button button-primary" type="submit" { "Save" }
}
form
hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id))
@@ -395,7 +744,6 @@ fn category_options(categories: &[Category], selected: Option<i64>) -> Markup {
}
pub fn categories_panel(
list: &GroceryList,
categories: &[Category],
csrf_token: &str,
out_of_band: bool,
@@ -407,10 +755,10 @@ pub fn categories_panel(
}
p { "Organize items by aisle or shopping area." }
form
hx-post=(format!("/lists/{}/categories", list.id))
hx-post="/categories"
hx-target="#category-result"
hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
hx-on::after-request="if (event.detail.successful) window.location.reload()"
class="category-form"
{
input type="hidden" name="csrf" value=(csrf_token);
@@ -448,19 +796,7 @@ pub fn live_list_fragments(
) -> Markup {
html! {
(list_content_fragment(list, items, categories, csrf_token, true))
(categories_panel(list, categories, csrf_token, true))
}
}
pub fn category_created(
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
p class="category-success" { "Category added." }
(live_list_fragments(list, items, categories, csrf_token))
(categories_panel(categories, csrf_token, true))
}
}
@@ -579,6 +915,10 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
header class="site-header" {
a class="brand" href="/lists" { span class="brand-mark" { "S" } "Sustenance" }
@if let Some(user) = user {
nav class="site-nav" {
a href="/lists" { "Lists" }
a href="/meals" { "Meals" }
}
div class="account-nav" {
span class="user-name" { (user.display_name) }
form method="post" action="/logout" {