use maud::{DOCTYPE, Markup, html}; use pulldown_cmark::{Options, Parser, html as cmark_html}; use crate::{ domain::PresenceUser, domain::{Category, GroceryList, Item, Meal, MealIngredient, 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" required autofocus; label for="password" { "Password" } input id="password" name="password" type="password" autocomplete="current-password" required; button class="button button-primary" type="submit" { "Sign in" } } p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } } } }, ) } 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" } input id="password" name="password" type="password" autocomplete="new-password" required; button class="button button-primary" type="submit" { "Create account" } } p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } } } }, ) } 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 lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Markup { page( "Your lists", Some(user), html! { div class="page-heading" { div { p class="eyebrow" { "SHARED LISTS" } h1 { "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()) } } @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" } } } 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 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 id="meal-edit-name" name="name" value=(meal.name) maxlength="120" required; 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 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 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 { "Quantity" } input id="ingredient-quantity" name="quantity" type="text" maxlength="40"; label { "Note" } input id="ingredient-note" name="note" type="text" maxlength="120"; label { "Category" } select id="ingredient-category" name="category_id" { (category_options(categories, None)) } 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 { "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"; label { "Category" } select id=(format!("ingredient-edit-category-{}", ingredient.id)) name="category_id" { (category_options(categories, ingredient.category_id)) } 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="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::>(); 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("