Add 18 new recipes and update index + lemon pasta

This commit is contained in:
Johannes
2026-03-12 18:53:40 +01:00
parent e5d3a82dd6
commit 99bf15c859
22 changed files with 1244 additions and 41 deletions

View File

@@ -397,6 +397,88 @@
gap: 1rem;
}
/* Step-by-step instruction input */
.steps-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.step-input-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
animation: fadeInUp 0.3s ease-out;
}
.step-number {
width: 32px;
height: 32px;
background: var(--terracotta);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Playfair Display', serif;
font-weight: 600;
font-size: 0.85rem;
flex-shrink: 0;
margin-top: 0.75rem;
}
.step-input-row textarea {
flex: 1;
min-height: 60px;
resize: vertical;
}
.step-remove-btn {
width: 32px;
height: 32px;
background: transparent;
border: 1.5px solid rgba(107, 101, 96, 0.2);
border-radius: 50%;
cursor: pointer;
font-size: 1.25rem;
color: var(--warm-gray);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 0.75rem;
}
.step-remove-btn:hover {
background: var(--terracotta);
border-color: var(--terracotta);
color: white;
}
.add-step-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: transparent;
border: 2px dashed rgba(107, 101, 96, 0.25);
border-radius: 0.75rem;
cursor: pointer;
font-family: 'Source Sans 3', sans-serif;
font-size: 0.9rem;
color: var(--warm-gray);
transition: all 0.2s;
margin-top: 0.5rem;
}
.add-step-btn:hover {
border-color: var(--olive);
color: var(--olive);
background: rgba(92, 107, 74, 0.05);
}
.form-actions {
display: flex;
gap: 1rem;
@@ -406,6 +488,120 @@
border-top: 1px solid rgba(107, 101, 96, 0.1);
}
/* Tag checkboxes */
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-checkbox {
cursor: pointer;
}
.tag-checkbox input {
display: none;
}
.tag-pill {
display: inline-block;
padding: 0.5rem 1rem;
border: 1.5px solid rgba(107, 101, 96, 0.25);
border-radius: 50px;
font-size: 0.85rem;
color: var(--warm-gray);
transition: all 0.2s;
background: var(--warm-white);
}
.tag-checkbox input:checked + .tag-pill {
background: var(--olive);
border-color: var(--olive);
color: white;
}
.tag-checkbox:hover .tag-pill {
border-color: var(--olive);
}
/* Recipe card tags */
.recipe-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.recipe-tag {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--olive);
font-weight: 600;
background: rgba(92, 107, 74, 0.1);
padding: 0.25rem 0.6rem;
border-radius: 50px;
}
/* Filter bar */
.filter-bar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: var(--warm-gray);
font-weight: 500;
}
.filter-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-tag {
padding: 0.5rem 1.25rem;
border: 1.5px solid rgba(107, 101, 96, 0.2);
border-radius: 50px;
font-size: 0.85rem;
color: var(--warm-gray);
background: transparent;
cursor: pointer;
transition: all 0.2s;
font-family: 'Source Sans 3', sans-serif;
}
.filter-tag:hover {
border-color: var(--olive);
color: var(--olive);
}
.filter-tag.active {
background: var(--olive);
border-color: var(--olive);
color: white;
}
.filter-clear {
font-size: 0.85rem;
color: var(--warm-gray);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
font-family: 'Source Sans 3', sans-serif;
padding: 0.5rem;
}
.filter-clear:hover {
color: var(--terracotta);
}
/* View Modal */
.view-modal .modal {
max-width: 700px;
@@ -417,13 +613,22 @@
position: relative;
}
.view-category {
font-size: 0.75rem;
.view-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.view-tag {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.2em;
letter-spacing: 0.15em;
color: var(--olive);
font-weight: 600;
margin-bottom: 0.75rem;
background: rgba(92, 107, 74, 0.12);
padding: 0.3rem 0.75rem;
border-radius: 50px;
}
.view-title {
@@ -447,6 +652,8 @@
gap: 2rem;
padding: 1.5rem 2.5rem;
border-bottom: 1px dashed rgba(107, 101, 96, 0.15);
flex-wrap: wrap;
align-items: center;
}
.meta-item {
@@ -468,6 +675,60 @@
margin-top: 0.25rem;
}
/* Serving Scaler */
.serving-scaler {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--paper);
padding: 0.5rem 1rem;
border-radius: 50px;
margin-left: auto;
}
.serving-scaler label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--warm-gray);
margin: 0;
}
.scaler-btn {
width: 32px;
height: 32px;
border: none;
background: var(--warm-white);
border-radius: 50%;
cursor: pointer;
font-size: 1.25rem;
color: var(--charcoal);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 300;
}
.scaler-btn:hover {
background: var(--terracotta);
color: white;
}
.scaler-value {
font-family: 'Playfair Display', serif;
font-size: 1.25rem;
font-weight: 600;
color: var(--charcoal);
min-width: 2rem;
text-align: center;
}
.scaled-amount {
color: var(--terracotta);
font-weight: 600;
}
.view-section {
padding: 2rem 2.5rem;
}
@@ -595,6 +856,14 @@
<button class="btn btn-primary" onclick="openAddModal()">+ Add Recipe</button>
</div>
<div class="filter-bar" id="filterBar" style="display:none">
<span class="filter-label">Filter:</span>
<div class="filter-tags" id="filterTags">
<!-- Tags will be generated dynamically -->
</div>
<button class="filter-clear" id="clearFilters" onclick="clearFilters()" style="display:none">Clear</button>
</div>
<div class="recipes-grid" id="recipesGrid">
<!-- Recipes will be loaded here -->
</div>
@@ -618,16 +887,25 @@
<div class="form-row">
<div class="form-group">
<label for="category">Category</label>
<select id="category">
<option value="Breakfast">Breakfast</option>
<option value="Lunch">Lunch</option>
<option value="Dinner">Dinner</option>
<option value="Dessert">Dessert</option>
<option value="Appetizer">Appetizer</option>
<option value="Snack">Snack</option>
<option value="Beverage">Beverage</option>
</select>
<label>Tags</label>
<div class="tags-input" id="tagsInput">
<label class="tag-checkbox">
<input type="checkbox" name="tags" value="Breakfast">
<span class="tag-pill">Breakfast</span>
</label>
<label class="tag-checkbox">
<input type="checkbox" name="tags" value="Lunch">
<span class="tag-pill">Lunch</span>
</label>
<label class="tag-checkbox">
<input type="checkbox" name="tags" value="Dinner">
<span class="tag-pill">Dinner</span>
</label>
<label class="tag-checkbox">
<input type="checkbox" name="tags" value="Snack">
<span class="tag-pill">Snack</span>
</label>
</div>
</div>
<div class="form-group">
<label for="servings">Servings</label>
@@ -657,8 +935,13 @@
</div>
<div class="form-group">
<label for="instructions">Instructions (one step per line)</label>
<textarea id="instructions" rows="6" placeholder="Preheat oven to 350°F&#10;Mix dry ingredients&#10;Add wet ingredients" required></textarea>
<label>Instructions</label>
<div class="steps-container" id="stepsContainer">
<!-- Steps will be added dynamically -->
</div>
<button type="button" class="add-step-btn" onclick="addStep()">
<span>+</span> Add Step
</button>
</div>
<div class="form-actions">
@@ -675,7 +958,7 @@
<div class="modal">
<button class="close-btn" onclick="closeModal('viewModal')" style="position:absolute;top:1rem;right:1rem;z-index:10">&times;</button>
<div class="view-header">
<p class="view-category" id="viewCategory"></p>
<div class="view-tags" id="viewTags"></div>
<h2 class="view-title" id="viewTitle"></h2>
<p class="view-description" id="viewDescription"></p>
</div>
@@ -701,6 +984,11 @@
<script>
let recipes = [];
let currentRecipeId = null;
let currentServings = 1;
let originalServings = 1;
let originalIngredients = [];
let activeFilters = new Set();
let allTags = new Set();
// Load recipes on page load
document.addEventListener('DOMContentLoaded', loadRecipes);
@@ -709,32 +997,105 @@
try {
const response = await fetch('/api/recipes');
recipes = await response.json();
buildFilterTags();
renderRecipes();
} catch (error) {
showToast('Failed to load recipes');
}
}
function buildFilterTags() {
// Collect all unique tags from all recipes
allTags.clear();
recipes.forEach(recipe => {
(recipe.tags || []).forEach(tag => allTags.add(tag));
});
const filterBar = document.getElementById('filterBar');
const filterTagsContainer = document.getElementById('filterTags');
// Hide filter bar if no tags exist
if (allTags.size === 0) {
filterBar.style.display = 'none';
return;
}
filterBar.style.display = 'flex';
// Sort tags alphabetically for consistency
const sortedTags = Array.from(allTags).sort();
// Generate filter buttons
filterTagsContainer.innerHTML = sortedTags.map(tag => {
const isActive = activeFilters.has(tag) ? 'active' : '';
return `<button class="filter-tag ${isActive}" data-tag="${tag}" onclick="toggleFilter('${tag}')">${tag}</button>`;
}).join('');
}
function toggleFilter(tag) {
const btn = document.querySelector(`.filter-tag[data-tag="${tag}"]`);
if (activeFilters.has(tag)) {
activeFilters.delete(tag);
btn.classList.remove('active');
} else {
activeFilters.add(tag);
btn.classList.add('active');
}
document.getElementById('clearFilters').style.display =
activeFilters.size > 0 ? 'inline' : 'none';
renderRecipes();
}
function clearFilters() {
activeFilters.clear();
document.querySelectorAll('.filter-tag').forEach(btn => btn.classList.remove('active'));
document.getElementById('clearFilters').style.display = 'none';
renderRecipes();
}
function getFilteredRecipes() {
if (activeFilters.size === 0) return recipes;
return recipes.filter(recipe => {
const recipeTags = recipe.tags || [];
// Recipe must have at least one of the active filter tags
return recipeTags.some(tag => activeFilters.has(tag));
});
}
function renderRecipes() {
const grid = document.getElementById('recipesGrid');
const count = document.getElementById('recipeCount');
count.textContent = recipes.length;
const filteredRecipes = getFilteredRecipes();
count.textContent = filteredRecipes.length;
if (recipes.length === 0) {
if (filteredRecipes.length === 0) {
const message = activeFilters.size > 0
? 'No recipes match your filters'
: 'Your recipe box is empty';
const submessage = activeFilters.size > 0
? 'Try removing some filters or add a new recipe!'
: 'Add your first recipe to get started!';
grid.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📖</div>
<h3>Your recipe box is empty</h3>
<p>Add your first recipe to get started!</p>
<h3>${message}</h3>
<p>${submessage}</p>
</div>
`;
return;
}
grid.innerHTML = recipes.map(recipe => `
grid.innerHTML = filteredRecipes.map(recipe => `
<article class="recipe-card" onclick="viewRecipe('${recipe.id}')">
<div class="card-header">
<p class="recipe-category">${recipe.category || 'Uncategorized'}</p>
<div class="recipe-tags">
${(recipe.tags || []).map(tag => `<span class="recipe-tag">${tag}</span>`).join('')}
</div>
<h3 class="recipe-title">${recipe.title}</h3>
</div>
<div class="card-body">
@@ -748,13 +1109,199 @@
`).join('');
}
// Parse serving number from string like "4-6" or "4 servings" or just "4"
function parseServings(servingsStr) {
if (!servingsStr) return 4;
const match = servingsStr.match(/(\d+)/);
return match ? parseInt(match[1]) : 4;
}
// Parse amount from ingredient string
function parseIngredientAmount(ingredient) {
// Match fractions, decimals, and whole numbers at the start
const fractionMap = {
'½': 0.5, '⅓': 0.333, '⅔': 0.667, '¼': 0.25, '¾': 0.75,
'⅛': 0.125, '⅜': 0.375, '⅝': 0.625, '⅞': 0.875
};
let text = ingredient.trim();
let amount = null;
let rest = text;
// Check for unicode fractions
for (const [frac, val] of Object.entries(fractionMap)) {
if (text.startsWith(frac)) {
amount = val;
rest = text.slice(1).trim();
break;
}
// Check for "1½" style
const mixedMatch = text.match(/^(\d+)\s*([½⅓⅔¼¾⅛⅜⅝⅞])/);
if (mixedMatch) {
amount = parseInt(mixedMatch[1]) + fractionMap[mixedMatch[2]];
rest = text.slice(mixedMatch[0].length).trim();
break;
}
}
// Check for "1/2" style fractions
if (amount === null) {
const fracMatch = text.match(/^(\d+)\s*\/\s*(\d+)/);
if (fracMatch) {
amount = parseInt(fracMatch[1]) / parseInt(fracMatch[2]);
rest = text.slice(fracMatch[0].length).trim();
}
}
// Check for mixed "1 1/2" style
if (amount === null) {
const mixedFracMatch = text.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)/);
if (mixedFracMatch) {
amount = parseInt(mixedFracMatch[1]) + parseInt(mixedFracMatch[2]) / parseInt(mixedFracMatch[3]);
rest = text.slice(mixedFracMatch[0].length).trim();
}
}
// Check for decimal or whole number
if (amount === null) {
const numMatch = text.match(/^(\d+\.?\d*)/);
if (numMatch) {
amount = parseFloat(numMatch[1]);
rest = text.slice(numMatch[0].length).trim();
}
}
return { amount, rest };
}
// Format a number nicely (fractions where sensible)
function formatAmount(num) {
if (num === null) return null;
const fractions = [
{ val: 0.125, str: '⅛' }, { val: 0.25, str: '¼' }, { val: 0.333, str: '⅓' },
{ val: 0.375, str: '⅜' }, { val: 0.5, str: '½' }, { val: 0.625, str: '⅝' },
{ val: 0.667, str: '⅔' }, { val: 0.75, str: '¾' }, { val: 0.875, str: '⅞' }
];
const whole = Math.floor(num);
const decimal = num - whole;
// Find closest fraction
let fracStr = '';
if (decimal > 0.05) {
const closest = fractions.reduce((prev, curr) =>
Math.abs(curr.val - decimal) < Math.abs(prev.val - decimal) ? curr : prev
);
if (Math.abs(closest.val - decimal) < 0.1) {
fracStr = closest.str;
} else {
// Just use decimal
return num % 1 === 0 ? num.toString() : num.toFixed(1).replace(/\.0$/, '');
}
}
if (whole === 0 && fracStr) return fracStr;
if (fracStr) return `${whole}${fracStr}`;
return whole.toString();
}
// Scale ingredients based on serving ratio
function scaleIngredients(ingredients, ratio) {
return ingredients.map(ing => {
const { amount, rest } = parseIngredientAmount(ing);
if (amount !== null) {
const scaledAmount = amount * ratio;
const formattedAmount = formatAmount(scaledAmount);
return `<span class="scaled-amount">${formattedAmount}</span> ${rest}`;
}
return ing;
});
}
function updateIngredientDisplay() {
const ratio = currentServings / originalServings;
const scaledIngredients = scaleIngredients(originalIngredients, ratio);
document.getElementById('viewIngredients').innerHTML =
scaledIngredients.map(i => `<li>${i}</li>`).join('');
document.getElementById('scalerValue').textContent = currentServings;
}
function adjustServings(delta) {
const newServings = currentServings + delta;
if (newServings >= 1 && newServings <= 99) {
currentServings = newServings;
updateIngredientDisplay();
}
}
function openAddModal() {
document.getElementById('modalTitle').textContent = 'Add New Recipe';
document.getElementById('recipeForm').reset();
document.getElementById('recipeId').value = '';
// Reset tag checkboxes
document.querySelectorAll('input[name="tags"]').forEach(cb => cb.checked = false);
// Reset steps with one empty step
document.getElementById('stepsContainer').innerHTML = '';
addStep();
openModal('formModal');
}
// Step management
let stepCounter = 0;
function addStep(content = '') {
stepCounter++;
const container = document.getElementById('stepsContainer');
const stepNum = container.children.length + 1;
const stepDiv = document.createElement('div');
stepDiv.className = 'step-input-row';
stepDiv.dataset.stepId = stepCounter;
stepDiv.innerHTML = `
<span class="step-number">${stepNum}</span>
<textarea placeholder="Describe this step..." required>${content}</textarea>
<button type="button" class="step-remove-btn" onclick="removeStep(${stepCounter})">×</button>
`;
container.appendChild(stepDiv);
// Focus the new textarea
stepDiv.querySelector('textarea').focus();
}
function removeStep(stepId) {
const container = document.getElementById('stepsContainer');
const steps = container.querySelectorAll('.step-input-row');
// Don't remove if it's the only step
if (steps.length <= 1) {
showToast('Need at least one step!');
return;
}
const stepToRemove = container.querySelector(`[data-step-id="${stepId}"]`);
if (stepToRemove) {
stepToRemove.remove();
renumberSteps();
}
}
function renumberSteps() {
const container = document.getElementById('stepsContainer');
const steps = container.querySelectorAll('.step-input-row');
steps.forEach((step, index) => {
step.querySelector('.step-number').textContent = index + 1;
});
}
function getStepsContent() {
const container = document.getElementById('stepsContainer');
const textareas = container.querySelectorAll('textarea');
return Array.from(textareas)
.map(ta => ta.value.trim())
.filter(content => content.length > 0);
}
function openModal(id) {
document.getElementById(id).classList.add('active');
document.body.style.overflow = 'hidden';
@@ -768,16 +1315,26 @@
async function saveRecipe(event) {
event.preventDefault();
const instructions = getStepsContent();
if (instructions.length === 0) {
showToast('Add at least one instruction step!');
return;
}
// Get selected tags
const tagCheckboxes = document.querySelectorAll('input[name="tags"]:checked');
const tags = Array.from(tagCheckboxes).map(cb => cb.value);
const id = document.getElementById('recipeId').value;
const recipe = {
title: document.getElementById('title').value,
category: document.getElementById('category').value,
tags: tags,
servings: document.getElementById('servings').value,
prepTime: document.getElementById('prepTime').value,
cookTime: document.getElementById('cookTime').value,
description: document.getElementById('description').value,
ingredients: document.getElementById('ingredients').value.split('\n').filter(i => i.trim()),
instructions: document.getElementById('instructions').value.split('\n').filter(i => i.trim())
instructions: instructions
};
try {
@@ -805,21 +1362,35 @@
if (!recipe) return;
currentRecipeId = id;
originalServings = parseServings(recipe.servings);
currentServings = originalServings;
originalIngredients = recipe.ingredients || [];
document.getElementById('viewCategory').textContent = recipe.category || 'Uncategorized';
// Tags
document.getElementById('viewTags').innerHTML =
(recipe.tags || []).map(tag => `<span class="view-tag">${tag}</span>`).join('');
document.getElementById('viewTitle').textContent = recipe.title;
document.getElementById('viewDescription').textContent = recipe.description || '';
// Meta
// Meta with serving scaler
let metaHtml = '';
if (recipe.prepTime) metaHtml += `<div class="meta-item"><p class="meta-value">${recipe.prepTime}</p><p class="meta-label">Prep Time</p></div>`;
if (recipe.cookTime) metaHtml += `<div class="meta-item"><p class="meta-value">${recipe.cookTime}</p><p class="meta-label">Cook Time</p></div>`;
if (recipe.servings) metaHtml += `<div class="meta-item"><p class="meta-value">${recipe.servings}</p><p class="meta-label">Servings</p></div>`;
// Always show serving scaler
metaHtml += `
<div class="serving-scaler">
<label>Servings</label>
<button class="scaler-btn" onclick="adjustServings(-1)"></button>
<span class="scaler-value" id="scalerValue">${currentServings}</span>
<button class="scaler-btn" onclick="adjustServings(1)">+</button>
</div>
`;
document.getElementById('viewMeta').innerHTML = metaHtml;
// Ingredients
document.getElementById('viewIngredients').innerHTML =
(recipe.ingredients || []).map(i => `<li>${i}</li>`).join('');
// Ingredients (will be scaled)
updateIngredientDisplay();
// Instructions
document.getElementById('viewInstructions').innerHTML =
@@ -837,13 +1408,27 @@
document.getElementById('modalTitle').textContent = 'Edit Recipe';
document.getElementById('recipeId').value = recipe.id;
document.getElementById('title').value = recipe.title;
document.getElementById('category').value = recipe.category || 'Dinner';
// Set tag checkboxes
document.querySelectorAll('input[name="tags"]').forEach(cb => {
cb.checked = (recipe.tags || []).includes(cb.value);
});
document.getElementById('servings').value = recipe.servings || '';
document.getElementById('prepTime').value = recipe.prepTime || '';
document.getElementById('cookTime').value = recipe.cookTime || '';
document.getElementById('description').value = recipe.description || '';
document.getElementById('ingredients').value = (recipe.ingredients || []).join('\n');
document.getElementById('instructions').value = (recipe.instructions || []).join('\n');
// Populate step inputs
const stepsContainer = document.getElementById('stepsContainer');
stepsContainer.innerHTML = '';
const instructions = recipe.instructions || [];
if (instructions.length === 0) {
addStep();
} else {
instructions.forEach(step => addStep(step));
}
openModal('formModal');
}
@@ -856,6 +1441,12 @@
if (response.ok) {
closeModal('viewModal');
await loadRecipes();
// Clean up any filters for tags that no longer exist
activeFilters.forEach(tag => {
if (!allTags.has(tag)) {
activeFilters.delete(tag);
}
});
showToast('Recipe deleted');
}
} catch (error) {
@@ -871,4 +1462,4 @@
}
</script>
</body>
</html>
</html>