Files
the-daily-epub/src/web/static/app.js
T
thalladaandClaude Fable 5.1 ca910d99e5 Web dashboard v2 step 4: table-of-contents sidebar
Signed-in issue pages (issue, article, world, behind) get a chapter list
that marks where the reader is. One `issue_toc` helper builds it for all
four handlers: the picks in issue order numbered across sections, then
World Briefing, Behind the paper and a colophon anchor, with
position/total over the navigable chapters.

At `lg` and up it is a sticky left column with its own sticky header
(issue number, "Chapter N of M", a 2px progress bar); below `lg` the same
`<nav>` collapses behind a sticky hamburger bar and drops down as a
panel that closes on link tap, Escape and outside tap. Without JS the
panel is simply visible; `theme.js` marks the document scripted before
paint so it never flashes open. Article pages advance the progress bar
with scroll position through `requestAnimationFrame`, and the bar is a
`<progress>` element because a percentage width would need an inline
style the CSP forbids.

Also fixes the phone "ears" row from the step 2 review: the row no
longer wraps at 390px, the dateline has a short form below `sm:`, and
the toggle and account link stay on one line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 21:50:05 +00:00

180 lines
7.2 KiB
JavaScript

const themeToggle = document.querySelector("[data-theme-toggle]");
if (themeToggle) {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const readTheme = () => {
try {
const saved = localStorage.getItem("theme");
return saved === "light" || saved === "dark" ? saved : "system";
} catch (_) {
return "system";
}
};
const renderTheme = (theme) => {
themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
themeToggle.querySelectorAll("[data-theme-icon]").forEach((icon) => {
// SVGElement has no `hidden` IDL attribute; toggle the content attribute.
icon.toggleAttribute("hidden", icon.getAttribute("data-theme-icon") !== theme);
});
};
const setTheme = (theme) => {
if (theme === "system") document.documentElement.removeAttribute("data-theme");
else document.documentElement.dataset.theme = theme;
try {
if (theme === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", theme);
} catch (_) {}
renderTheme(theme);
};
renderTheme(readTheme());
themeToggle.addEventListener("click", () => {
const current = readTheme();
setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system");
});
media.addEventListener("change", () => {
if (readTheme() === "system") {
document.documentElement.removeAttribute("data-theme");
renderTheme("system");
}
});
}
document.addEventListener("submit", (event) => {
const form = event.target;
if (form.matches("form.rating") && event.submitter) {
event.preventDefault();
const submitted = event.submitter;
const body = new URLSearchParams(new FormData(form));
body.set(submitted.name, submitted.value);
fetch(form.action, {
method: "POST",
body,
credentials: "same-origin",
headers: { Accept: "application/json" },
}).then((response) => {
if (!response.ok) throw new Error("rating request failed");
return response.json();
}).then((result) => {
form.querySelectorAll("button[data-label]").forEach((button) => {
const active = button.dataset.label === result.label;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}).catch(() => {
const label = document.createElement("input");
label.type = "hidden";
label.name = submitted.name;
label.value = submitted.value;
form.appendChild(label);
form.submit();
});
return;
}
const message = form.dataset.confirm;
if (message && !window.confirm(message)) event.preventDefault();
});
document.querySelectorAll("details[id]").forEach((details) => {
try {
const key = "details:" + details.id;
details.open = localStorage.getItem(key) === "open";
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
} catch (_) {}
});
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
document.querySelectorAll("table[data-filter]").forEach((table) => {
const rows = table.querySelectorAll("tbody tr");
if (rows.length < 2) return;
const input = document.createElement("input");
input.type = "search";
input.className = "table-filter";
input.placeholder = "Filter rows on this page";
input.setAttribute("aria-label", "Filter rows on this page");
const host = table.closest(".scroll-x") || table;
host.parentNode.insertBefore(input, host);
input.addEventListener("input", () => {
const needle = input.value.trim().toLowerCase();
rows.forEach((row) => {
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
});
});
});
/* step 5: settings — "reset to default" fills the field with its default */
document.addEventListener("click", (event) => {
const button = event.target.closest("button[data-reset]");
if (!button) return;
const input = document.getElementById(button.dataset.reset);
if (!input) return;
input.value = button.dataset.default;
});
/* step 6: reload a job page every N seconds while its job is requested/running */
document.querySelectorAll("[data-refresh]").forEach((element) => {
const seconds = Number(element.dataset.refresh);
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
});
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
const tocPanel = document.querySelector("[data-toc-panel]");
const tocToggle = document.querySelector("[data-toc-toggle]");
if (tocPanel) {
// Long issues overflow the sidebar; scroll just enough to show where we are.
const revealCurrent = () => {
const current = tocPanel.querySelector("a[aria-current=page]");
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
const margin = 24;
const top = current.offsetTop - margin;
const bottom = current.offsetTop + current.offsetHeight + margin;
if (bottom > tocPanel.scrollTop + tocPanel.clientHeight) {
tocPanel.scrollTop = bottom - tocPanel.clientHeight;
} else if (top < tocPanel.scrollTop) {
tocPanel.scrollTop = Math.max(0, top);
}
};
const setOpen = (open) => {
tocPanel.dataset.open = open ? "true" : "false";
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
if (open) revealCurrent();
};
setOpen(false);
revealCurrent();
if (tocToggle) {
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
document.addEventListener("click", (event) => {
if (tocPanel.dataset.open !== "true" || tocToggle.contains(event.target)) return;
// A tap on a chapter closes the panel; so does a tap anywhere outside it.
if (!tocPanel.contains(event.target) || event.target.closest("a")) setOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || tocPanel.dataset.open !== "true") return;
setOpen(false);
tocToggle.focus();
});
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
}
}
const tocBars = document.querySelectorAll("[data-toc-progress]");
const tocChapter = document.querySelector("[data-toc-scroll]");
if (tocBars.length && tocChapter) {
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
let queued = false;
const paint = () => {
queued = false;
const start = window.scrollY + tocChapter.getBoundingClientRect().top;
const end = start + tocChapter.offsetHeight - window.innerHeight;
const read = end > start ? (window.scrollY - start) / (end - start) : 1;
const value = base + Math.min(1, Math.max(0, read));
tocBars.forEach((bar) => {
// The CSS transition is for navigation, not for tracking a finger.
bar.dataset.live = "true";
bar.value = value;
});
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
};
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
schedule();
}