Import Export Grocery List

And misc improvements
This commit is contained in:
2026-03-30 21:05:12 -05:00
parent 5e0b096082
commit 9a78c4564e
9 changed files with 919 additions and 225 deletions
+98 -2
View File
@@ -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,
+157 -14
View File
@@ -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() {