This commit is contained in:
Johannes
2026-03-12 17:08:00 +01:00
commit 8766d87a3c
5 changed files with 1088 additions and 0 deletions

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# 📖 The Recipe Box
A gorgeous, minimal cookbook website that stores your recipes as JSON files locally.
## ✨ Features
- **Beautiful UI** — Warm, editorial design inspired by vintage recipe cards
- **JSON Storage** — All recipes saved as individual `.json` files in the `recipes/` folder
- **Full CRUD** — Add, view, edit, and delete recipes
- **Zero Database** — Everything is file-based, easy to backup and version control
## 🚀 Quick Start
1. **Install dependencies**
```bash
npm install
```
2. **Start the server**
```bash
npm start
```
3. **Open your browser**
```
http://localhost:3000
```
That's it! Your cookbook is ready. 🍳
## 📁 Recipe Format
Recipes are stored as JSON files in the `recipes/` folder:
```json
{
"id": "unique-recipe-id",
"title": "Recipe Name",
"category": "Dinner",
"description": "A brief description",
"servings": "4",
"prepTime": "15 mins",
"cookTime": "30 mins",
"ingredients": [
"First ingredient",
"Second ingredient"
],
"instructions": [
"Step one",
"Step two"
],
"createdAt": "2025-03-12T10:00:00.000Z",
"updatedAt": "2025-03-12T10:00:00.000Z"
}
```
## 💡 Tips
- **Backup recipes**: Just copy the entire `recipes/` folder
- **Share recipes**: Send individual `.json` files to friends
- **Version control**: Track your recipe evolution with git!
---
Made with 🧈 and ❤️

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "the-recipe-box",
"version": "1.0.0",
"description": "A beautiful cookbook website for your culinary treasures",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}

874
public/index.html Normal file
View File

