Import Export Grocery List
And misc improvements
This commit is contained in:
parent
5e0b096082
commit
9a78c4564e
49
api/meal_export.php
Normal file
49
api/meal_export.php
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||||
|
requireActivePerson($people);
|
||||||
|
|
||||||
|
$mealId = isset($_GET['mealId']) ? trim((string) $_GET['mealId']) : '';
|
||||||
|
if ($mealId === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'mealId is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||||
|
$meal = findMealById($meals, $mealId);
|
||||||
|
if ($meal === null) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meal = normalizeMealRow($meal);
|
||||||
|
$plan = normalizeMealPlan(readJsonFile('meal_plans.json'));
|
||||||
|
$weekStart = $plan['weekStart'];
|
||||||
|
$slots = $plan['slots'] ?? [];
|
||||||
|
$pruned = mealDefaultEmptySlots();
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
$key = (string) $i;
|
||||||
|
$day = $slots[$key] ?? [];
|
||||||
|
if (!is_array($day)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (mealSlotTypes() as $mt) {
|
||||||
|
$v = $day[$mt] ?? null;
|
||||||
|
$pruned[$key][$mt] = (is_string($v) && $v === $mealId) ? $mealId : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson([
|
||||||
|
'success' => true,
|
||||||
|
'version' => '1',
|
||||||
|
'meals' => [$meal],
|
||||||
|
'meal_plans' => [
|
||||||
|
'weekStart' => $weekStart,
|
||||||
|
'slots' => $pruned,
|
||||||
|
],
|
||||||
|
]);
|
||||||
102
api/meal_import.php
Normal file
102
api/meal_import.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||||
|
$actor = requireActivePerson($people);
|
||||||
|
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Only a verified Head of household can import recipes'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
$data = $body;
|
||||||
|
if (isset($body['package']) && is_array($body['package'])) {
|
||||||
|
$data = $body['package'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$incomingMeals = $data['meals'] ?? null;
|
||||||
|
if (!is_array($incomingMeals) || !array_is_list($incomingMeals) || count($incomingMeals) === 0) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Invalid package: meals array required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mealsUpserted = 0;
|
||||||
|
$importedIds = [];
|
||||||
|
$normalizedRows = [];
|
||||||
|
|
||||||
|
foreach ($incomingMeals as $row) {
|
||||||
|
if (!is_array($row) || empty($row['id']) || !is_string($row['id'])) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Invalid meal row'], 400);
|
||||||
|
}
|
||||||
|
$norm = normalizeMealRow($row);
|
||||||
|
if (trim((string) ($norm['title'] ?? '')) === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Each meal must have a title'], 400);
|
||||||
|
}
|
||||||
|
$mid = (string) ($norm['id'] ?? '');
|
||||||
|
if ($mid === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Invalid meal id'], 400);
|
||||||
|
}
|
||||||
|
$importedIds[$mid] = true;
|
||||||
|
$normalizedRows[] = $norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||||
|
|
||||||
|
foreach ($normalizedRows as $norm) {
|
||||||
|
$mid = (string) ($norm['id'] ?? '');
|
||||||
|
$idx = findMealIndexById($meals, $mid);
|
||||||
|
if ($idx !== null) {
|
||||||
|
$meals[$idx] = $norm;
|
||||||
|
} else {
|
||||||
|
$meals[] = $norm;
|
||||||
|
}
|
||||||
|
$mealsUpserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeJsonFile('meals.json', $meals)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save meals'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = normalizeMealPlan(readJsonFile('meal_plans.json'));
|
||||||
|
$slotsApplied = 0;
|
||||||
|
|
||||||
|
$impPlan = $data['meal_plans'] ?? [];
|
||||||
|
$impSlots = is_array($impPlan) && isset($impPlan['slots']) && is_array($impPlan['slots'])
|
||||||
|
? $impPlan['slots']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
$key = (string) $i;
|
||||||
|
$day = $impSlots[$key] ?? $impSlots[$i] ?? [];
|
||||||
|
if (!is_array($day)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (mealSlotTypes() as $mt) {
|
||||||
|
$mid = $day[$mt] ?? null;
|
||||||
|
if ($mid === null || $mid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_string($mid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($importedIds[$mid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$plan['slots'][$key][$mt] = $mid;
|
||||||
|
$slotsApplied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeJsonFile('meal_plans.json', $plan)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save meal plan'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson([
|
||||||
|
'success' => true,
|
||||||
|
'mealsUpserted' => $mealsUpserted,
|
||||||
|
'slotsApplied' => $slotsApplied,
|
||||||
|
]);
|
||||||
91
api/meal_shopping_item_to_grocery.php
Normal file
91
api/meal_shopping_item_to_grocery.php
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/meal_helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = normalizePeopleList(readJsonFile('people.json'));
|
||||||
|
requireActivePerson($people);
|
||||||
|
|
||||||
|
migrateLegacyGroceriesIfNeeded();
|
||||||
|
ensureDefaultGroceryStore();
|
||||||
|
$stores = normalizeStoresList(readJsonFile('stores.json'));
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
$mealId = isset($body['mealId']) ? trim((string) $body['mealId']) : '';
|
||||||
|
$itemIndex = isset($body['itemIndex']) ? (int) $body['itemIndex'] : -1;
|
||||||
|
$storeId = isset($body['storeId']) ? trim((string) $body['storeId']) : '';
|
||||||
|
|
||||||
|
if ($mealId === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'mealId is required'], 400);
|
||||||
|
}
|
||||||
|
if ($itemIndex < 0) {
|
||||||
|
sendJson(['success' => false, 'error' => 'itemIndex is required'], 400);
|
||||||
|
}
|
||||||
|
if (count($stores) === 0) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Add a grocery store first'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meals = migrateLegacyMealsList(normalizeMealsList(readJsonFile('meals.json')));
|
||||||
|
$meal = findMealById($meals, $mealId);
|
||||||
|
if ($meal === null) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Meal not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meal = normalizeMealRow($meal);
|
||||||
|
$items = $meal['items'] ?? [];
|
||||||
|
if (!is_array($items) || $itemIndex >= count($items)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Invalid shopping item'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $items[$itemIndex];
|
||||||
|
if (!is_array($row)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Invalid shopping item'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Item has no name'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mealTitle = (string) ($meal['title'] ?? '');
|
||||||
|
$desc = trim((string) ($row['description'] ?? ''));
|
||||||
|
if ($desc === '') {
|
||||||
|
$desc = $mealTitle !== '' ? 'From meal: ' . $mealTitle : 'From meal shopping list';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storeId === '' || findStoreById($stores, $storeId) === null) {
|
||||||
|
$rowStore = trim((string) ($row['storeId'] ?? ''));
|
||||||
|
if ($rowStore !== '' && findStoreById($stores, $rowStore) !== null) {
|
||||||
|
$storeId = $rowStore;
|
||||||
|
} else {
|
||||||
|
$storeId = groceryFirstStoreId($stores);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($storeId === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'No valid store'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = groceryAppendShoppingLine(
|
||||||
|
$stores,
|
||||||
|
$storeId,
|
||||||
|
$name,
|
||||||
|
$desc,
|
||||||
|
trim((string) ($row['size'] ?? '')),
|
||||||
|
trim((string) ($row['quantity'] ?? '1')) ?: '1',
|
||||||
|
trim((string) ($row['price'] ?? '')),
|
||||||
|
trim((string) ($row['image'] ?? '')),
|
||||||
|
'meal_plan',
|
||||||
|
0,
|
||||||
|
$mealId,
|
||||||
|
$mealTitle !== '' ? $mealTitle : null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
sendJson(['success' => false, 'error' => $res['error'] ?? 'Failed'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(['success' => true, 'item' => $res['item']]);
|
||||||
@ -22,14 +22,14 @@
|
|||||||
inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Opacity only — do not animate transform on #dashboardContentArea: it would trap
|
||||||
|
position:fixed Bootstrap modals (backdrops on body would cover the dialog). */
|
||||||
@keyframes fh-content-in {
|
@keyframes fh-content-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(6px);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -513,6 +513,102 @@ body.header-tone-light .app-header .tab.active {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Meal detail (recipe) page: sections and flow */
|
||||||
|
.meal-detail-header {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-intro.card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (color: color-mix(in srgb, #fff 50%, #000)) {
|
||||||
|
.meal-detail-intro.card {
|
||||||
|
background-color: color-mix(in srgb, var(--person-accent, #4a90e2) 8%, #fff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-lead {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail .meal-detail-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-intro + .meal-detail-section {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail .meal-detail-section:not(:first-of-type) {
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-section-heading .fa {
|
||||||
|
color: var(--person-accent, var(--primary-color));
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-section-lead {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-list .list-group-item {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-checklist {
|
||||||
|
margin-bottom: 0;
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-checklist li {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-directions {
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-editor-page {
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meal plan grid + share/import actions */
|
||||||
|
.meal-grid-table thead th {
|
||||||
|
background-color: color-mix(in srgb, var(--person-accent, var(--primary-color)) 18%, var(--secondary-color, #f8f9fa));
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-grid-table tbody tr:nth-child(odd) td,
|
||||||
|
.meal-grid-table tbody tr:nth-child(odd) th[scope="row"] {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-detail-actions,
|
||||||
|
.meals-recipe-actions {
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-import-modal-body .form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@ -569,36 +569,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var weekStart = ($tab.attr('data-week-start') || '').trim();
|
var weekStart = ($tab.attr('data-week-start') || '').trim();
|
||||||
|
var mealImportParsed = null;
|
||||||
|
|
||||||
$('#btnPushMealGrocery').on('click', function () {
|
$(document).on('click', '.btn-meal-shopping-to-grocery', function () {
|
||||||
var id = $(this).data('meal-id');
|
var $btn = $(this);
|
||||||
if (!id) {
|
var mealId = $btn.data('meal-id');
|
||||||
|
var itemIndex = parseInt($btn.attr('data-item-index'), 10);
|
||||||
|
var label = ($btn.attr('data-item-name') || $btn.data('itemName') || '').trim() || 'Item';
|
||||||
|
var $li = $btn.closest('li');
|
||||||
|
var storeId = $li.find('.meal-shopping-store').val() || '';
|
||||||
|
var $fb = $('#mealShoppingGroceryFeedback');
|
||||||
|
if (!mealId || itemIndex < 0 || isNaN(itemIndex)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
postJson('/meal_push_grocery.php', { id: id })
|
postJson('/meal_shopping_item_to_grocery.php', { mealId: mealId, itemIndex: itemIndex, storeId: storeId })
|
||||||
.then(function (data) {
|
.then(function () {
|
||||||
var n = data.groceryLinesAdded != null ? data.groceryLinesAdded : 0;
|
if ($fb.length) {
|
||||||
showAlert($('#pushMealFeedback'), 'success', 'Added ' + n + ' line(s) to grocery (pending review).');
|
showAlert($fb, 'success', 'Added “' + label + '” to the grocery list (pending review).');
|
||||||
$('#pushMealFeedback').removeClass('d-none');
|
$fb.removeClass('d-none');
|
||||||
|
var el = $fb.get(0);
|
||||||
|
if (el && typeof el.scrollIntoView === 'function') {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
showAlert($('#pushMealFeedback'), 'danger', err.message || 'Could not push');
|
if ($fb.length) {
|
||||||
$('#pushMealFeedback').removeClass('d-none');
|
showAlert($fb, 'danger', (err.message || 'Could not add') + ' — “' + label + '”');
|
||||||
|
$fb.removeClass('d-none');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('click', '.btn-ingredient-to-grocery', function () {
|
$(document).on('click', '.btn-ingredient-to-grocery', function () {
|
||||||
var $btn = $(this);
|
var $btn = $(this);
|
||||||
var mealId = $btn.data('meal-id');
|
var mealId = $btn.data('meal-id');
|
||||||
var ing = $btn.data('ingredient');
|
var ing = $btn.attr('data-ingredient') || $btn.data('ingredient') || '';
|
||||||
|
var label = String(ing).trim() || 'Item';
|
||||||
var $li = $btn.closest('li');
|
var $li = $btn.closest('li');
|
||||||
var storeId = $li.find('.ingredient-store').val() || '';
|
var storeId = $li.find('.ingredient-store').val() || '';
|
||||||
|
var $fb = $('#mealPantryGroceryFeedback');
|
||||||
postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId })
|
postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId })
|
||||||
.then(function () {
|
.then(function () {
|
||||||
window.alert('Added to grocery list.');
|
if ($fb.length) {
|
||||||
|
showAlert($fb, 'success', 'Added “' + label + '” to the grocery list (pending review).');
|
||||||
|
$fb.removeClass('d-none');
|
||||||
|
var el = $fb.get(0);
|
||||||
|
if (el && typeof el.scrollIntoView === 'function') {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
window.alert(err.message || 'Could not add');
|
if ($fb.length) {
|
||||||
|
showAlert($fb, 'danger', (err.message || 'Could not add') + ' — “' + label + '”');
|
||||||
|
$fb.removeClass('d-none');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -623,7 +649,7 @@
|
|||||||
var slotModalEl = document.getElementById('mealSlotModal');
|
var slotModalEl = document.getElementById('mealSlotModal');
|
||||||
var slotModal = null;
|
var slotModal = null;
|
||||||
if (slotModalEl && typeof bootstrap !== 'undefined') {
|
if (slotModalEl && typeof bootstrap !== 'undefined') {
|
||||||
slotModal = new bootstrap.Modal(slotModalEl);
|
slotModal = new bootstrap.Modal(slotModalEl, { backdrop: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSlotPickerList() {
|
function renderSlotPickerList() {
|
||||||
@ -764,6 +790,123 @@
|
|||||||
window.alert(err.message || 'Could not delete');
|
window.alert(err.message || 'Could not delete');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.btn-meal-export-json', function () {
|
||||||
|
var mealId = $(this).data('meal-id');
|
||||||
|
if (!mealId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var safeId = String(mealId);
|
||||||
|
fetch(apiBase + '/meal_export.php?mealId=' + encodeURIComponent(safeId), { credentials: 'same-origin' })
|
||||||
|
.then(function (res) {
|
||||||
|
return res.json().then(function (data) {
|
||||||
|
return { res: res, data: data };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.res.ok || !r.data || !r.data.success) {
|
||||||
|
var msg = (r.data && r.data.error) ? r.data.error : 'Export failed';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
var pkg = {
|
||||||
|
version: r.data.version || '1',
|
||||||
|
meals: r.data.meals,
|
||||||
|
meal_plans: r.data.meal_plans
|
||||||
|
};
|
||||||
|
var json = JSON.stringify(pkg, null, 2);
|
||||||
|
var blob = new Blob([json], { type: 'application/json' });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
var ymd = new Date().toISOString().slice(0, 10);
|
||||||
|
var fileStem = safeId.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
|
||||||
|
a.download = 'meal_export_' + fileStem + '_' + ymd + '.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
window.alert(err.message || 'Could not export recipe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#mealImportFile').on('change', function () {
|
||||||
|
mealImportParsed = null;
|
||||||
|
var f = this.files && this.files[0];
|
||||||
|
var $prev = $('#mealImportPreview');
|
||||||
|
var $fb = $('#mealImportFeedback');
|
||||||
|
if ($fb.length) {
|
||||||
|
$fb.addClass('d-none').removeClass('alert-success alert-danger alert-warning');
|
||||||
|
}
|
||||||
|
$('#btnMealImportSubmit').prop('disabled', true);
|
||||||
|
if (!$prev.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!f) {
|
||||||
|
$prev.text('—');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
try {
|
||||||
|
mealImportParsed = JSON.parse(String(reader.result || ''));
|
||||||
|
$prev.text(JSON.stringify(mealImportParsed, null, 2));
|
||||||
|
$('#btnMealImportSubmit').prop('disabled', false);
|
||||||
|
if ($fb.length) {
|
||||||
|
$fb.addClass('d-none');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
mealImportParsed = null;
|
||||||
|
$prev.text('Invalid JSON: ' + (e.message || String(e)));
|
||||||
|
if ($fb.length) {
|
||||||
|
showAlert($fb, 'danger', 'Could not parse JSON from this file.');
|
||||||
|
$fb.removeClass('d-none');
|
||||||
|
}
|
||||||
|
$('#btnMealImportSubmit').prop('disabled', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(f);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnMealImportSubmit').on('click', function () {
|
||||||
|
if (!mealImportParsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $fb = $('#mealImportFeedback');
|
||||||
|
var $btn = $(this);
|
||||||
|
$btn.prop('disabled', true);
|
||||||
|
postJson('/meal_import.php', mealImportParsed)
|
||||||
|
.then(function (data) {
|
||||||
|
var n = typeof data.mealsUpserted === 'number' ? data.mealsUpserted : 0;
|
||||||
|
var s = typeof data.slotsApplied === 'number' ? data.slotsApplied : 0;
|
||||||
|
if ($fb.length) {
|
||||||
|
showAlert($fb, 'success', 'Imported ' + n + ' meal(s), ' + s + ' slot(s). Reloading…');
|
||||||
|
$fb.removeClass('d-none');
|
||||||
|
}
|
||||||
|
window.setTimeout(function () {
|
||||||
|
window.location.href = window.location.pathname + '?tab=meals';
|
||||||
|
}, 600);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if ($fb.length) {
|
||||||
|
showAlert($fb, 'danger', err.message || 'Import failed');
|
||||||
|
$fb.removeClass('d-none');
|
||||||
|
}
|
||||||
|
$btn.prop('disabled', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#mealImportModal').on('hidden.bs.modal', function () {
|
||||||
|
mealImportParsed = null;
|
||||||
|
$('#mealImportFile').val('');
|
||||||
|
var $prev = $('#mealImportPreview');
|
||||||
|
if ($prev.length) {
|
||||||
|
$prev.text('—');
|
||||||
|
}
|
||||||
|
$('#mealImportFeedback').addClass('d-none');
|
||||||
|
$('#btnMealImportSubmit').prop('disabled', true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindCurrencyPage() {
|
function bindCurrencyPage() {
|
||||||
|
|||||||
@ -202,12 +202,16 @@ function migrateLegacyGroceriesIfNeeded(): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create catalog row + list line (used by grocery_item_create and meal → grocery).
|
* Append one shopping line using already-loaded catalog and lists (mutates arrays).
|
||||||
*
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $catalog
|
||||||
|
* @param array{byStore: array<string, array<int, array<string, mixed>>>} $lists
|
||||||
* @param array<int, array<string, mixed>> $stores
|
* @param array<int, array<string, mixed>> $stores
|
||||||
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
|
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
|
||||||
*/
|
*/
|
||||||
function groceryAppendShoppingLine(
|
function groceryAppendShoppingLineIntoBuffers(
|
||||||
|
array &$catalog,
|
||||||
|
array &$lists,
|
||||||
array $stores,
|
array $stores,
|
||||||
string $storeId,
|
string $storeId,
|
||||||
string $name,
|
string $name,
|
||||||
@ -234,7 +238,6 @@ function groceryAppendShoppingLine(
|
|||||||
$price = trim($price);
|
$price = trim($price);
|
||||||
$image = trim($image);
|
$image = trim($image);
|
||||||
|
|
||||||
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
|
||||||
$dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size);
|
$dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size);
|
||||||
$catalogId = null;
|
$catalogId = null;
|
||||||
foreach ($catalog as $i => $c) {
|
foreach ($catalog as $i => $c) {
|
||||||
@ -296,12 +299,58 @@ function groceryAppendShoppingLine(
|
|||||||
$line['mealTitle'] = $mealTitleMeta;
|
$line['mealTitle'] = $mealTitleMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
|
||||||
if (!isset($lists['byStore'][$storeId])) {
|
if (!isset($lists['byStore'][$storeId])) {
|
||||||
$lists['byStore'][$storeId] = [];
|
$lists['byStore'][$storeId] = [];
|
||||||
}
|
}
|
||||||
$lists['byStore'][$storeId][] = $line;
|
$lists['byStore'][$storeId][] = $line;
|
||||||
|
|
||||||
|
return ['ok' => true, 'item' => $line];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create catalog row + list line (used by grocery_item_create and meal → grocery).
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $stores
|
||||||
|
* @return array{ok: bool, item?: array<string, mixed>, error?: string}
|
||||||
|
*/
|
||||||
|
function groceryAppendShoppingLine(
|
||||||
|
array $stores,
|
||||||
|
string $storeId,
|
||||||
|
string $name,
|
||||||
|
string $description = '',
|
||||||
|
string $size = '',
|
||||||
|
string $quantity = '1',
|
||||||
|
string $price = '',
|
||||||
|
string $image = '',
|
||||||
|
string $source = 'manual',
|
||||||
|
int $recurringIntervalDays = 0,
|
||||||
|
?string $mealId = null,
|
||||||
|
?string $mealTitleMeta = null
|
||||||
|
): array {
|
||||||
|
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||||
|
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||||
|
|
||||||
|
$res = groceryAppendShoppingLineIntoBuffers(
|
||||||
|
$catalog,
|
||||||
|
$lists,
|
||||||
|
$stores,
|
||||||
|
$storeId,
|
||||||
|
$name,
|
||||||
|
$description,
|
||||||
|
$size,
|
||||||
|
$quantity,
|
||||||
|
$price,
|
||||||
|
$image,
|
||||||
|
$source,
|
||||||
|
$recurringIntervalDays,
|
||||||
|
$mealId,
|
||||||
|
$mealTitleMeta
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$res['ok']) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
|
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
|
||||||
return ['ok' => false, 'error' => 'Failed to save catalog'];
|
return ['ok' => false, 'error' => 'Failed to save catalog'];
|
||||||
}
|
}
|
||||||
@ -309,7 +358,79 @@ function groceryAppendShoppingLine(
|
|||||||
return ['ok' => false, 'error' => 'Failed to save grocery list'];
|
return ['ok' => false, 'error' => 'Failed to save grocery list'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['ok' => true, 'item' => $line];
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append several meal shopping lines in one disk transaction (catalog + lists).
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $stores
|
||||||
|
* @param list<array{name?:mixed, storeId?:mixed, description?:mixed, size?:mixed, quantity?:mixed, price?:mixed, image?:mixed}> $itemRows
|
||||||
|
* @return int number of lines successfully added
|
||||||
|
*/
|
||||||
|
function groceryAppendMealItemRowsBatch(
|
||||||
|
array $stores,
|
||||||
|
array $itemRows,
|
||||||
|
string $mealId,
|
||||||
|
string $mealTitle
|
||||||
|
): int {
|
||||||
|
if (count($itemRows) === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$defaultStore = groceryFirstStoreId($stores);
|
||||||
|
if ($defaultStore === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json'));
|
||||||
|
$lists = normalizeGroceryLists(readJsonFile('grocery_lists.json'));
|
||||||
|
$added = 0;
|
||||||
|
|
||||||
|
foreach ($itemRows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sid = trim((string) ($row['storeId'] ?? ''));
|
||||||
|
if ($sid === '' || findStoreById($stores, $sid) === null) {
|
||||||
|
$sid = $defaultStore;
|
||||||
|
}
|
||||||
|
$res = groceryAppendShoppingLineIntoBuffers(
|
||||||
|
$catalog,
|
||||||
|
$lists,
|
||||||
|
$stores,
|
||||||
|
$sid,
|
||||||
|
$name,
|
||||||
|
trim((string) ($row['description'] ?? '')),
|
||||||
|
trim((string) ($row['size'] ?? '')),
|
||||||
|
trim((string) ($row['quantity'] ?? '1')) ?: '1',
|
||||||
|
trim((string) ($row['price'] ?? '')),
|
||||||
|
trim((string) ($row['image'] ?? '')),
|
||||||
|
'meal_plan',
|
||||||
|
0,
|
||||||
|
$mealId !== '' ? $mealId : null,
|
||||||
|
$mealTitle !== '' ? $mealTitle : null
|
||||||
|
);
|
||||||
|
if ($res['ok']) {
|
||||||
|
$added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($added === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeJsonFile('grocery_catalog.json', $catalog)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!writeJsonFile('grocery_lists.json', $lists)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $added;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -251,42 +251,7 @@ function pushMealItemsToGrocery(array $meal, array $stores): int {
|
|||||||
}
|
}
|
||||||
$mealId = (string) ($meal['id'] ?? '');
|
$mealId = (string) ($meal['id'] ?? '');
|
||||||
$mealTitle = (string) ($meal['title'] ?? '');
|
$mealTitle = (string) ($meal['title'] ?? '');
|
||||||
$defaultStore = groceryFirstStoreId($stores);
|
return groceryAppendMealItemRowsBatch($stores, $items, $mealId, $mealTitle);
|
||||||
if ($defaultStore === '') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
$added = 0;
|
|
||||||
foreach ($items as $row) {
|
|
||||||
if (!is_array($row)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$name = trim((string) ($row['name'] ?? ''));
|
|
||||||
if ($name === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$sid = trim((string) ($row['storeId'] ?? ''));
|
|
||||||
if ($sid === '' || findStoreById($stores, $sid) === null) {
|
|
||||||
$sid = $defaultStore;
|
|
||||||
}
|
|
||||||
$res = groceryAppendShoppingLine(
|
|
||||||
$stores,
|
|
||||||
$sid,
|
|
||||||
$name,
|
|
||||||
trim((string) ($row['description'] ?? '')),
|
|
||||||
trim((string) ($row['size'] ?? '')),
|
|
||||||
trim((string) ($row['quantity'] ?? '1')) ?: '1',
|
|
||||||
trim((string) ($row['price'] ?? '')),
|
|
||||||
trim((string) ($row['image'] ?? '')),
|
|
||||||
'meal_plan',
|
|
||||||
0,
|
|
||||||
$mealId !== '' ? $mealId : null,
|
|
||||||
$mealTitle !== '' ? $mealTitle : null
|
|
||||||
);
|
|
||||||
if ($res['ok']) {
|
|
||||||
$added++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $added;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mealDayShortLabel(string $weekStart, int $offset): string {
|
function mealDayShortLabel(string $weekStart, int $offset): string {
|
||||||
|
|||||||
@ -15,10 +15,13 @@ const FAMILYHUB_MCP_PROTOCOL_VERSION = '2024-11-05';
|
|||||||
const FAMILYHUB_MCP_POST_ALLOWLIST = [
|
const FAMILYHUB_MCP_POST_ALLOWLIST = [
|
||||||
'family_settings_save.php',
|
'family_settings_save.php',
|
||||||
'meal_ingredient_to_grocery.php',
|
'meal_ingredient_to_grocery.php',
|
||||||
|
'meal_shopping_item_to_grocery.php',
|
||||||
'meal_push_grocery.php',
|
'meal_push_grocery.php',
|
||||||
'meal_plan_set_slot.php',
|
'meal_plan_set_slot.php',
|
||||||
'meal_plan_set_week.php',
|
'meal_plan_set_week.php',
|
||||||
'meal_delete.php',
|
'meal_delete.php',
|
||||||
|
'meal_export.php',
|
||||||
|
'meal_import.php',
|
||||||
'meal_save.php',
|
'meal_save.php',
|
||||||
'grocery_item_create.php',
|
'grocery_item_create.php',
|
||||||
'grocery_item_review.php',
|
'grocery_item_review.php',
|
||||||
|
|||||||
460
tabs/meals.php
460
tabs/meals.php
@ -54,187 +54,171 @@ $listItems = $firstList['items'] ?? [];
|
|||||||
if (!is_array($listItems)) {
|
if (!is_array($listItems)) {
|
||||||
$listItems = [];
|
$listItems = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mealEditorNotFound = $editMealId !== '' && $editMealId !== 'new' && $prefillMeal === null && $canManageMeals;
|
||||||
|
$mealEditorAllowed = ($editMealId === 'new' && $canManageMeals)
|
||||||
|
|| ($prefillMeal !== null && $canEditMeal($prefillMeal));
|
||||||
|
$showMealEditorPage = $editMealId !== '' && ($mealEditorAllowed || $mealEditorNotFound);
|
||||||
|
$mealEditorForbidden = $editMealId !== '' && !$showMealEditorPage;
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($detailMeal !== null): ?>
|
<?php if ($detailMeal !== null && $editMealId === ''): ?>
|
||||||
<div id="meals" class="tab-content">
|
<?php
|
||||||
<p class="mb-2"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">← Back to meal plan</a></p>
|
$detailDesc = trim((string) ($detailMeal['description'] ?? ''));
|
||||||
<h2 class="mb-2"><?= sanitizeInput($detailMeal['title'] ?? '') ?></h2>
|
$detailImageOk = !empty($detailMeal['image']) && preg_match('#^https?://#i', (string) $detailMeal['image']);
|
||||||
<?php if (!empty($detailMeal['image']) && preg_match('#^https?://#i', (string) $detailMeal['image'])): ?>
|
$hasRecipeLists = false;
|
||||||
<img src="<?= sanitizeInput((string) $detailMeal['image']) ?>" class="img-fluid rounded mb-3 meal-hero" alt="">
|
foreach (($detailMeal['lists'] ?? []) as $block) {
|
||||||
<?php endif; ?>
|
if (is_array($block) && !empty($block['items'])) {
|
||||||
<p class="lead"><?= nl2br(sanitizeInput((string) ($detailMeal['description'] ?? ''))) ?></p>
|
$hasRecipeLists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div id="meals" class="tab-content meal-detail">
|
||||||
|
<p class="mb-3"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">← Back to meal plan</a></p>
|
||||||
|
|
||||||
<?php if (count($detailMeal['tags'] ?? []) > 0): ?>
|
<header class="meal-detail-header d-flex flex-wrap align-items-start justify-content-between gap-3 pb-3 mb-0">
|
||||||
<p><?php foreach ($detailMeal['tags'] as $t): ?><span class="badge text-bg-secondary me-1"><?= sanitizeInput((string) $t) ?></span><?php endforeach; ?></p>
|
<div class="min-w-0 flex-grow-1">
|
||||||
|
<h2 class="h3 mb-2"><?= sanitizeInput($detailMeal['title'] ?? '') ?></h2>
|
||||||
|
<?php if (count($detailMeal['tags'] ?? []) > 0): ?>
|
||||||
|
<p class="meal-detail-tags mb-0"><?php foreach ($detailMeal['tags'] as $t): ?><span class="badge text-bg-secondary me-1"><?= sanitizeInput((string) $t) ?></span><?php endforeach; ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 flex-shrink-0 align-items-start meal-detail-actions">
|
||||||
|
<?php if ($activePerson !== null && $detailId !== ''): ?>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-meal-export-json"
|
||||||
|
data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Export recipe JSON</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($canEditMeal($detailMeal)): ?>
|
||||||
|
<a class="btn btn-outline-primary" href="?tab=meals&edit=<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Edit meal</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if ($detailImageOk || $detailDesc !== ''): ?>
|
||||||
|
<section class="meal-detail-intro card border-0 shadow-sm mb-4" aria-label="Summary">
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if ($detailImageOk): ?>
|
||||||
|
<img src="<?= sanitizeInput((string) $detailMeal['image']) ?>" class="img-fluid rounded mb-3 meal-hero" alt="">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($detailDesc !== ''): ?>
|
||||||
|
<div class="meal-detail-lead text-body-secondary"><?= nl2br(sanitizeInput($detailDesc)) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h3 class="h5">Directions</h3>
|
<section class="meal-detail-section" aria-labelledby="meal-shopping-heading">
|
||||||
<div class="mb-4"><?= nl2br(sanitizeInput((string) ($detailMeal['directions'] ?? ''))) ?></div>
|
<h3 id="meal-shopping-heading" class="h5 meal-detail-section-heading">
|
||||||
|
<i class="fa fa-cart-shopping fa-fw" aria-hidden="true"></i> Shopping list
|
||||||
<?php foreach (($detailMeal['lists'] ?? []) as $block): ?>
|
</h3>
|
||||||
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
|
<p class="text-muted small meal-detail-section-lead">Items to buy for this recipe. Choose a store and add each line to the grocery tab as <strong>pending review</strong>.</p>
|
||||||
<?php $t = $block['type'] ?? 'checkbox'; ?>
|
<?php if (count($detailMeal['items'] ?? []) === 0): ?>
|
||||||
<?php if ($t === 'ordered'): ?>
|
<p class="text-muted small mb-0">None listed for this meal.</p>
|
||||||
<ol><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
|
|
||||||
<?php elseif ($t === 'unordered'): ?>
|
|
||||||
<ul><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ul class="list-unstyled"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-1"></i><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
<ul class="list-group meal-detail-list">
|
||||||
|
<?php foreach ($detailMeal['items'] as $shopIdx => $it): ?>
|
||||||
|
<?php
|
||||||
|
$shopName = (string) ($it['name'] ?? '');
|
||||||
|
$shopStorePref = (string) ($it['storeId'] ?? '');
|
||||||
|
?>
|
||||||
|
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<span>
|
||||||
|
<?= sanitizeInput($shopName) ?>
|
||||||
|
<?php if (!empty($it['quantity'])): ?> · <?= sanitizeInput((string) $it['quantity']) ?><?php endif; ?>
|
||||||
|
<?php if (!empty($it['size'])): ?> <span class="text-muted">(<?= sanitizeInput((string) $it['size']) ?>)</span><?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($activePerson !== null && count($stores) > 0): ?>
|
||||||
|
<span class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
|
<select class="form-select form-select-sm meal-shopping-store" style="width:auto; min-width:8rem;" aria-label="Store for grocery">
|
||||||
|
<?php foreach ($stores as $st): ?>
|
||||||
|
<?php $stid = (string) ($st['id'] ?? ''); ?>
|
||||||
|
<option value="<?= htmlspecialchars($stid, ENT_QUOTES, 'UTF-8') ?>" <?= ($shopStorePref !== '' && $shopStorePref === $stid) ? 'selected' : '' ?>><?= sanitizeInput($st['name'] ?? '') ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary btn-meal-shopping-to-grocery"
|
||||||
|
data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-item-index="<?= (int) $shopIdx ?>"
|
||||||
|
data-item-name="<?= htmlspecialchars($shopName, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
>Add to grocery list</button>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<div class="alert d-none mt-3 mb-0" id="mealShoppingGroceryFeedback" role="status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3 class="h5">Pantry / staples (ingredients)</h3>
|
<section class="meal-detail-section meal-detail-pantry" aria-labelledby="meal-pantry-heading">
|
||||||
<?php if (count($detailMeal['ingredients'] ?? []) === 0): ?>
|
<h3 id="meal-pantry-heading" class="h5 meal-detail-section-heading">
|
||||||
<p class="text-muted small">None listed.</p>
|
<i class="fa fa-seedling fa-fw" aria-hidden="true"></i> Pantry / staples
|
||||||
<?php else: ?>
|
</h3>
|
||||||
<ul class="list-group mb-3">
|
<p class="text-muted small meal-detail-section-lead">Ingredients you may already have. Add one line to the grocery list if you need to restock.</p>
|
||||||
<?php foreach ($detailMeal['ingredients'] as $ing): ?>
|
<?php if (count($detailMeal['ingredients'] ?? []) === 0): ?>
|
||||||
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<p class="text-muted small mb-0">None listed.</p>
|
||||||
<span><?= sanitizeInput((string) $ing) ?></span>
|
<?php else: ?>
|
||||||
<?php if ($activePerson !== null && count($stores) > 0): ?>
|
<ul class="list-group meal-detail-list">
|
||||||
<span class="d-flex flex-wrap gap-1 align-items-center">
|
<?php foreach ($detailMeal['ingredients'] as $ing): ?>
|
||||||
<select class="form-select form-select-sm ingredient-store" style="width:auto; min-width:8rem;" aria-label="Store for grocery">
|
<li class="list-group-item d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<?php foreach ($stores as $st): ?>
|
<span><?= sanitizeInput((string) $ing) ?></span>
|
||||||
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($st['name'] ?? '') ?></option>
|
<?php if ($activePerson !== null && count($stores) > 0): ?>
|
||||||
<?php endforeach; ?>
|
<span class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
</select>
|
<select class="form-select form-select-sm ingredient-store" style="width:auto; min-width:8rem;" aria-label="Store for grocery">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary btn-ingredient-to-grocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>" data-ingredient="<?= htmlspecialchars((string) $ing, ENT_QUOTES, 'UTF-8') ?>">Add to grocery list</button>
|
<?php foreach ($stores as $st): ?>
|
||||||
</span>
|
<option value="<?= htmlspecialchars((string) ($st['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($st['name'] ?? '') ?></option>
|
||||||
<?php endif; ?>
|
<?php endforeach; ?>
|
||||||
</li>
|
</select>
|
||||||
<?php endforeach; ?>
|
<button type="button" class="btn btn-sm btn-outline-primary btn-ingredient-to-grocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>" data-ingredient="<?= htmlspecialchars((string) $ing, ENT_QUOTES, 'UTF-8') ?>">Add to grocery list</button>
|
||||||
</ul>
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="alert d-none mt-3 mb-0" id="mealPantryGroceryFeedback" role="status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($hasRecipeLists): ?>
|
||||||
|
<section class="meal-detail-section" aria-labelledby="meal-prep-heading">
|
||||||
|
<h3 id="meal-prep-heading" class="h5 meal-detail-section-heading">
|
||||||
|
<i class="fa fa-list-check fa-fw" aria-hidden="true"></i> Recipe checklist
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted small meal-detail-section-lead">Quick reminders while you cook (not interactive).</p>
|
||||||
|
<?php foreach (($detailMeal['lists'] ?? []) as $block): ?>
|
||||||
|
<?php if (!is_array($block) || empty($block['items'])) { continue; } ?>
|
||||||
|
<?php $t = $block['type'] ?? 'checkbox'; ?>
|
||||||
|
<?php if ($t === 'ordered'): ?>
|
||||||
|
<ol class="meal-detail-checklist"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ol>
|
||||||
|
<?php elseif ($t === 'unordered'): ?>
|
||||||
|
<ul class="meal-detail-checklist"><?php foreach ($block['items'] as $it): ?><li><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-unstyled meal-detail-checklist"><?php foreach ($block['items'] as $it): ?><li><i class="fa-regular fa-square me-2 text-muted" aria-hidden="true"></i><?= sanitizeInput((string) $it) ?></li><?php endforeach; ?></ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h3 class="h5">Shopping items</h3>
|
<?php if (trim((string) ($detailMeal['directions'] ?? '')) !== ''): ?>
|
||||||
<?php if (count($detailMeal['items'] ?? []) === 0): ?>
|
<section class="meal-detail-section" aria-labelledby="meal-directions-heading">
|
||||||
<p class="text-muted small">None listed.</p>
|
<h3 id="meal-directions-heading" class="h5 meal-detail-section-heading">
|
||||||
<?php else: ?>
|
<i class="fa fa-book-open fa-fw" aria-hidden="true"></i> Directions
|
||||||
<ul class="list-group mb-3">
|
</h3>
|
||||||
<?php foreach ($detailMeal['items'] as $it): ?>
|
<div class="meal-detail-directions"><?= nl2br(sanitizeInput((string) ($detailMeal['directions'] ?? ''))) ?></div>
|
||||||
<li class="list-group-item"><?= sanitizeInput((string) ($it['name'] ?? '')) ?>
|
</section>
|
||||||
<?php if (!empty($it['quantity'])): ?> · <?= sanitizeInput((string) $it['quantity']) ?><?php endif; ?>
|
|
||||||
<?php if (!empty($it['size'])): ?> (<?= sanitizeInput((string) $it['size']) ?>)<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($activePerson !== null && count($detailMeal['items'] ?? []) > 0): ?>
|
|
||||||
<button type="button" class="btn btn-primary mb-3" id="btnPushMealGrocery" data-meal-id="<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Send shopping items to grocery (pending review)</button>
|
|
||||||
<div class="alert d-none" id="pushMealFeedback" role="status"></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($detailMeal && $canEditMeal($detailMeal)): ?>
|
|
||||||
<p><a class="btn btn-outline-primary" href="?tab=meals&edit=<?= htmlspecialchars($detailId, ENT_QUOTES, 'UTF-8') ?>">Edit meal</a></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php else: ?>
|
<?php elseif ($showMealEditorPage): ?>
|
||||||
|
<div id="meals" class="tab-content meal-editor-page">
|
||||||
<div id="meals" class="tab-content" data-week-start="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
<p class="mb-3"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">← Back to meal plan</a></p>
|
||||||
<h2 class="mb-2">Meal plan</h2>
|
<h2 class="h3 mb-3"><?= $editMealId === 'new' ? 'New meal' : 'Edit meal' ?></h2>
|
||||||
<p class="text-muted small">Week starts on <strong><?= sanitizeInput($weekStart) ?></strong> (day 0). Assign meals to each slot; shopping items go to the grocery list as <strong>pending review</strong>.</p>
|
<div class="card border-primary shadow-sm" id="mealEditorCard">
|
||||||
|
<div class="card-header"><?= $editMealId === 'new' ? 'Recipe details' : 'Update recipe' ?></div>
|
||||||
<?php if ($activePerson === null): ?>
|
|
||||||
<div class="alert alert-warning">Choose who is using the hub to use the meal planner.</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($canManageMeals): ?>
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-body row g-2 align-items-end">
|
|
||||||
<div class="col-md-auto">
|
|
||||||
<label class="form-label" for="meal_week_start">Week starting (Monday)</label>
|
|
||||||
<input type="date" class="form-control" id="meal_week_start" value="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-auto">
|
|
||||||
<button type="button" class="btn btn-primary" id="btnMealWeekApply">Set planning week</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="alert d-none" id="mealWeekFeedback" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="table-responsive mb-4">
|
|
||||||
<table class="table table-bordered meal-grid-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>Breakfast</th>
|
|
||||||
<th>Lunch</th>
|
|
||||||
<th>Dinner</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php for ($d = 0; $d < 7; $d++): ?>
|
|
||||||
<tr>
|
|
||||||
<th scope="row" class="text-nowrap"><?= sanitizeInput(mealDayShortLabel($weekStart, $d)) ?></th>
|
|
||||||
<?php foreach (mealSlotTypes() as $mt): ?>
|
|
||||||
<?php
|
|
||||||
$mid = $slots[(string) $d][$mt] ?? null;
|
|
||||||
$label = $mid && isset($mealTitleById[$mid]) ? $mealTitleById[$mid] : '—';
|
|
||||||
?>
|
|
||||||
<td>
|
|
||||||
<?php if ($mid): ?>
|
|
||||||
<a href="?tab=meals&meal=<?= htmlspecialchars($mid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($label) ?></a>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($activePerson !== null): ?>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 btn-open-slot-picker"
|
|
||||||
data-day="<?= (int) $d ?>"
|
|
||||||
data-meal-type="<?= htmlspecialchars($mt, ENT_QUOTES, 'UTF-8') ?>"
|
|
||||||
data-current-meal-id="<?= htmlspecialchars((string) ($mid ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
|
||||||
><?= $mid ? 'Change' : '+' ?></button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tr>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="h5">Meal library</h3>
|
|
||||||
<?php if ($canManageMeals): ?>
|
|
||||||
<p><a href="?tab=meals&edit=new" class="btn btn-success btn-sm">New meal</a></p>
|
|
||||||
<?php else: ?>
|
|
||||||
<p class="text-muted small">Only a verified Head of household can add meals.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (count($meals) === 0): ?>
|
|
||||||
<p class="text-muted">No meals in the library yet.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<ul class="list-group mb-4">
|
|
||||||
<?php foreach ($meals as $m): ?>
|
|
||||||
<?php $mid = htmlspecialchars((string) ($m['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
||||||
<div>
|
|
||||||
<a href="?tab=meals&meal=<?= $mid ?>"><strong><?= sanitizeInput($m['title'] ?? '') ?></strong></a>
|
|
||||||
<?php foreach (($m['tags'] ?? []) as $t): ?>
|
|
||||||
<span class="badge text-bg-light border ms-1"><?= sanitizeInput((string) $t) ?></span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
<?php if ($canEditMeal($m)): ?>
|
|
||||||
<a class="btn btn-sm btn-outline-primary" href="?tab=meals&edit=<?= $mid ?>">Edit</a>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger btn-meal-delete" data-id="<?= $mid ?>">Delete</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($canManageMeals && ($editMealId === 'new' || $prefillMeal !== null)): ?>
|
|
||||||
<div class="card mb-4 border-primary" id="mealEditorCard">
|
|
||||||
<div class="card-header"><?= $editMealId === 'new' ? 'New meal' : 'Edit meal' ?></div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<?php if ($editMealId !== 'new' && $prefillMeal === null): ?>
|
<?php if ($mealEditorNotFound): ?>
|
||||||
<div class="alert alert-warning">Meal not found.</div>
|
<div class="alert alert-warning mb-0">Meal not found.</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<form id="mealSaveForm" class="row g-3">
|
<form id="mealSaveForm" class="row g-3">
|
||||||
<input type="hidden" id="meal_id" value="<?= $prefillMeal ? htmlspecialchars((string) ($prefillMeal['id'] ?? ''), ENT_QUOTES, 'UTF-8') : '' ?>">
|
<input type="hidden" id="meal_id" value="<?= $prefillMeal ? htmlspecialchars((string) ($prefillMeal['id'] ?? ''), ENT_QUOTES, 'UTF-8') : '' ?>">
|
||||||
@ -329,10 +313,121 @@ if (!is_array($listItems)) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="mealSlotModal" tabindex="-1" aria-labelledby="mealSlotModalLabel" aria-hidden="true">
|
<?php elseif ($mealEditorForbidden): ?>
|
||||||
|
<div id="meals" class="tab-content meal-editor-page">
|
||||||
|
<p class="mb-3"><a href="?tab=meals" class="btn btn-sm btn-outline-secondary">← Back to meal plan</a></p>
|
||||||
|
<div class="alert alert-warning mb-0" role="alert">You can’t open this editor. You may need to switch to a profile that is allowed to create or edit this meal.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<div id="meals" class="tab-content" data-week-start="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<h2 class="mb-2">Meal plan</h2>
|
||||||
|
<p class="text-muted small">Week starts on <strong><?= sanitizeInput($weekStart) ?></strong> (day 0). Assign meals to each slot; shopping items go to the grocery list as <strong>pending review</strong>.</p>
|
||||||
|
|
||||||
|
<?php if ($activePerson === null): ?>
|
||||||
|
<div class="alert alert-warning">Choose who is using the hub to use the meal planner.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($canManageMeals): ?>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body row g-2 align-items-end">
|
||||||
|
<div class="col-md-auto">
|
||||||
|
<label class="form-label" for="meal_week_start">Week starting (Monday)</label>
|
||||||
|
<input type="date" class="form-control" id="meal_week_start" value="<?= htmlspecialchars($weekStart, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-auto">
|
||||||
|
<button type="button" class="btn btn-primary" id="btnMealWeekApply">Set planning week</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert d-none" id="mealWeekFeedback" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-bordered meal-grid-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Breakfast</th>
|
||||||
|
<th>Lunch</th>
|
||||||
|
<th>Dinner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php for ($d = 0; $d < 7; $d++): ?>
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="text-nowrap"><?= sanitizeInput(mealDayShortLabel($weekStart, $d)) ?></th>
|
||||||
|
<?php foreach (mealSlotTypes() as $mt): ?>
|
||||||
|
<?php
|
||||||
|
$mid = $slots[(string) $d][$mt] ?? null;
|
||||||
|
$label = $mid && isset($mealTitleById[$mid]) ? $mealTitleById[$mid] : '—';
|
||||||
|
?>
|
||||||
|
<td>
|
||||||
|
<?php if ($mid): ?>
|
||||||
|
<a href="?tab=meals&meal=<?= htmlspecialchars($mid, ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($label) ?></a>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($activePerson !== null): ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 btn-open-slot-picker"
|
||||||
|
data-day="<?= (int) $d ?>"
|
||||||
|
data-meal-type="<?= htmlspecialchars($mt, ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
data-current-meal-id="<?= htmlspecialchars((string) ($mid ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||||||
|
><?= $mid ? 'Change' : '+' ?></button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h5">Meal library</h3>
|
||||||
|
<?php if ($canManageMeals): ?>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-3 meals-recipe-actions">
|
||||||
|
<a href="?tab=meals&edit=new" class="btn btn-success btn-sm">New meal</a>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#mealImportModal">Import recipe JSON</button>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="text-muted small">Only a verified Head of household can add or import meals.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (count($meals) === 0): ?>
|
||||||
|
<p class="text-muted">No meals in the library yet.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-group mb-4">
|
||||||
|
<?php foreach ($meals as $m): ?>
|
||||||
|
<?php $mid = htmlspecialchars((string) ($m['id'] ?? ''), ENT_QUOTES, 'UTF-8'); ?>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<a href="?tab=meals&meal=<?= $mid ?>"><strong><?= sanitizeInput($m['title'] ?? '') ?></strong></a>
|
||||||
|
<?php foreach (($m['tags'] ?? []) as $t): ?>
|
||||||
|
<span class="badge text-bg-light border ms-1"><?= sanitizeInput((string) $t) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<span class="d-flex flex-wrap gap-1 align-items-center">
|
||||||
|
<?php if ($activePerson !== null && $mid !== ''): ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary btn-meal-export-json" data-meal-id="<?= $mid ?>">Export</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($canEditMeal($m)): ?>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="?tab=meals&edit=<?= $mid ?>">Edit</a>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger btn-meal-delete" data-id="<?= $mid ?>">Delete</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="mealSlotModal" tabindex="-1" aria-labelledby="mealSlotModalLabel" aria-hidden="true" data-bs-backdrop="false">
|
||||||
<div class="modal-dialog modal-dialog-scrollable">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@ -362,6 +457,35 @@ if (!is_array($listItems)) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canManageMeals): ?>
|
||||||
|
<div class="modal fade" id="mealImportModal" tabindex="-1" aria-labelledby="mealImportModalLabel" aria-hidden="true" data-bs-backdrop="false">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title h5" id="mealImportModalLabel">Import recipe JSON</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body meal-import-modal-body">
|
||||||
|
<p class="text-muted small">Choose a JSON file (export from this hub or a shared recipe package). Only slots that reference the imported meal id(s) are applied to the <strong>current</strong> planning week.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="mealImportFile">Recipe file</label>
|
||||||
|
<input type="file" class="form-control" id="mealImportFile" accept="application/json,.json">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Preview</label>
|
||||||
|
<pre class="small bg-light border rounded p-2 mb-0 meal-import-preview" id="mealImportPreview" style="max-height: 200px; overflow: auto;">—</pre>
|
||||||
|
</div>
|
||||||
|
<div class="alert d-none" id="mealImportFeedback" role="status"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer flex-wrap gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btnMealImportSubmit" disabled>Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<script type="application/json" id="mealsLibraryJson"><?= json_encode(array_map(static function ($m) {
|
<script type="application/json" id="mealsLibraryJson"><?= json_encode(array_map(static function ($m) {
|
||||||
return [
|
return [
|
||||||
'id' => (string) ($m['id'] ?? ''),
|
'id' => (string) ($m['id'] ?? ''),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user