1726 lines
71 KiB
Rust
1726 lines
71 KiB
Rust
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,
|
|
RewardsCard, 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=(crate::assets::url("password-toggle.js")) {}
|
|
script src=(crate::assets::url("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=(crate::assets::url("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=(crate::assets::url("password-toggle.js")) {}
|
|
script src=(crate::assets::url("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,
|
|
q: &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() && q.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()) }
|
|
}
|
|
div class="meal-filter-bar" {
|
|
input
|
|
class="meal-filter"
|
|
id="meal-search"
|
|
type="search"
|
|
name="q"
|
|
value=(q)
|
|
placeholder="Search meals"
|
|
hx-get="/meals"
|
|
hx-trigger="input changed delay:200ms"
|
|
hx-target="#meal-results"
|
|
hx-swap="innerHTML";
|
|
}
|
|
div id="meal-results" {
|
|
(meal_results(meals, meal_categories, q))
|
|
}
|
|
}
|
|
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)) { "✕" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Renders the meals page's grouped results, or the appropriate empty state.
|
|
/// Distinct from the database-empty state: when a search yields no matches the
|
|
/// list is empty (pre-filtered in SQL) even though meals exist, so we show a
|
|
/// search-specific message.
|
|
pub fn meal_results(meals: &[Meal], meal_categories: &[MealCategory], q: &str) -> Markup {
|
|
html! {
|
|
@if meals.is_empty() && q.is_empty() {
|
|
p class="muted" { "Create a meal to get started." }
|
|
} @else if meals.is_empty() {
|
|
div class="meal-filter-empty" {
|
|
p { "No meals match your search." }
|
|
}
|
|
} @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" { "→" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
q: &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()" { "✕" }
|
|
}
|
|
div class="meal-filter-bar meal-filter-bar-picker" {
|
|
input
|
|
class="meal-filter"
|
|
name="q"
|
|
type="search"
|
|
value=(q)
|
|
placeholder="Search meals"
|
|
hx-get=(format!("/meals?picker={}", list_id))
|
|
hx-trigger="input changed delay:200ms"
|
|
hx-target="#meal-picker-results"
|
|
hx-swap="innerHTML";
|
|
}
|
|
@if meals.is_empty() && q.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 id="meal-picker-results" class="meal-picker-list" {
|
|
(meal_picker_results(meals, meal_categories, list_id, csrf_token))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The grouped picker rows, or the search-specific empty state. Kept separate
|
|
/// from the database-empty state above.
|
|
pub fn meal_picker_results(
|
|
meals: &[Meal],
|
|
meal_categories: &[MealCategory],
|
|
list_id: i64,
|
|
csrf_token: &str,
|
|
) -> Markup {
|
|
html! {
|
|
@if meals.is_empty() {
|
|
div class="meal-picker-empty" {
|
|
p { "No meals match your search." }
|
|
}
|
|
} @else {
|
|
@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="morph: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)) hx-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::<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))
|
|
}
|
|
}
|
|
|
|
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) }
|
|
(list_meta_fragment(items, false))
|
|
}
|
|
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="morph: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="morph" {
|
|
(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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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="morph: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="morph: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="morph: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<i64>) -> 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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn list_meta_fragment(items: &[Item], out_of_band: bool) -> Markup {
|
|
let left = items.iter().filter(|item| !item.checked).count();
|
|
let total = items.len();
|
|
let meta = html! {
|
|
p id="list-meta" class="list-meta" { (left) " items left out of " (total) }
|
|
};
|
|
if out_of_band {
|
|
html! {
|
|
p id="list-meta" class="list-meta" hx-swap-oob="outerHTML" { (left) " items left out of " (total) }
|
|
}
|
|
} else {
|
|
meta
|
|
}
|
|
}
|
|
|
|
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_meta_fragment(items, true))
|
|
(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="morph: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 {
|
|
div class="list-meals-actions" {
|
|
button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" }
|
|
button class="button button-small carry-over-button" hx-get=(format!("/lists/{}/carry", list_id)) hx-target="#meal-picker" hx-swap="innerHTML" { "Carry over" }
|
|
}
|
|
}
|
|
};
|
|
|
|
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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The carry-over modal: a picker of active source lists (newest first,
|
|
/// excluding the current list) that the user can import meals from.
|
|
pub fn carry_over_modal(sources: &[GroceryList], dest_list_id: i64) -> 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="Carry over meals" {
|
|
div class="meal-picker-header" {
|
|
div {
|
|
p class="eyebrow" { "CARRY OVER" }
|
|
h2 { "Carry over meals" }
|
|
}
|
|
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "✕" }
|
|
}
|
|
p class="muted carry-intro" { "Import meals from another list. Their ingredients are not re-added — they were already purchased." }
|
|
@if sources.is_empty() {
|
|
div class="meal-picker-empty" {
|
|
p { "No other lists to carry from." }
|
|
}
|
|
} @else {
|
|
div class="carry-source-picker" {
|
|
label for="carry-source" { "From list" }
|
|
select
|
|
id="carry-source"
|
|
name="source"
|
|
hx-get=(format!("/lists/{}/carry/meals", dest_list_id))
|
|
hx-trigger="change"
|
|
hx-target="#carry-results"
|
|
hx-swap="innerHTML"
|
|
{
|
|
option value="" selected disabled { "Choose a list…" }
|
|
@for source in sources {
|
|
option value=(source.id) { (source.name) }
|
|
}
|
|
}
|
|
}
|
|
div id="carry-results" class="carry-results" {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The selectable meal rows for a chosen source list inside the carry modal.
|
|
pub fn carry_source_meals(
|
|
list_meals: &[ListMeal],
|
|
source_id: i64,
|
|
dest_list_id: i64,
|
|
csrf_token: &str,
|
|
) -> Markup {
|
|
html! {
|
|
@if list_meals.is_empty() {
|
|
div class="meal-picker-empty" {
|
|
p { "This list has no meals to carry over." }
|
|
}
|
|
} @else {
|
|
form
|
|
hx-post=(format!("/lists/{}/carry", dest_list_id))
|
|
hx-target="#list-items"
|
|
hx-swap="morph:outerHTML"
|
|
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
|
|
class="carry-form"
|
|
{
|
|
input type="hidden" name="csrf" value=(csrf_token);
|
|
input type="hidden" name="source_list_id" value=(source_id);
|
|
// Collected as a comma-separated list by the checkboxes below;
|
|
// the form extractor does not coalesce repeated keys into a Vec.
|
|
input id="carry-selected" type="hidden" name="list_meal_ids" value="";
|
|
div class="carry-list" {
|
|
@for meal in list_meals {
|
|
label class="carry-row" {
|
|
input
|
|
type="checkbox"
|
|
value=(meal.id)
|
|
onchange="var form=this.closest('form'); var box=form.querySelector('#carry-selected'); box.value=Array.from(form.querySelectorAll('input[type=checkbox]:checked')).map(function(c){return c.value}).join(',');";
|
|
span class="carry-row-icon" { "🍽" }
|
|
span class="carry-row-name" { (meal.name) }
|
|
}
|
|
}
|
|
}
|
|
button class="button button-primary carry-submit" type="submit" { "Carry over" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn rewards_page(user: &User, cards: &[RewardsCard], csrf_token: &str) -> Markup {
|
|
page(
|
|
"Rewards cards",
|
|
Some(user),
|
|
html! {
|
|
div class="page-heading" {
|
|
div {
|
|
p class="eyebrow" { "REWARDS CARDS" }
|
|
h1 class="page-title" { "Rewards cards" }
|
|
p class="lede" { "Your store cards, ready to scan." }
|
|
}
|
|
}
|
|
div class="dashboard-grid" {
|
|
section class="panel" {
|
|
div class="panel-heading" {
|
|
h2 { "Your cards" }
|
|
span class="count-badge" { (cards.len()) }
|
|
}
|
|
@if cards.is_empty() {
|
|
div class="empty-state" {
|
|
div class="empty-mark" { "🏷" }
|
|
h3 { "No rewards cards yet" }
|
|
p { "Add a card to see its barcode here." }
|
|
}
|
|
} @else {
|
|
div class="rewards-grid" {
|
|
@for card in cards {
|
|
div class="rewards-card" {
|
|
a class="rewards-card-link" href=(format!("/rewards/{}", card.id)) aria-label=(format!("Show {} barcode", card.store_name)) {}
|
|
div class="rewards-card-heading" {
|
|
strong { (card.store_name) }
|
|
form method="post" action=(format!("/rewards/{}/delete", card.id)) hx-confirm="Remove this rewards card? This cannot be undone." {
|
|
input type="hidden" name="csrf" value=(csrf_token);
|
|
button class="danger-link bordered-delete" type="submit" { "Remove" }
|
|
}
|
|
}
|
|
div class="rewards-barcode" {
|
|
(barcode_svg(&card.symbology, &card.number))
|
|
}
|
|
p class="rewards-number" { (card.number) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
section class="panel create-panel" {
|
|
div class="panel-heading" { h2 { "Add a card" } }
|
|
form method="post" action="/rewards" class="stack" {
|
|
input type="hidden" name="csrf" value=(csrf_token);
|
|
label for="store-name" { "Store name" }
|
|
input id="store-name" name="store_name" type="text" maxlength="60" placeholder="e.g. Kroger" required;
|
|
label for="card-number" { "Card number" }
|
|
input id="card-number" name="number" type="text" maxlength="80" placeholder="e.g. 6011 2345 6789" required;
|
|
label for="symbology" { "Barcode type" }
|
|
select id="symbology" name="symbology" {
|
|
option value="code128" selected { "Code 128 (recommended)" }
|
|
option value="code39" { "Code 39" }
|
|
}
|
|
button class="button button-primary" type="submit" { "Add card" }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
/// A focused, single-barcode view for scanning at the register. Only one
|
|
/// barcode is shown at a time so a scanner can't pick up multiple codes.
|
|
pub fn rewards_scan_page(user: &User, card: &RewardsCard) -> Markup {
|
|
page(
|
|
&card.store_name,
|
|
Some(user),
|
|
html! {
|
|
div class="scan-page" {
|
|
a class="button button-quiet scan-back" href="/rewards" { "← All cards" }
|
|
div class="scan-card" {
|
|
p class="eyebrow" { "REWARDS CARD" }
|
|
h1 class="scan-store" { (card.store_name) }
|
|
div class="scan-barcode" {
|
|
(barcode_svg(&card.symbology, &card.number))
|
|
}
|
|
p class="rewards-number" { (card.number) }
|
|
}
|
|
}
|
|
script src=(crate::assets::url("rewards.js")) {}
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Renders a rewards-card number as an inline SVG barcode using the given
|
|
/// symbology. Falls back to a plain text label if the number can't be encoded
|
|
/// (for example, a Code 128 number that isn't valid for the chosen symbology).
|
|
fn barcode_svg(symbology: &str, number: &str) -> Markup {
|
|
let generated = match symbology {
|
|
"code39" => barcoders::sym::code39::Code39::new(number)
|
|
.ok()
|
|
.map(|code| code.encode())
|
|
.and_then(|encoded| {
|
|
barcoders::generators::svg::SVG::new(60)
|
|
.generate(&encoded)
|
|
.ok()
|
|
}),
|
|
_ => barcoders::sym::code128::Code128::new(code128_input(number))
|
|
.ok()
|
|
.map(|code| code.encode())
|
|
.and_then(|encoded| {
|
|
barcoders::generators::svg::SVG::new(60)
|
|
.generate(&encoded)
|
|
.ok()
|
|
}),
|
|
};
|
|
match generated {
|
|
Some(svg) => html! { (maud::PreEscaped(svg)) },
|
|
None => html! { span class="rewards-unencodable" { (number) } },
|
|
}
|
|
}
|
|
|
|
/// Builds the Code 128 input string, choosing the most compact character set.
|
|
///
|
|
/// Code 128 set C packs two digits per symbol, so numeric data renders roughly
|
|
/// half as wide as set B (one character per symbol). Google Wallet (via ZXing)
|
|
/// picks set C automatically for numeric runs, so we do the same to keep the
|
|
/// rendered barcode compact and consistent with it:
|
|
///
|
|
/// - Even-length digit-only numbers: all set C (`Ć` prefix).
|
|
/// - Odd-length digit-only numbers: first digit in set B, then switch to set C
|
|
/// for the remaining even count of digits (e.g. `Ɓ6Ć0171...`).
|
|
/// - Anything else: set B (`Ɓ` prefix).
|
|
fn code128_input(number: &str) -> String {
|
|
let is_all_digits = !number.is_empty() && number.chars().all(|c| c.is_ascii_digit());
|
|
if !is_all_digits {
|
|
return format!("Ɓ{number}");
|
|
}
|
|
let len = number.chars().count();
|
|
if len.is_multiple_of(2) {
|
|
format!("Ć{number}")
|
|
} else {
|
|
let (first, rest) = number.split_at(1);
|
|
format!("Ɓ{first}Ć{rest}")
|
|
}
|
|
}
|
|
|
|
/// 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="icon" href=(crate::assets::url("favicon.ico")) sizes="any";
|
|
link rel="icon" type="image/png" href=(crate::assets::url("favicon-32x32.png")) sizes="32x32";
|
|
link rel="apple-touch-icon" href=(crate::assets::url("apple-touch-icon.png"));
|
|
link rel="stylesheet" href=(crate::assets::url("style.css"));
|
|
script src=(crate::assets::url("htmx.min.js")) {}
|
|
script src=(crate::assets::url("idiomorph-ext.min.js")) {}
|
|
script src=(crate::assets::url("htmx-ws.min.js")) {}
|
|
}
|
|
body hx-boost="true" hx-ext="morph" {
|
|
header class="site-header" {
|
|
a class="brand" href="/lists" { img class="brand-mark" src=(crate::assets::url("logo.svg")) alt="Sustenance logo"; "Sustenance" }
|
|
@if let Some(user) = user {
|
|
nav class="site-nav" {
|
|
a href="/lists" { "Lists" }
|
|
a href="/meals" { "Meals" }
|
|
a href="/rewards" { "Rewards" }
|
|
}
|
|
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::<String>()
|
|
.to_uppercase()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Extracts the rendered SVG width from `viewBox="0 0 <width> <height>"`.
|
|
fn svg_width(svg: &str) -> u32 {
|
|
let viewbox = svg
|
|
.split("viewBox=\"")
|
|
.nth(1)
|
|
.expect("expected viewBox")
|
|
.split('"')
|
|
.next()
|
|
.unwrap();
|
|
let width = viewbox.split_whitespace().nth(2).expect("expected width");
|
|
width.parse().expect("expected numeric width")
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[test]
|
|
fn barcode_svg_renders_code128_svg() {
|
|
let html = barcode_svg("code128", "601123456789").into_string();
|
|
assert!(html.contains("<svg"), "expected svg, got: {html}");
|
|
assert!(html.contains("</svg>"), "expected closing svg, got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn code128_uses_set_c_for_even_digit_numbers() {
|
|
// Set C (Ć) packs two digits per symbol, so an even-length digit-only
|
|
// number should be encoded with set C rather than set B.
|
|
assert_eq!(code128_input("601123456789"), "Ć601123456789");
|
|
// Non-numeric data falls back to set B.
|
|
assert_eq!(code128_input("ABC123"), "ƁABC123");
|
|
assert_eq!(code128_input(""), "Ɓ");
|
|
}
|
|
|
|
#[test]
|
|
fn code128_odd_digit_numbers_lead_with_set_b_then_switch_to_set_c() {
|
|
// 21-digit number: first digit in set B, then set C for the rest.
|
|
assert_eq!(
|
|
code128_input("606171584511340224537"),
|
|
"Ɓ6Ć06171584511340224537"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code128_set_c_renders_shorter_than_set_b() {
|
|
let number = "601123456789";
|
|
let set_c = barcode_svg("code128", number).into_string();
|
|
// Force set B by using a non-numeric character so the digit-only
|
|
// fast path doesn't kick in.
|
|
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
|
|
let width_c = svg_width(&set_c);
|
|
let width_b = svg_width(&set_b);
|
|
assert!(
|
|
width_c < width_b,
|
|
"expected set C ({width_c}) narrower than set B ({width_b})"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code128_odd_digit_number_renders_shorter_than_pure_set_b() {
|
|
let number = "606171584511340224537";
|
|
let mixed = barcode_svg("code128", number).into_string();
|
|
// Force pure set B by appending a non-numeric character.
|
|
let set_b = barcode_svg("code128", &format!("{number} ")).into_string();
|
|
let width_mixed = svg_width(&mixed);
|
|
let width_b = svg_width(&set_b);
|
|
assert!(
|
|
width_mixed < width_b,
|
|
"expected mixed ({width_mixed}) narrower than set B ({width_b})"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn barcode_svg_renders_code39_svg() {
|
|
let html = barcode_svg("code39", "ABC123").into_string();
|
|
assert!(html.contains("<svg"), "expected svg, got: {html}");
|
|
}
|
|
|
|
#[test]
|
|
fn barcode_svg_falls_back_to_text_for_invalid_number() {
|
|
// Code 39 only supports uppercase letters and digits, so lowercase
|
|
// input cannot be encoded and falls back to plain text.
|
|
let html = barcode_svg("code39", "abc").into_string();
|
|
assert!(!html.contains("<svg"), "expected no svg, got: {html}");
|
|
assert!(html.contains("abc"), "expected fallback text, got: {html}");
|
|
}
|
|
}
|