The Ratings page gains an "Import ratings" form: paste URLs (one per line), choose a verdict and an optional note. Rows land in the new rating_imports table and the new import-ratings job (same systemd job template as the rest of the catalogue) canonicalizes each URL, reuses or fetches + extracts the article, embeds it with Voyage when enabled, and appends an explicit rating event with source "import". Per-URL status shows on the Ratings page; the job page's journal is the live log. Imported articles have no entry row (best_entry_id NULL, feed "Imported") and no sources, so they act as rated neighbours without touching the feed prior. The CLI's rating-event construction moves to rate::record_explicit and the dashboard job start path is shared as jobs::start_job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
273 lines
12 KiB
JavaScript
273 lines
12 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";
|
|
}
|
|
};
|
|
// Icons and label are picked by CSS from `html[data-theme]`, which theme.js sets
|
|
// before first paint, so nothing here repaints the toggle after load.
|
|
const renderTheme = (theme) => themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
|
|
const setTheme = (theme) => {
|
|
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
|
else document.documentElement.dataset.theme = theme;
|
|
const scheme = document.querySelector('meta[name="color-scheme"]');
|
|
if (scheme) scheme.content = theme === "system" ? "light dark" : 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;
|
|
const saved = localStorage.getItem(key);
|
|
if (saved === "open" || saved === "closed") details.open = saved === "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");
|
|
const host = table.closest(".scroll-x") || table;
|
|
// The template renders the input (shown only under `.has-js`) so the table does
|
|
// not jump when this script runs after first paint; create one if it is missing.
|
|
let input = host.previousElementSibling;
|
|
if (!(input && input.matches("input[data-table-filter]"))) {
|
|
if (rows.length < 2) return;
|
|
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");
|
|
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) return;
|
|
const schedule = () => setTimeout(() => window.location.reload(), seconds * 1000);
|
|
if (document.prerendering) {
|
|
document.addEventListener("prerenderingchange", schedule, { once: true });
|
|
} else {
|
|
schedule();
|
|
}
|
|
});
|
|
/* step 4: table-of-contents panel, current chapter, and reading progress */
|
|
const tocPanel = document.querySelector("[data-toc-panel]");
|
|
const tocToggle = document.querySelector("[data-toc-toggle]");
|
|
if (tocPanel) {
|
|
const tocBar = document.querySelector("[data-toc-bar]");
|
|
const tocLinks = Array.from(tocPanel.querySelectorAll(".toc-link"));
|
|
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
|
const tocLabel = document.querySelector("[data-toc-label]");
|
|
const tocCount = document.querySelector("[data-toc-count]");
|
|
const tocStatus = document.querySelector("[data-toc-status]");
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const largeScreen = window.matchMedia("(min-width: 64rem)");
|
|
|
|
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
|
const revealCurrent = () => {
|
|
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
|
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
|
const stickyHeader = tocPanel.querySelector(":scope > div");
|
|
const topMargin = (stickyHeader && stickyHeader.offsetHeight > 0 ? stickyHeader.offsetHeight : 0) + 24;
|
|
const panelRect = tocPanel.getBoundingClientRect();
|
|
const currentRect = current.getBoundingClientRect();
|
|
const top = tocPanel.scrollTop + currentRect.top - panelRect.top;
|
|
const visibleTop = tocPanel.scrollTop + topMargin;
|
|
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - 24;
|
|
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
|
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
|
|
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" });
|
|
};
|
|
const sizeOpenPanel = () => {
|
|
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
|
tocPanel.style.setProperty("--toc-panel-top", `${Math.max(0, tocBar.getBoundingClientRect().bottom)}px`);
|
|
};
|
|
const setOpen = (open) => {
|
|
tocPanel.dataset.open = open ? "true" : "false";
|
|
document.documentElement.classList.toggle("toc-panel-open", open && !largeScreen.matches);
|
|
if (tocToggle) {
|
|
tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
tocToggle.setAttribute("aria-label", open ? "Close contents" : "Contents");
|
|
}
|
|
if (open) {
|
|
sizeOpenPanel();
|
|
revealCurrent();
|
|
} else {
|
|
tocPanel.style.removeProperty("--toc-panel-top");
|
|
}
|
|
};
|
|
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();
|
|
});
|
|
largeScreen.addEventListener("change", () => setOpen(false));
|
|
window.addEventListener("resize", sizeOpenPanel);
|
|
if (window.visualViewport) window.visualViewport.addEventListener("resize", sizeOpenPanel);
|
|
}
|
|
|
|
let currentLink = tocPanel.querySelector(".toc-link[aria-current]");
|
|
// Chapters above the current one read as "already passed" in ink rather than ink-2.
|
|
const markPassed = (link) => {
|
|
let passed = true;
|
|
tocLinks.forEach((candidate) => {
|
|
if (candidate === link) passed = false;
|
|
candidate.toggleAttribute("data-passed", passed);
|
|
});
|
|
};
|
|
if (currentLink) markPassed(currentLink);
|
|
const setCurrent = (link) => {
|
|
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
|
|
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
|
|
markPassed(link);
|
|
link.setAttribute("aria-current", "location");
|
|
currentLink = link;
|
|
const position = Math.max(0, Number(link.dataset.tocPosition));
|
|
const total = tocBars.length ? Number(tocBars[0].max) : 0;
|
|
const isEnd = link.hasAttribute("data-toc-end");
|
|
const label = link.querySelector("[data-toc-link-label]");
|
|
if (tocLabel && label) tocLabel.textContent = label.textContent;
|
|
if (tocCount) {
|
|
tocCount.hidden = position === 0 || isEnd;
|
|
tocCount.textContent = `${position} / ${total}`;
|
|
}
|
|
if (tocStatus) tocStatus.textContent = isEnd ? "End of issue" : position > 0 ? `Chapter ${position} of ${total}` : "Front page";
|
|
tocBars.forEach((bar) => {
|
|
delete bar.dataset.live;
|
|
bar.value = position;
|
|
});
|
|
revealCurrent();
|
|
};
|
|
|
|
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
|
|
entry,
|
|
link: tocLinks.find((candidate) => candidate.getAttribute("href") === entry.dataset.tocEntry),
|
|
})).filter(({ link }) => link);
|
|
if (tocEntries.length) {
|
|
let queued = false;
|
|
const paintCurrent = () => {
|
|
queued = false;
|
|
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
|
|
let next = tocEntries[0].link;
|
|
tocEntries.forEach(({ entry, link }) => {
|
|
if (entry.getBoundingClientRect().top <= threshold) next = link;
|
|
});
|
|
setCurrent(next);
|
|
};
|
|
const scheduleCurrent = () => {
|
|
if (queued) return;
|
|
queued = true;
|
|
window.requestAnimationFrame(paintCurrent);
|
|
};
|
|
window.addEventListener("scroll", scheduleCurrent, { passive: true });
|
|
window.addEventListener("resize", scheduleCurrent);
|
|
scheduleCurrent();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|