diff --git a/api/meal_export.php b/api/meal_export.php new file mode 100644 index 0000000..ad0390d --- /dev/null +++ b/api/meal_export.php @@ -0,0 +1,49 @@ + 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, + ], +]); diff --git a/api/meal_import.php b/api/meal_import.php new file mode 100644 index 0000000..bd7e6cd --- /dev/null +++ b/api/meal_import.php @@ -0,0 +1,102 @@ + 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, +]); diff --git a/api/meal_shopping_item_to_grocery.php b/api/meal_shopping_item_to_grocery.php new file mode 100644 index 0000000..fab0b39 --- /dev/null +++ b/api/meal_shopping_item_to_grocery.php @@ -0,0 +1,91 @@ + 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']]); diff --git a/assets/css/style.css b/assets/css/style.css index 95be709..9f43c45 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -22,14 +22,14 @@ 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 { from { opacity: 0; - transform: translateY(6px); } to { 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) { *, *::before, diff --git a/assets/js/main.js b/assets/js/main.js index c7ab4d3..f840515 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -569,36 +569,62 @@ } var weekStart = ($tab.attr('data-week-start') || '').trim(); + var mealImportParsed = null; - $('#btnPushMealGrocery').on('click', function () { - var id = $(this).data('meal-id'); - if (!id) { + $(document).on('click', '.btn-meal-shopping-to-grocery', function () { + var $btn = $(this); + 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; } - postJson('/meal_push_grocery.php', { id: id }) - .then(function (data) { - var n = data.groceryLinesAdded != null ? data.groceryLinesAdded : 0; - showAlert($('#pushMealFeedback'), 'success', 'Added ' + n + ' line(s) to grocery (pending review).'); - $('#pushMealFeedback').removeClass('d-none'); + postJson('/meal_shopping_item_to_grocery.php', { mealId: mealId, itemIndex: itemIndex, storeId: storeId }) + .then(function () { + 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) { - showAlert($('#pushMealFeedback'), 'danger', err.message || 'Could not push'); - $('#pushMealFeedback').removeClass('d-none'); + if ($fb.length) { + showAlert($fb, 'danger', (err.message || 'Could not add') + ' — “' + label + '”'); + $fb.removeClass('d-none'); + } }); }); $(document).on('click', '.btn-ingredient-to-grocery', function () { var $btn = $(this); 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 storeId = $li.find('.ingredient-store').val() || ''; + var $fb = $('#mealPantryGroceryFeedback'); postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId }) .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) { - 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 slotModal = null; if (slotModalEl && typeof bootstrap !== 'undefined') { - slotModal = new bootstrap.Modal(slotModalEl); + slotModal = new bootstrap.Modal(slotModalEl, { backdrop: false }); } function renderSlotPickerList() { @@ -764,6 +790,123 @@ 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() { diff --git a/includes/grocery_helpers.php b/includes/grocery_helpers.php index 71c67c5..87b7623 100644 --- a/includes/grocery_helpers.php +++ b/includes/grocery_helpers.php @@ -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> $catalog + * @param array{byStore: array>>} $lists * @param array> $stores * @return array{ok: bool, item?: array, error?: string} */ -function groceryAppendShoppingLine( +function groceryAppendShoppingLineIntoBuffers( + array &$catalog, + array &$lists, array $stores, string $storeId, string $name, @@ -234,7 +238,6 @@ function groceryAppendShoppingLine( $price = trim($price); $image = trim($image); - $catalog = normalizeCatalogList(readJsonFile('grocery_catalog.json')); $dedupeKey = groceryCatalogDedupeKey($storeId, $name, $size); $catalogId = null; foreach ($catalog as $i => $c) { @@ -296,12 +299,58 @@ function groceryAppendShoppingLine( $line['mealTitle'] = $mealTitleMeta; } - $lists = normalizeGroceryLists(readJsonFile('grocery_lists.json')); if (!isset($lists['byStore'][$storeId])) { $lists['byStore'][$storeId] = []; } $lists['byStore'][$storeId][] = $line; + return ['ok' => true, 'item' => $line]; +} + +/** + * Create catalog row + list line (used by grocery_item_create and meal → grocery). + * + * @param array> $stores + * @return array{ok: bool, item?: array, 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)) { 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' => true, 'item' => $line]; + return $res; +} + +/** + * Append several meal shopping lines in one disk transaction (catalog + lists). + * + * @param array> $stores + * @param list $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; } /** diff --git a/includes/meal_helpers.php b/includes/meal_helpers.php index 50ecb00..bdd31ad 100644 --- a/includes/meal_helpers.php +++ b/includes/meal_helpers.php @@ -251,42 +251,7 @@ function pushMealItemsToGrocery(array $meal, array $stores): int { } $mealId = (string) ($meal['id'] ?? ''); $mealTitle = (string) ($meal['title'] ?? ''); - $defaultStore = groceryFirstStoreId($stores); - 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; + return groceryAppendMealItemRowsBatch($stores, $items, $mealId, $mealTitle); } function mealDayShortLabel(string $weekStart, int $offset): string { diff --git a/mcp/familyhub/server.php b/mcp/familyhub/server.php index f9b89d9..9edcedb 100644 --- a/mcp/familyhub/server.php +++ b/mcp/familyhub/server.php @@ -15,10 +15,13 @@ const FAMILYHUB_MCP_PROTOCOL_VERSION = '2024-11-05'; const FAMILYHUB_MCP_POST_ALLOWLIST = [ 'family_settings_save.php', 'meal_ingredient_to_grocery.php', + 'meal_shopping_item_to_grocery.php', 'meal_push_grocery.php', 'meal_plan_set_slot.php', 'meal_plan_set_week.php', 'meal_delete.php', + 'meal_export.php', + 'meal_import.php', 'meal_save.php', 'grocery_item_create.php', 'grocery_item_review.php', diff --git a/tabs/meals.php b/tabs/meals.php index bebd351..6a0600b 100644 --- a/tabs/meals.php +++ b/tabs/meals.php @@ -54,187 +54,171 @@ $listItems = $firstList['items'] ?? []; if (!is_array($listItems)) { $listItems = []; } + +$mealEditorNotFound = $editMealId !== '' && $editMealId !== 'new' && $prefillMeal === null && $canManageMeals; +$mealEditorAllowed = ($editMealId === 'new' && $canManageMeals) + || ($prefillMeal !== null && $canEditMeal($prefillMeal)); +$showMealEditorPage = $editMealId !== '' && ($mealEditorAllowed || $mealEditorNotFound); +$mealEditorForbidden = $editMealId !== '' && !$showMealEditorPage; ?> - -
-

