This commit is contained in:
Flatlogic Bot 2025-12-04 21:30:27 +00:00
parent e97488f9dc
commit 4ea0efca37
5 changed files with 170 additions and 90 deletions

View File

@ -2,6 +2,17 @@
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php'; require_once __DIR__ . '/../db/config.php';
function get_ingredient_category($name) {
$name = strtolower($name);
$drink_keywords = ["water", "juice", "milk", "wine", "beer", "soda", "spirit", "vodka", "gin", "rum", "tequila", "whiskey", "liqueur", "coke", "pepsi", "tea", "coffee"];
foreach ($drink_keywords as $keyword) {
if (strpos($name, $keyword) !== false) {
return 'drink';
}
}
return 'food';
}
// The request is now multipart/form-data, so we use $_POST and $_FILES // The request is now multipart/form-data, so we use $_POST and $_FILES
$data = $_POST; $data = $_POST;
$files = $_FILES; $files = $_FILES;
@ -68,9 +79,10 @@ try {
} }
// Insert ingredients // Insert ingredients
$stmt = $pdo->prepare("INSERT INTO ingredients (recipe_id, name, quantity, unit) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO ingredients (recipe_id, name, quantity, unit, category) VALUES (?, ?, ?, ?, ?)");
foreach ($ingredients as $ing) { foreach ($ingredients as $ing) {
$stmt->execute([$recipeId, $ing['name'], $ing['quantity'], $ing['unit']]); $ingredientCategory = get_ingredient_category($ing['name']);
$stmt->execute([$recipeId, $ing['name'], $ing['quantity'], $ing['unit'], $ingredientCategory]);
} }
$pdo->commit(); $pdo->commit();

View File

@ -328,26 +328,29 @@ footer.bg-light {
} }
/* Modal Styles */ /* Modal Styles */
#recipe-form-modal .modal-content, #add-product-modal .modal-content, #confirmRemoveModal .modal-content { #recipe-form-modal .modal-content, #add-product-modal .modal-content, #confirmRemoveModal .modal-content, #view-recipe-modal .modal-content {
background-color: #142E35; background-color: #142E35;
color: white; color: white;
} }
#recipe-form-modal .modal-header, #recipe-form-modal .modal-header,
#add-product-modal .modal-header, #add-product-modal .modal-header,
#confirmRemoveModal .modal-header { #confirmRemoveModal .modal-header,
#view-recipe-modal .modal-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.2); border-bottom: 1px solid rgba(255, 255, 255, 0.2);
} }
#recipe-form-modal .modal-footer, #recipe-form-modal .modal-footer,
#add-product-modal .modal-footer, #add-product-modal .modal-footer,
#confirmRemoveModal .modal-footer { #confirmRemoveModal .modal-footer,
#view-recipe-modal .modal-footer {
border-top: 1px solid rgba(255, 255, 255, 0.2); border-top: 1px solid rgba(255, 255, 255, 0.2);
} }
#recipe-form-modal .btn-close, #recipe-form-modal .btn-close,
#add-product-modal .btn-close, #add-product-modal .btn-close,
#confirmRemoveModal .btn-close { #confirmRemoveModal .btn-close,
#view-recipe-modal .btn-close {
filter: invert(1); filter: invert(1);
} }
@ -375,21 +378,16 @@ footer.bg-light {
} }
/* Recipe Card Category Label */ /* Recipe Card Category Label */
.card {
position: relative;
}
.recipe-category-label { .recipe-category-label {
position: absolute;
top: 10px;
right: 10px;
background-color: #142E35; background-color: #142E35;
color: white; color: white;
padding: 5px 10px; padding: 5px 10px;
border-radius: 5px; border-radius: 5px;
font-size: 0.8em; font-size: 0.8em;
font-weight: 600; font-weight: 600;
z-index: 1; margin-left: 10px; /* Add some space between title and label */
white-space: nowrap; /* Prevent the label itself from wrapping */
align-self: flex-start; /* Align to the top of the flex container */
} }
/* Shopping List Quantity Controls */ /* Shopping List Quantity Controls */
@ -432,13 +430,7 @@ footer.bg-light {
object-fit: cover; object-fit: cover;
} }
/* Overlay category label */
.card .recipe-category-label {
top: 15px;
right: 15px;
background-color: rgba(20, 46, 53, 0.8);
backdrop-filter: blur(5px);
}
/* Recipe Card Animation */ /* Recipe Card Animation */
.recipe-card-enter { .recipe-card-enter {
@ -561,3 +553,21 @@ footer.bg-light {
border-color: transparent; border-color: transparent;
opacity: 0.7; opacity: 0.7;
} }
.card-title {
white-space: normal;
word-wrap: break-word;
}
#view-recipe-ingredients {
background-color: rgba(0, 0, 0, 0.2); /* Darker shade */
padding: 15px;
border-radius: 10px;
margin-top: 10px;
}
#view-recipe-ingredients .list-group-item {
background-color: transparent;
border: none;
color: #fff;
}

