meal filtering feature
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline failed

This commit is contained in:
2026-08-08 16:19:49 -04:00
parent 975cf38170
commit 35de0e1990
7 changed files with 274 additions and 56 deletions
+111 -49
View File
@@ -293,6 +293,7 @@ pub fn meals_page(
meals: &[Meal],
meal_categories: &[MealCategory],
csrf_token: &str,
q: &str,
) -> Markup {
page(
"Meals",
@@ -306,7 +307,7 @@ pub fn meals_page(
}
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-mark" { "🍽" }
h3 { "No meals yet" }
@@ -319,28 +320,21 @@ pub fn meals_page(
h2 { "All meals" }
span class="count-badge" { (meals.len()) }
}
@if meals.is_empty() {
p class="muted" { "Create a meal to get started." }
} @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" { "" }
}
}
}
}
}
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" {
@@ -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>(
meals: &'a [Meal],
meal_categories: &[MealCategory],
@@ -416,6 +446,7 @@ pub fn meal_picker(
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()" {
@@ -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()" { "" }
}
@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" {
span class="empty-mark" { "🍽" }
h3 { "No meals yet" }
@@ -435,32 +478,51 @@ pub fn meal_picker(
a class="button button-primary" href="/meals/new" { "Create a meal" }
}
} @else {
div class="meal-picker-list" {
@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" }
}
}
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" }
}
}
}