26
This commit is contained in:
parent
e97488f9dc
commit
4ea0efca37
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = `
|
||||
<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');
|
||||
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>
|
||||
`;
|
||||
|
||||
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) {
|
||||
let groupName = 'Food'; // Default to Food
|
||||
|
||||
if (item.category) {
|
||||
const normalizedCategory = item.category.toLowerCase();
|
||||
if (groups.hasOwnProperty(item.category)) {
|
||||
groupName = item.category;
|
||||
} else {
|
||||
for (const name in groups) {
|
||||
if (groups[name].units.includes(item.unit)) {
|
||||
groupName = name;
|
||||
break;
|
||||
} 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 += `<h4 class="mt-3">${groupName}</h4>`;
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
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 += `<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">
|
||||
<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}">
|
||||
${item.name}
|
||||
</label>
|
||||
@ -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 = '<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 () {
|
||||
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();
|
||||
|
||||
1
db/migrations/004_add_category_to_ingredients.sql
Normal file
1
db/migrations/004_add_category_to_ingredients.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `ingredients` ADD `category` VARCHAR(50) NOT NULL DEFAULT 'food';
|
||||
15
index.php
15
index.php
@ -45,6 +45,7 @@
|
||||
<div class="text-center mb-5" style="padding-top: 20px;">
|
||||
<h1 class="display-4 mt-4">Hey, it's Christmas time!</h1>
|
||||
<p class="lead">Let's get your holiday recipes sorted.</p>
|
||||
<div id="christmas-countdown" class="lead"></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
@ -197,12 +198,13 @@
|
||||
</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>
|
||||
<select class="form-select" id="productCategory">
|
||||
<option selected>Choose...</option>
|
||||
<option value="Tableware and consumables">Tableware and consumables</option>
|
||||
<option value="Food" selected>Food</option>
|
||||
<option value="Drinks">Drinks</option>
|
||||
<option value="Cooking and serving">Cooking and serving</option>
|
||||
<option value="Tableware and consumables">Tableware and consumables</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
@ -243,6 +245,13 @@
|
||||
<p class="mb-0">© <?php echo date("Y"); ?> Christmas Recipe Calculator. Happy Holidays!</p>
|
||||
</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 -->
|
||||
<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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user