← Back to meal plan

-

- - - -

+ + +
+

← Back to meal plan

- 0): ?> -

+
+
+

+ 0): ?> +

+ +
+
+ + + + + Edit meal + +
+
+ + +
+
+ + + + +
+ +
+
-

Directions

-
- - - - - -
- -
+
+

+ Shopping list +

+

Items to buy for this recipe. Choose a store and add each line to the grocery tab as pending review.

+ +

None listed for this meal.

-
+
    + $it): ?> + +
  • + + + · + () + + 0): ?> + + + + + +
  • + +
- +
+
-

Pantry / staples (ingredients)

- -

None listed.

- -
    - -
  • - - 0): ?> - - - - - -
  • - -
+
+

+ Pantry / staples +

+

Ingredients you may already have. Add one line to the grocery list if you need to restock.

+ +

None listed.

+ +
    + +
  • + + 0): ?> + + + + + +
  • + +
+ +
+
+ + +
+

+ Recipe checklist +

+

Quick reminders while you cook (not interactive).

+ + + + +
+ +
+ +
+ + +
-

Shopping items

- -

None listed.

- -
    - -
  • - · - () -
  • - -
+ +
+

+ Directions +

+
+
- 0): ?> - -
- - - -

Edit meal

-
- - -
-

Meal plan

-

Week starts on (day 0). Assign meals to each slot; shopping items go to the grocery list as pending review.

- - -
Choose who is using the hub to use the meal planner.
- - - -
-
-
- - -
-
- -
-
-
-
-
-
- - -
- - - - - - - - - - - - - - - - - - - - -
BreakfastLunchDinner
- - - - - - - - -
-
- -

Meal library

- -

New meal

- -

Only a verified Head of household can add meals.

- - - -

No meals in the library yet.

- -
    - - -
  • -
    - - - - -
    - - - Edit - - - -
  • - -
- - - -
-
+ +
+

← Back to meal plan

+

+
+
- -
Meal not found.
+ +
Meal not found.
@@ -329,10 +313,121 @@ if (!is_array($listItems)) {
-
-