initial vibe coded app
This commit is contained in:
+610
@@ -0,0 +1,610 @@
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
|
||||
use crate::{
|
||||
db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User},
|
||||
hub::PresenceUser,
|
||||
};
|
||||
|
||||
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 household running, one item at a time." }
|
||||
@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" minlength="8" 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 HOUSEHOLD" }
|
||||
h1 { "Registration is invite-only" }
|
||||
p class="lede" { "Ask someone who owns a list to send you an invitation link." }
|
||||
a class="button button-primary" href="/login" { "Back to sign in" }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Markup {
|
||||
page(
|
||||
"Your lists",
|
||||
Some(user),
|
||||
html! {
|
||||
div class="page-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "YOUR HOUSEHOLD" }
|
||||
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 summary in lists {
|
||||
a class="list-card" href=(format!("/lists/{}", summary.list.id)) {
|
||||
span class="list-card-icon" { "✓" }
|
||||
span class="list-card-copy" {
|
||||
strong { (summary.list.name) }
|
||||
small { @if summary.role == "owner" { "Owner" } @else { "Member" } }
|
||||
}
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_page(
|
||||
user: &User,
|
||||
access: &ListAccess,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
presence: &[PresenceUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let is_owner = access.role == "owner";
|
||||
page(
|
||||
&access.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 is_owner {
|
||||
a class="button button-small button-quiet" href="#sharing" { "Share list" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="list-layout" {
|
||||
section class="panel list-panel" {
|
||||
div class="list-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "SHARED LIST" }
|
||||
h1 { (access.list.name) }
|
||||
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
|
||||
}
|
||||
}
|
||||
(list_content_fragment(&access.list, items, categories, csrf_token, false))
|
||||
}
|
||||
aside class="side-column" {
|
||||
(presence_panel(presence, false))
|
||||
(categories_panel(&access.list, categories, csrf_token, false))
|
||||
@if is_owner {
|
||||
section id="sharing" class="panel sharing-panel" {
|
||||
div class="panel-heading" { h2 { "Share this list" } }
|
||||
p { "Create a one-time invite link for someone you shop with." }
|
||||
form
|
||||
hx-post=(format!("/lists/{}/invitations", access.list.id))
|
||||
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" {}
|
||||
}
|
||||
}
|
||||
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", access.list.id)) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_content_fragment(
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
out_of_band: bool,
|
||||
) -> Markup {
|
||||
if out_of_band {
|
||||
html! {
|
||||
div id="list-content" hx-swap-oob="outerHTML" {
|
||||
(list_content(list, items, categories, csrf_token))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
div id="list-content" {
|
||||
(list_content(list, items, categories, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_content(
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
html! {
|
||||
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;
|
||||
input name="quantity" type="text" maxlength="40" placeholder="Qty" aria-label="Quantity";
|
||||
select name="category_id" aria-label="Category" {
|
||||
(category_options(categories, None))
|
||||
}
|
||||
button class="button button-primary add-button" type="submit" { "+ Add" }
|
||||
}
|
||||
(list_items_fragment(list, items, categories, csrf_token, false))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_items_fragment(
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
out_of_band: 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))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
div id="list-items" class="items" data-revision=(list.revision) {
|
||||
(list_items_content(items, categories, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_items_content(items: &[Item], categories: &[Category], csrf_token: &str) -> 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.as_deref() == Some(category.id.as_str()))
|
||||
.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,
|
||||
) -> Markup {
|
||||
html! {
|
||||
section class="category-group" {
|
||||
h2 class="category-heading" { (name) }
|
||||
@for item in items {
|
||||
(item_row(item, categories, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> 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) {
|
||||
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 class="check-button" type="submit" aria-label=(if item.checked { "Mark unchecked" } else { "Mark complete" }) {
|
||||
@if item.checked { "✓" } @else { "" }
|
||||
}
|
||||
}
|
||||
div class="item-copy" {
|
||||
strong { (item.name) }
|
||||
@if !item.quantity.is_empty() || !item.note.is_empty() {
|
||||
small {
|
||||
@if !item.quantity.is_empty() { (item.quantity) }
|
||||
@if !item.quantity.is_empty() && !item.note.is_empty() { " · " }
|
||||
@if !item.note.is_empty() { (item.note) }
|
||||
}
|
||||
}
|
||||
}
|
||||
details class="item-actions" {
|
||||
summary aria-label="Item actions" { "•••" }
|
||||
div class="item-menu" {
|
||||
form
|
||||
hx-post=(format!("/lists/{}/items/{}/edit", item.list_id, item.id))
|
||||
hx-target="#list-items"
|
||||
hx-swap="outerHTML"
|
||||
class="edit-form stack"
|
||||
{
|
||||
input type="hidden" name="csrf" value=(csrf_token);
|
||||
label { "Name" }
|
||||
input name="name" value=(item.name) maxlength="120" required;
|
||||
label { "Quantity" }
|
||||
input name="quantity" value=(item.quantity) maxlength="40";
|
||||
label { "Note" }
|
||||
input name="note" value=(item.note) maxlength="120";
|
||||
label { "Category" }
|
||||
select name="category_id" {
|
||||
(category_options(categories, item.category_id.as_deref()))
|
||||
}
|
||||
button class="button button-small button-secondary" 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 class="danger-link" type="submit" { "Remove item" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn category_options(categories: &[Category], selected: Option<&str>) -> 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.as_str()) {
|
||||
option value=(category.id) selected { (category.name) }
|
||||
} @else {
|
||||
option value=(category.id) { (category.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn categories_panel(
|
||||
list: &GroceryList,
|
||||
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=(format!("/lists/{}/categories", list.id))
|
||||
hx-target="#category-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
class="category-form"
|
||||
{
|
||||
input type="hidden" name="csrf" value=(csrf_token);
|
||||
input name="name" type="text" maxlength="60" placeholder="Add a category" required;
|
||||
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(
|
||||
access: &ListAccess,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(list_content_fragment(&access.list, items, categories, csrf_token, true))
|
||||
(categories_panel(&access.list, categories, csrf_token, true))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_created(
|
||||
access: &ListAccess,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
html! {
|
||||
p class="category-success" { "Category added." }
|
||||
(live_list_fragments(access, items, categories, csrf_token))
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
info: &InvitationInfo,
|
||||
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 " (info.list_name) }
|
||||
p class="lede" { "Shop together and keep the list 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 list" }
|
||||
}
|
||||
} @else {
|
||||
p { "Sign in or create an account to accept this invite." }
|
||||
div class="invite-actions" {
|
||||
a class="button button-primary" href=(format!("/login?invite={}", token)) { "Sign in" }
|
||||
a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 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.4" {}
|
||||
script src="https://unpkg.com/htmx-ext-ws@2.0.2/ws.js" {}
|
||||
}
|
||||
body {
|
||||
header class="site-header" {
|
||||
a class="brand" href="/lists" { span class="brand-mark" { "S" } "Sustenance" }
|
||||
@if let Some(user) = user {
|
||||
div class="account-nav" {
|
||||
span class="user-name" { (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()
|
||||
}
|
||||
Reference in New Issue
Block a user