PatchHub

patchhub / recipe-box Public

Keep your favorite recipes in one place

recipe-box / index.html 21815 bytes Β· raw
1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Recipe Box</title>
7<style>
8 :root {
9 --bg: #faf6f0;
10 --card: #ffffff;
11 --ink: #2d2a26;
12 --muted: #7a726a;
13 --accent: #c2562b;
14 --accent-dark: #9e4220;
15 --line: #e7ded3;
16 --tag: #f3e6d8;
17 --danger: #b3261e;
18 --radius: 10px;
19 }
20 * { box-sizing: border-box; }
21 body {
22 margin: 0;
23 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
24 background: var(--bg);
25 color: var(--ink);
26 line-height: 1.5;
27 }
28 header {
29 background: var(--card);
30 border-bottom: 1px solid var(--line);
31 position: sticky;
32 top: 0;
33 z-index: 10;
34 }
35 .bar {
36 max-width: 1000px;
37 margin: 0 auto;
38 padding: 12px 16px;
39 display: flex;
40 gap: 12px;
41 align-items: center;
42 flex-wrap: wrap;
43 }
44 .logo {
45 font-size: 1.4rem;
46 font-weight: 700;
47 color: var(--accent);
48 cursor: pointer;
49 margin-right: auto;
50 user-select: none;
51 }
52 .search {
53 flex: 1 1 260px;
54 max-width: 420px;
55 padding: 9px 12px;
56 border: 1px solid var(--line);
57 border-radius: var(--radius);
58 font-size: 1rem;
59 background: var(--bg);
60 }
61 .search:focus, input:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
62 button {
63 font: inherit;
64 cursor: pointer;
65 border-radius: var(--radius);
66 border: 1px solid var(--line);
67 background: var(--card);
68 color: var(--ink);
69 padding: 8px 14px;
70 }
71 button:hover { background: var(--tag); }
72 button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
73 button.primary:hover { background: var(--accent-dark); }
74 button.danger { color: var(--danger); border-color: #e9c3c0; }
75 button.danger:hover { background: #fbeceb; }
76 button.link { border: none; background: none; color: var(--accent); padding: 0; text-decoration: underline; }
77 main { max-width: 1000px; margin: 0 auto; padding: 20px 16px 60px; }
78 .tags-filter { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
79 .chip {
80 padding: 4px 12px;
81 border-radius: 999px;
82 border: 1px solid var(--line);
83 background: var(--card);
84 font-size: 0.9rem;
85 }
86 .chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
87 .count { color: var(--muted); margin: 0 0 12px; font-size: 0.95rem; }
88 .grid {
89 display: grid;
90 grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
91 gap: 16px;
92 }
93 .card {
94 background: var(--card);
95 border: 1px solid var(--line);
96 border-radius: var(--radius);
97 padding: 16px;
98 cursor: pointer;
99 transition: box-shadow .15s, transform .15s;
100 display: flex;
101 flex-direction: column;
102 gap: 6px;
103 }
104 .card:hover, .card:focus { box-shadow: 0 4px 14px rgba(0,0,0,.08); transform: translateY(-2px); outline: none; }
105 .card h3 { margin: 0; font-size: 1.15rem; }
106 .card p { margin: 0; color: var(--muted); font-size: 0.95rem; }
107 .meta { color: var(--muted); font-size: 0.9rem; display: flex; gap: 14px; flex-wrap: wrap; }
108 .tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; padding-top: 6px; }
109 .tag { background: var(--tag); border-radius: 999px; padding: 2px 10px; font-size: 0.8rem; }
110 .empty { text-align: center; color: var(--muted); padding: 50px 10px; }
111 .panel {
112 background: var(--card);
113 border: 1px solid var(--line);
114 border-radius: var(--radius);
115 padding: 24px;
116 }
117 .detail h2 { margin: 0 0 6px; font-size: 1.8rem; }
118 .detail .desc { color: var(--muted); margin: 0 0 12px; }
119 .detail h3 { margin: 24px 0 8px; border-bottom: 1px solid var(--line); padding-bottom: 4px; }
120 .columns { display: grid; grid-template-columns: 1fr 1.6fr; gap: 28px; }
121 @media (max-width: 700px) { .columns { grid-template-columns: 1fr; } }
122 ul.ingredients { padding-left: 0; list-style: none; margin: 0; }
123 ul.ingredients li { padding: 4px 0; display: flex; gap: 8px; align-items: flex-start; }
124 ul.ingredients input { margin-top: 5px; accent-color: var(--accent); }
125 ul.ingredients li.done span { text-decoration: line-through; color: var(--muted); }
126 ol.steps { padding-left: 22px; margin: 0; }
127 ol.steps li { margin-bottom: 10px; }
128 .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
129 .actions .spacer { flex: 1; }
130 form label { display: block; font-weight: 600; margin: 14px 0 4px; }
131 form .hint { font-weight: 400; color: var(--muted); font-size: 0.88rem; }
132 form input[type=text], form textarea {
133 width: 100%;
134 padding: 9px 11px;
135 border: 1px solid var(--line);
136 border-radius: var(--radius);
137 font: inherit;
138 background: var(--bg);
139 }
140 form textarea { min-height: 140px; resize: vertical; }
141 .row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
142 @media (max-width: 600px) { .row { grid-template-columns: 1fr; } }
143 .form-actions { display: flex; gap: 8px; margin-top: 20px; justify-content: flex-end; }
144 .error { color: var(--danger); font-size: 0.9rem; margin-top: 4px; }
145 .warning {
146 background: #fff4d6; border: 1px solid #f0d58a; border-radius: var(--radius);
147 padding: 10px 14px; margin-bottom: 16px; font-size: 0.95rem;
148 }
149 @media print {
150 header, .actions, .no-print { display: none !important; }
151 body { background: #fff; }
152 .panel { border: none; padding: 0; }
153 }
154</style>
155</head>
156<body>
157<header>
158 <div class="bar">
159 <div class="logo" id="logo" title="Back to all recipes">🍲 Recipe Box</div>
160 <input class="search" id="search" type="search" placeholder="Search recipes, ingredients, tags…" aria-label="Search recipes">
161 <button class="primary" id="addBtn">+ Add recipe</button>
162 </div>
163</header>
164
165<main id="app"></main>
166
167<script>
168(function () {
169 "use strict";
170
171 var STORAGE_KEY = "recipeBox.recipes.v1";
172
173 var EXAMPLES = [
174 {
175 title: "Classic Pancakes",
176 description: "Fluffy weekend pancakes that come together in minutes.",
177 servings: "4",
178 time: "25 min",
179 tags: ["breakfast", "sweet", "vegetarian"],
180 ingredients: [
181 "1 1/2 cups (190 g) all-purpose flour",
182 "2 tbsp sugar",
183 "2 tsp baking powder",
184 "1/2 tsp salt",
185 "1 1/4 cups (300 ml) milk",
186 "1 egg",
187 "3 tbsp melted butter, plus extra for the pan"
188 ],
189 steps: [
190 "Whisk the flour, sugar, baking powder and salt together in a large bowl.",
191 "In another bowl, whisk the milk, egg and melted butter.",
192 "Pour the wet ingredients into the dry ones and stir until just combined. A few lumps are fine.",
193 "Heat a lightly buttered pan over medium heat.",
194 "Pour about 1/4 cup of batter per pancake. Cook until bubbles form on top, then flip and cook until golden.",
195 "Serve warm with maple syrup, fruit or butter."
196 ]
197 },
198 {
199 title: "Tomato Basil Pasta",
200 description: "A quick, bright weeknight pasta with garlic, tomatoes and fresh basil.",
201 servings: "2",
202 time: "20 min",
203 tags: ["dinner", "pasta", "vegetarian", "quick"],
204 ingredients: [
205 "200 g spaghetti",
206 "2 tbsp olive oil",
207 "3 cloves garlic, thinly sliced",
208 "300 g cherry tomatoes, halved",
209 "Pinch of chili flakes (optional)",
210 "Handful of fresh basil leaves",
211 "Salt and pepper",
212 "Grated parmesan, to serve"
213 ],
214 steps: [
215 "Cook the spaghetti in well-salted boiling water until al dente. Save a cup of the pasta water before draining.",
216 "Meanwhile, warm the olive oil in a large pan over medium heat and gently cook the garlic for 1 minute.",
217 "Add the tomatoes and chili flakes, season with salt, and cook for 5–7 minutes until they soften and burst.",
218 "Add the drained pasta and a splash of pasta water; toss until glossy.",
219 "Tear in the basil, season with pepper and serve with parmesan."
220 ]
221 },
222 {
223 title: "Chicken Vegetable Soup",
224 description: "Comforting, hearty soup β€” perfect for chilly days.",
225 servings: "6",
226 time: "1 hr",
227 tags: ["soup", "dinner", "comfort food"],
228 ingredients: [
229 "1 tbsp olive oil",
230 "1 onion, diced",
231 "2 carrots, sliced",
232 "2 celery stalks, sliced",
233 "2 cloves garlic, minced",
234 "500 g boneless chicken thighs",
235 "1.5 litres chicken stock",
236 "1 tsp dried thyme",
237 "1 bay leaf",
238 "1 cup small pasta or rice",
239 "Salt, pepper and chopped parsley"
240 ],
241 steps: [
242 "Heat the oil in a large pot. Add the onion, carrots and celery and cook for 6–8 minutes until softened.",
243 "Stir in the garlic and cook for 1 minute.",
244 "Add the chicken, stock, thyme and bay leaf. Bring to a boil, then simmer gently for 25 minutes.",
245 "Lift out the chicken, shred it with two forks and return it to the pot.",
246 "Add the pasta or rice and simmer until tender, about 10 minutes.",
247 "Remove the bay leaf, season to taste and finish with parsley."
248 ]
249 }
250 ];
251
252 // ---------- Storage ----------
253 var storageOk = true;
254 var recipes = load();
255
256 function makeId() {
257 return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
258 }
259
260 function withIds(list) {
261 var now = Date.now();
262 return list.map(function (r, i) {
263 return Object.assign({}, r, { id: makeId() + i, createdAt: now + i, updatedAt: now + i });
264 });
265 }
266
267 function load() {
268 try {
269 var raw = localStorage.getItem(STORAGE_KEY);
270 if (raw === null) {
271 var seeded = withIds(EXAMPLES);
272 localStorage.setItem(STORAGE_KEY, JSON.stringify(seeded));
273 return seeded;
274 }
275 var data = JSON.parse(raw);
276 return Array.isArray(data) ? data : [];
277 } catch (e) {
278 storageOk = false;
279 return withIds(EXAMPLES);
280 }
281 }
282
283 function save() {
284 try {
285 localStorage.setItem(STORAGE_KEY, JSON.stringify(recipes));
286 storageOk = true;
287 } catch (e) {
288 storageOk = false;
289 }
290 }
291
292 // ---------- Helpers ----------
293 function el(tag, props, children) {
294 var node = document.createElement(tag);
295 if (props) {
296 Object.keys(props).forEach(function (k) {
297 var v = props[k];
298 if (k === "class") node.className = v;
299 else if (k === "text") node.textContent = v;
300 else if (k.slice(0, 2) === "on") node.addEventListener(k.slice(2), v);
301 else node.setAttribute(k, v);
302 });
303 }
304 (children || []).forEach(function (c) {
305 if (c == null || c === false) return;
306 node.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
307 });
308 return node;
309 }
310
311 function lines(text) {
312 return text.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
313 }
314
315 function find(id) {
316 for (var i = 0; i < recipes.length; i++) if (recipes[i].id === id) return recipes[i];
317 return null;
318 }
319
320 function allTags() {
321 var set = {};
322 recipes.forEach(function (r) { (r.tags || []).forEach(function (t) { set[t] = true; }); });
323 return Object.keys(set).sort(function (a, b) { return a.localeCompare(b); });
324 }
325
326 // ---------- State ----------
327 var state = { view: "list", id: null, query: "", tag: null };
328 var app = document.getElementById("app");
329 var searchInput = document.getElementById("search");
330
331 function go(view, id) {
332 state.view = view;
333 state.id = id || null;
334 render();
335 window.scrollTo(0, 0);
336 }
337
338 function render() {
339 app.innerHTML = "";
340 if (!storageOk) {
341 app.appendChild(el("div", { class: "warning", text:
342 "Heads up: your browser isn't letting this page save data, so changes will be lost when you close it. " +
343 "Try opening the file in a regular (not private) window or a different browser." }));
344 }
345 if (state.view === "detail" && find(state.id)) renderDetail(find(state.id));
346 else if (state.view === "form") renderForm(state.id ? find(state.id) : null);
347 else { state.view = "list"; renderList(); }
348 }
349
350 // ---------- List view ----------
351 function matches(r, q) {
352 if (!q) return true;
353 var hay = [r.title, r.description, (r.tags || []).join(" "), (r.ingredients || []).join(" "), (r.steps || []).join(" ")]
354 .join(" ").toLowerCase();
355 return q.split(/\s+/).every(function (word) { return hay.indexOf(word) !== -1; });
356 }
357
358 function renderList() {
359 var q = state.query.trim().toLowerCase();
360 var tags = allTags();
361 if (state.tag && tags.indexOf(state.tag) === -1) state.tag = null;
362
363 if (tags.length) {
364 var bar = el("div", { class: "tags-filter" }, [
365 el("button", { class: "chip" + (state.tag ? "" : " active"), text: "All", onclick: function () { state.tag = null; render(); } })
366 ]);
367 tags.forEach(function (t) {
368 bar.appendChild(el("button", {
369 class: "chip" + (state.tag === t ? " active" : ""),
370 text: t,
371 onclick: function () { state.tag = state.tag === t ? null : t; render(); }
372 }));
373 });
374 app.appendChild(bar);
375 }
376
377 var list = recipes
378 .filter(function (r) { return matches(r, q); })
379 .filter(function (r) { return !state.tag || (r.tags || []).indexOf(state.tag) !== -1; })
380 .sort(function (a, b) { return a.title.localeCompare(b.title); });
381
382 if (recipes.length === 0) {
383 app.appendChild(el("div", { class: "empty" }, [
384 el("p", { text: "Your recipe box is empty." }),
385 el("p", null, [
386 el("button", { class: "primary", text: "+ Add your first recipe", onclick: function () { go("form"); } })
387 ]),
388 el("p", null, [
389 el("button", { class: "link", text: "Bring back the example recipes", onclick: function () {
390 recipes = withIds(EXAMPLES); save(); render();
391 } })
392 ])
393 ]));
394 return;
395 }
396
397 app.appendChild(el("p", { class: "count", text:
398 list.length === recipes.length
399 ? recipes.length + (recipes.length === 1 ? " recipe" : " recipes")
400 : "Showing " + list.length + " of " + recipes.length + " recipes" }));
401
402 if (list.length === 0) {
403 app.appendChild(el("div", { class: "empty" }, [
404 el("p", { text: "No recipes match your search." }),
405 el("button", { text: "Clear search", onclick: function () {
406 state.query = ""; state.tag = null; searchInput.value = ""; render();
407 } })
408 ]));
409 return;
410 }
411
412 var grid = el("div", { class: "grid" });
413 list.forEach(function (r) {
414 var meta = [];
415 if (r.time) meta.push(el("span", { text: "⏱ " + r.time }));
416 if (r.servings) meta.push(el("span", { text: "🍽 Serves " + r.servings }));
417 meta.push(el("span", { text: "πŸ₯• " + (r.ingredients || []).length + " ingredients" }));
418 var card = el("div", {
419 class: "card", tabindex: "0", role: "button",
420 onclick: function () { go("detail", r.id); },
421 onkeydown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go("detail", r.id); } }
422 }, [
423 el("h3", { text: r.title }),
424 r.description ? el("p", { text: r.description }) : null,
425 el("div", { class: "meta" }, meta),
426 (r.tags && r.tags.length) ? el("div", { class: "tags" }, r.tags.map(function (t) { return el("span", { class: "tag", text: t }); })) : null
427 ]);
428 grid.appendChild(card);
429 });
430 app.appendChild(grid);
431 }
432
433 // ---------- Detail view ----------
434 function renderDetail(r) {
435 var actions = el("div", { class: "actions" }, [
436 el("button", { text: "← All recipes", onclick: function () { go("list"); } }),
437 el("span", { class: "spacer" }),
438 el("button", { text: "πŸ–¨ Print", onclick: function () { window.print(); } }),
439 el("button", { text: "✏️ Edit", onclick: function () { go("form", r.id); } }),
440 el("button", { class: "danger", text: "πŸ—‘ Delete", onclick: function () {
441 if (confirm('Delete "' + r.title + '"? This can\'t be undone.')) {
442 recipes = recipes.filter(function (x) { return x.id !== r.id; });
443 save();
444 go("list");
445 }
446 } })
447 ]);
448
449 var meta = [];
450 if (r.time) meta.push(el("span", { text: "⏱ " + r.time }));
451 if (r.servings) meta.push(el("span", { text: "🍽 Serves " + r.servings }));
452
453 var ing = el("ul", { class: "ingredients" });
454 (r.ingredients || []).forEach(function (item) {
455 var box = el("input", { type: "checkbox", "aria-label": "Mark as gathered" });
456 var li = el("li", null, [box, el("span", { text: item })]);
457 box.addEventListener("change", function () { li.classList.toggle("done", box.checked); });
458 ing.appendChild(li);
459 });
460
461 var steps = el("ol", { class: "steps" });
462 (r.steps || []).forEach(function (s) { steps.appendChild(el("li", { text: s })); });
463
464 app.appendChild(actions);
465 app.appendChild(el("div", { class: "panel detail" }, [
466 el("h2", { text: r.title }),
467 r.description ? el("p", { class: "desc", text: r.description }) : null,
468 meta.length ? el("div", { class: "meta" }, meta) : null,
469 (r.tags && r.tags.length) ? el("div", { class: "tags" }, r.tags.map(function (t) { return el("span", { class: "tag", text: t }); })) : null,
470 el("div", { class: "columns" }, [
471 el("div", null, [
472 el("h3", { text: "Ingredients" }),
473 (r.ingredients && r.ingredients.length) ? ing : el("p", { class: "desc", text: "No ingredients listed." })
474 ]),
475 el("div", null, [
476 el("h3", { text: "Steps" }),
477 (r.steps && r.steps.length) ? steps : el("p", { class: "desc", text: "No steps listed." })
478 ])
479 ])
480 ]));
481 }
482
483 // ---------- Add / edit form ----------
484 function renderForm(r) {
485 var editing = !!r;
486 r = r || { title: "", description: "", servings: "", time: "", tags: [], ingredients: [], steps: [] };
487
488 function field(labelText, hint, input) {
489 return el("div", null, [
490 el("label", null, [labelText, hint ? el("span", { class: "hint", text: " " + hint }) : null]),
491 input
492 ]);
493 }
494
495 var title = el("input", { type: "text", maxlength: "120", placeholder: "e.g. Grandma's Apple Pie" });
496 title.value = r.title;
497 var desc = el("input", { type: "text", maxlength: "300", placeholder: "A short note about this dish (optional)" });
498 desc.value = r.description || "";
499 var servings = el("input", { type: "text", maxlength: "20", placeholder: "e.g. 4" });
500 servings.value = r.servings || "";
501 var time = el("input", { type: "text", maxlength: "30", placeholder: "e.g. 45 min" });
502 time.value = r.time || "";
503 var tags = el("input", { type: "text", placeholder: "e.g. dessert, baking" });
504 tags.value = (r.tags || []).join(", ");
505 var ingredients = el("textarea", { placeholder: "2 cups flour\n1 tsp salt\n3 eggs" });
506 ingredients.value = (r.ingredients || []).join("\n");
507 var steps = el("textarea", { placeholder: "Preheat the oven to 180Β°C.\nMix the dry ingredients.\n…" });
508 steps.value = (r.steps || []).join("\n");
509 var error = el("div", { class: "error" });
510
511 var form = el("form", { class: "panel", novalidate: "" }, [
512 el("h2", { text: editing ? "Edit recipe" : "Add a new recipe", style: "margin-top:0" }),
513 field("Recipe name", "(required)", title),
514 error,
515 field("Description", null, desc),
516 el("div", { class: "row" }, [
517 field("Servings", null, servings),
518 field("Total time", null, time)
519 ]),
520 field("Tags", "(separate with commas)", tags),
521 field("Ingredients", "(one per line)", ingredients),
522 field("Steps", "(one per line β€” they'll be numbered for you)", steps),
523 el("div", { class: "form-actions" }, [
524 el("button", { type: "button", text: "Cancel", onclick: function () {
525 go(editing ? "detail" : "list", editing ? r.id : null);
526 } }),
527 el("button", { type: "submit", class: "primary", text: editing ? "Save changes" : "Save recipe" })
528 ])
529 ]);
530
531 form.addEventListener("submit", function (e) {
532 e.preventDefault();
533 var name = title.value.trim();
534 if (!name) {
535 error.textContent = "Please give your recipe a name.";
536 title.focus();
537 return;
538 }
539 var seen = {};
540 var data = {
541 title: name,
542 description: desc.value.trim(),
543 servings: servings.value.trim(),
544 time: time.value.trim(),
545 tags: tags.value.split(",").map(function (t) { return t.trim().toLowerCase(); })
546 .filter(function (t) { if (!t || seen[t]) return false; seen[t] = true; return true; }),
547 ingredients: lines(ingredients.value),
548 steps: lines(steps.value),
549 updatedAt: Date.now()
550 };
551 var id;
552 if (editing) {
553 Object.assign(r, data);
554 id = r.id;
555 } else {
556 data.id = makeId();
557 data.createdAt = data.updatedAt;
558 recipes.push(data);
559 id = data.id;
560 }
561 save();
562 go("detail", id);
563 });
564
565 app.appendChild(form);
566 title.focus();
567 }
568
569 // ---------- Wiring ----------
570 document.getElementById("logo").addEventListener("click", function () { go("list"); });
571 document.getElementById("addBtn").addEventListener("click", function () { go("form"); });
572 searchInput.addEventListener("input", function () {
573 state.query = searchInput.value;
574 if (state.view !== "list") state.view = "list";
575 render();
576 });
577
578 // Keep multiple open tabs in sync
579 window.addEventListener("storage", function (e) {
580 if (e.key === STORAGE_KEY) {
581 recipes = load();
582 if (state.view !== "form") render();
583 }
584 });
585
586 render();
587})();
588</script>
589</body>
590</html>