use maud::{DOCTYPE, Markup, html}; use pulldown_cmark::{Options, Parser, html as cmark_html}; use crate::{ domain::PresenceUser, domain::{ Category, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Passkey, User, }, }; pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { page( "Sign in", None, html! { div class="auth-card" { p class="eyebrow" { "SUSTENANCE" } h1 { "Welcome back" } p class="lede" { "Keep the shopping list in sync." } @if let Some(error) = error { div class="alert alert-error" role="alert" { (error) } } form method="post" action="/login" class="stack" { @if let Some(invite) = invite { input type="hidden" name="invite" value=(invite); } label for="email" { "Email" } input id="email" name="email" type="email" autocomplete="email" autofocus; label for="password" { "Password" } div class="password-field" { input id="password" name="password" type="password" autocomplete="current-password" required; button class="password-toggle" type="button" data-toggle-for="password" aria-label="Show password" { "Show" } } button class="button button-primary" type="submit" { "Sign in" } } div class="auth-divider" { span { "or" } } button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" } p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } } } script src="/static/password-toggle.js" {} script src="/static/passkey-login.js" {} }, ) } pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup { page( "Create account", None, html! { div class="auth-card" { p class="eyebrow" { "SUSTENANCE" } h1 { "Make a shared list" } p class="lede" { "A simple grocery list for the people you shop with." } @if let Some(error) = error { div class="alert alert-error" role="alert" { (error) } } form method="post" action="/register" class="stack" { @if let Some(invite) = invite { input type="hidden" name="invite" value=(invite); } label for="display-name" { "Your name" } input id="display-name" name="display_name" type="text" autocomplete="name" maxlength="50" required autofocus; label for="email" { "Email" } input id="email" name="email" type="email" autocomplete="email" required; label for="password" { "Password" } div class="password-field" { input id="password" name="password" type="password" autocomplete="new-password" required; button class="password-toggle" type="button" data-toggle-for="password" aria-label="Show password" { "Show" } } button class="button button-primary" type="submit" { "Create account" } } p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } } } script src="/static/password-toggle.js" {} }, ) } pub fn registration_closed_page() -> Markup { page( "Registration closed", None, html! { div class="auth-card" { p class="eyebrow" { "PRIVATE LISTS" } h1 { "Registration is invite-only" } p class="lede" { "Ask someone who uses the app to send you an invitation link." } a class="button button-primary" href="/login" { "Back to sign in" } } }, ) } pub fn account_page( user: &User, passkeys: &[Passkey], csrf_token: &str, password_error: Option<&str>, password_success: bool, ) -> Markup { page( "Account", Some(user), html! { div class="page-heading" { div { p class="eyebrow" { "ACCOUNT" } h1 { "Account" } p class="lede" { "Manage your sign-in methods." } } } div class="dashboard-grid" { section class="panel" { div class="panel-heading" { h2 { "Passkeys" } span class="count-badge" { (passkeys.len()) } } p { "Passkeys let you sign in without a password using your device." } @if passkeys.is_empty() { p class="muted" { "You have no passkeys yet." } } @else { div class="passkey-list" { @for passkey in passkeys { div class="passkey-row" { div class="item-copy" { strong { "Passkey" } small { (passkey.credential_id) } } form method="post" action=(format!("/account/passkeys/{}/delete", passkey.id)) { input type="hidden" name="csrf" value=(csrf_token); button class="danger-link" type="submit" { "Remove" } } } } } } button id="add-passkey" class="button button-primary" type="button" data-csrf=(csrf_token) { "Add a passkey" } } section class="panel" { div class="panel-heading" { h2 { "Password" } } p { "Set a new password for your account." } @if let Some(error) = password_error { div class="alert alert-error" role="alert" { (error) } } @if password_success { div class="alert alert-success" role="alert" { "Your password has been updated." } } form method="post" action="/account/password" class="stack" { input type="hidden" name="csrf" value=(csrf_token); label for="new-password" { "New password" } div class="password-field" { input id="new-password" name="new_password" type="password" autocomplete="new-password" required; button class="password-toggle" type="button" data-toggle-for="new-password" aria-label="Show password" { "Show" } } label for="confirm-password" { "Confirm new password" } div class="password-field" { input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required; button class="password-toggle" type="button" data-toggle-for="confirm-password" aria-label="Show password" { "Show" } } button class="button button-primary" type="submit" { "Update password" } } } } script src="/static/password-toggle.js" {} script src="/static/passkey-register.js" {} }, ) } pub fn lists_page( user: &User, lists: &[GroceryList], archived_count: usize, categories: &[Category], csrf_token: &str, ) -> Markup { page( "Your lists", Some(user), html! { div class="page-heading" { div { p class="eyebrow" { "SHARED LISTS" } h1 class="page-title" { "Grocery lists" } p class="lede" { "Everything you need, in one place." } } } div class="dashboard-grid" { section class="panel" { div class="panel-heading" { h2 { "Lists" } span class="count-badge" { (lists.len()) } a class="archive-link" href="/archive" { "Archived " span class="count-badge" { (archived_count) } " โ†’" } } @if lists.is_empty() { div class="empty-state" { div class="empty-mark" { "+" } h3 { "Start your first list" } p { "Create a list for the weekly shop, a party, or anything else." } } } @else { div class="list-cards" { @for list in lists { a class="list-card" href=(format!("/lists/{}", list.id)) { span class="list-card-icon" { "โœ“" } span class="list-card-copy" { strong { (list.name) } } span class="list-card-arrow" { "โ†’" } } } } } } section class="panel create-panel" { div class="panel-heading" { h2 { "New list" } } form method="post" action="/lists" class="stack" { input type="hidden" name="csrf" value=(csrf_token); label for="list-name" { "List name" } input id="list-name" name="name" type="text" maxlength="80" placeholder="Weekly shop" required; button class="button button-primary" type="submit" { "Create list" } } } (categories_panel(categories, csrf_token, false)) section id="sharing" class="panel sharing-panel" { div class="panel-heading" { h2 { "Invite someone" } } p { "Create a one-time invite link so a new person can join." } form hx-post="/invitations" hx-target="#invite-result" hx-swap="innerHTML" class="stack" { input type="hidden" name="csrf" value=(csrf_token); button class="button button-secondary" type="submit" { "Create invite link" } } div id="invite-result" class="invite-result" {} } } }, ) } pub fn archive_page(user: &User, archived_lists: &[GroceryList]) -> Markup { page( "Archive", Some(user), html! { div class="page-heading" { div { p class="eyebrow" { "ARCHIVE" } h1 class="page-title" { "Archived lists" } p class="lede" { "Past lists, kept for reference." } } a class="button button-quiet" href="/lists" { "โ† Back to lists" } } section class="panel" { div class="panel-heading" { h2 { "Archive" } span class="count-badge" { (archived_lists.len()) } } @if archived_lists.is_empty() { div class="empty-state" { div class="empty-mark" { "๐Ÿ—„" } h3 { "Nothing archived yet" } p { "Archive a list and it will show up here." } } } @else { div class="list-cards" { @for list in archived_lists { a class="list-card archived-list-card" href=(format!("/lists/{}", list.id)) { span class="list-card-icon" { "๐Ÿ—„" } span class="list-card-copy" { strong { (list.name) } small { "Created " (format_date(list.created_at)) } } span class="list-card-arrow" { "โ†’" } } } } } } }, ) } pub fn meals_page( user: &User, meals: &[Meal], meal_categories: &[MealCategory], csrf_token: &str, ) -> Markup { page( "Meals", Some(user), html! { div class="page-heading" { div { p class="eyebrow" { "MEAL LIBRARY" } h1 class="page-title" { "Meals" } p class="lede" { "Save a meal and add its ingredients to any list." } } 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" { h2 { "All meals" } span class="count-badge" { (meals.len()) } } @if meals.is_empty() { p class="muted" { "Create a meal to get started." } } @else { @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)) { "โœ•" } } } } } } } } } }, ) } 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::>(); if !in_category.is_empty() { groups.push((category.name.clone(), in_category)); } } let uncategorized = meals .iter() .filter(|meal| meal.category_id.is_none()) .collect::>(); 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 { 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" { 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 (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" } } } } } } } } } } } } 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, ), }; 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-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" } } } @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], meal_categories: &[MealCategory], csrf_token: &str, ) -> Markup { page( &meal.name, Some(user), html! { div class="page-heading" { a class="back-link" href="/meals" { "โ† All meals" } } 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 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" } } } } div class="list-layout" { section class="panel list-panel" { div class="list-heading" { div { p class="eyebrow" { "MEAL" } h1 { (meal.name) @if let Some(category_name) = meal_category_name(meal, meal_categories) { span class="meal-category-label" { "(" (category_name) ")" } } } } 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)) onsubmit="return confirm('Delete this meal and its ingredients? This cannot be undone.')" { input type="hidden" name="csrf" value=(csrf_token); button class="danger-link bordered-delete" type="submit" { "Delete" } } } } @if meal.description.is_empty() { p class="muted" { "No description." } } @else { div class="markdown" { (render_markdown(&meal.description)) } } hr class="ingredients-divider" {} @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 id="add-ingredient-form" method="post" action=(format!("/meals/{}/ingredients", meal.id)) class="stack" { input type="hidden" name="csrf" value=(csrf_token); label { "Name" } input id="ingredient-name" name="name" type="text" maxlength="120" required; label { "Category" } select id="ingredient-category" name="category_id" { (category_options(categories, None)) } label { "Quantity" } input id="ingredient-quantity" name="quantity" type="text" maxlength="40"; label { "Note" } input id="ingredient-note" name="note" type="text" maxlength="120"; button id="add-ingredient-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 id=(format!("ingredient-edit-name-{}", ingredient.id)) name="name" value=(ingredient.name) maxlength="120" required; label { "Category" } select id=(format!("ingredient-edit-category-{}", ingredient.id)) name="category_id" { (category_options(categories, ingredient.category_id)) } label { "Quantity" } input id=(format!("ingredient-edit-quantity-{}", ingredient.id)) name="quantity" value=(ingredient.quantity) maxlength="40"; label { "Note" } input id=(format!("ingredient-edit-note-{}", ingredient.id)) name="note" value=(ingredient.note) maxlength="120"; button id=(format!("ingredient-edit-save-{}", ingredient.id)) 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 id=(format!("ingredient-edit-delete-{}", ingredient.id)) class="button button-danger" 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::>(); if !in_category.is_empty() { groups.push((category.name.clone(), in_category)); } } let uncategorized = ingredients .iter() .filter(|ingredient| ingredient.category_id.is_none()) .collect::>(); 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("
    "), "expected
      , got: {html}"); assert!(html.contains("
    • "), "expected
    • , 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(""), "expected , got: {html}"); assert!(html.contains(""), "expected , got: {html}"); } #[test] fn format_date_renders_human_readable_date() { // 2026-08-07T00:00:00Z in Unix seconds. let ts = 1_786_060_800; assert_eq!(format_date(ts), "7 Aug 2026"); } } pub fn list_page( user: &User, list: &GroceryList, items: &[Item], categories: &[Category], list_meals: &[ListMeal], presence: &[PresenceUser], csrf_token: &str, ) -> Markup { page( &list.name, Some(user), html! { div class="list-topbar" { a class="back-link" href="/lists" { "โ† All lists" } div class="list-topbar-actions" { span class="live-pill" { span class="live-dot" {} "Live" } } } @if let Some(ts) = list.archived_at { div class="archived-banner" { "This list was archived on " (format_date(ts)) "." } } div id="meal-picker" class="meal-picker" {} div class="list-layout" { section class="panel list-panel" { div class="list-heading" { div { p class="eyebrow" { "SHARED LIST" } h1 { (list.name) } p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" } } div class="list-topbar-actions" { @if list.archived_at.is_some() { form method="post" action=(format!("/lists/{}/unarchive", list.id)) { input type="hidden" name="csrf" value=(csrf_token); button class="button button-small button-quiet" type="submit" { "Restore" } } } @else { form method="post" action=(format!("/lists/{}/archive", list.id)) { input type="hidden" name="csrf" value=(csrf_token); button class="button button-small button-quiet" type="submit" { "Archive" } } } } } (list_content_fragment(list, items, categories, csrf_token, false, list.archived_at.is_none())) } aside class="side-column" { (list_meals_panel(list_meals, list.id, csrf_token, false, list.archived_at.is_none())) (presence_panel(presence, 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." } } } } div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", list.id)) {} }, ) } pub fn list_content_fragment( list: &GroceryList, items: &[Item], categories: &[Category], csrf_token: &str, out_of_band: bool, editable: bool, ) -> Markup { if out_of_band { html! { div id="list-content" hx-swap-oob="outerHTML" { (list_content(list, items, categories, csrf_token, editable)) } } } else { html! { div id="list-content" { (list_content(list, items, categories, csrf_token, editable)) } } } } fn list_content( list: &GroceryList, items: &[Item], categories: &[Category], csrf_token: &str, editable: bool, ) -> Markup { html! { @if editable { form id="add-item-form" class="add-item-form" hx-post=(format!("/lists/{}/items", list.id)) hx-target="#list-items" hx-swap="outerHTML" hx-on::after-request="if (event.detail.successful) this.reset()" { input type="hidden" name="csrf" value=(csrf_token); label class="sr-only" for="item-name" { "Item name" } input id="item-name" name="name" type="text" maxlength="120" placeholder="Add an item..." autocomplete="off" required; select id="item-category" name="category_id" aria-label="Category" { (category_options(categories, None)) } input id="item-quantity" name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity"; button id="add-item-button" class="button button-primary add-button" type="submit" { "+ Add" } } } (list_items_fragment(list, items, categories, csrf_token, false, editable)) } } pub fn list_items_fragment( list: &GroceryList, items: &[Item], categories: &[Category], csrf_token: &str, out_of_band: bool, editable: bool, ) -> Markup { if out_of_band { html! { div id="list-items" class="items" data-revision=(list.revision) hx-swap-oob="outerHTML" { (list_items_content(items, categories, csrf_token, editable)) } } } else { html! { div id="list-items" class="items" data-revision=(list.revision) { (list_items_content(items, categories, csrf_token, editable)) } } } } fn list_items_content( items: &[Item], categories: &[Category], csrf_token: &str, editable: bool, ) -> Markup { html! { @if items.is_empty() { div class="empty-items" { span class="empty-items-icon" { "โœฆ" } p { "Your list is clear." } small { "Add the first thing you need above." } } } @else { div class="item-list" { @for group in item_groups(items, categories) { (category_group(&group.0, &group.1, categories, csrf_token, editable)) } } } } } fn item_groups<'a>(items: &'a [Item], categories: &[Category]) -> Vec<(String, Vec<&'a Item>)> { let mut groups = Vec::new(); for category in categories { let items_in_category = items .iter() .filter(|item| item.category_id == Some(category.id)) .collect::>(); if !items_in_category.is_empty() { groups.push((category.name.clone(), items_in_category)); } } let uncategorized = items .iter() .filter(|item| item.category_id.is_none()) .collect::>(); if !uncategorized.is_empty() { groups.push(("Uncategorized".into(), uncategorized)); } groups } fn category_group( name: &str, items: &[&Item], categories: &[Category], csrf_token: &str, editable: bool, ) -> Markup { html! { section class="category-group" { h2 class="category-heading" { (name) } @for item in items { (item_row(item, categories, csrf_token, editable)) } } } } fn item_row(item: &Item, categories: &[Category], csrf_token: &str, editable: bool) -> Markup { let next_checked = if item.checked { "0" } else { "1" }; html! { article class=(if item.checked { "item-row is-checked" } else { "item-row" }) id=(format!("item-{}", item.id)) data-version=(item.version) { @if editable { form class="check-form" hx-post=(format!("/lists/{}/items/{}/check", item.list_id, item.id)) hx-target="#list-items" hx-swap="outerHTML" { input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="checked" value=(next_checked); button id=(format!("item-check-{}", item.id)) class="check-button" type="submit" aria-label=(if item.checked { "Mark unchecked" } else { "Mark complete" }) { @if item.checked { "โœ“" } @else { "" } } } } @else if item.checked { span class="check-button check-button-static" { "โœ“" } } label class="item-copy" for=(format!("item-check-{}", item.id)) { @if !item.quantity.is_empty() { span class="item-qty" { "(" (item.quantity) ")" } } strong { (item.name) } @if !item.note.is_empty() { small { (item.note) } } } @if editable { 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="stack" { input type="hidden" name="csrf" value=(csrf_token); label { "Name" } input id=(format!("item-edit-name-{}", item.id)) name="name" value=(item.name) maxlength="120" required; label { "Category" } select id=(format!("item-edit-category-{}", item.id)) name="category_id" { (category_options(categories, item.category_id)) } label { "Quantity" } input id=(format!("item-edit-quantity-{}", item.id)) name="quantity" value=(item.quantity) maxlength="40"; label { "Note" } input id=(format!("item-edit-note-{}", item.id)) name="note" value=(item.note) maxlength="120"; button id=(format!("item-edit-save-{}", item.id)) class="button button-primary" type="submit" { "Save" } } form hx-post=(format!("/lists/{}/items/{}/delete", item.list_id, item.id)) hx-target="#list-items" hx-swap="outerHTML" { input type="hidden" name="csrf" value=(csrf_token); button id=(format!("item-edit-delete-{}", item.id)) class="button button-danger" type="submit" { "Remove item" } } } } } } } } fn category_options(categories: &[Category], selected: Option) -> Markup { html! { @if selected.is_none() { option value="" selected { "No category" } } @else { option value="" { "No category" } } @for category in categories { @if selected == Some(category.id) { option value=(category.id) selected { (category.name) } } @else { option value=(category.id) { (category.name) } } } } } pub fn categories_panel(categories: &[Category], csrf_token: &str, out_of_band: bool) -> Markup { let panel = html! { div class="panel-heading" { h2 { "Categories" } span class="count-badge" { (categories.len()) } } p { "Organize items by aisle or shopping area." } form hx-post="/categories" hx-target="#category-result" hx-swap="innerHTML" hx-on::after-request="if (event.detail.successful) window.location.reload()" class="category-form" { input type="hidden" name="csrf" value=(csrf_token); input id="category-name" name="name" type="text" maxlength="60" placeholder="Add a category" required; button id="add-category-button" class="button button-small button-secondary" type="submit" { "Add" } } @if categories.is_empty() { p class="muted category-empty" { "No categories yet." } } @else { div class="category-list" { @for category in categories { span class="category-chip" { (category.name) } } } } div id="category-result" class="category-result" {} }; if out_of_band { html! { section id="categories-panel" class="panel categories-panel" hx-swap-oob="outerHTML" { (panel) } } } else { html! { section id="categories-panel" class="panel categories-panel" { (panel) } } } } pub fn live_list_fragments( list: &GroceryList, items: &[Item], categories: &[Category], list_meals: &[ListMeal], csrf_token: &str, ) -> Markup { let editable = list.archived_at.is_none(); html! { (list_items_fragment(list, items, categories, csrf_token, true, editable)) (list_meals_panel(list_meals, list.id, csrf_token, true, editable)) } } pub fn list_meals_panel( list_meals: &[ListMeal], list_id: i64, csrf_token: &str, out_of_band: bool, editable: bool, ) -> Markup { let panel = html! { div class="panel-heading" { h2 { "Meals on this list" } span class="count-badge" { (list_meals.len()) } } @if list_meals.is_empty() { p class="muted" { "No meals added yet." } } @else { div class="list-meals" { @for meal in list_meals { div class="list-meal-row" { span class="list-meal-icon" { "๐Ÿฝ" } span class="list-meal-name" { (meal.name) } @if editable { form hx-post=(format!("/lists/{}/meals/{}/remove", list_id, meal.id)) hx-target="#list-items" hx-swap="outerHTML" class="list-meal-remove" { input type="hidden" name="csrf" value=(csrf_token); button type="submit" class="list-meal-remove-button" aria-label=(format!("Remove {} from list", meal.name)) { "โœ•" } } } } } } } @if editable { button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" } } }; if out_of_band { html! { section id="list-meals-panel" class="panel list-meals-panel" hx-swap-oob="outerHTML" { (panel) } } } else { html! { section id="list-meals-panel" class="panel list-meals-panel" { (panel) } } } } pub fn presence_panel(presence: &[PresenceUser], out_of_band: bool) -> Markup { if out_of_band { html! { section id="presence" class="panel presence-panel" hx-swap-oob="outerHTML" { (presence_content(presence)) } } } else { html! { section id="presence" class="panel presence-panel" { (presence_content(presence)) } } } } fn presence_content(presence: &[PresenceUser]) -> Markup { html! { div class="panel-heading" { h2 { "Viewing now" } span class="count-badge" { (presence.len()) } } @if presence.is_empty() { p class="muted" { "Just you for now." } } @else { div class="presence-list" { @for person in presence { div class="presence-person" data-user-id=(person.user_id) { span class="avatar" { (initials(&person.display_name)) } span { (person.display_name) } } } } } } } pub fn invite_page( user: Option<&User>, token: &str, error: Option<&str>, csrf_token: Option<&str>, ) -> Markup { page( "Join a list", user, html! { div class="auth-card invite-card" { p class="eyebrow" { "YOU'RE INVITED" } h1 { "Join the shared lists" } p class="lede" { "Shop together and keep all the lists in sync." } @if let Some(error) = error { div class="alert alert-error" role="alert" { (error) } } @if let Some(user) = user { p { "Signed in as " strong { (user.display_name) } "." } form method="post" action=(format!("/invite/{}/accept", token)) class="stack" { @if let Some(csrf_token) = csrf_token { input type="hidden" name="csrf" value=(csrf_token); } button class="button button-primary" type="submit" { "Join lists" } } p class="muted" { "Want to join as a different person? Create a new account below." } } @else { p { "Sign in or create an account to accept this invite." } } div class="invite-actions" { a class="button button-primary" href=(format!("/register?invite={}", token)) { "Create account" } a class="button button-secondary" href=(format!("/login?invite={}", token)) { "Sign in" } } } }, ) } pub fn invite_result(url: &str) -> Markup { html! { div class="invite-link-result" { p { "Copy this invite link:" } input readonly value=(url) aria-label="Invitation URL"; } } } /// Formats a Unix timestamp as a human-readable date (e.g. "7 Aug 2026"). fn format_date(timestamp: i64) -> String { let days = timestamp.div_euclid(86_400); let (y, m, d) = civil_from_days(days); const MONTHS: [&str; 12] = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; format!("{} {} {}", d, MONTHS[(m - 1) as usize], y) } /// Converts a count of days since the Unix epoch into a (year, month, day) /// civil date using Howard Hinnant's `civil_from_days` algorithm. fn civil_from_days(z: i64) -> (i64, i64, i64) { let z = z + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; (if m <= 2 { y + 1 } else { y }, m, d) } pub fn error_page(status: &str, message: &str) -> Markup { page( status, None, html! { div class="auth-card" { p class="eyebrow" { "SOMETHING WENT WRONG" } h1 { (status) } p class="lede" { (message) } a class="button button-primary" href="/lists" { "Back to lists" } } }, ) } fn page(title: &str, user: Option<&User>, content: Markup) -> Markup { html! { (DOCTYPE) html lang="en" { head { meta charset="utf-8"; meta name="viewport" content="width=device-width, initial-scale=1"; title { (title) " ยท Sustenance" } link rel="stylesheet" href="/static/style.css"; script src="https://unpkg.com/htmx.org@2.0.10" {} script src="https://unpkg.com/htmx-ext-ws@2.0.4/ws.js" {} } body hx-boost="true" { 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" { a class="user-name" href="/account" { (user.display_name) } form method="post" action="/logout" { button class="text-button" type="submit" { "Sign out" } } } } } main class="site-main" { (content) } footer class="site-footer" { "A small, shared grocery list." } } } } } fn initials(name: &str) -> String { name.split_whitespace() .filter_map(|part| part.chars().next()) .take(2) .collect::() .to_uppercase() }