Files
org_roam/assets/scripts/cookbook.js
Zaine Arch 74449c345a
All checks were successful
Build Roam Site / build (push) Successful in 30s
commits
2026-05-13 22:48:34 +01:00

264 lines
10 KiB
JavaScript

/**
* cookbook.js — fixed for the new tab-based layout
*
* Changes vs original:
* 1. initCookbook() is exported so script.js can call it after tab content loads
* 2. cellValue() scans ALL tr elements (not just tbody) — org-mode hides thead via CSS
* but the rows are still in the DOM under thead
* 3. watchForPanes() now watches #content-area (the new tab container) instead of .stack-track
* 4. Toolbar falls back to inserting before the first .outline-3 if .outline-2 is absent
*/
(function () {
"use strict";
// ── Core init — scoped to a content root ──────────────────────────────────
function initCookbook(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) {
// Scan ALL tr elements — org-mode thead is hidden via CSS but rows are
// still in the DOM there; relying only on tbody misses them.
for (const row of table.querySelectorAll("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>`;
// Insert before first .outline-2; fall back to first .outline-3
const insertTarget =
root.querySelector(".outline-2") ||
root.querySelector(".outline-3");
if (insertTarget) {
insertTarget.parentNode.insertBefore(toolbar, insertTarget);
} else {
// Last resort: prepend into root
root.prepend(toolbar);
}
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 = 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++;
});
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 ──────────────────────────────────────────────────────────
function tryInitOnContent() {
const content = document.getElementById("content");
if (content && content.querySelector("table.recipe-meta")) {
initCookbook(content);
}
}
function watchForPanes() {
// Watch #content-area — this is where script.js appends new .tab-content divs.
// Falls back to watching document.body if #content-area isn't ready yet.
const getTrack = () =>
document.getElementById("content-area") || document.body;
const observe = (track) => {
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.nodeType !== 1) return;
// script.js appends .tab-content divs containing a div#content
const content =
(node.id === "content" ? node : null) ||
node.querySelector("#content, .content");
if (content && content.querySelector("table.recipe-meta")) {
initCookbook(content);
}
});
});
});
observer.observe(track, { childList: true, subtree: true });
};
const track = document.getElementById("content-area");
if (track) {
observe(track);
} else {
// content-area is built by script.js after DOMContentLoaded;
// wait for it to appear then start observing.
const bodyObserver = new MutationObserver(() => {
const area = document.getElementById("content-area");
if (area) {
bodyObserver.disconnect();
observe(area);
}
});
bodyObserver.observe(document.body, { childList: true, subtree: false });
}
}
// Expose for direct calls from script.js if needed:
// window.initCookbook(contentEl);
window.initCookbook = initCookbook;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
tryInitOnContent();
watchForPanes();
});
} else {
tryInitOnContent();
watchForPanes();
}
})();