Files
Cookbook/public/index.html

1465 lines
48 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Recipe Box</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--cream: #FDF6E3;
--warm-white: #FFFEF9;
--terracotta: #C45D3A;
--terracotta-dark: #A34A2D;
--olive: #5C6B4A;
--olive-light: #8B9A6D;
--charcoal: #2D2A26;
--warm-gray: #6B6560;
--gold: #D4A853;
--paper: #F9F3E8;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Source Sans 3', sans-serif;
background: var(--cream);
color: var(--charcoal);
min-height: 100vh;
background-image:
radial-gradient(ellipse at 20% 0%, rgba(212, 168, 83, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(196, 93, 58, 0.06) 0%, transparent 50%);
}
/* Header */
header {
text-align: center;
padding: 4rem 2rem 3rem;
position: relative;
}
header::after {
content: '✦';
display: block;
font-size: 1.2rem;
color: var(--gold);
margin-top: 1.5rem;
letter-spacing: 1rem;
}
h1 {
font-family: 'Playfair Display', serif;
font-size: clamp(2.5rem, 8vw, 4.5rem);
font-weight: 400;
letter-spacing: -0.02em;
color: var(--charcoal);
}
h1 span {
font-style: italic;
color: var(--terracotta);
}
.subtitle {
font-size: 0.95rem;
color: var(--warm-gray);
margin-top: 0.75rem;
letter-spacing: 0.15em;
text-transform: uppercase;
font-weight: 300;
}
/* Main Layout */
main {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem 4rem;
}
/* Toolbar */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
flex-wrap: wrap;
gap: 1rem;
}
.recipe-count {
font-family: 'Playfair Display', serif;
font-size: 1.1rem;
color: var(--warm-gray);
}
.recipe-count strong {
color: var(--charcoal);
font-weight: 600;
}
.btn {
font-family: 'Source Sans 3', sans-serif;
font-size: 0.9rem;
font-weight: 500;
padding: 0.9rem 2rem;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: 0.05em;
}
.btn-primary {
background: var(--terracotta);
color: white;
box-shadow: 0 4px 20px rgba(196, 93, 58, 0.25);
}
.btn-primary:hover {
background: var(--terracotta-dark);
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(196, 93, 58, 0.35);
}
.btn-secondary {
background: transparent;
color: var(--charcoal);
border: 1.5px solid var(--charcoal);
}
.btn-secondary:hover {
background: var(--charcoal);
color: white;
}
.btn-ghost {
background: transparent;
color: var(--warm-gray);
padding: 0.5rem 1rem;
}
.btn-ghost:hover {
color: var(--terracotta);
}
/* Recipe Grid */
.recipes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
}
.recipe-card {
background: var(--warm-white);
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 4px 30px rgba(45, 42, 38, 0.06);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
animation: fadeInUp 0.5s ease-out backwards;
}
.recipe-card:nth-child(1) { animation-delay: 0.05s; }
.recipe-card:nth-child(2) { animation-delay: 0.1s; }
.recipe-card:nth-child(3) { animation-delay: 0.15s; }
.recipe-card:nth-child(4) { animation-delay: 0.2s; }
.recipe-card:nth-child(5) { animation-delay: 0.25s; }
.recipe-card:nth-child(6) { animation-delay: 0.3s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.recipe-card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 50px rgba(45, 42, 38, 0.12);
}
.card-header {
padding: 2rem 2rem 1.5rem;
background: linear-gradient(135deg, var(--paper) 0%, var(--warm-white) 100%);
border-bottom: 1px dashed rgba(107, 101, 96, 0.15);
position: relative;
}
.card-header::before {
content: '';
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='20' cy='20' r='18' fill='none' stroke='%23D4A853' stroke-width='1' opacity='0.3'/%3E%3C/svg%3E");
}
.recipe-category {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--olive);
font-weight: 600;
margin-bottom: 0.5rem;
}
.recipe-title {
font-family: 'Playfair Display', serif;
font-size: 1.5rem;
font-weight: 600;
color: var(--charcoal);
line-height: 1.3;
}
.card-body {
padding: 1.5rem 2rem;
}
.recipe-description {
color: var(--warm-gray);
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 1.25rem;
}
.recipe-meta {
display: flex;
gap: 1.5rem;
font-size: 0.85rem;
color: var(--warm-gray);
}
.recipe-meta span {
display: flex;
align-items: center;
gap: 0.4rem;
}
.recipe-meta svg {
width: 16px;
height: 16px;
opacity: 0.6;
}
.card-footer {
padding: 1rem 2rem 1.5rem;
display: flex;
gap: 0.75rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 5rem 2rem;
grid-column: 1 / -1;
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.3;
}
.empty-state h3 {
font-family: 'Playfair Display', serif;
font-size: 1.5rem;
color: var(--charcoal);
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--warm-gray);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(45, 42, 38, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
padding: 1rem;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--warm-white);
border-radius: 1.5rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.9) translateY(20px);
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-overlay.active .modal {
transform: scale(1) translateY(0);
}
.modal-header {
padding: 2rem 2rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(107, 101, 96, 0.1);
}
.modal-header h2 {
font-family: 'Playfair Display', serif;
font-size: 1.75rem;
font-weight: 600;
}
.close-btn {
width: 40px;
height: 40px;
border: none;
background: var(--paper);
border-radius: 50%;
cursor: pointer;
font-size: 1.5rem;
color: var(--warm-gray);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: var(--terracotta);
color: white;
}
.modal-body {
padding: 2rem;
}
/* Form Styles */
.form-group {
margin-bottom: 1.75rem;
}
label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--charcoal);
margin-bottom: 0.5rem;
letter-spacing: 0.03em;
}
input, textarea, select {
width: 100%;
padding: 1rem 1.25rem;
border: 1.5px solid rgba(107, 101, 96, 0.2);
border-radius: 0.75rem;
font-family: 'Source Sans 3', sans-serif;
font-size: 1rem;
background: var(--paper);
transition: all 0.2s;
color: var(--charcoal);
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--terracotta);
box-shadow: 0 0 0 4px rgba(196, 93, 58, 0.1);
}
textarea {
resize: vertical;
min-height: 100px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
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;
justify-content: flex-end;
margin-top: 2rem;
padding-top: 1.5rem;
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;
}
.view-header {
padding: 2.5rem 2.5rem 2rem;
background: linear-gradient(135deg, var(--paper) 0%, var(--warm-white) 100%);
position: relative;
}
.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.15em;
color: var(--olive);
font-weight: 600;
background: rgba(92, 107, 74, 0.12);
padding: 0.3rem 0.75rem;
border-radius: 50px;
}
.view-title {
font-family: 'Playfair Display', serif;
font-size: 2.25rem;
font-weight: 600;
color: var(--charcoal);
line-height: 1.2;
margin-bottom: 1rem;
}
.view-description {
font-size: 1.1rem;
color: var(--warm-gray);
font-style: italic;
font-family: 'Playfair Display', serif;
}
.view-meta {
display: flex;
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 {
text-align: center;
}
.meta-value {
font-family: 'Playfair Display', serif;
font-size: 1.5rem;
color: var(--terracotta);
font-weight: 600;
}
.meta-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--warm-gray);
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;
}
.view-section:not(:last-child) {
border-bottom: 1px solid rgba(107, 101, 96, 0.08);
}
.view-section h3 {
font-family: 'Playfair Display', serif;
font-size: 1.25rem;
margin-bottom: 1.25rem;
color: var(--charcoal);
display: flex;
align-items: center;
gap: 0.75rem;
}
.view-section h3::before {
content: '';
width: 24px;
height: 2px;
background: var(--gold);
}
.ingredients-list {
list-style: none;
}
.ingredients-list li {
padding: 0.6rem 0;
border-bottom: 1px dotted rgba(107, 101, 96, 0.15);
color: var(--charcoal);
position: relative;
padding-left: 1.5rem;
}
.ingredients-list li::before {
content: '◇';
position: absolute;
left: 0;
color: var(--olive-light);
font-size: 0.7rem;
}
.instructions-list {
list-style: none;
counter-reset: step;
}
.instructions-list li {
padding: 1rem 0;
padding-left: 3.5rem;
position: relative;
color: var(--charcoal);
line-height: 1.7;
}
.instructions-list li::before {
counter-increment: step;
content: counter(step);
position: absolute;
left: 0;
width: 2rem;
height: 2rem;
background: var(--paper);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Playfair Display', serif;
font-weight: 600;
color: var(--terracotta);
font-size: 0.9rem;
}
/* Toast */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--charcoal);
color: white;
padding: 1rem 2rem;
border-radius: 50px;
font-size: 0.95rem;
opacity: 0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 2000;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
/* Responsive */
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
text-align: center;
}
.view-meta {
flex-wrap: wrap;
gap: 1rem;
}
}
</style>
</head>
<body>
<header>
<h1>The <span>Recipe</span> Box</h1>
<p class="subtitle">A collection of culinary treasures</p>
</header>
<main>
<div class="toolbar">
<p class="recipe-count"><strong id="recipeCount">0</strong> recipes in your collection</p>
<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>
</main>
<!-- Add/Edit Modal -->
<div class="modal-overlay" id="formModal">
<div class="modal">
<div class="modal-header">
<h2 id="modalTitle">Add New Recipe</h2>
<button class="close-btn" onclick="closeModal('formModal')">&times;</button>
</div>
<div class="modal-body">
<form id="recipeForm" onsubmit="saveRecipe(event)">
<input type="hidden" id="recipeId">
<div class="form-group">
<label for="title">Recipe Title</label>
<input type="text" id="title" placeholder="e.g., Grandma's Apple Pie" required>
</div>
<div class="form-row">
<div class="form-group">
<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>
<input type="text" id="servings" placeholder="e.g., 4-6">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="prepTime">Prep Time</label>
<input type="text" id="prepTime" placeholder="e.g., 20 mins">
</div>
<div class="form-group">
<label for="cookTime">Cook Time</label>
<input type="text" id="cookTime" placeholder="e.g., 45 mins">
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" placeholder="A brief description of this dish..."></textarea>
</div>
<div class="form-group">
<label for="ingredients">Ingredients (one per line)</label>
<textarea id="ingredients" rows="6" placeholder="2 cups flour&#10;1 tsp salt&#10;3 eggs" required></textarea>
</div>
<div class="form-group">
<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">
<button type="button" class="btn btn-secondary" onclick="closeModal('formModal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save Recipe</button>
</div>
</form>
</div>
</div>
</div>
<!-- View Modal -->
<div class="modal-overlay view-modal" id="viewModal">
<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">
<div class="view-tags" id="viewTags"></div>
<h2 class="view-title" id="viewTitle"></h2>
<p class="view-description" id="viewDescription"></p>
</div>
<div class="view-meta" id="viewMeta"></div>
<div class="view-section">
<h3>Ingredients</h3>
<ul class="ingredients-list" id="viewIngredients"></ul>
</div>
<div class="view-section">
<h3>Instructions</h3>
<ol class="instructions-list" id="viewInstructions"></ol>
</div>
<div class="view-section" style="display:flex;gap:1rem;">
<button class="btn btn-secondary" onclick="editCurrentRecipe()">Edit Recipe</button>
<button class="btn btn-ghost" onclick="deleteCurrentRecipe()" style="color:var(--terracotta)">Delete</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<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);
async function loadRecipes() {
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');
const filteredRecipes = getFilteredRecipes();
count.textContent = filteredRecipes.length;
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>${message}</h3>
<p>${submessage}</p>
</div>
`;
return;
}
grid.innerHTML = filteredRecipes.map(recipe => `
<article class="recipe-card" onclick="viewRecipe('${recipe.id}')">
<div class="card-header">
<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">
<p class="recipe-description">${recipe.description || 'No description provided.'}</p>
<div class="recipe-meta">
${recipe.prepTime ? `<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>${recipe.prepTime}</span>` : ''}
${recipe.servings ? `<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${recipe.servings}</span>` : ''}
</div>
</div>
</article>
`).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';
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
document.body.style.overflow = '';
}
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,
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: instructions
};
try {
const url = id ? `/api/recipes/${id}` : '/api/recipes';
const method = id ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe)
});
if (response.ok) {
closeModal('formModal');
await loadRecipes();
showToast(id ? 'Recipe updated!' : 'Recipe saved!');
}
} catch (error) {
showToast('Failed to save recipe');
}
}
function viewRecipe(id) {
const recipe = recipes.find(r => r.id === id);
if (!recipe) return;
currentRecipeId = id;
originalServings = parseServings(recipe.servings);
currentServings = originalServings;
originalIngredients = recipe.ingredients || [];
// 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 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>`;
// 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 (will be scaled)
updateIngredientDisplay();
// Instructions
document.getElementById('viewInstructions').innerHTML =
(recipe.instructions || []).map(i => `<li>${i}</li>`).join('');
openModal('viewModal');
}
function editCurrentRecipe() {
const recipe = recipes.find(r => r.id === currentRecipeId);
if (!recipe) return;
closeModal('viewModal');
document.getElementById('modalTitle').textContent = 'Edit Recipe';
document.getElementById('recipeId').value = recipe.id;
document.getElementById('title').value = recipe.title;
// 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');
// 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');
}
async function deleteCurrentRecipe() {
if (!confirm('Are you sure you want to delete this recipe?')) return;
try {
const response = await fetch(`/api/recipes/${currentRecipeId}`, { method: 'DELETE' });
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) {
showToast('Failed to delete recipe');
}
}
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
</script>
</body>
</html>