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> |