diff --git a/api/save_recipe.php b/api/save_recipe.php index e6f783b..e3d4581 100644 --- a/api/save_recipe.php +++ b/api/save_recipe.php @@ -17,8 +17,9 @@ try { if (isset($data['id']) && !empty($data['id'])) { // Update existing recipe $recipeId = $data['id']; - $stmt = $pdo->prepare("UPDATE recipes SET name = ?, guests = ? WHERE id = ?"); - $stmt->execute([$data['name'], $data['guests'], $recipeId]); + $category = !empty($data['category']) ? $data['category'] : 'No category'; + $stmt = $pdo->prepare("UPDATE recipes SET name = ?, guests = ?, category = ? WHERE id = ?"); + $stmt->execute([$data['name'], $data['guests'], $category, $recipeId]); // Easiest way to handle ingredients is to delete old ones and insert new ones $stmt = $pdo->prepare("DELETE FROM ingredients WHERE recipe_id = ?"); @@ -31,8 +32,9 @@ try { } else { // Insert new recipe - $stmt = $pdo->prepare("INSERT INTO recipes (name, guests) VALUES (?, ?)"); - $stmt->execute([$data['name'], $data['guests']]); + $category = !empty($data['category']) ? $data['category'] : 'No category'; + $stmt = $pdo->prepare("INSERT INTO recipes (name, guests, category) VALUES (?, ?, ?)"); + $stmt->execute([$data['name'], $data['guests'], $category]); $recipeId = $pdo->lastInsertId(); $stmt = $pdo->prepare("INSERT INTO ingredients (recipe_id, name, quantity, unit) VALUES (?, ?, ?, ?)"); diff --git a/assets/css/custom.css b/assets/css/custom.css index d3f6b5c..42cac91 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -301,15 +301,72 @@ animation: fall linear infinite; } /* Modal Styles */ -#recipe-form-modal .modal-content { +#recipe-form-modal .modal-content, #add-product-modal .modal-content, #confirmRemoveModal .modal-content { background-color: #013617; color: white; } -#recipe-form-modal .modal-header { +#recipe-form-modal .modal-header, #add-product-modal .modal-header, #confirmationModal .modal-header { border-bottom: 1px solid rgba(255, 255, 255, 0.2); } -#recipe-form-modal .btn-close { +#recipe-form-modal .btn-close, #add-product-modal .btn-close, #confirmationModal .btn-close { filter: invert(1); } + +#category-filters .btn.active { + background-color: #012a10; + border-color: #012a10; + color: white; +} + +/* Recipe Card Category Label */ +.card { + position: relative; +} + +.recipe-category-label { + position: absolute; + top: 10px; + right: 10px; + background-color: #013617; + color: white; + padding: 5px 10px; + border-radius: 5px; + font-size: 0.8em; + font-weight: 600; + z-index: 1; +} + +#category-filters .btn { + padding: 8px 15px; + font-size: 0.9rem; +} + +/* Shopping List Quantity Controls */ +.btn-quantity-modifier { + width: 28px; + height: 28px; + border-radius: 50% !important; + padding: 0; + font-size: 1.2rem; + font-weight: bold; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + border: none !important; + outline: none !important; + box-shadow: none !important; + color: white; + background-color: #013617; /* Very dark green */ + transition: background-color 0.2s ease; +} + +.btn-quantity-modifier:hover { + background-color: #025c27; /* Slightly lighter on hover */ +} + +.quantity-controls .quantity { + margin: 0 10px; +} diff --git a/assets/js/main.js b/assets/js/main.js index b1bf3d6..62b69ad 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -2,6 +2,7 @@ const app = { dom: {}, state: { recipes: [], + confirmedRecipeProducts: [], }, api: { async getRecipes() { @@ -67,6 +68,13 @@ const app = { const card = document.createElement('div'); card.className = 'card h-100'; + + if (recipe.category) { + const categoryLabel = document.createElement('div'); + categoryLabel.className = 'recipe-category-label'; + categoryLabel.textContent = recipe.category; + card.appendChild(categoryLabel); + } const cardBody = document.createElement('div'); cardBody.className = 'card-body d-flex flex-column'; @@ -83,7 +91,7 @@ const app = { buttonGroup.className = 'mt-auto pt-2'; buttonGroup.innerHTML = ` - + `; cardBody.appendChild(title); @@ -99,91 +107,129 @@ const app = { const portionsPerGuest = parseInt(app.dom.portionsPerGuestInput.value, 10) || 1; const totalMultiplier = guestCount * portionsPerGuest; - const groups = { - Weight: { units: ['g', 'kg'], ingredients: new Map() }, - Volume: { units: ['ml', 'l'], ingredients: new Map() }, - Count: { units: ['piece', 'pack'], ingredients: new Map() }, - Other: { units: [], ingredients: new Map() } - }; + const combinedIngredients = new Map(); + // 1. Process recipe ingredients app.state.recipes.forEach(recipe => { if (recipe.ingredients) { recipe.ingredients.forEach(ing => { - const ingName = ing.name || ''; - const ingUnit = ing.unit || ''; - const key = `${ingName.trim().toLowerCase()}|${ingUnit.trim().toLowerCase()}`; + const name = ing.name.trim(); + const unit = ing.unit.trim(); + if (!name) return; + const key = `${name.toLowerCase()}|${unit.toLowerCase()}`; - let groupName = 'Other'; - for (const name in groups) { - if (groups[name].units.includes(ingUnit)) { - groupName = name; - break; - } - } - - const existing = groups[groupName].ingredients.get(key); - - if (existing) { - existing.qty += (ing.quantity || 0); - } else { - groups[groupName].ingredients.set(key, { - name: ing.name, - qty: (ing.quantity || 0), - unit: ing.unit + if (!combinedIngredients.has(key)) { + combinedIngredients.set(key, { + name: name, + unit: unit, + recipeQty: 0, + additionalQty: 0, + sources: [], + category: null // For 'pack' items }); } + const item = combinedIngredients.get(key); + item.recipeQty += (ing.quantity || 0); + if (!item.sources.includes(recipe.name)) { + item.sources.push(recipe.name); + } }); } }); - let html = ''; - let totalIngredients = 0; - + // 2. Process additional products if (app.state.additionalProducts) { app.state.additionalProducts.forEach(prod => { - const key = `${prod.name.trim().toLowerCase()}|${prod.unit.trim().toLowerCase()}`; - let groupName = 'Other'; - for (const name in groups) { - if (groups[name].units.includes(prod.unit)) { - groupName = name; - break; - } - } + const name = prod.name.trim(); + const unit = prod.unit.trim(); + if (!name) return; + const key = `${name.toLowerCase()}|${unit.toLowerCase()}`; - const existing = groups[groupName].ingredients.get(key); - if (existing) { - existing.qty += prod.quantity; - } else { - groups[groupName].ingredients.set(key, { - name: prod.name, - qty: prod.quantity, - unit: prod.unit + if (!combinedIngredients.has(key)) { + combinedIngredients.set(key, { + name: name, + unit: unit, + recipeQty: 0, + additionalQty: 0, + sources: [], + category: null }); } + const item = combinedIngredients.get(key); + item.additionalQty += prod.quantity; + const source = prod.source || 'Additional product'; + if (!item.sources.includes(source)) { + item.sources.push(source); + } + if (prod.unit === 'pack' && prod.category) { + item.category = prod.category; + } }); } + // 3. Group for display + const groups = { + Food: { units: ['g', 'kg'], ingredients: [] }, + Drinks: { units: ['ml', 'l'], ingredients: [] }, + Count: { units: ['piece'], ingredients: [] }, + "Tableware and consumables": { units: [], ingredients: [] }, + "Cooking and serving": { units: [], ingredients: [] }, + Other: { units: [], ingredients: [] } + }; + + combinedIngredients.forEach((item, key) => { + let groupName = 'Other'; + if (item.unit === 'pack' && item.category) { + groupName = item.category; + } else { + for (const name in groups) { + 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); + }); + + // 4. Render HTML + let html = ''; + let totalIngredients = 0; + for (const groupName in groups) { const group = groups[groupName]; - const ingredientList = Array.from(group.ingredients.values()); - - if (ingredientList.length > 0) { - totalIngredients += ingredientList.length; + if (group.ingredients.length > 0) { + totalIngredients += group.ingredients.length; html += `

${groupName}

`; html += ''; } @@ -194,6 +240,11 @@ const app = { } app.dom.shoppingListContainer.innerHTML = html; + + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); }, addIngredientRow(ingredient = { name: '', quantity: '', unit: 'g' }) { const row = document.createElement('div'); @@ -221,6 +272,7 @@ const app = { clearForm() { app.dom.recipeIdInput.value = ''; app.dom.recipeNameInput.value = ''; + app.dom.recipeCategoryInput.value = ''; app.dom.guestCountInput.value = '1'; app.dom.ingredientsContainer.innerHTML = ''; app.ui.addIngredientRow(); @@ -234,6 +286,7 @@ const app = { app.dom.recipeIdInput.value = recipe.id; app.dom.recipeNameInput.value = recipe.name; + app.dom.recipeCategoryInput.value = recipe.category || ''; app.dom.guestCountInput.value = recipe.guests; app.dom.ingredientsContainer.innerHTML = ''; @@ -253,6 +306,7 @@ const app = { getRecipeDataFromForm() { const recipeName = app.dom.recipeNameInput.value.trim(); const guests = parseInt(app.dom.guestCountInput.value, 10) || 0; + const category = app.dom.recipeCategoryInput.value; const ingredients = []; const rows = app.dom.ingredientsContainer.querySelectorAll('.ingredient-row'); @@ -268,7 +322,7 @@ const app = { }); if (recipeName && guests > 0 && ingredients.length > 0) { - return { name: recipeName, guests, ingredients }; + return { name: recipeName, guests, ingredients, category }; } return null; }, @@ -316,7 +370,7 @@ const app = { app.dom.newRecipeBtn.addEventListener('click', async function() { const recipeData = app.ui.getRecipeDataFromForm(); if (!recipeData) { - alert('Please fill out the recipe name, guests, and at least one ingredient before saving.'); + alert('Please fill out the recipe name, category, guests, and at least one ingredient before saving.'); return; } @@ -328,25 +382,18 @@ const app = { const data = await app.api.saveRecipe(recipeData); if (data.success && data.recipe) { - const savedRecipe = data.recipe; - if (recipeId) { - const index = app.state.recipes.findIndex(r => r.id == recipeId); - if (index !== -1) { - app.state.recipes[index] = savedRecipe; + app.api.getRecipes().then(() => { + const currentCategory = app.dom.categoryFilters.querySelector('.active').dataset.category; + if (currentCategory === 'all') { + app.ui.renderRecipeCards(app.state.recipes); + } else { + const filteredRecipes = app.state.recipes.filter(recipe => recipe.category === currentCategory); + app.ui.renderRecipeCards(filteredRecipes); } - const card = app.dom.recipeCardsContainer.querySelector(`[data-id="${recipeId}"]`); - if (card) { - card.querySelector('.card-title').textContent = savedRecipe.name; - card.querySelector('.card-text').textContent = `${savedRecipe.ingredients.length} ingredients`; - } - } else { - app.state.recipes.unshift(savedRecipe); - app.ui.renderRecipeCards(app.state.recipes); - } - - app.ui.updateShoppingList(); - app.ui.clearForm(); - app.dom.recipeFormModal.hide(); + app.ui.updateShoppingList(); + app.ui.clearForm(); + app.dom.recipeFormModal.hide(); + }); } else { alert('Failed to save recipe: ' + data.error); } @@ -370,12 +417,95 @@ const app = { } }); - app.dom.shoppingListContainer.addEventListener('change', function(e) { + app.dom.shoppingListContainer.addEventListener('click', function(e) { if (e.target.matches('.form-check-input')) { const listItem = e.target.closest('.list-group-item'); if (listItem) { listItem.classList.toggle('checked', e.target.checked); } + } else if (e.target.matches('.increment-item')) { + const key = e.target.dataset.key; + const [name, unit] = key.split('|'); + + if (!app.state.additionalProducts) { + app.state.additionalProducts = []; + } + + let productToModify = app.state.additionalProducts.find(p => p.name.toLowerCase() === name.toLowerCase() && p.unit.toLowerCase() === unit.toLowerCase()); + + if (!productToModify) { + const properName = name.charAt(0).toUpperCase() + name.slice(1); + productToModify = { + name: properName, + quantity: 0, + unit: unit, + source: 'Additional product' + }; + app.state.additionalProducts.push(productToModify); + } + + productToModify.quantity++; + app.ui.updateShoppingList(); + + } else if (e.target.matches('.decrement-item')) { + const key = e.target.dataset.key; + const [name, unit] = key.split('|'); + + if (!app.state.additionalProducts) { + app.state.additionalProducts = []; + } + + let productToModify = app.state.additionalProducts.find(p => p.name.toLowerCase() === name.toLowerCase() && p.unit.toLowerCase() === unit.toLowerCase()); + + if (productToModify && productToModify.quantity > 0) { + productToModify.quantity--; + app.ui.updateShoppingList(); + } else { + // It's a recipe ingredient, show confirmation + let recipeName = ''; + let ingredientName = ''; + let recipeId = null; + let ingredientIndex = -1; + + for (const recipe of app.state.recipes) { + const foundIngredientIndex = recipe.ingredients.findIndex(ing => ing.name.toLowerCase() === name.toLowerCase() && ing.unit.toLowerCase() === unit.toLowerCase()); + if (foundIngredientIndex !== -1) { + recipeName = recipe.name; + ingredientName = recipe.ingredients[foundIngredientIndex].name; + recipeId = recipe.id; + ingredientIndex = foundIngredientIndex; + break; + } + } + + if (recipeId !== null) { + const confirmationKey = `${recipeId}-${name.toLowerCase()}-${unit.toLowerCase()}`; + const recipeToUpdate = app.state.recipes.find(r => r.id === recipeId); + + const proceedWithRemoval = () => { + if (recipeToUpdate && recipeToUpdate.ingredients[ingredientIndex].quantity > 0) { + recipeToUpdate.ingredients[ingredientIndex].quantity--; + } + app.ui.updateShoppingList(); + }; + + if (app.state.confirmedRecipeProducts.includes(confirmationKey)) { + proceedWithRemoval(); + } else { + document.getElementById('modal-recipe-name').textContent = recipeName; + document.getElementById('modal-ingredient-name').textContent = ingredientName; + + const confirmModal = new bootstrap.Modal(document.getElementById('confirmRemoveModal')); + confirmModal.show(); + + document.getElementById('confirm-remove-btn').onclick = () => { + app.state.confirmedRecipeProducts.push(confirmationKey); + proceedWithRemoval(); + confirmModal.hide(); + }; + } + } + } } }); @@ -393,38 +523,112 @@ const app = { app.ui.renderRecipeCards(filteredRecipes); }); + app.dom.categoryFilters.addEventListener('click', function(e) { + if (e.target.tagName === 'BUTTON') { + const category = e.target.dataset.category; + + app.dom.categoryFilters.querySelectorAll('button').forEach(btn => { + btn.classList.remove('active', 'btn-secondary'); + btn.classList.add('btn-outline-secondary'); + }); + + e.target.classList.add('active', 'btn-secondary'); + e.target.classList.remove('btn-outline-secondary'); + + if (category === 'all') { + app.ui.renderRecipeCards(app.state.recipes); + } else { + const filteredRecipes = app.state.recipes.filter(recipe => recipe.category === category); + app.ui.renderRecipeCards(filteredRecipes); + } + } + }); + app.dom.printShoppingListBtn.addEventListener('click', function() { window.print(); }); - app.dom.addProductBtn.addEventListener('click', () => { - const name = prompt('Enter product name:'); - if (!name) return; + app.dom.addProductModal._element.addEventListener('click', function(e) { + if (e.target.classList.contains('unit-btn')) { + const group = e.target.closest('.unit-selector'); + if (group) { + group.querySelectorAll('.unit-btn').forEach(btn => { + btn.classList.remove('btn-secondary'); + btn.classList.add('btn-outline-secondary'); + }); + e.target.classList.remove('btn-outline-secondary'); + e.target.classList.add('btn-secondary'); - const quantity = parseFloat(prompt('Enter quantity:')); - if (isNaN(quantity) || quantity <= 0) { - alert('Please enter a valid quantity.'); + if (e.target.textContent.trim() === 'pack') { + app.dom.productCategoryWrapper.style.display = 'block'; + } else { + app.dom.productCategoryWrapper.style.display = 'none'; + } + } + } + }); + + app.dom.addProductForm.addEventListener('submit', (e) => { + e.preventDefault(); + const name = app.dom.productNameInput.value.trim(); + const quantity = parseFloat(app.dom.productQuantityInput.value); + const unitButton = app.dom.addProductModal._element.querySelector('.unit-selector .btn-secondary'); + const unit = unitButton ? unitButton.textContent.trim() : 'g'; + const category = app.dom.productCategory.value; + + if (!name || isNaN(quantity) || quantity <= 0 || !unit) { + alert('Please fill out all fields with valid values.'); return; } - const unit = prompt('Enter unit (e.g., g, kg, ml, l, piece, pack):'); - if (!unit) return; + if (unit === 'pack' && category === 'Choose...') { + alert('Please select a category for products in packs.'); + return; + } if (!app.state.additionalProducts) { app.state.additionalProducts = []; } - app.state.additionalProducts.push({ - name: name.trim(), - quantity: quantity, - unit: unit.trim() - }); + const key = `${name.toLowerCase()}|${unit.toLowerCase()}`; + const existingProduct = app.state.additionalProducts.find(p => `${p.name.toLowerCase()}|${p.unit.toLowerCase()}` === key); + + if (existingProduct) { + existingProduct.quantity += quantity; + } else { + const newProduct = { name, quantity, unit, source: 'Additional product' }; + if (unit === 'pack') { + newProduct.category = category; + } + app.state.additionalProducts.push(newProduct); + } app.ui.updateShoppingList(); + + // Reset form + app.dom.productNameInput.value = ''; + app.dom.productQuantityInput.value = '1'; + app.dom.productCategory.value = 'Choose...'; + app.dom.productCategoryWrapper.style.display = 'none'; + + const unitButtons = app.dom.addProductModal._element.querySelectorAll('.unit-selector .unit-btn'); + unitButtons.forEach((btn, index) => { + if (index === 0) { + btn.classList.add('btn-secondary'); + btn.classList.remove('btn-outline-secondary'); + } else { + btn.classList.remove('btn-secondary'); + btn.classList.add('btn-outline-secondary'); + } + }); + + app.dom.addProductModal.hide(); }); + + document.getElementById('recipe-form-modal').addEventListener('show.bs.modal', function () { if (!app.dom.recipeIdInput.value) { app.ui.clearForm(); @@ -444,11 +648,20 @@ const app = { shoppingListContainer: document.getElementById('shopping-list-container'), recipeCardsContainer: document.getElementById('recipe-cards-container'), recipeIdInput: document.getElementById('recipeId'), + recipeCategoryInput: document.getElementById('recipeCategory'), recipeSearchInput: document.getElementById('recipe-search'), + categoryFilters: document.getElementById('category-filters'), addProductBtn: document.getElementById('add-product-btn'), printShoppingListBtn: document.getElementById('print-shopping-list-btn'), - recipeFormModal: new bootstrap.Modal(document.getElementById('recipe-form-modal')) + recipeFormModal: new bootstrap.Modal(document.getElementById('recipe-form-modal')), + addProductModal: new bootstrap.Modal(document.getElementById('add-product-modal')), + addProductForm: document.getElementById('add-product-form'), + productNameInput: document.getElementById('productName'), + productQuantityInput: document.getElementById('productQuantity'), + productCategoryWrapper: document.getElementById('product-category-wrapper'), + productCategory: document.getElementById('productCategory'), + }; app.ui.createSnowflakes(); diff --git a/assets/pasted-20251117-200938-7a012c0d.png b/assets/pasted-20251117-200938-7a012c0d.png new file mode 100644 index 0000000..fb0a0b6 Binary files /dev/null and b/assets/pasted-20251117-200938-7a012c0d.png differ diff --git a/assets/pasted-20251123-172354-6ed9c79c.png b/assets/pasted-20251123-172354-6ed9c79c.png new file mode 100644 index 0000000..fb0a0b6 Binary files /dev/null and b/assets/pasted-20251123-172354-6ed9c79c.png differ diff --git a/assets/pasted-20251123-172752-08f5dba8.png b/assets/pasted-20251123-172752-08f5dba8.png new file mode 100644 index 0000000..fb0a0b6 Binary files /dev/null and b/assets/pasted-20251123-172752-08f5dba8.png differ diff --git a/assets/vm-shot-2025-11-23T17-18-09-594Z.jpg b/assets/vm-shot-2025-11-23T17-18-09-594Z.jpg new file mode 100644 index 0000000..be90979 Binary files /dev/null and b/assets/vm-shot-2025-11-23T17-18-09-594Z.jpg differ diff --git a/db/migrations/002_add_category_to_recipes.sql b/db/migrations/002_add_category_to_recipes.sql new file mode 100644 index 0000000..12bbc1c --- /dev/null +++ b/db/migrations/002_add_category_to_recipes.sql @@ -0,0 +1 @@ +ALTER TABLE `recipes` ADD `category` VARCHAR(255) NULL DEFAULT NULL AFTER `guests`; \ No newline at end of file diff --git a/index.php b/index.php index d48b91c..5246f34 100644 --- a/index.php +++ b/index.php @@ -58,6 +58,14 @@
+
+ + + + + + +

Your saved recipes will appear here.

@@ -70,7 +78,7 @@

Shopping List

- +
@@ -101,6 +109,16 @@
+
+ + +

@@ -138,13 +156,87 @@
+ + + - + + + + \ No newline at end of file