meal filtering feature
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { test } from "../fixtures";
|
||||||
|
import { registerAndLogin, createList, createMeal } from "../helpers";
|
||||||
|
|
||||||
|
test("the meals page filters by name as you type", async ({ page }) => {
|
||||||
|
await registerAndLogin(page, "alice@example.com");
|
||||||
|
await createMeal(page, "Spaghetti Bolognese", "");
|
||||||
|
await createMeal(page, "Chicken Curry", "");
|
||||||
|
await createMeal(page, "Pancakes", "");
|
||||||
|
|
||||||
|
await page.goto("/meals");
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Chicken Curry" })).toBeVisible();
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Pancakes" })).toBeVisible();
|
||||||
|
|
||||||
|
// Type a query; only matching meals remain.
|
||||||
|
await page.fill("#meal-search", "chicken");
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Chicken Curry" })).toBeVisible();
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toHaveCount(0);
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Pancakes" })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Clearing the search restores all meals.
|
||||||
|
await page.fill("#meal-search", "");
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
|
||||||
|
await expect(page.locator(".list-card").filter({ hasText: "Pancakes" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the meals page shows an empty state when nothing matches", async ({ page }) => {
|
||||||
|
await registerAndLogin(page, "alice@example.com");
|
||||||
|
await createMeal(page, "Spaghetti Bolognese", "");
|
||||||
|
|
||||||
|
await page.goto("/meals");
|
||||||
|
await page.fill("#meal-search", "zzzz");
|
||||||
|
await expect(page.locator(".meal-filter-empty")).toBeVisible();
|
||||||
|
await expect(page.locator(".list-card")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the add-meal picker filters meals as you type", async ({ page }) => {
|
||||||
|
await registerAndLogin(page, "alice@example.com");
|
||||||
|
await createMeal(page, "Spaghetti Bolognese", "");
|
||||||
|
await createMeal(page, "Chicken Curry", "");
|
||||||
|
await createList(page, "Weekly shop");
|
||||||
|
|
||||||
|
await page.click(".add-meal-button");
|
||||||
|
const picker = page.locator(".meal-picker-backdrop");
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await expect(picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible();
|
||||||
|
await expect(picker.locator(".meal-picker-button").filter({ hasText: "Chicken Curry" })).toBeVisible();
|
||||||
|
|
||||||
|
await picker.locator(".meal-filter").fill("curry");
|
||||||
|
await expect(picker.locator(".meal-picker-button").filter({ hasText: "Chicken Curry" })).toBeVisible();
|
||||||
|
await expect(picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" })).toHaveCount(0);
|
||||||
|
|
||||||
|
// The search box keeps focus and the modal stays open.
|
||||||
|
await expect(picker.locator(".meal-filter")).toBeFocused();
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
});
|
||||||
+39
-2
@@ -340,6 +340,7 @@ struct AddMealForm {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct MealPickerQuery {
|
struct MealPickerQuery {
|
||||||
picker: Option<i64>,
|
picker: Option<i64>,
|
||||||
|
q: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn home() -> Redirect {
|
async fn home() -> Redirect {
|
||||||
@@ -897,15 +898,50 @@ async fn meals_page(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
Query(query): Query<MealPickerQuery>,
|
Query(query): Query<MealPickerQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let meals = state.meals.list_meals().await?;
|
let q = query.q.clone().unwrap_or_default();
|
||||||
|
let meals = state.meals.list_meals(&q).await?;
|
||||||
let meal_categories = state.meals.list_meal_categories().await?;
|
let meal_categories = state.meals.list_meal_categories().await?;
|
||||||
|
let is_htmx = headers
|
||||||
|
.get("hx-request")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value == "true");
|
||||||
|
// A boosted navigation (e.g. the category form's POST redirect) also sends
|
||||||
|
// `HX-Request: true`, but must render the full page, not just the results
|
||||||
|
// fragment. Only a live search (an `hx-get` on the filter box) swaps the
|
||||||
|
// fragment. htmx marks boosted requests with `HX-Boosted: true`.
|
||||||
|
let is_boosted = headers
|
||||||
|
.get("hx-boosted")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value == "true");
|
||||||
if let Some(list_id) = query.picker {
|
if let Some(list_id) = query.picker {
|
||||||
return Ok(html_response(views::meal_picker(
|
if query.q.is_some() {
|
||||||
|
// Live search inside the picker: swap only the grouped rows, keeping
|
||||||
|
// the modal and search box focused. The initial picker load has no
|
||||||
|
// `q`, so it falls through to the full modal below.
|
||||||
|
return Ok(html_response(views::meal_picker_results(
|
||||||
|
&meals,
|
||||||
|
&meal_categories,
|
||||||
|
list_id,
|
||||||
|
&user.session.csrf_token,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let picker = views::meal_picker(
|
||||||
&meals,
|
&meals,
|
||||||
&meal_categories,
|
&meal_categories,
|
||||||
list_id,
|
list_id,
|
||||||
&user.session.csrf_token,
|
&user.session.csrf_token,
|
||||||
|
&q,
|
||||||
|
);
|
||||||
|
return Ok(html_response(picker));
|
||||||
|
}
|
||||||
|
if is_htmx && !is_boosted {
|
||||||
|
// Live search on the meals page: swap only the grouped results.
|
||||||
|
return Ok(html_response(views::meal_results(
|
||||||
|
&meals,
|
||||||
|
&meal_categories,
|
||||||
|
&q,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(html_response(views::meals_page(
|
Ok(html_response(views::meals_page(
|
||||||
@@ -913,6 +949,7 @@ async fn meals_page(
|
|||||||
&meals,
|
&meals,
|
||||||
&meal_categories,
|
&meal_categories,
|
||||||
&user.session.csrf_token,
|
&user.session.csrf_token,
|
||||||
|
&q,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -238,7 +238,7 @@ pub trait MealRepository: Send + Sync {
|
|||||||
txn: &mut SqliteConnection,
|
txn: &mut SqliteConnection,
|
||||||
meal_id: i64,
|
meal_id: i64,
|
||||||
) -> DomainResult<Option<Meal>>;
|
) -> DomainResult<Option<Meal>>;
|
||||||
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>>;
|
async fn list_meals(&self, txn: &mut SqliteConnection, query: &str) -> DomainResult<Vec<Meal>>;
|
||||||
async fn update_meal(
|
async fn update_meal(
|
||||||
&self,
|
&self,
|
||||||
txn: &mut SqliteConnection,
|
txn: &mut SqliteConnection,
|
||||||
|
|||||||
+3
-2
@@ -390,10 +390,11 @@ impl MealService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_meals(&self) -> DomainResult<Vec<Meal>> {
|
pub async fn list_meals(&self, query: &str) -> DomainResult<Vec<Meal>> {
|
||||||
let meals = Arc::clone(&self.meals);
|
let meals = Arc::clone(&self.meals);
|
||||||
|
let query = query.to_owned();
|
||||||
self.db
|
self.db
|
||||||
.run(move |txn| Box::pin(async move { meals.list_meals(txn).await }))
|
.run(move |txn| Box::pin(async move { meals.list_meals(txn, &query).await }))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+58
-2
@@ -1097,12 +1097,15 @@ impl MealRepository for SqliteMealRepository {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>> {
|
async fn list_meals(&self, txn: &mut SqliteConnection, query: &str) -> DomainResult<Vec<Meal>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, name, description, category_id
|
"SELECT id, name, description, category_id
|
||||||
FROM meals
|
FROM meals
|
||||||
|
WHERE (?1 = '' OR name LIKE '%' || ?2 || '%' ESCAPE '\\')
|
||||||
ORDER BY name COLLATE NOCASE ASC",
|
ORDER BY name COLLATE NOCASE ASC",
|
||||||
)
|
)
|
||||||
|
.bind(query)
|
||||||
|
.bind(escape_like(query))
|
||||||
.fetch_all(&mut *txn)
|
.fetch_all(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(db_error)?;
|
.map_err(db_error)?;
|
||||||
@@ -1456,6 +1459,14 @@ fn db_error(error: sqlx::Error) -> DomainError {
|
|||||||
DomainError::Database(error.to_string())
|
DomainError::Database(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Escapes a user-supplied search term so `%`, `_` and `\` are matched
|
||||||
|
/// literally when interpolated into a `LIKE` pattern with `ESCAPE '\'`.
|
||||||
|
fn escape_like(term: &str) -> String {
|
||||||
|
term.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_")
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate_error(error: sqlx::migrate::MigrateError) -> DomainError {
|
fn migrate_error(error: sqlx::migrate::MigrateError) -> DomainError {
|
||||||
DomainError::Database(error.to_string())
|
DomainError::Database(error.to_string())
|
||||||
}
|
}
|
||||||
@@ -2428,7 +2439,7 @@ mod tests {
|
|||||||
let meals = db
|
let meals = db
|
||||||
.run(move |txn| {
|
.run(move |txn| {
|
||||||
let meals = meals.clone();
|
let meals = meals.clone();
|
||||||
Box::pin(async move { meals.list_meals(txn).await })
|
Box::pin(async move { meals.list_meals(txn, "").await })
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2437,6 +2448,51 @@ mod tests {
|
|||||||
assert_eq!(names, vec!["Pasta".to_owned(), "Salad".to_owned()]);
|
assert_eq!(names, vec!["Pasta".to_owned(), "Salad".to_owned()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_meals_filters_by_name_case_insensitively() {
|
||||||
|
let db = setup().await;
|
||||||
|
create_meal(&db, "Pasta Carbonara").await;
|
||||||
|
create_meal(&db, "Chicken Curry").await;
|
||||||
|
let meals = SqliteMealRepository;
|
||||||
|
let names = |posted_query: &str| {
|
||||||
|
let meals = meals.clone();
|
||||||
|
let posted_query = posted_query.to_owned();
|
||||||
|
db.run(move |txn| {
|
||||||
|
let meals = meals.clone();
|
||||||
|
Box::pin(async move { meals.list_meals(txn, &posted_query).await })
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let matched = names("pasta").await.unwrap();
|
||||||
|
let matched_names = matched.iter().map(|m| m.name.as_str()).collect::<Vec<_>>();
|
||||||
|
assert_eq!(matched_names, vec!["Pasta Carbonara"]);
|
||||||
|
assert!(names("pizza").await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_meals_escapes_like_wildcards() {
|
||||||
|
let db = setup().await;
|
||||||
|
create_meal(&db, "100% Wholemeal Bread").await;
|
||||||
|
create_meal(&db, "Salad").await;
|
||||||
|
let meals = SqliteMealRepository;
|
||||||
|
let names = |posted_query: &str| {
|
||||||
|
let meals = meals.clone();
|
||||||
|
let posted_query = posted_query.to_owned();
|
||||||
|
db.run(move |txn| {
|
||||||
|
let meals = meals.clone();
|
||||||
|
Box::pin(async move { meals.list_meals(txn, &posted_query).await })
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// A bare `%` must not act as a wildcard matching every meal: it only
|
||||||
|
// matches meals that contain a literal `%` character.
|
||||||
|
let matched = names("%").await.unwrap();
|
||||||
|
assert_eq!(matched.len(), 1);
|
||||||
|
assert_eq!(matched[0].name, "100% Wholemeal Bread");
|
||||||
|
// `_` and `%` in the query are matched literally.
|
||||||
|
let matched = names("100%").await.unwrap();
|
||||||
|
assert_eq!(matched.len(), 1);
|
||||||
|
assert_eq!(matched[0].name, "100% Wholemeal Bread");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_meal_returns_meal_with_ingredients() {
|
async fn get_meal_returns_meal_with_ingredients() {
|
||||||
let db = setup().await;
|
let db = setup().await;
|
||||||
|
|||||||
+111
-49
@@ -293,6 +293,7 @@ pub fn meals_page(
|
|||||||
meals: &[Meal],
|
meals: &[Meal],
|
||||||
meal_categories: &[MealCategory],
|
meal_categories: &[MealCategory],
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
|
q: &str,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
page(
|
page(
|
||||||
"Meals",
|
"Meals",
|
||||||
@@ -306,7 +307,7 @@ pub fn meals_page(
|
|||||||
}
|
}
|
||||||
a class="button button-primary" href="/meals/new" { "New meal" }
|
a class="button button-primary" href="/meals/new" { "New meal" }
|
||||||
}
|
}
|
||||||
@if meals.is_empty() {
|
@if meals.is_empty() && q.is_empty() {
|
||||||
div class="empty-state" {
|
div class="empty-state" {
|
||||||
div class="empty-mark" { "🍽" }
|
div class="empty-mark" { "🍽" }
|
||||||
h3 { "No meals yet" }
|
h3 { "No meals yet" }
|
||||||
@@ -319,28 +320,21 @@ pub fn meals_page(
|
|||||||
h2 { "All meals" }
|
h2 { "All meals" }
|
||||||
span class="count-badge" { (meals.len()) }
|
span class="count-badge" { (meals.len()) }
|
||||||
}
|
}
|
||||||
@if meals.is_empty() {
|
div class="meal-filter-bar" {
|
||||||
p class="muted" { "Create a meal to get started." }
|
input
|
||||||
} @else {
|
class="meal-filter"
|
||||||
@for (category_name, category_meals) in meal_groups(meals, meal_categories) {
|
id="meal-search"
|
||||||
div class="category-group" {
|
type="search"
|
||||||
div class="category-heading" {
|
name="q"
|
||||||
h3 { (category_name) " (" (category_meals.len()) ")" }
|
value=(q)
|
||||||
}
|
placeholder="Search meals"
|
||||||
div class="list-cards" {
|
hx-get="/meals"
|
||||||
@for meal in category_meals {
|
hx-trigger="input changed delay:200ms"
|
||||||
a class="list-card" href=(format!("/meals/{}", meal.id)) {
|
hx-target="#meal-results"
|
||||||
span class="list-card-icon" { "🍽" }
|
hx-swap="innerHTML";
|
||||||
span class="list-card-copy" {
|
}
|
||||||
strong { (meal.name) }
|
div id="meal-results" {
|
||||||
small { (meal.ingredients.len()) " ingredients" }
|
(meal_results(meals, meal_categories, q))
|
||||||
}
|
|
||||||
span class="list-card-arrow" { "→" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
aside class="side-column" {
|
aside class="side-column" {
|
||||||
@@ -376,6 +370,42 @@ pub fn meals_page(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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>(
|
fn meal_groups<'a>(
|
||||||
meals: &'a [Meal],
|
meals: &'a [Meal],
|
||||||
meal_categories: &[MealCategory],
|
meal_categories: &[MealCategory],
|
||||||
@@ -416,6 +446,7 @@ pub fn meal_picker(
|
|||||||
meal_categories: &[MealCategory],
|
meal_categories: &[MealCategory],
|
||||||
list_id: i64,
|
list_id: i64,
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
|
q: &str,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" {
|
div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" {
|
||||||
@@ -427,7 +458,19 @@ pub fn meal_picker(
|
|||||||
}
|
}
|
||||||
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "✕" }
|
button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "✕" }
|
||||||
}
|
}
|
||||||
@if meals.is_empty() {
|
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" {
|
div class="meal-picker-empty" {
|
||||||
span class="empty-mark" { "🍽" }
|
span class="empty-mark" { "🍽" }
|
||||||
h3 { "No meals yet" }
|
h3 { "No meals yet" }
|
||||||
@@ -435,32 +478,51 @@ pub fn meal_picker(
|
|||||||
a class="button button-primary" href="/meals/new" { "Create a meal" }
|
a class="button button-primary" href="/meals/new" { "Create a meal" }
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
div class="meal-picker-list" {
|
div id="meal-picker-results" class="meal-picker-list" {
|
||||||
@for (category_name, category_meals) in meal_groups(meals, meal_categories) {
|
(meal_picker_results(meals, meal_categories, list_id, csrf_token))
|
||||||
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"
|
/// The grouped picker rows, or the search-specific empty state. Kept separate
|
||||||
hx-swap="morph:outerHTML"
|
/// from the database-empty state above.
|
||||||
hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()"
|
pub fn meal_picker_results(
|
||||||
class="meal-picker-row"
|
meals: &[Meal],
|
||||||
{
|
meal_categories: &[MealCategory],
|
||||||
input type="hidden" name="csrf" value=(csrf_token);
|
list_id: i64,
|
||||||
input type="hidden" name="meal_id" value=(meal.id);
|
csrf_token: &str,
|
||||||
button class="meal-picker-button" type="submit" {
|
) -> Markup {
|
||||||
span class="meal-picker-icon" { "🍽" }
|
html! {
|
||||||
span class="meal-picker-copy" {
|
@if meals.is_empty() {
|
||||||
strong { (meal.name) }
|
div class="meal-picker-empty" {
|
||||||
small { (meal.ingredients.len()) " ingredients" }
|
p { "No meals match your search." }
|
||||||
}
|
}
|
||||||
span class="meal-picker-add" { "Add" }
|
} @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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,6 +378,11 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
|
|||||||
.meal-picker-empty { padding: 34px 22px 30px; text-align: center; color: var(--muted); }
|
.meal-picker-empty { padding: 34px 22px 30px; text-align: center; color: var(--muted); }
|
||||||
.meal-picker-empty h3 { color: var(--ink); }
|
.meal-picker-empty h3 { color: var(--ink); }
|
||||||
.meal-picker-empty p { margin-bottom: 18px; }
|
.meal-picker-empty p { margin-bottom: 18px; }
|
||||||
|
.meal-filter-bar { margin: -6px 0 18px; }
|
||||||
|
.meal-filter-bar input { min-height: 40px; padding: 8px 12px; font-size: .9rem; }
|
||||||
|
.meal-filter-bar-picker { margin: 14px 24px 4px; }
|
||||||
|
.meal-filter-empty { padding: 28px 18px; text-align: center; color: var(--muted); }
|
||||||
|
.meal-filter-empty p { margin: 0; }
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
@media (max-width: 780px) {
|
||||||
.site-header, .site-main, .site-footer { width: min(100% - 28px, 600px); }
|
.site-header, .site-main, .site-footer { width: min(100% - 28px, 600px); }
|
||||||
|
|||||||
Reference in New Issue
Block a user