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 @@
+
+
+
-
+
+
+
+
+
+
+
+
+
You are about to remove an ingredient from a recipe. This will affect the recipe itself. Are you sure you want to continue?
+
Recipe:
+
Ingredient:
+
+
+
+
+