2
This commit is contained in:
parent
d39bcc532b
commit
24d0513664
24
api/delete_recipe.php
Normal file
24
api/delete_recipe.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$data || !isset($data['id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid input. Recipe ID is missing.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$recipeId = $data['id'];
|
||||
|
||||
$pdo = db();
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("DELETE FROM recipes WHERE id = ?");
|
||||
$stmt->execute([$recipeId]);
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
26
api/get_recipes.php
Normal file
26
api/get_recipes.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$recipes_stmt = $pdo->query('SELECT * FROM recipes ORDER BY created_at DESC');
|
||||
$recipes = $recipes_stmt->fetchAll();
|
||||
|
||||
$ingredients_stmt = $pdo->query('SELECT * FROM ingredients');
|
||||
$all_ingredients = $ingredients_stmt->fetchAll();
|
||||
|
||||
$ingredients_by_recipe = [];
|
||||
foreach ($all_ingredients as $ingredient) {
|
||||
$ingredients_by_recipe[$ingredient['recipe_id']][] = $ingredient;
|
||||
}
|
||||
|
||||
foreach ($recipes as $i => $recipe) {
|
||||
$recipes[$i]['ingredients'] = $ingredients_by_recipe[$recipe['id']] ?? [];
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'recipes' => $recipes]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
43
api/save_recipe.php
Normal file
43
api/save_recipe.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$data || !isset($data['name']) || !isset($data['guests']) || !isset($data['ingredients'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid input.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO recipes (name, guests) VALUES (?, ?)");
|
||||
$stmt->execute([$data['name'], $data['guests']]);
|
||||
$recipeId = $pdo->lastInsertId();
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO ingredients (recipe_id, name, quantity, unit) VALUES (?, ?, ?, ?)");
|
||||
foreach ($data['ingredients'] as $ing) {
|
||||
$stmt->execute([$recipeId, $ing['name'], $ing['quantity'], $ing['unit']]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// Fetch the newly created recipe to return it to the client
|
||||
$stmt = $pdo->prepare("SELECT * FROM recipes WHERE id = ?");
|
||||
$stmt->execute([$recipeId]);
|
||||
$recipe = $stmt->fetch();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT * FROM ingredients WHERE recipe_id = ?");
|
||||
$stmt->execute([$recipeId]);
|
||||
$ingredients = $stmt->fetchAll();
|
||||
$recipe['ingredients'] = $ingredients;
|
||||
|
||||
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
@ -1,95 +1,245 @@
|
||||
|
||||
/* assets/css/custom.css */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Mountains+of+Christmas:wght@700&family=Lato:wght@400;700&display=swap');
|
||||
|
||||
/* General Body Styles */
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
background-color: #F0F8FF;
|
||||
color: #292B2C;
|
||||
overflow-x: hidden;
|
||||
background-color: #0a2e36; /* Dark teal background */
|
||||
color: #ffffff; /* White text */
|
||||
font-family: 'Poppins', sans-serif;
|
||||
padding-top: 40px; /* Make space for garland */
|
||||
}
|
||||
|
||||
h1, h2, h3, .h1, .h2, .h3 {
|
||||
font-family: 'Mountains of Christmas', cursive;
|
||||
font-weight: 700;
|
||||
/* Headings */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #ffffff !important; /* Use !important to override other styles if necessary */
|
||||
}
|
||||
|
||||
/* Christmas Tree */
|
||||
.christmas-tree-container {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100px;
|
||||
height: 150px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.christmas-tree-container.left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.christmas-tree-container.right {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.tree {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tree::before { /* The tree itself */
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 30px; /* Height of the trunk */
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 50px solid transparent;
|
||||
border-right: 50px solid transparent;
|
||||
border-bottom: 120px solid #2C5F2D; /* Dark green */
|
||||
}
|
||||
|
||||
.tree::after { /* The trunk */
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 30px;
|
||||
background: #5C3D2E; /* Brown */
|
||||
}
|
||||
|
||||
.ornament {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #ff6f61; /* Coral red */
|
||||
}
|
||||
|
||||
.ornament.o1 { top: 50px; left: 45px; }
|
||||
.ornament.o2 { top: 70px; left: 30px; background: #ffff24; }
|
||||
.ornament.o3 { top: 75px; left: 60px; background: #2424ff; }
|
||||
.ornament.o4 { top: 95px; left: 40px; }
|
||||
.ornament.o5 { top: 100px; left: 15px; background: #24ff24;}
|
||||
.ornament.o6 { top: 105px; left: 70px; background: #ff24ff;}
|
||||
|
||||
|
||||
/* Garland */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: -5%;
|
||||
width: 110%;
|
||||
height: 20px;
|
||||
background:
|
||||
radial-gradient(circle, #ff2424 4px, transparent 5px),
|
||||
radial-gradient(circle, #24ff24 4px, transparent 5px),
|
||||
radial-gradient(circle, #2424ff 4px, transparent 5px),
|
||||
radial-gradient(circle, #ffff24 4px, transparent 5px),
|
||||
radial-gradient(circle, #ff24ff 4px, transparent 5px);
|
||||
background-size: 100px 20px;
|
||||
background-position: 0 0, 20px 0, 40px 0, 60px 0, 80px 0;
|
||||
background-repeat: repeat-x;
|
||||
z-index: 1031;
|
||||
animation: garland-animation 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes garland-animation {
|
||||
50% {
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background-color: rgba(10, 46, 54, 0.8) !important; /* Semi-transparent dark teal */
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-family: 'Mountains of Christmas', cursive;
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.display-4 {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-color: #ff6f61; /* Coral red accent */
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 111, 97, 0.25);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background-color: #D9534F;
|
||||
border-color: #D9534F;
|
||||
transition: background-color 0.3s ease;
|
||||
background-color: #ff6f61; /* Coral red */
|
||||
border-color: #ff6f61;
|
||||
font-weight: 600;
|
||||
padding: 12px 30px;
|
||||
border-radius: 50px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #c9302c;
|
||||
border-color: #c9302c;
|
||||
background-color: #e65a50;
|
||||
border-color: #e65a50;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(255, 111, 97, 0.2);
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
padding: 12px 30px;
|
||||
border-radius: 50px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #5CB85C;
|
||||
border-color: #5CB85C;
|
||||
transition: background-color 0.3s ease;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #4cae4c;
|
||||
border-color: #4cae4c;
|
||||
/* Shopping List & Recipe Cards */
|
||||
#shopping-list-container .text-muted,
|
||||
#recipe-cards-container .text-muted {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #F0AD4E;
|
||||
border-color: #F0AD4E;
|
||||
transition: background-color 0.3s ease;
|
||||
.recipe-card {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #ec971f;
|
||||
border-color: #ec971f;
|
||||
/* Footer */
|
||||
footer.bg-light {
|
||||
background-color: transparent !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#shopping-list-container {
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.ingredient-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ingredient-row .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Snowflakes animation */
|
||||
.snowflake {
|
||||
color: #fff;
|
||||
font-size: 1em;
|
||||
font-family: Arial, sans-serif;
|
||||
text-shadow: 0 0 5px #000;
|
||||
/* Snow Effect */
|
||||
#snow-container {
|
||||
position: fixed;
|
||||
top: -5%;
|
||||
z-index: -1;
|
||||
user-select: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1032;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
to {
|
||||
transform: translateY(105vh);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,147 +1,319 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
// --- Snowflakes Effect ---
|
||||
function createSnowflakes() {
|
||||
const snowflakeContainer = document.body;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const snowContainer = document.getElementById('snow-container');
|
||||
if (!snowContainer) return;
|
||||
|
||||
snowContainer.innerHTML = '';
|
||||
|
||||
const numberOfSnowflakes = 50;
|
||||
|
||||
for (let i = 0; i < numberOfSnowflakes; i++) {
|
||||
const snowflake = document.createElement('div');
|
||||
snowflake.className = 'snowflake';
|
||||
snowflake.textContent = '❄';
|
||||
|
||||
const size = Math.random() * 4 + 2; // size from 2px to 6px
|
||||
snowflake.style.width = `${size}px`;
|
||||
snowflake.style.height = `${size}px`;
|
||||
|
||||
snowflake.style.left = Math.random() * 100 + 'vw';
|
||||
snowflake.style.animationDuration = (Math.random() * 3 + 2) + 's'; // 2-5 seconds
|
||||
snowflake.style.animationDelay = Math.random() * 2 + 's';
|
||||
snowflake.style.opacity = Math.random();
|
||||
snowflake.style.fontSize = Math.random() * 10 + 10 + 'px';
|
||||
snowflakeContainer.appendChild(snowflake);
|
||||
|
||||
const animationDuration = Math.random() * 5 + 5; // 5 to 10 seconds
|
||||
snowflake.style.animationDuration = `${animationDuration}s`;
|
||||
|
||||
const animationDelay = Math.random() * 5; // 0 to 5 seconds
|
||||
snowflake.style.animationDelay = `${animationDelay}s`;
|
||||
|
||||
snowflake.style.opacity = Math.random() * 0.7 + 0.3; // 0.3 to 1.0
|
||||
|
||||
snowContainer.appendChild(snowflake);
|
||||
}
|
||||
}
|
||||
|
||||
createSnowflakes();
|
||||
|
||||
// --- Calculator Logic ---
|
||||
// --- DOM Elements ---
|
||||
const recipeNameInput = document.getElementById('recipeName');
|
||||
const guestCountInput = document.getElementById('guestCount');
|
||||
const ingredientsContainer = document.getElementById('ingredients-container');
|
||||
const addIngredientBtn = document.getElementById('add-ingredient');
|
||||
const calculateBtn = document.getElementById('calculate-btn');
|
||||
const newRecipeBtn = document.getElementById('new-recipe-btn');
|
||||
const shoppingListContainer = document.getElementById('shopping-list-container');
|
||||
const recipeCardsContainer = document.getElementById('recipe-cards-container');
|
||||
|
||||
let ingredientIndex = 1;
|
||||
// --- Core Functions ---
|
||||
|
||||
function addIngredientRow() {
|
||||
ingredientIndex++;
|
||||
async function loadRecipes() {
|
||||
try {
|
||||
const response = await fetch('api/get_recipes.php');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
renderRecipeCards(data.recipes);
|
||||
} else {
|
||||
console.error('Failed to load recipes:', data.error);
|
||||
recipeCardsContainer.innerHTML = '<div class="col-12"><p class="text-center text-danger">Error loading recipes.</p></div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
recipeCardsContainer.innerHTML = '<div class="col-12"><p class="text-center text-danger">Could not connect to the server.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function addIngredientRow(ingredient = { name: '', quantity: '', unit: '' }) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ingredient-row mb-2';
|
||||
row.innerHTML = `
|
||||
<input type="text" class="form-control" placeholder="Ingredient Name" aria-label="Ingredient Name">
|
||||
<input type="number" class="form-control" placeholder="Qty" aria-label="Quantity" min="0" step="any">
|
||||
<input type="text" class="form-control" placeholder="Unit (e.g., grams, ml)" aria-label="Unit">
|
||||
<input type="text" class="form-control" placeholder="Ingredient Name" aria-label="Ingredient Name" value="${ingredient.name}">
|
||||
<input type="number" class="form-control" placeholder="Qty" aria-label="Quantity" min="0" step="any" value="${ingredient.quantity}">
|
||||
<input type="text" class="form-control" placeholder="Unit (e.g., grams, ml)" aria-label="Unit" value="${ingredient.unit}">
|
||||
<button type="button" class="btn btn-danger btn-sm remove-ingredient">×</button>
|
||||
`;
|
||||
ingredientsContainer.appendChild(row);
|
||||
}
|
||||
|
||||
if (addIngredientBtn) {
|
||||
addIngredientBtn.addEventListener('click', addIngredientRow);
|
||||
function clearForm() {
|
||||
recipeNameInput.value = '';
|
||||
guestCountInput.value = '1';
|
||||
ingredientsContainer.innerHTML = '';
|
||||
addIngredientRow();
|
||||
shoppingListContainer.innerHTML = `
|
||||
<div class="text-center text-muted p-5">
|
||||
<h3 class="h4">Your Shopping List</h3>
|
||||
<p>Your calculated list will appear here.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (ingredientsContainer) {
|
||||
ingredientsContainer.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('remove-ingredient')) {
|
||||
e.target.closest('.ingredient-row').remove();
|
||||
}
|
||||
function renderRecipeCards(recipes) {
|
||||
recipeCardsContainer.innerHTML = '';
|
||||
if (!recipes || recipes.length === 0) {
|
||||
recipeCardsContainer.innerHTML = '<div class="col-12"><p class="text-center text-muted">Здесь будут появляться ваши сохраненные рецепты.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
recipes.forEach(recipe => {
|
||||
const cardCol = document.createElement('div');
|
||||
cardCol.className = 'col-lg-4 col-md-6 mb-4';
|
||||
cardCol.setAttribute('data-id', recipe.id);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card h-100';
|
||||
|
||||
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 subtitle = document.createElement('h6');
|
||||
subtitle.className = 'card-subtitle mb-2 text-muted';
|
||||
subtitle.textContent = `${recipe.guests} guest(s)`;
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.className = 'card-text';
|
||||
text.textContent = `${recipe.ingredients.length} ingredients`;
|
||||
|
||||
const buttonGroup = document.createElement('div');
|
||||
buttonGroup.className = 'mt-auto';
|
||||
buttonGroup.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-primary edit-recipe-btn">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger delete-recipe-btn">Delete</button>
|
||||
`;
|
||||
|
||||
cardBody.appendChild(title);
|
||||
cardBody.appendChild(subtitle);
|
||||
cardBody.appendChild(text);
|
||||
cardBody.appendChild(buttonGroup);
|
||||
card.appendChild(cardBody);
|
||||
cardCol.appendChild(card);
|
||||
recipeCardsContainer.appendChild(cardCol);
|
||||
});
|
||||
}
|
||||
|
||||
if (calculateBtn) {
|
||||
calculateBtn.addEventListener('click', function() {
|
||||
const recipeName = document.getElementById('recipeName').value || 'My Festive Recipe';
|
||||
const guestCount = parseInt(document.getElementById('guestCount').value, 10);
|
||||
|
||||
if (isNaN(guestCount) || guestCount <= 0) {
|
||||
alert('Please enter a valid number of guests.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ingredients = [];
|
||||
const rows = ingredientsContainer.querySelectorAll('.ingredient-row');
|
||||
rows.forEach(row => {
|
||||
const name = row.children[0].value;
|
||||
const qty = parseFloat(row.children[1].value);
|
||||
const unit = row.children[2].value;
|
||||
|
||||
if (name && !isNaN(qty) && qty > 0) {
|
||||
ingredients.push({ name, qty, unit });
|
||||
}
|
||||
});
|
||||
|
||||
if (ingredients.length === 0) {
|
||||
alert('Please add at least one ingredient.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const shoppingList = {};
|
||||
ingredients.forEach(ing => {
|
||||
const totalQty = ing.qty * guestCount;
|
||||
const key = ing.name.toLowerCase().trim() + '_' + (ing.unit || '').toLowerCase().trim();
|
||||
|
||||
if (shoppingList[key]) {
|
||||
shoppingList[key].qty += totalQty;
|
||||
} else {
|
||||
shoppingList[key] = {
|
||||
name: ing.name,
|
||||
qty: totalQty,
|
||||
unit: ing.unit
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Render shopping list
|
||||
renderShoppingList(recipeName, guestCount, Object.values(shoppingList));
|
||||
});
|
||||
}
|
||||
|
||||
function renderShoppingList(recipeName, guestCount, list) {
|
||||
let html = `<h3>${recipeName} - Shopping List for ${guestCount} Guests</h3><hr>`;
|
||||
|
||||
function renderShoppingList(list) {
|
||||
let html = '<h3>Общий список покупок</h3><hr>';
|
||||
if (list.length === 0) {
|
||||
html += '<p>No ingredients to show. Please fill out the recipe form.</p>';
|
||||
html += '<p>Нет ингредиентов для расчета.</p>';
|
||||
} else {
|
||||
html += '<ul class="list-group list-group-flush">';
|
||||
list.forEach(item => {
|
||||
const quantityStr = Number.isInteger(item.qty) ? item.qty : parseFloat(item.qty.toFixed(2));
|
||||
html += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>${item.name}</span>
|
||||
<span class="badge bg-primary rounded-pill">${formatQuantity(item.qty)} ${item.unit}</span>
|
||||
<span class="badge bg-primary rounded-pill">${quantityStr} ${item.unit}</span>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
shoppingListContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatQuantity(qty) {
|
||||
// Simple formatting, can be expanded for fractions
|
||||
return parseFloat(qty.toFixed(2));
|
||||
}
|
||||
|
||||
const newRecipeBtn = document.getElementById('new-recipe-btn');
|
||||
|
||||
if (newRecipeBtn) {
|
||||
newRecipeBtn.addEventListener('click', function() {
|
||||
document.getElementById('recipeName').value = '';
|
||||
document.getElementById('guestCount').value = '';
|
||||
ingredientsContainer.innerHTML = '';
|
||||
addIngredientRow(); // Add a fresh row
|
||||
shoppingListContainer.innerHTML = `
|
||||
<div class="text-center text-muted p-5">
|
||||
<h3 class="h4">Your Shopping List</h3>
|
||||
<p>Your calculated list will appear here.</p>
|
||||
</div>
|
||||
`;
|
||||
function getRecipeDataFromForm() {
|
||||
const recipeName = recipeNameInput.value.trim();
|
||||
const guests = parseInt(guestCountInput.value, 10) || 0;
|
||||
|
||||
const ingredients = [];
|
||||
const rows = ingredientsContainer.querySelectorAll('.ingredient-row');
|
||||
rows.forEach(row => {
|
||||
const name = row.querySelector('input[placeholder="Ingredient Name"]').value.trim();
|
||||
const qty = parseFloat(row.querySelector('input[placeholder="Qty"]').value);
|
||||
const unit = row.querySelector('input[placeholder="Unit (e.g., grams, ml)"]').value.trim();
|
||||
if (name && !isNaN(qty) && qty > 0) {
|
||||
ingredients.push({ name, quantity: qty, unit });
|
||||
}
|
||||
});
|
||||
|
||||
if (recipeName && guests > 0 && ingredients.length > 0) {
|
||||
return { name: recipeName, guests, ingredients };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add one ingredient row by default
|
||||
// --- Event Listeners ---
|
||||
|
||||
addIngredientBtn.addEventListener('click', () => addIngredientRow());
|
||||
|
||||
ingredientsContainer.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('remove-ingredient')) {
|
||||
e.target.closest('.ingredient-row').remove();
|
||||
}
|
||||
});
|
||||
|
||||
recipeCardsContainer.addEventListener('click', async function(e) {
|
||||
const target = e.target;
|
||||
const card = target.closest('.col-lg-4');
|
||||
if (!card) return;
|
||||
|
||||
const recipeId = card.getAttribute('data-id');
|
||||
|
||||
if (target.classList.contains('delete-recipe-btn')) {
|
||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||
try {
|
||||
const response = await fetch('api/delete_recipe.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: recipeId })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
card.remove();
|
||||
} else {
|
||||
alert('Failed to delete recipe: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target.classList.contains('edit-recipe-btn')) {
|
||||
// Find the recipe data from the currently rendered cards
|
||||
const response = await fetch('api/get_recipes.php');
|
||||
const data = await response.json();
|
||||
if(!data.success) return;
|
||||
const recipeToEdit = data.recipes.find(r => r.id == recipeId);
|
||||
|
||||
if (recipeToEdit) {
|
||||
// Populate form
|
||||
recipeNameInput.value = recipeToEdit.name;
|
||||
guestCountInput.value = recipeToEdit.guests;
|
||||
ingredientsContainer.innerHTML = '';
|
||||
recipeToEdit.ingredients.forEach(ing => addIngredientRow(ing));
|
||||
|
||||
// Delete the old recipe from DB
|
||||
try {
|
||||
await fetch('api/delete_recipe.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: recipeId })
|
||||
});
|
||||
card.remove(); // Remove from UI immediately
|
||||
} catch (error) {
|
||||
alert('Error preparing for edit: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
newRecipeBtn.addEventListener('click', async function() {
|
||||
const recipeData = getRecipeDataFromForm();
|
||||
if (recipeData) {
|
||||
try {
|
||||
const response = await fetch('api/save_recipe.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recipeData)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await loadRecipes(); // Reload all recipes to show the new one
|
||||
clearForm();
|
||||
} else {
|
||||
alert('Failed to save recipe: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
}
|
||||
} else {
|
||||
alert('Please fill out the recipe name, guests, and at least one ingredient before saving.');
|
||||
}
|
||||
});
|
||||
|
||||
calculateBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
const response = await fetch('api/get_recipes.php');
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
alert('Could not get recipes for calculation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const allRecipesToCalculate = data.recipes;
|
||||
const currentRecipe = getRecipeDataFromForm();
|
||||
|
||||
if (currentRecipe) {
|
||||
// Give it a temporary ID to avoid collisions
|
||||
currentRecipe.id = 'current';
|
||||
allRecipesToCalculate.push(currentRecipe);
|
||||
}
|
||||
|
||||
if (allRecipesToCalculate.length === 0) {
|
||||
alert('There are no recipes to calculate. Please fill out the form or save a recipe.');
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedIngredients = new Map();
|
||||
|
||||
allRecipesToCalculate.forEach(recipe => {
|
||||
const multiplier = recipe.guests;
|
||||
recipe.ingredients.forEach(ing => {
|
||||
const ingName = ing.name || '';
|
||||
const ingUnit = ing.unit || '';
|
||||
const key = `${ingName.trim().toLowerCase()}|${ingUnit.trim().toLowerCase()}`;
|
||||
const existing = combinedIngredients.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.qty += (ing.quantity || 0) * multiplier;
|
||||
} else {
|
||||
combinedIngredients.set(key, {
|
||||
name: ing.name,
|
||||
qty: (ing.quantity || 0) * multiplier,
|
||||
unit: ing.unit
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
renderShoppingList(Array.from(combinedIngredients.values()));
|
||||
|
||||
} catch(error) {
|
||||
alert('Calculation Error: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Initial State ---
|
||||
addIngredientRow();
|
||||
loadRecipes();
|
||||
});
|
||||
|
||||
BIN
assets/pasted-20251109-213319-755f95ba.png
Normal file
BIN
assets/pasted-20251109-213319-755f95ba.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 983 KiB |
BIN
assets/vm-shot-2025-11-09T21-33-11-565Z.jpg
Normal file
BIN
assets/vm-shot-2025-11-09T21-33-11-565Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
26
db/migrate.php
Normal file
26
db/migrate.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function run_migrations() {
|
||||
$pdo = db();
|
||||
$migrationsDir = __DIR__ . '/migrations';
|
||||
$files = glob($migrationsDir . '/*.sql');
|
||||
sort($files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
echo "Running migration: " . basename($file) . "\n";
|
||||
$sql = file_get_contents($file);
|
||||
try {
|
||||
$pdo->exec($sql);
|
||||
echo "Success.\n";
|
||||
} catch (PDOException $e) {
|
||||
echo "Error: " . $e->getMessage() . "\n";
|
||||
// A simple way to track which migrations have been run is needed
|
||||
// For now, we'll just stop on error.
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run_migrations();
|
||||
|
||||
16
db/migrations/001_create_recipes_tables.sql
Normal file
16
db/migrations/001_create_recipes_tables.sql
Normal file
@ -0,0 +1,16 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `recipes` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`guests` INT NOT NULL,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ingredients` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`recipe_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`quantity` FLOAT NOT NULL,
|
||||
`unit` VARCHAR(50) NOT NULL,
|
||||
FOREIGN KEY (`recipe_id`) REFERENCES `recipes`(`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
64
index.php
64
index.php
@ -23,6 +23,9 @@
|
||||
|
||||
<!-- Styles -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
|
||||
|
||||
</head>
|
||||
@ -30,22 +33,44 @@
|
||||
|
||||
<div id="snow-container"></div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light shadow-sm">
|
||||
<!-- Christmas Trees -->
|
||||
<div class="christmas-tree-container left">
|
||||
<div class="tree">
|
||||
<div class="ornament o1"></div>
|
||||
<div class="ornament o2"></div>
|
||||
<div class="ornament o3"></div>
|
||||
<div class="ornament o4"></div>
|
||||
<div class="ornament o5"></div>
|
||||
<div class="ornament o6"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="christmas-tree-container right">
|
||||
<div class="tree">
|
||||
<div class="ornament o1"></div>
|
||||
<div class="ornament o2"></div>
|
||||
<div class="ornament o3"></div>
|
||||
<div class="ornament o4"></div>
|
||||
<div class="ornament o5"></div>
|
||||
<div class="ornament o6"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark shadow-sm">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#">Christmas Recipe Calculator</a>
|
||||
<a class="navbar-brand" href="#">Recipe Calculator</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container my-5">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-4">Holiday Recipe Planner</h1>
|
||||
<p class="lead">Enter a recipe for one person, set your guest count, and we'll make your shopping list!</p>
|
||||
<h1 class="display-4">Hey, it's Christmas time!</h1>
|
||||
<p class="lead">Let's get your holiday recipes sorted.</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
<!-- Left Column: Recipe Input -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card p-4">
|
||||
<div class="card p-4 shadow">
|
||||
<h2 class="mb-4">Your Recipe</h2>
|
||||
<form id="recipe-form">
|
||||
<div class="mb-3">
|
||||
@ -53,7 +78,7 @@
|
||||
<input type="text" class="form-control" id="recipeName" placeholder="e.g., Gingerbread Cookies">
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<hr class="my-4 border-secondary">
|
||||
|
||||
<h3 class="h5 mb-3">Ingredients (for 1 person)</h3>
|
||||
<div id="ingredients-container">
|
||||
@ -61,16 +86,16 @@
|
||||
</div>
|
||||
<button type="button" id="add-ingredient" class="btn btn-secondary btn-sm mt-2">+ Add Ingredient</button>
|
||||
|
||||
<hr class="my-4">
|
||||
<hr class="my-4 border-secondary">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="guestCount" class="form-label">How many guests are coming?</label>
|
||||
<label for="guestCount" class="form-label">How many guests?</label>
|
||||
<input type="number" class="form-control" id="guestCount" placeholder="e.g., 8" min="1">
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex mt-4">
|
||||
<button type="button" id="calculate-btn" class="btn btn-primary btn-lg">Calculate Shopping List</button>
|
||||
<button type="button" id="new-recipe-btn" class="btn btn-outline-secondary btn-lg">New Recipe</button>
|
||||
<div class="d-grid gap-3 d-md-flex justify-content-md-start mt-4">
|
||||
<button type="button" id="calculate-btn" class="btn btn-primary">Calculate Shopping List</button>
|
||||
<button type="button" id="new-recipe-btn" class="btn btn-outline-secondary">New Recipe</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -78,7 +103,7 @@
|
||||
|
||||
<!-- Right Column: Shopping List -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card shadow">
|
||||
<div class="card-body" id="shopping-list-container">
|
||||
<div class="text-center text-muted p-5">
|
||||
<h3 class="h4">Your Shopping List</h3>
|
||||
@ -88,9 +113,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-5 border-secondary">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="text-center mb-4">Saved Recipes</h2>
|
||||
<div id="recipe-cards-container" class="row">
|
||||
<div class="col-12">
|
||||
<p class="text-center text-muted">Your saved recipes will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 mt-5 bg-light">
|
||||
<footer class="text-center py-4 mt-5">
|
||||
<p class="mb-0">© <?php echo date("Y"); ?> Christmas Recipe Calculator. Happy Holidays!</p>
|
||||
</footer>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user