add meal carry over feature
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
2026-08-08 21:47:26 -04:00
parent 950add40a3
commit 77aac1e6ba
7 changed files with 514 additions and 5 deletions
+89 -1
View File
@@ -1307,7 +1307,10 @@ pub fn list_meals_panel(
}
}
@if editable {
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 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" }
}
}
};
@@ -1322,6 +1325,91 @@ pub fn list_meals_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! {