@@ -0,0 +1,874 @@
<!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;
}
.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);
}
/* 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-category {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--olive);
font-weight: 600;
margin-bottom: 0.75rem;
}
.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);
}
.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;
}
.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="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 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>
</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 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>
</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">
<p class="view-category" id="viewCategory"></p>
<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;
// Load recipes on page load
document.addEventListener('DOMContentLoaded', loadRecipes);
async function loadRecipes() {
try {
const response = await fetch('/api/recipes');
recipes = await response.json();
renderRecipes();
} catch (error) {
showToast('Failed to load recipes');
}
}
function renderRecipes() {
const grid = document.getElementById('recipesGrid');
const count = document.getElementById('recipeCount');
count.textContent = recipes.length;
if (recipes.length === 0) {
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>
</div>
`;
return;
}
grid.innerHTML = recipes.map(recipe => `
<article class="recipe-card" onclick="viewRecipe('${recipe.id}')">
<div class="card-header">
<p class="recipe-category">${recipe.category || 'Uncategorized'}</p>
<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('');
}
function openAddModal() {
document.getElementById('modalTitle').textContent = 'Add New Recipe';
document.getElementById('recipeForm').reset();
document.getElementById('recipeId').value = '';
openModal('formModal');
}
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 id = document.getElementById('recipeId').value;
const recipe = {
title: document.getElementById('title').value,
category: document.getElementById('category').value,
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())
};
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;
document.getElementById('viewCategory').textContent = recipe.category || 'Uncategorized';
document.getElementById('viewTitle').textContent = recipe.title;
document.getElementById('viewDescription').textContent = recipe.description || '';
// Meta
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>`;
document.getElementById('viewMeta').innerHTML = metaHtml;
// Ingredients
document.getElementById('viewIngredients').innerHTML =
(recipe.ingredients || []).map(i => `<li>${i}</li>`).join('');
// 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;
document.getElementById('category').value = recipe.category || 'Dinner';
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');
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();
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>

View File

@@ -0,0 +1,33 @@
{
"id": "sample-lemon-pasta",
"title": "Creamy Lemon Pasta",
"category": "Dinner",
"description": "A bright, zesty pasta that comes together in under 30 minutes. Perfect for busy weeknights when you want something that feels fancy but isn't fussy.",
"servings": "4",
"prepTime": "10 mins",
"cookTime": "15 mins",
"ingredients": [
"400g spaghetti or linguine",
"3 tablespoons butter",
"2 cloves garlic, minced",
"Zest of 2 lemons",
"Juice of 1 lemon",
"1 cup heavy cream",
"¾ cup freshly grated Parmesan",
"Salt and black pepper to taste",
"Fresh basil or parsley for garnish",
"Red pepper flakes (optional)"
],
"instructions": [
"Bring a large pot of salted water to boil. Cook pasta according to package directions until al dente. Reserve 1 cup pasta water before draining.",
"While pasta cooks, melt butter in a large skillet over medium heat. Add garlic and cook for 1 minute until fragrant.",
"Add lemon zest and cook for another 30 seconds, stirring constantly.",
"Pour in the heavy cream and bring to a gentle simmer. Let it reduce slightly for 2-3 minutes.",
"Add the drained pasta to the skillet along with lemon juice and half the Parmesan. Toss to combine.",
"Add pasta water a splash at a time until you reach your desired sauce consistency.",
"Remove from heat, add remaining Parmesan, and season with salt and pepper.",
"Serve immediately, garnished with fresh herbs and red pepper flakes if desired."
],
"createdAt": "2025-03-12T10:00:00.000Z",
"updatedAt": "2025-03-12T10:00:00.000Z"
}

104
server.js Normal file
View File

@@ -0,0 +1,104 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const app = express();
const PORT = 3000;
const RECIPES_DIR = path.join(__dirname, 'recipes');
app.use(express.json());
app.use(express.static('public'));
// Ensure recipes directory exists
async function ensureRecipesDir() {
try {
await fs.access(RECIPES_DIR);
} catch {
await fs.mkdir(RECIPES_DIR, { recursive: true });
}
}
// GET all recipes
app.get('/api/recipes', async (req, res) => {
try {
await ensureRecipesDir();
const files = await fs.readdir(RECIPES_DIR);
const recipes = [];
for (const file of files) {
if (file.endsWith('.json')) {
const content = await fs.readFile(path.join(RECIPES_DIR, file), 'utf-8');
recipes.push(JSON.parse(content));
}
}
res.json(recipes);
} catch (error) {
res.status(500).json({ error: 'Failed to load recipes' });
}
});
// GET single recipe by id
app.get('/api/recipes/:id', async (req, res) => {
try {
const filePath = path.join(RECIPES_DIR, `${req.params.id}.json`);
const content = await fs.readFile(filePath, 'utf-8');
res.json(JSON.parse(content));
} catch (error) {
res.status(404).json({ error: 'Recipe not found' });
}
});
// POST new recipe
app.post('/api/recipes', async (req, res) => {
try {
await ensureRecipesDir();
const recipe = req.body;
// Generate ID if not provided
if (!recipe.id) {
recipe.id = Date.now().toString(36) + Math.random().toString(36).substr(2);
}
recipe.createdAt = recipe.createdAt || new Date().toISOString();
recipe.updatedAt = new Date().toISOString();
const filePath = path.join(RECIPES_DIR, `${recipe.id}.json`);
await fs.writeFile(filePath, JSON.stringify(recipe, null, 2));
res.status(201).json(recipe);
} catch (error) {
res.status(500).json({ error: 'Failed to save recipe' });
}
});
// PUT update recipe
app.put('/api/recipes/:id', async (req, res) => {
try {
const recipe = req.body;
recipe.id = req.params.id;
recipe.updatedAt = new Date().toISOString();
const filePath = path.join(RECIPES_DIR, `${recipe.id}.json`);
await fs.writeFile(filePath, JSON.stringify(recipe, null, 2));
res.json(recipe);
} catch (error) {
res.status(500).json({ error: 'Failed to update recipe' });
}
});
// DELETE recipe
app.delete('/api/recipes/:id', async (req, res) => {
try {
const filePath = path.join(RECIPES_DIR, `${req.params.id}.json`);
await fs.unlink(filePath);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete recipe' });
}
});
app.listen(PORT, () => {
console.log(`🍳 Cookbook server running at http://localhost:${PORT}`);
});