More claude-d changes
This commit is contained in:
232
assets/scripts/cookbook.js
Normal file
232
assets/scripts/cookbook.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="cb-toolbar-inner">
|
||||
<input class="cb-search" type="search" placeholder="filter recipes…"
|
||||
aria-label="Filter recipes" autocomplete="off" spellcheck="false" />
|
||||
<div class="cb-filters" role="group" aria-label="Cook status">
|
||||
<button class="cb-filter-btn active" data-filter="all">all</button>
|
||||
<button class="cb-filter-btn" data-filter="cooked">✓ made</button>
|
||||
<button class="cb-filter-btn" data-filter="uncooked">✗ not yet</button>
|
||||
</div>
|
||||
<div class="cb-tags" role="group" aria-label="Filter by keyword"></div>
|
||||
<span class="cb-count" aria-live="polite"></span>
|
||||
</div>`;
|
||||
|
||||
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 <article>
|
||||
// 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 <article class="stack-pane">
|
||||
// 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();
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user