/** * cookbook.js * Works both as a direct page load AND when injected as a stacked pane * by script.js (which fetches pages and injects only #content). */ (function () { "use strict"; // ── Core init — scoped to a content root ────────────────────────────────── function initCookbook(root) { // Already initialised on this root? if (root.dataset.cbInit) return; root.dataset.cbInit = "1"; const recipes = Array.from(root.querySelectorAll(".outline-3")) .filter(el => el.querySelector("table.recipe-meta")); if (!recipes.length) return; // ── Helpers ───────────────────────────────────────────────────────────── function cellValue(table, label) { for (const row of table.querySelectorAll("tbody tr")) { const cells = row.querySelectorAll("td"); if (cells.length >= 2 && cells[0].textContent.trim() === label) return cells[1].textContent.trim(); } return ""; } function isCooked(table) { const v = cellValue(table, "Cooked"); return Boolean(v && v !== "not yet made"); } // ── Collect keywords ───────────────────────────────────────────────────── const allKeywords = new Set(); recipes.forEach(block => { const kw = cellValue(block.querySelector("table.recipe-meta"), "Keywords"); if (kw) kw.split(",").forEach(k => allKeywords.add(k.trim())); }); // ── Build toolbar ──────────────────────────────────────────────────────── const toolbar = document.createElement("div"); toolbar.className = "cb-toolbar"; toolbar.setAttribute("role", "search"); toolbar.innerHTML = `
`; const firstSection = root.querySelector(".outline-2"); if (firstSection) firstSection.parentNode.insertBefore(toolbar, firstSection); const searchInput = toolbar.querySelector(".cb-search"); const filterBtns = toolbar.querySelectorAll(".cb-filter-btn"); const tagsWrap = toolbar.querySelector(".cb-tags"); const countEl = toolbar.querySelector(".cb-count"); // Keyword pills Array.from(allKeywords).sort().forEach(kw => { const btn = document.createElement("button"); btn.className = "cb-tag"; btn.textContent = kw; btn.dataset.tag = kw; btn.setAttribute("aria-pressed", "false"); tagsWrap.appendChild(btn); }); // ── State ──────────────────────────────────────────────────────────────── let activeFilter = "all"; let activeTags = new Set(); let query = ""; // ── Filter engine ──────────────────────────────────────────────────────── function applyFilters() { let shown = 0; recipes.forEach(block => { const table = block.querySelector("table.recipe-meta"); const cooked = isCooked(table); const title = (block.querySelector("h3")?.textContent ?? "").toLowerCase(); const kwRaw = cellValue(table, "Keywords"); const kwLow = kwRaw.toLowerCase(); if (activeFilter === "cooked" && !cooked) { hide(block); return; } if (activeFilter === "uncooked" && cooked) { hide(block); return; } if (activeTags.size) { const recipeKws = kwRaw.split(",").map(k => k.trim()); if ([...activeTags].some(t => !recipeKws.includes(t))) { hide(block); return; } } if (query && !title.includes(query) && !kwLow.includes(query)) { hide(block); return; } show(block); shown++; }); // Hide category headings with no visible recipes root.querySelectorAll(".outline-2").forEach(section => { const hasVisible = Array.from(section.querySelectorAll(".outline-3")) .some(b => b.style.display !== "none"); section.style.display = hasVisible ? "" : "none"; }); countEl.textContent = shown === recipes.length ? `${shown} recipe${shown !== 1 ? "s" : ""}` : `${shown} / ${recipes.length}`; } function hide(el) { el.style.display = "none"; } function show(el) { el.style.display = ""; } // ── Events ─────────────────────────────────────────────────────────────── searchInput.addEventListener("input", () => { query = searchInput.value.trim().toLowerCase(); applyFilters(); }); filterBtns.forEach(btn => { btn.addEventListener("click", () => { filterBtns.forEach(b => b.classList.remove("active")); btn.classList.add("active"); activeFilter = btn.dataset.filter; applyFilters(); }); }); tagsWrap.addEventListener("click", e => { const btn = e.target.closest(".cb-tag"); if (!btn) return; const tag = btn.dataset.tag; if (activeTags.has(tag)) { activeTags.delete(tag); btn.classList.remove("active"); btn.setAttribute("aria-pressed", "false"); } else { activeTags.add(tag); btn.classList.add("active"); btn.setAttribute("aria-pressed", "true"); } applyFilters(); }); // ── Badges ─────────────────────────────────────────────────────────────── recipes.forEach(block => { const table = block.querySelector("table.recipe-meta"); const h3 = block.querySelector("h3"); const cooked = isCooked(table); block.classList.add(cooked ? "cb-cooked" : "cb-uncooked"); const badge = document.createElement("span"); badge.className = "cb-badge " + (cooked ? "cb-badge-cooked" : "cb-badge-uncooked"); badge.textContent = cooked ? "made" : "not yet"; const tagSpan = h3.querySelector(".tag"); if (tagSpan) h3.insertBefore(badge, tagSpan); else h3.appendChild(badge); }); applyFilters(); } // ── Entry points ────────────────────────────────────────────────────────── // 1. Direct page load — #content is already in the DOM function tryInitOnContent() { const content = document.getElementById("content"); if (content && content.querySelector("table.recipe-meta")) { initCookbook(content); } } // 2. Stacked pane — script.js fetches a page and appends a new
// with the #content inside it. Watch for that. function watchForPanes() { const track = document.querySelector(".stack-track"); if (!track) return; const observer = new MutationObserver(mutations => { mutations.forEach(m => { m.addedNodes.forEach(node => { if (node.nodeType !== 1) return; // script.js appends an
// containing a div#content const content = node.id === "content" ? node : node.querySelector("#content, .content"); if (content && content.querySelector("table.recipe-meta")) { initCookbook(content); } }); }); }); observer.observe(track, { childList: true, subtree: true }); } // Run both if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { tryInitOnContent(); watchForPanes(); }); } else { // Already parsed (script has `defer` but DOM may already be ready) tryInitOnContent(); watchForPanes(); } })();