View File

@ -3,6 +3,8 @@ const app = {
state: { state: {
recipes: [], recipes: [],
confirmedRecipeProducts: [], confirmedRecipeProducts: [],
checkedItems: [],
additionalProducts: []
}, },
api: { api: {
async getRecipes() { async getRecipes() {
@ -77,23 +79,40 @@ const app = {
card.appendChild(img); card.appendChild(img);
} }
const cardBody = document.createElement('div');
cardBody.className = 'card-body d-flex flex-column';
const titleWrapper = document.createElement('div');
titleWrapper.className = 'd-flex justify-content-between align-items-start mb-2 gap-2';
const title = document.createElement('h5');
title.className = 'card-title mb-0';
title.textContent = recipe.name;
titleWrapper.appendChild(title);
if (recipe.category) { if (recipe.category) {
const categoryLabel = document.createElement('div'); const categoryLabel = document.createElement('div');
categoryLabel.className = 'recipe-category-label'; categoryLabel.className = 'recipe-category-label';
categoryLabel.textContent = recipe.category; categoryLabel.textContent = recipe.category;
card.appendChild(categoryLabel); titleWrapper.appendChild(categoryLabel);
} }
const cardBody = document.createElement('div');
cardBody.className = 'card-body d-flex flex-column';
const title = document.createElement('h5');
title.className = 'card-title';
title.textContent = recipe.name;
const text = document.createElement('p'); const text = document.createElement('p');
text.className = 'card-text text-muted'; text.className = 'card-text text-muted';
text.textContent = `${recipe.ingredients.length} ingredients`; text.textContent = `Serves ${recipe.guests} | ${recipe.ingredients.length} ingredients`;
const guestPortionControls = document.createElement('div');
guestPortionControls.className = 'row g-2 mb-3';
guestPortionControls.innerHTML = `
<div class="col">
<label for="recipe-guests-${recipe.id}" class="form-label form-label-sm">Guests</label>
<input type="number" id="recipe-guests-${recipe.id}" class="form-control form-control-sm recipe-guests-input" value="${recipe.guests}" min="1" data-id="${recipe.id}">
</div>
<div class="col">
<label for="recipe-portions-${recipe.id}" class="form-label form-label-sm">Portions</label>
<input type="number" id="recipe-portions-${recipe.id}" class="form-control form-control-sm recipe-portions-input" value="1" min="1" data-id="${recipe.id}">
</div>
`;
const buttonGroup = document.createElement('div'); const buttonGroup = document.createElement('div');
buttonGroup.className = 'mt-auto pt-2'; buttonGroup.className = 'mt-auto pt-2';
@ -103,8 +122,9 @@ const app = {
<button class="btn btn-danger btn-sm delete-recipe" title="Delete"><i class="bi bi-trash"></i></button> <button class="btn btn-danger btn-sm delete-recipe" title="Delete"><i class="bi bi-trash"></i></button>
`; `;
cardBody.appendChild(title); cardBody.appendChild(titleWrapper);
cardBody.appendChild(text); cardBody.appendChild(text);
cardBody.appendChild(guestPortionControls);
cardBody.appendChild(buttonGroup); cardBody.appendChild(buttonGroup);
card.appendChild(cardBody); card.appendChild(cardBody);
cardCol.appendChild(card); cardCol.appendChild(card);
@ -112,14 +132,23 @@ const app = {
}); });
}, },
updateShoppingList() { updateShoppingList() {
const guestCount = parseInt(app.dom.guestCountInput.value, 10) || 1;
const portionsPerGuest = parseInt(app.dom.portionsPerGuestInput.value, 10) || 1;
const totalMultiplier = guestCount * portionsPerGuest;
const combinedIngredients = new Map(); const combinedIngredients = new Map();
// 1. Process recipe ingredients // 1. Process recipe ingredients and calculate total based on per-recipe inputs
app.state.recipes.forEach(recipe => { app.state.recipes.forEach(recipe => {
const card = app.dom.recipeCardsContainer.querySelector(`[data-id="${recipe.id}"]`);
if (!card) return;
const guestsInput = card.querySelector('.recipe-guests-input');
const portionsInput = card.querySelector('.recipe-portions-input');
const targetGuests = guestsInput ? parseInt(guestsInput.value, 10) : recipe.guests;
const targetPortions = portionsInput ? parseInt(portionsInput.value, 10) : 1;
if (isNaN(targetGuests) || targetGuests <= 0) return;
const baseGuests = parseInt(recipe.guests, 10) || 1;
if (recipe.ingredients) { if (recipe.ingredients) {
recipe.ingredients.forEach(ing => { recipe.ingredients.forEach(ing => {
const name = ing.name.trim(); const name = ing.name.trim();
@ -134,11 +163,12 @@ const app = {
recipeQty: 0, recipeQty: 0,
additionalQty: 0, additionalQty: 0,
sources: [], sources: [],
category: null // For 'pack' items category: ing.category
}); });
} }
const item = combinedIngredients.get(key); const item = combinedIngredients.get(key);
item.recipeQty += (ing.quantity || 0); item.recipeQty += (ing.quantity || 0) * targetGuests * targetPortions;
if (!item.sources.includes(recipe.name)) { if (!item.sources.includes(recipe.name)) {
item.sources.push(recipe.name); item.sources.push(recipe.name);
} }
@ -161,7 +191,7 @@ const app = {
recipeQty: 0, recipeQty: 0,
additionalQty: 0, additionalQty: 0,
sources: [], sources: [],
category: null category: prod.category
}); });
} }
const item = combinedIngredients.get(key); const item = combinedIngredients.get(key);
@ -170,7 +200,7 @@ const app = {
if (!item.sources.includes(source)) { if (!item.sources.includes(source)) {
item.sources.push(source); item.sources.push(source);
} }
if (prod.unit === 'pack' && prod.category) { if (prod.category && !item.category) {
item.category = prod.category; item.category = prod.category;
} }
}); });
@ -178,29 +208,24 @@ const app = {
// 3. Group for display // 3. Group for display
const groups = { const groups = {
Food: { units: ['g', 'kg'], ingredients: [] }, 'Food': { ingredients: [] },
Drinks: { units: ['ml', 'l'], ingredients: [] }, 'Drinks': { ingredients: [] },
Count: { units: ['piece'], ingredients: [] }, 'Cooking and serving': { ingredients: [] },
"Tableware and consumables": { units: [], ingredients: [] }, 'Tableware and consumables': { ingredients: [] }
"Cooking and serving": { units: [], ingredients: [] },
Other: { units: [], ingredients: [] }
}; };
combinedIngredients.forEach((item, key) => { combinedIngredients.forEach((item, key) => {
let groupName = 'Other'; let groupName = 'Food'; // Default to Food
if (item.unit === 'pack' && item.category) {
if (item.category) {
const normalizedCategory = item.category.toLowerCase();
if (groups.hasOwnProperty(item.category)) {
groupName = item.category; groupName = item.category;
} else { } else if (normalizedCategory === 'drinks' || normalizedCategory === 'drink') {
for (const name in groups) { groupName = 'Drinks';
if (groups[name].units.includes(item.unit)) {
groupName = name;
break;
} }
} }
}
if (!groups[groupName]) { // handle dynamic categories from 'pack'
groups[groupName] = { units: [], ingredients: [] };
}
groups[groupName].ingredients.push(item); groups[groupName].ingredients.push(item);
}); });
@ -215,17 +240,18 @@ const app = {
html += `<h4 class="mt-3">${groupName}</h4>`; html += `<h4 class="mt-3">${groupName}</h4>`;
html += '<ul class="list-group list-group-flush">'; html += '<ul class="list-group list-group-flush">';
group.ingredients.forEach((item, index) => { group.ingredients.forEach((item, index) => {
const totalQty = (item.recipeQty * totalMultiplier) + item.additionalQty; const totalQty = item.recipeQty + item.additionalQty;
if (totalQty === 0) return; if (totalQty <= 0) return;
const quantityStr = Number.isInteger(totalQty) ? totalQty : parseFloat(totalQty.toFixed(2)); const quantityStr = Number.isInteger(totalQty) ? totalQty : parseFloat(totalQty.toFixed(2));
const uniqueId = `shopping-item-${groupName.replace(/\s/g, '-')}-${index}`; const uniqueId = `shopping-item-${groupName.replace(/\s/g, '-')}-${index}`;
const tooltipText = item.sources.join(', '); const tooltipText = item.sources.join(', ');
const itemKey = `${item.name.toLowerCase()}|${item.unit.toLowerCase()}`; const itemKey = `${item.name.toLowerCase()}|${item.unit.toLowerCase()}`;
const isChecked = app.state.checkedItems.includes(itemKey);
html += `<li class="list-group-item d-flex justify-content-between align-items-center"> html += `<li class="list-group-item d-flex justify-content-between align-items-center ${isChecked ? 'checked' : ''}">
<div class="form-check d-flex align-items-center"> <div class="form-check d-flex align-items-center">
<input class="form-check-input" type="checkbox" id="${uniqueId}"> <input class="form-check-input" type="checkbox" id="${uniqueId}" data-key="${itemKey}" ${isChecked ? 'checked' : ''}>
<label class="form-check-label ms-2" for="${uniqueId}"> <label class="form-check-label ms-2" for="${uniqueId}">
${item.name} ${item.name}
</label> </label>
@ -391,6 +417,15 @@ const app = {
snowflake.style.opacity = Math.random() * 0.7 + 0.3; snowflake.style.opacity = Math.random() * 0.7 + 0.3;
snowContainer.appendChild(snowflake); snowContainer.appendChild(snowflake);
} }
},
loadCheckedItems() {
const checkedItems = localStorage.getItem('checkedItems');
if (checkedItems) {
app.state.checkedItems = JSON.parse(checkedItems);
}
},
saveCheckedItems() {
localStorage.setItem('checkedItems', JSON.stringify(app.state.checkedItems));
} }
}, },
events: { events: {
@ -457,32 +492,50 @@ const app = {
}); });
app.dom.recipeCardsContainer.addEventListener('click', function(e) { app.dom.recipeCardsContainer.addEventListener('click', function(e) {
const target = e.target; const target = e.target.closest('button');
const card = target.closest('.col-12[data-id]'); const card = e.target.closest('.col-12[data-id]');
if (!card) return; if (!card) return;
const recipeId = card.getAttribute('data-id'); const recipeId = card.getAttribute('data-id');
if (target.classList.contains('delete-recipe')) { if (target && target.classList.contains('delete-recipe')) {
if (confirm('Are you sure you want to delete this recipe?')) { if (confirm('Are you sure you want to delete this recipe?')) {
app.api.deleteRecipe(recipeId); app.api.deleteRecipe(recipeId);
} }
} }
if (target.classList.contains('edit-recipe')) { if (target && target.classList.contains('edit-recipe')) {
app.ui.populateFormForEdit(recipeId); app.ui.populateFormForEdit(recipeId);
} }
if (target.classList.contains('view-recipe')) { if (target && target.classList.contains('view-recipe')) {
app.ui.populateViewModal(recipeId); app.ui.populateViewModal(recipeId);
} }
}); });
app.dom.recipeCardsContainer.addEventListener('input', function(e) {
if (e.target.classList.contains('recipe-guests-input') || e.target.classList.contains('recipe-portions-input')) {
app.ui.updateShoppingList();
}
});
app.dom.shoppingListContainer.addEventListener('click', function(e) { app.dom.shoppingListContainer.addEventListener('click', function(e) {
if (e.target.matches('.form-check-input')) { if (e.target.matches('.form-check-input')) {
const listItem = e.target.closest('.list-group-item'); const listItem = e.target.closest('.list-group-item');
if (listItem) { if (listItem) {
listItem.classList.toggle('checked', e.target.checked); listItem.classList.toggle('checked', e.target.checked);
const itemKey = e.target.dataset.key;
if (e.target.checked) {
if (!app.state.checkedItems.includes(itemKey)) {
app.state.checkedItems.push(itemKey);
}
} else {
const index = app.state.checkedItems.indexOf(itemKey);
if (index > -1) {
app.state.checkedItems.splice(index, 1);
}
}
app.ui.saveCheckedItems();
} }
} else if (e.target.matches('.increment-item')) { } else if (e.target.matches('.increment-item')) {
const key = e.target.dataset.key; const key = e.target.dataset.key;
@ -569,8 +622,7 @@ const app = {
} }
}); });
app.dom.guestCountInput.addEventListener('input', app.ui.updateShoppingList);
app.dom.portionsPerGuestInput.addEventListener('input', app.ui.updateShoppingList);
app.dom.cancelEditBtn.addEventListener('click', function() { app.dom.cancelEditBtn.addEventListener('click', function() {
app.ui.clearForm(); app.ui.clearForm();
@ -624,12 +676,6 @@ const app = {
}); });
e.target.classList.remove('btn-outline-secondary'); e.target.classList.remove('btn-outline-secondary');
e.target.classList.add('btn-secondary'); e.target.classList.add('btn-secondary');
if (e.target.textContent.trim() === 'pack') {
app.dom.productCategoryWrapper.style.display = 'block';
} else {
app.dom.productCategoryWrapper.style.display = 'none';
}
} }
} }
}); });
@ -647,11 +693,6 @@ const app = {
return; return;
} }
if (unit === 'pack' && category === 'Choose...') {
alert('Please select a category for products in packs.');
return;
}
if (!app.state.additionalProducts) { if (!app.state.additionalProducts) {
app.state.additionalProducts = []; app.state.additionalProducts = [];
} }
@ -662,10 +703,7 @@ const app = {
if (existingProduct) { if (existingProduct) {
existingProduct.quantity += quantity; existingProduct.quantity += quantity;
} else { } else {
const newProduct = { name, quantity, unit, source: 'Additional product' }; const newProduct = { name, quantity, unit, source: 'Additional product', category: category };
if (unit === 'pack') {
newProduct.category = category;
}
app.state.additionalProducts.push(newProduct); app.state.additionalProducts.push(newProduct);
} }
@ -674,8 +712,7 @@ const app = {
// Reset form // Reset form
app.dom.productNameInput.value = ''; app.dom.productNameInput.value = '';
app.dom.productQuantityInput.value = '1'; app.dom.productQuantityInput.value = '1';
app.dom.productCategory.value = 'Choose...'; app.dom.productCategory.value = 'Food';
app.dom.productCategoryWrapper.style.display = 'none';
const unitButtons = app.dom.addProductModal._element.querySelectorAll('.unit-selector .unit-btn'); const unitButtons = app.dom.addProductModal._element.querySelectorAll('.unit-selector .unit-btn');
unitButtons.forEach((btn, index) => { unitButtons.forEach((btn, index) => {
@ -691,7 +728,15 @@ const app = {
app.dom.addProductModal.hide(); app.dom.addProductModal.hide();
}); });
app.dom.musicToggle.addEventListener('click', function() {
if (app.dom.christmasMusic.paused) {
app.dom.christmasMusic.play();
app.dom.musicToggle.innerHTML = '<i class="bi bi-pause-fill" style="font-size: 1.5rem;"></i>';
} else {
app.dom.christmasMusic.pause();
app.dom.musicToggle.innerHTML = '<i class="bi bi-play-fill" style="font-size: 1.5rem;"></i>';
}
});
document.getElementById('recipe-form-modal').addEventListener('show.bs.modal', function () { document.getElementById('recipe-form-modal').addEventListener('show.bs.modal', function () {
if (!app.dom.recipeIdInput.value) { if (!app.dom.recipeIdInput.value) {
@ -726,10 +771,13 @@ const app = {
productQuantityInput: document.getElementById('productQuantity'), productQuantityInput: document.getElementById('productQuantity'),
productCategoryWrapper: document.getElementById('product-category-wrapper'), productCategoryWrapper: document.getElementById('product-category-wrapper'),
productCategory: document.getElementById('productCategory'), productCategory: document.getElementById('productCategory'),
christmasMusic: document.getElementById('christmas-music'),
musicToggle: document.getElementById('music-toggle'),
}; };
app.ui.createSnowflakes(); app.ui.createSnowflakes();
app.ui.loadCheckedItems();
app.events.attachEventListeners(); app.events.attachEventListeners();
app.dom.cancelEditBtn.style.display = 'none'; app.dom.cancelEditBtn.style.display = 'none';
app.ui.addIngredientRow(); app.ui.addIngredientRow();

View File

@ -0,0 +1 @@
ALTER TABLE `ingredients` ADD `category` VARCHAR(50) NOT NULL DEFAULT 'food';

View File

@ -45,6 +45,7 @@
<div class="text-center mb-5" style="padding-top: 20px;"> <div class="text-center mb-5" style="padding-top: 20px;">
<h1 class="display-4 mt-4">Hey, it's Christmas time!</h1> <h1 class="display-4 mt-4">Hey, it's Christmas time!</h1>
<p class="lead">Let's get your holiday recipes sorted.</p> <p class="lead">Let's get your holiday recipes sorted.</p>
<div id="christmas-countdown" class="lead"></div>
</div> </div>
<div class="row g-4"> <div class="row g-4">
@ -197,12 +198,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3" id="product-category-wrapper" style="display: none;"> <div class="mb-3" id="product-category-wrapper">
<label for="productCategory" class="form-label">Category</label> <label for="productCategory" class="form-label">Category</label>
<select class="form-select" id="productCategory"> <select class="form-select" id="productCategory">
<option selected>Choose...</option> <option value="Food" selected>Food</option>
<option value="Tableware and consumables">Tableware and consumables</option> <option value="Drinks">Drinks</option>
<option value="Cooking and serving">Cooking and serving</option> <option value="Cooking and serving">Cooking and serving</option>
<option value="Tableware and consumables">Tableware and consumables</option>
</select> </select>
</div> </div>
<div class="d-grid gap-2 mt-4"> <div class="d-grid gap-2 mt-4">
@ -243,6 +245,13 @@
<p class="mb-0">&copy; <?php echo date("Y"); ?> Christmas Recipe Calculator. Happy Holidays!</p> <p class="mb-0">&copy; <?php echo date("Y"); ?> Christmas Recipe Calculator. Happy Holidays!</p>
</footer> </footer>
<audio id="christmas-music" loop>
<source src="https://www.fesliyanstudios.com/download-link.php?i=230" type="audio/mpeg">
</audio>
<button id="music-toggle" class="btn btn-light" style="position: fixed; bottom: 20px; right: 20px; border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-play-fill" style="font-size: 1.5rem;"></i>
</button>
<!-- Scripts --> <!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=1700253313"></script> <script src="assets/js/main.js?v=1700253313"></script>