diff --git a/api/save_recipe.php b/api/save_recipe.php
index 8fb2603..422f7ca 100644
--- a/api/save_recipe.php
+++ b/api/save_recipe.php
@@ -2,6 +2,17 @@
header('Content-Type: application/json');
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
$data = $_POST;
$files = $_FILES;
@@ -68,9 +79,10 @@ try {
}
// 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) {
- $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();
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 9c35226..0216418 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -328,26 +328,29 @@ footer.bg-light {
}
/* 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;
color: white;
}
#recipe-form-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);
}
#recipe-form-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);
}
#recipe-form-modal .btn-close,
#add-product-modal .btn-close,
-#confirmRemoveModal .btn-close {
+#confirmRemoveModal .btn-close,
+#view-recipe-modal .btn-close {
filter: invert(1);
}
@@ -375,21 +378,16 @@ footer.bg-light {
}
/* Recipe Card Category Label */
-.card {
- position: relative;
-}
-
.recipe-category-label {
- position: absolute;
- top: 10px;
- right: 10px;
background-color: #142E35;
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 0.8em;
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 */
@@ -432,13 +430,7 @@ footer.bg-light {
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-enter {
@@ -561,3 +553,21 @@ footer.bg-light {
border-color: transparent;
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;
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index 70f8a5f..f95301e 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -3,6 +3,8 @@ const app = {
state: {
recipes: [],
confirmedRecipeProducts: [],
+ checkedItems: [],
+ additionalProducts: []
},
api: {
async getRecipes() {
@@ -77,23 +79,40 @@ const app = {
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) {
const categoryLabel = document.createElement('div');
categoryLabel.className = 'recipe-category-label';
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');
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 = `
+
+
+
+
+
+
+
+
+ `;
const buttonGroup = document.createElement('div');
buttonGroup.className = 'mt-auto pt-2';
@@ -103,8 +122,9 @@ const app = {
`;
- cardBody.appendChild(title);
+ cardBody.appendChild(titleWrapper);
cardBody.appendChild(text);
+ cardBody.appendChild(guestPortionControls);
cardBody.appendChild(buttonGroup);
card.appendChild(cardBody);
cardCol.appendChild(card);
@@ -112,14 +132,23 @@ const app = {
});
},
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();
- // 1. Process recipe ingredients
+ // 1. Process recipe ingredients and calculate total based on per-recipe inputs
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) {
recipe.ingredients.forEach(ing => {
const name = ing.name.trim();
@@ -134,11 +163,12 @@ const app = {
recipeQty: 0,
additionalQty: 0,
sources: [],
- category: null // For 'pack' items
+ category: ing.category
});
}
const item = combinedIngredients.get(key);
- item.recipeQty += (ing.quantity || 0);
+ item.recipeQty += (ing.quantity || 0) * targetGuests * targetPortions;
+
if (!item.sources.includes(recipe.name)) {
item.sources.push(recipe.name);
}
@@ -161,7 +191,7 @@ const app = {
recipeQty: 0,
additionalQty: 0,
sources: [],
- category: null
+ category: prod.category
});
}
const item = combinedIngredients.get(key);
@@ -170,7 +200,7 @@ const app = {
if (!item.sources.includes(source)) {
item.sources.push(source);
}
- if (prod.unit === 'pack' && prod.category) {
+ if (prod.category && !item.category) {
item.category = prod.category;
}
});
@@ -178,29 +208,24 @@ const app = {
// 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: [] }
+ 'Food': { ingredients: [] },
+ 'Drinks': { ingredients: [] },
+ 'Cooking and serving': { ingredients: [] },
+ 'Tableware and consumables': { 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;
- }
+ let groupName = 'Food'; // Default to Food
+
+ if (item.category) {
+ const normalizedCategory = item.category.toLowerCase();
+ if (groups.hasOwnProperty(item.category)) {
+ groupName = item.category;
+ } else if (normalizedCategory === 'drinks' || normalizedCategory === 'drink') {
+ groupName = 'Drinks';
}
}
- if (!groups[groupName]) { // handle dynamic categories from 'pack'
- groups[groupName] = { units: [], ingredients: [] };
- }
+
groups[groupName].ingredients.push(item);
});
@@ -215,17 +240,18 @@ const app = {
html += `${groupName}
`;
html += '';
group.ingredients.forEach((item, index) => {
- const totalQty = (item.recipeQty * totalMultiplier) + item.additionalQty;
- if (totalQty === 0) return;
+ const totalQty = item.recipeQty + item.additionalQty;
+ if (totalQty <= 0) return;
const quantityStr = Number.isInteger(totalQty) ? totalQty : parseFloat(totalQty.toFixed(2));
const uniqueId = `shopping-item-${groupName.replace(/\s/g, '-')}-${index}`;
const tooltipText = item.sources.join(', ');
const itemKey = `${item.name.toLowerCase()}|${item.unit.toLowerCase()}`;
+ const isChecked = app.state.checkedItems.includes(itemKey);
- html += `-
+ html += `
-
-
+
@@ -391,6 +417,15 @@ const app = {
snowflake.style.opacity = Math.random() * 0.7 + 0.3;
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: {
@@ -457,32 +492,50 @@ const app = {
});
app.dom.recipeCardsContainer.addEventListener('click', function(e) {
- const target = e.target;
- const card = target.closest('.col-12[data-id]');
+ const target = e.target.closest('button');
+ const card = e.target.closest('.col-12[data-id]');
if (!card) return;
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?')) {
app.api.deleteRecipe(recipeId);
}
}
- if (target.classList.contains('edit-recipe')) {
+ if (target && target.classList.contains('edit-recipe')) {
app.ui.populateFormForEdit(recipeId);
}
- if (target.classList.contains('view-recipe')) {
+ if (target && target.classList.contains('view-recipe')) {
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) {
if (e.target.matches('.form-check-input')) {
const listItem = e.target.closest('.list-group-item');
if (listItem) {
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')) {
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.ui.clearForm();
@@ -624,12 +676,6 @@ const app = {
});
e.target.classList.remove('btn-outline-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;
}
- if (unit === 'pack' && category === 'Choose...') {
- alert('Please select a category for products in packs.');
- return;
- }
-
if (!app.state.additionalProducts) {
app.state.additionalProducts = [];
}
@@ -662,10 +703,7 @@ const app = {
if (existingProduct) {
existingProduct.quantity += quantity;
} else {
- const newProduct = { name, quantity, unit, source: 'Additional product' };
- if (unit === 'pack') {
- newProduct.category = category;
- }
+ const newProduct = { name, quantity, unit, source: 'Additional product', category: category };
app.state.additionalProducts.push(newProduct);
}
@@ -674,8 +712,7 @@ const app = {
// Reset form
app.dom.productNameInput.value = '';
app.dom.productQuantityInput.value = '1';
- app.dom.productCategory.value = 'Choose...';
- app.dom.productCategoryWrapper.style.display = 'none';
+ app.dom.productCategory.value = 'Food';
const unitButtons = app.dom.addProductModal._element.querySelectorAll('.unit-selector .unit-btn');
unitButtons.forEach((btn, index) => {
@@ -691,7 +728,15 @@ const app = {
app.dom.addProductModal.hide();
});
-
+ app.dom.musicToggle.addEventListener('click', function() {
+ if (app.dom.christmasMusic.paused) {
+ app.dom.christmasMusic.play();
+ app.dom.musicToggle.innerHTML = '
';
+ } else {
+ app.dom.christmasMusic.pause();
+ app.dom.musicToggle.innerHTML = '
';
+ }
+ });
document.getElementById('recipe-form-modal').addEventListener('show.bs.modal', function () {
if (!app.dom.recipeIdInput.value) {
@@ -726,10 +771,13 @@ const app = {
productQuantityInput: document.getElementById('productQuantity'),
productCategoryWrapper: document.getElementById('product-category-wrapper'),
productCategory: document.getElementById('productCategory'),
+ christmasMusic: document.getElementById('christmas-music'),
+ musicToggle: document.getElementById('music-toggle'),
};
app.ui.createSnowflakes();
+ app.ui.loadCheckedItems();
app.events.attachEventListeners();
app.dom.cancelEditBtn.style.display = 'none';
app.ui.addIngredientRow();
diff --git a/db/migrations/004_add_category_to_ingredients.sql b/db/migrations/004_add_category_to_ingredients.sql
new file mode 100644
index 0000000..e373f8c
--- /dev/null
+++ b/db/migrations/004_add_category_to_ingredients.sql
@@ -0,0 +1 @@
+ALTER TABLE `ingredients` ADD `category` VARCHAR(50) NOT NULL DEFAULT 'food';
\ No newline at end of file
diff --git a/index.php b/index.php
index 166f80f..3b6f718 100644
--- a/index.php
+++ b/index.php
@@ -45,6 +45,7 @@
Hey, it's Christmas time!
Let's get your holiday recipes sorted.
+
@@ -197,12 +198,13 @@
-
+
@@ -243,6 +245,13 @@
© Christmas Recipe Calculator. Happy Holidays!
+
+
+