From 863d43ef8c049b6c3f90002433b844bfdf6ddeba Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Tue, 4 Aug 2026 00:01:23 -0400 Subject: [PATCH] record meals that were added to list, allow delete with ingredients --- e2e/tests/add-meal.spec.ts | 44 +++++ e2e/tests/lists.spec.ts | 2 + migrations/20240103000000_list_meals.sql | 12 ++ src/domain.rs | 15 ++ src/http.rs | 66 ++++++-- src/main.rs | 9 +- src/ports.rs | 29 +++- src/services.rs | 40 ++++- src/sqlite.rs | 202 ++++++++++++++++++++++- src/views.rs | 69 +++++++- static/style.css | 10 ++ 11 files changed, 465 insertions(+), 33 deletions(-) create mode 100644 migrations/20240103000000_list_meals.sql diff --git a/e2e/tests/add-meal.spec.ts b/e2e/tests/add-meal.spec.ts index 7119cbd..05bcba3 100644 --- a/e2e/tests/add-meal.spec.ts +++ b/e2e/tests/add-meal.spec.ts @@ -52,3 +52,47 @@ test("the add-meal picker closes when clicking outside", async ({ page }) => { await page.mouse.click(10, 10); await expect(picker).toHaveCount(0); }); + +test("a meal added to a list is shown in the meals panel", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMealWithIngredients(page, "Spaghetti Bolognese", [ + { name: "Penne", quantity: "500g" }, + { name: "Tomato", quantity: "2" }, + ]); + await createList(page, "Weekly shop"); + + await page.click(".add-meal-button"); + const picker = page.locator(".meal-picker-backdrop"); + await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click(); + + // The meal appears in the "Meals on this list" panel. + const panel = page.locator("#list-meals-panel"); + await expect(panel).toBeVisible(); + await expect(panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible(); +}); + +test("removing a meal from a list removes its ingredients", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMealWithIngredients(page, "Spaghetti Bolognese", [ + { name: "Penne", quantity: "500g" }, + { name: "Tomato", quantity: "2" }, + ]); + await createList(page, "Weekly shop"); + + await page.click(".add-meal-button"); + const picker = page.locator(".meal-picker-backdrop"); + await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click(); + + await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible(); + await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toBeVisible(); + + // Remove the meal from the list. + const panel = page.locator("#list-meals-panel"); + const row = panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" }); + await row.locator(".list-meal-remove-button").click(); + + // The meal's ingredients are removed from the list. + await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toHaveCount(0); + await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0); + await expect(row).toHaveCount(0); +}); diff --git a/e2e/tests/lists.spec.ts b/e2e/tests/lists.spec.ts index fc68fa6..05aaf43 100644 --- a/e2e/tests/lists.spec.ts +++ b/e2e/tests/lists.spec.ts @@ -84,6 +84,8 @@ test("a user can add a category", async ({ page }) => { await registerAndLogin(page, "alice@example.com"); await createList(page, "Weekly shop"); + // Categories are managed from the main lists page. + await page.goto("/lists"); await page.fill("#category-name", "Bakery"); await page.click("#add-category-button"); diff --git a/migrations/20240103000000_list_meals.sql b/migrations/20240103000000_list_meals.sql new file mode 100644 index 0000000..e4f4c10 --- /dev/null +++ b/migrations/20240103000000_list_meals.sql @@ -0,0 +1,12 @@ +CREATE TABLE list_meals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + meal_id INTEGER REFERENCES meals(id) ON DELETE SET NULL, + name TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +ALTER TABLE items ADD COLUMN list_meal_id INTEGER REFERENCES list_meals(id) ON DELETE CASCADE; + +CREATE INDEX list_meals_list_idx ON list_meals(list_id); +CREATE INDEX items_list_meal_idx ON items(list_meal_id); diff --git a/src/domain.rs b/src/domain.rs index 2c84095..4a11df2 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -31,6 +31,9 @@ pub struct Passkey { pub user_id: i64, pub credential_id: String, pub credential: String, + /// WebAuthn sign counter, persisted for future cloned-authenticator + /// detection. Not currently read by application logic. + #[allow(dead_code)] pub counter: i64, } @@ -89,6 +92,18 @@ pub struct MealIngredient { pub category_id: Option, } +#[derive(Clone, Debug)] +pub struct ListMeal { + pub id: i64, + /// The catalog meal this instance came from; `None` once the meal is deleted. + #[allow(dead_code)] + pub meal_id: Option, + pub name: String, + /// When the meal was added to the list. + #[allow(dead_code)] + pub created_at: i64, +} + #[derive(Clone, Debug)] pub struct PresenceUser { pub user_id: i64, diff --git a/src/http.rs b/src/http.rs index d55e13f..8c31f68 100644 --- a/src/http.rs +++ b/src/http.rs @@ -14,6 +14,7 @@ use axum::{ routing::{get, post}, }; use futures_util::{SinkExt, StreamExt}; +use maud::PreEscaped; use serde::{Deserialize, de::DeserializeOwned}; use thiserror::Error; use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; @@ -122,6 +123,10 @@ pub fn build_router(state: AppState) -> Router { post(delete_ingredient), ) .route("/lists/{list_id}/add-meal", post(add_meal_to_list)) + .route( + "/lists/{list_id}/meals/{list_meal_id}/remove", + post(remove_meal_from_list), + ) .route("/lists/{list_id}/stream", get(list_stream)) .route("/invite/{token}", get(invitation_page)) .route("/invite/{token}/accept", post(accept_invitation)) @@ -589,9 +594,11 @@ async fn lists_page( user: CurrentUser, ) -> Result { let lists = state.lists.list_summaries().await?; + let categories = state.lists.categories().await?; Ok(html_response(views::lists_page( &user.session.user, &lists, + &categories, &user.session.csrf_token, ))) } @@ -620,12 +627,14 @@ async fn list_page( let access = require_list(&state, list_id).await?; let items = state.lists.items(list_id).await?; let categories = state.lists.categories().await?; + let list_meals = state.meals.list_meals_on_list(list_id).await?; let presence = state.realtime.presence(list_id).await; Ok(html_response(views::list_page( &user.session.user, &access, &items, &categories, + &list_meals, &presence, &user.session.csrf_token, ))) @@ -953,6 +962,21 @@ async fn add_meal_to_list( list_fragment_response(&state, &user, list_id).await } +async fn remove_meal_from_list( + State(state): State, + user: CurrentUser, + Path((list_id, list_meal_id)): Path<(i64, i64)>, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + require_list(&state, list_id).await?; + state + .meals + .remove_meal_from_list(list_id, list_meal_id) + .await?; + list_fragment_response(&state, &user, list_id).await +} + async fn create_invitation( State(state): State, user: CurrentUser, @@ -1117,11 +1141,16 @@ async fn websocket_snapshot( let access = require_list(state, list_id).await?; let items = state.lists.items(list_id).await?; let categories = state.lists.categories().await?; - Ok( - views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) - .into_string() - + &views::presence_panel(presence, true).into_string(), + let list_meals = state.meals.list_meals_on_list(list_id).await?; + Ok(views::live_list_fragments( + &access, + &items, + &categories, + &list_meals, + &user.session.csrf_token, ) + .into_string() + + &views::presence_panel(presence, true).into_string()) } async fn websocket_list_update( @@ -1132,10 +1161,15 @@ async fn websocket_list_update( let access = require_list(state, list_id).await?; let items = state.lists.items(list_id).await?; let categories = state.lists.categories().await?; - Ok( - views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) - .into_string(), + let list_meals = state.meals.list_meals_on_list(list_id).await?; + Ok(views::live_list_fragments( + &access, + &items, + &categories, + &list_meals, + &user.session.csrf_token, ) + .into_string()) } async fn list_fragment_response( @@ -1146,12 +1180,18 @@ async fn list_fragment_response( let access = require_list(state, list_id).await?; let items = state.lists.items(list_id).await?; let categories = state.lists.categories().await?; - Ok(html_response(views::list_items_fragment( - &access, - &items, - &categories, - &user.session.csrf_token, - false, + let list_meals = state.meals.list_meals_on_list(list_id).await?; + Ok(html_response(PreEscaped( + views::list_items_fragment( + &access, + &items, + &categories, + &user.session.csrf_token, + false, + ) + .into_string() + + &views::list_meals_panel(&list_meals, list_id, &user.session.csrf_token, true) + .into_string(), ))) } diff --git a/src/main.rs b/src/main.rs index 4444002..8964111 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,7 @@ use tracing::{info, warn}; use crate::http::{AppState, build_router}; use crate::hub::InMemoryHub; use crate::ports::{ - CategoryRepository, InvitationRepository, ItemRepository, ListRepository, + CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository, PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, }; @@ -27,8 +27,9 @@ use crate::security::{Argon2PasswordHasher, RandomTokenGenerator}; use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode}; use crate::sqlite::{ SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository, - SqliteListRepository, SqliteMealCategoryRepository, SqliteMealIngredientRepository, - SqliteMealRepository, SqlitePasskeyRepository, SqliteSessionRepository, SqliteUserRepository, + SqliteListMealRepository, SqliteListRepository, SqliteMealCategoryRepository, + SqliteMealIngredientRepository, SqliteMealRepository, SqlitePasskeyRepository, + SqliteSessionRepository, SqliteUserRepository, }; use crate::webauthn::{AppWebauthnConfig, WebAuthnService}; @@ -74,6 +75,7 @@ async fn main() -> Result<(), Box> { let lists: Arc = Arc::new(SqliteListRepository); let categories: Arc = Arc::new(SqliteCategoryRepository); let items: Arc = Arc::new(SqliteItemRepository); + let list_meals: Arc = Arc::new(SqliteListMealRepository); let meals: Arc = Arc::new(SqliteMealRepository); let meal_ingredients: Arc = Arc::new(SqliteMealIngredientRepository); @@ -111,6 +113,7 @@ async fn main() -> Result<(), Box> { Arc::clone(&meal_categories), Arc::clone(&lists), Arc::clone(&items), + Arc::clone(&list_meals), Arc::clone(&realtime), )); diff --git a/src/ports.rs b/src/ports.rs index ec4aaaa..0bfdd5e 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -2,8 +2,8 @@ use async_trait::async_trait; use sqlx::SqliteConnection; use crate::domain::{ - Category, DomainResult, GroceryList, Item, Meal, MealCategory, MealIngredient, Passkey, - PresenceUser, SessionUser, User, + Category, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, + Passkey, PresenceUser, SessionUser, User, }; /// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to), @@ -113,6 +113,9 @@ pub struct NewItem { pub quantity: String, pub note: String, pub category_id: Option, + /// When set, links this item to the `list_meals` row it came from, so the + /// item is removed together with that meal instance. + pub list_meal_id: Option, } #[async_trait] @@ -158,6 +161,28 @@ pub trait ItemRepository: Send + Sync { ) -> DomainResult; } +#[async_trait] +pub trait ListMealRepository: Send + Sync { + async fn list_meals( + &self, + txn: &mut SqliteConnection, + list_id: i64, + ) -> DomainResult>; + async fn add_meal( + &self, + txn: &mut SqliteConnection, + list_id: i64, + meal_id: i64, + name: String, + ) -> DomainResult; + async fn remove_meal( + &self, + txn: &mut SqliteConnection, + list_id: i64, + list_meal_id: i64, + ) -> DomainResult; +} + #[async_trait] pub trait InvitationRepository: Send + Sync { async fn create_invitation( diff --git a/src/services.rs b/src/services.rs index 5ade949..7b716e1 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,10 +1,10 @@ use std::sync::Arc; use crate::domain::{ - DomainError, DomainResult, GroceryList, Item, Meal, MealCategory, SessionUser, User, + DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, SessionUser, User, }; use crate::ports::{ - CategoryRepository, InvitationRepository, ItemRepository, ListRepository, + CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, }; @@ -318,6 +318,7 @@ pub struct MealService { meal_categories: Arc, lists: Arc, items: Arc, + list_meals: Arc, realtime: Arc, } @@ -329,6 +330,7 @@ impl MealService { meal_categories: Arc, lists: Arc, items: Arc, + list_meals: Arc, realtime: Arc, ) -> Self { Self { @@ -338,6 +340,7 @@ impl MealService { meal_categories, lists, items, + list_meals, realtime, } } @@ -488,11 +491,12 @@ impl MealService { } /// Expands a meal's ingredients into items on a list in one unit of work, - /// bumping the list revision exactly once. + /// recording the meal on the list and bumping the list revision exactly once. pub async fn add_meal_to_list(&self, meal_id: i64, list_id: i64) -> DomainResult { let meals = Arc::clone(&self.meals); let lists = Arc::clone(&self.lists); let items = Arc::clone(&self.items); + let list_meals = Arc::clone(&self.list_meals); let revision = self .db .run(move |txn| { @@ -504,6 +508,9 @@ impl MealService { if lists.get_list(txn, list_id).await?.is_none() { return Err(DomainError::NotFound); } + let list_meal_id = list_meals + .add_meal(txn, list_id, meal.id, meal.name.clone()) + .await?; let new_items = meal .ingredients .into_iter() @@ -512,6 +519,7 @@ impl MealService { quantity: ingredient.quantity, note: ingredient.note, category_id: ingredient.category_id, + list_meal_id: Some(list_meal_id), }) .collect(); items.add_items_bulk(txn, list_id, new_items).await @@ -521,6 +529,32 @@ impl MealService { self.realtime.publish_list_changed(list_id, revision).await; Ok(revision) } + + /// Lists the meals that have been added to a list, most recent first. + pub async fn list_meals_on_list(&self, list_id: i64) -> DomainResult> { + let list_meals = Arc::clone(&self.list_meals); + self.db + .run(move |txn| Box::pin(async move { list_meals.list_meals(txn, list_id).await })) + .await + } + + /// Removes a meal instance from a list, deleting the items that came from it + /// and bumping the list revision exactly once. + pub async fn remove_meal_from_list( + &self, + list_id: i64, + list_meal_id: i64, + ) -> DomainResult { + let list_meals = Arc::clone(&self.list_meals); + let revision = self + .db + .run(move |txn| { + Box::pin(async move { list_meals.remove_meal(txn, list_id, list_meal_id).await }) + }) + .await?; + self.realtime.publish_list_changed(list_id, revision).await; + Ok(revision) + } } pub struct InvitationService { diff --git a/src/sqlite.rs b/src/sqlite.rs index 1994773..fc780dd 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -7,11 +7,11 @@ use sha2::{Digest, Sha256}; use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions}; use crate::domain::{ - Category, DomainError, DomainResult, GroceryList, Item, Meal, MealCategory, MealIngredient, - Passkey, SessionUser, User, + Category, DomainError, DomainResult, GroceryList, Item, ListMeal, Meal, MealCategory, + MealIngredient, Passkey, SessionUser, User, }; use crate::ports::{ - CategoryRepository, InvitationRepository, ItemRepository, ListRepository, + CategoryRepository, InvitationRepository, ItemRepository, ListMealRepository, ListRepository, MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository, SessionRepository, UserRepository, }; @@ -640,8 +640,8 @@ impl ItemRepository for SqliteItemRepository { ensure_category(txn, item.category_id).await?; sqlx::query( "INSERT INTO items - (list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)", + (list_id, name, quantity, note, category_id, checked, version, position, list_meal_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?8, ?8)", ) .bind(list_id) .bind(&item.name) @@ -649,6 +649,7 @@ impl ItemRepository for SqliteItemRepository { .bind(&item.note) .bind(item.category_id) .bind(position) + .bind(item.list_meal_id) .bind(now) .execute(&mut *txn) .await @@ -738,6 +739,83 @@ impl ItemRepository for SqliteItemRepository { } } +#[derive(Clone, Copy)] +pub struct SqliteListMealRepository; + +#[async_trait] +impl ListMealRepository for SqliteListMealRepository { + async fn list_meals( + &self, + txn: &mut SqliteConnection, + list_id: i64, + ) -> DomainResult> { + let rows = sqlx::query( + "SELECT id, meal_id, name, created_at + FROM list_meals + WHERE list_id = ?1 + ORDER BY created_at ASC, id ASC", + ) + .bind(list_id) + .fetch_all(&mut *txn) + .await + .map_err(db_error)?; + Ok(rows + .into_iter() + .map(|row| ListMeal { + id: row.get(0), + meal_id: row.get(1), + name: row.get(2), + created_at: row.get(3), + }) + .collect()) + } + + async fn add_meal( + &self, + txn: &mut SqliteConnection, + list_id: i64, + meal_id: i64, + name: String, + ) -> DomainResult { + sqlx::query( + "INSERT INTO list_meals (list_id, meal_id, name, created_at) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(list_id) + .bind(meal_id) + .bind(&name) + .bind(now()) + .execute(&mut *txn) + .await + .map_err(db_error)?; + let id = sqlx::query("SELECT last_insert_rowid()") + .fetch_one(&mut *txn) + .await + .map_err(db_error)? + .get::(0); + Ok(id) + } + + async fn remove_meal( + &self, + txn: &mut SqliteConnection, + list_id: i64, + list_meal_id: i64, + ) -> DomainResult { + let changed = sqlx::query("DELETE FROM list_meals WHERE id = ?1 AND list_id = ?2") + .bind(list_meal_id) + .bind(list_id) + .execute(&mut *txn) + .await + .map_err(db_error)? + .rows_affected(); + if changed == 0 { + return Err(DomainError::NotFound); + } + bump_revision(txn, list_id).await + } +} + #[derive(Clone, Copy)] pub struct SqliteInvitationRepository; @@ -1771,12 +1849,14 @@ mod tests { quantity: "500g".into(), note: String::new(), category_id: None, + list_meal_id: None, }, NewItem { name: "Tomato".into(), quantity: "2".into(), note: String::new(), category_id: None, + list_meal_id: None, }, ]; let revision = db @@ -2342,6 +2422,118 @@ mod tests { assert!(matches!(result, Err(DomainError::NotFound))); } + // ---- ListMealRepository ---- + + async fn add_meal_to_list(db: &SqliteDatabase, list_id: i64, meal: &Meal) -> ListMeal { + let list_meals = SqliteListMealRepository; + let name = meal.name.clone(); + let meal_id = meal.id; + let id = db + .run(move |txn| { + let list_meals = list_meals.clone(); + Box::pin(async move { list_meals.add_meal(txn, list_id, meal_id, name).await }) + }) + .await + .unwrap(); + ListMeal { + id, + meal_id: Some(meal.id), + name: meal.name.clone(), + created_at: 0, + } + } + + #[tokio::test] + async fn list_meals_returns_meals_added_to_a_list() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let meal = create_meal(&db, "Pasta").await; + add_meal_to_list(&db, list.id, &meal).await; + + let list_meals = SqliteListMealRepository; + let meals = db + .run(move |txn| { + let list_meals = list_meals.clone(); + Box::pin(async move { list_meals.list_meals(txn, list.id).await }) + }) + .await + .unwrap(); + assert_eq!(meals.len(), 1); + assert_eq!(meals[0].name, "Pasta"); + assert_eq!(meals[0].meal_id, Some(meal.id)); + } + + #[tokio::test] + async fn removing_a_meal_deletes_its_items_and_bumps_revision() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let meal = create_meal(&db, "Pasta").await; + add_ingredient(&db, meal.id, "Penne", None).await; + add_ingredient(&db, meal.id, "Tomato", None).await; + let list_meal = add_meal_to_list(&db, list.id, &meal).await; + + let items = SqliteItemRepository; + let ingredients = SqliteMealIngredientRepository; + let ingredient_rows = db + .run(move |txn| { + let ingredients = ingredients.clone(); + Box::pin(async move { ingredients.ingredients_for_meal(txn, meal.id).await }) + }) + .await + .unwrap(); + let new_items = ingredient_rows + .into_iter() + .map(|ingredient| NewItem { + name: ingredient.name, + quantity: ingredient.quantity, + note: ingredient.note, + category_id: ingredient.category_id, + list_meal_id: Some(list_meal.id), + }) + .collect(); + db.run(move |txn| { + let items = items.clone(); + Box::pin(async move { items.add_items_bulk(txn, list.id, new_items).await }) + }) + .await + .unwrap(); + assert_eq!(get_items(&db, list.id).await.len(), 2); + + let list_meals = SqliteListMealRepository; + let revision = db + .run(move |txn| { + let list_meals = list_meals.clone(); + Box::pin(async move { list_meals.remove_meal(txn, list.id, list_meal.id).await }) + }) + .await + .unwrap(); + assert_eq!(revision, 2); + + let meals = db + .run(move |txn| { + let list_meals = list_meals.clone(); + Box::pin(async move { list_meals.list_meals(txn, list.id).await }) + }) + .await + .unwrap(); + assert!(meals.is_empty()); + assert!(get_items(&db, list.id).await.is_empty()); + } + + #[tokio::test] + async fn removing_an_unknown_meal_from_a_list_fails() { + let db = setup().await; + let list = create_list(&db, "Weekly shop").await; + let list_meals = SqliteListMealRepository; + let result = db + .run(move |txn| { + let list_meals = list_meals.clone(); + Box::pin(async move { list_meals.remove_meal(txn, list.id, 9999).await }) + }) + .await; + assert!(matches!(result, Err(DomainError::NotFound))); + } + // ---- PasskeyRepository ---- #[tokio::test] diff --git a/src/views.rs b/src/views.rs index 5e7db87..2df83bb 100644 --- a/src/views.rs +++ b/src/views.rs @@ -3,7 +3,9 @@ use pulldown_cmark::{Options, Parser, html as cmark_html}; use crate::{ domain::PresenceUser, - domain::{Category, GroceryList, Item, Meal, MealCategory, MealIngredient, Passkey, User}, + domain::{ + Category, GroceryList, Item, ListMeal, Meal, MealCategory, MealIngredient, Passkey, User, + }, }; pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { @@ -168,7 +170,12 @@ pub fn account_page( ) } -pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Markup { +pub fn lists_page( + user: &User, + lists: &[GroceryList], + categories: &[Category], + csrf_token: &str, +) -> Markup { page( "Your lists", Some(user), @@ -176,7 +183,7 @@ pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Marku div class="page-heading" { div { p class="eyebrow" { "SHARED LISTS" } - h1 { "Grocery lists" } + h1 class="page-title" { "Grocery lists" } p class="lede" { "Everything you need, in one place." } } } @@ -215,6 +222,7 @@ pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Marku 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." } @@ -247,7 +255,7 @@ pub fn meals_page( div class="page-heading" { div { p class="eyebrow" { "MEAL LIBRARY" } - h1 { "Meals" } + 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" } @@ -711,6 +719,7 @@ pub fn list_page( list: &GroceryList, items: &[Item], categories: &[Category], + list_meals: &[ListMeal], presence: &[PresenceUser], csrf_token: &str, ) -> Markup { @@ -722,7 +731,6 @@ pub fn list_page( a class="back-link" href="/lists" { "← All lists" } div class="list-topbar-actions" { span class="live-pill" { span class="live-dot" {} "Live" } - button class="button button-small add-meal-button" hx-get=(format!("/meals?picker={}", list.id)) hx-target="#meal-picker" hx-swap="innerHTML" { "+ Add meal" } } } div id="meal-picker" class="meal-picker" {} @@ -738,8 +746,8 @@ pub fn list_page( (list_content_fragment(list, items, categories, csrf_token, false)) } aside class="side-column" { + (list_meals_panel(list_meals, list.id, csrf_token, false)) (presence_panel(presence, false)) - (categories_panel(categories, csrf_token, false)) section class="panel tip-panel" { span class="tip-label" { "TIP" } p { "Check items off as you go. Everyone viewing this list will see it instantly." } @@ -1006,11 +1014,58 @@ pub fn live_list_fragments( list: &GroceryList, items: &[Item], categories: &[Category], + list_meals: &[ListMeal], csrf_token: &str, ) -> Markup { html! { (list_content_fragment(list, items, categories, csrf_token, true)) - (categories_panel(categories, csrf_token, true)) + (list_meals_panel(list_meals, list.id, csrf_token, true)) + } +} + +pub fn list_meals_panel( + list_meals: &[ListMeal], + list_id: i64, + csrf_token: &str, + out_of_band: 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) } + 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)) { "✕" } + } + } + } + } + } + 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) } + } } } diff --git a/static/style.css b/static/style.css index 5226c7c..ff2f978 100644 --- a/static/style.css +++ b/static/style.css @@ -67,6 +67,7 @@ h3 { margin-bottom: 6px; font-size: 1rem; } .muted { color: var(--muted); } .page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 34px; } +.page-heading h1.page-title { font-size: clamp(1.6rem, 3.2vw, 2.3rem); } .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, .8fr); gap: 22px; align-items: start; } .panel { padding: 26px; border: 1px solid rgba(221, 225, 210, .9); border-radius: 24px; background: rgba(255, 253, 248, .88); box-shadow: var(--shadow); } .panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 22px; } @@ -244,6 +245,15 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .presence-list { display: grid; gap: 12px; } .presence-person { display: flex; align-items: center; gap: 10px; font-size: .9rem; font-weight: 700; } .avatar { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 11px; color: var(--deep-sage); background: #e6f0df; font-size: .7rem; font-weight: 900; } +.list-meals { display: grid; gap: 8px; } +.list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; } +.list-meal-row:last-child { border-bottom: 0; } +.list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; } +.list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; } +.list-meal-remove { margin: 0; flex: 0 0 auto; } +.list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; } +.list-meal-remove-button:hover { color: var(--coral); background: #fbeae4; } +.list-meals-panel .add-meal-button { margin-top: 12px; width: 100%; } .sharing-panel p, .tip-panel p { color: var(--muted); font-size: .86rem; } .invite-result { margin-top: 15px; } .invite-link-result { padding: 11px; border-radius: 12px; background: #f1f5ec; }