// Feed / reader mode. Presents the stories currently on the page one at a // time in a focused overlay, marking each read as it's shown. Left/right arrow // keys (or the on-screen buttons) page through them; the full article text is // whatever Pete captured at ingest (content:encoded or the scraped body), // fetched on demand from /api/article and cached for the session. // // Read state lives in localStorage under pete.read.v1. It's deliberately // device-local β unlike the small prefs blob, the read set grows unbounded, so // it isn't mirrored through PetePrefs to the account. (function () { var overlay = document.getElementById("pete-reader"); if (!overlay) return; var READ_KEY = "pete.read.v1"; var articleEl = overlay.querySelector("[data-reader-article]"); var scrollEl = overlay.querySelector("[data-reader-scroll]"); var progressEl = overlay.querySelector("[data-reader-progress]"); var linkEl = overlay.querySelector("[data-reader-link]"); var prevBtn = overlay.querySelector("[data-reader-prev]"); var nextBtn = overlay.querySelector("[data-reader-next]"); var closeBtn = overlay.querySelector("[data-reader-close]"); var backdrop = overlay.querySelector("[data-reader-backdrop]"); var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]"); var listenBtn = overlay.querySelector("[data-reader-listen]"); var shareBtn = overlay.querySelector("[data-reader-share]"); var typeBtn = overlay.querySelector("[data-reader-type]"); var typeMenu = overlay.querySelector("[data-reader-typemenu]"); var relatedEl = overlay.querySelector("[data-reader-related]"); var relatedCache = {}; // id -> results array // Signed-in users (Authentik) get read + bookmark state synced server-side; // window.PETE_USER is non-null for them. Anonymous visitors keep the // localStorage-only behaviour and never see the bookmark controls. var SIGNED_IN = !!(window.PETE_USER); var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories // Read-aloud is a signed-in-only perk and needs Web Speech support. Share is // available to everyone (native sheet where present, clipboard copy otherwise). var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" && typeof window.SpeechSynthesisUtterance !== "undefined"; var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled} var index = 0; var open = false; var contentCache = {}; // id -> {content, lede} var inflight = null; // ---- read-state store ----------------------------------------------------- function loadRead() { try { var raw = localStorage.getItem(READ_KEY); var obj = raw ? JSON.parse(raw) : {}; return obj && typeof obj === "object" ? obj : {}; } catch (e) { return {}; } } function saveRead(set) { try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {} } var readSet = loadRead(); function isRead(id) { return !!readSet[id]; } function setRead(id, on) { if (on) readSet[id] = 1; else delete readSet[id]; saveRead(readSet); paintCard(id, on); if (SIGNED_IN) postState("/api/read", { id: Number(id), read: !!on }); } // ---- server sync (signed-in only) ----------------------------------------- function postState(url, body) { try { fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), credentials: "same-origin", keepalive: true }).catch(function () {}); } catch (e) {} } function isBookmarked(id) { return !!bookmarkSet[id]; } // setBookmark updates memory, paints every matching control, and persists. function setBookmark(id, on) { if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id]; paintBookmark(id, on); postState("/api/bookmark", { id: Number(id), on: !!on }); // On the bookmarks page, an un-bookmark should drop the card immediately. if (!on && location.pathname === "/bookmarks") { document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) { c.parentNode && c.parentNode.removeChild(c); }); } } // setBookmarkQuiet applies server-provided state without echoing it back. function setBookmarkQuiet(id, on) { if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id]; paintBookmark(id, on); } function paintBookmark(id, on) { document.querySelectorAll('[data-bookmark-btn][data-story-id="' + cssEsc(id) + '"]').forEach(function (b) { applyCardBookmark(b, on); }); if (readerBookmarkBtn && items[index] && String(items[index].id) === String(id)) { applyReaderBookmark(on); } } function applyCardBookmark(btn, on) { btn.setAttribute("aria-pressed", on ? "true" : "false"); var svg = btn.querySelector("svg"); if (on) { btn.style.background = "var(--accent)"; btn.style.color = "#1c1305"; if (svg) svg.setAttribute("fill", "currentColor"); } else { btn.style.background = "rgba(20,14,6,.62)"; btn.style.color = "#fff"; if (svg) svg.setAttribute("fill", "none"); } } function applyReaderBookmark(on) { if (!readerBookmarkBtn) return; readerBookmarkBtn.setAttribute("aria-pressed", on ? "true" : "false"); readerBookmarkBtn.textContent = on ? "π Saved" : "π Save"; } // initUserState reveals the bookmark controls and pulls the signed-in user's // read + bookmark state for the stories on this page, painting them and // migrating any device-local reads the account doesn't have yet. function initUserState() { if (!SIGNED_IN) return; document.querySelectorAll("[data-bookmark-btn]").forEach(function (b) { b.style.display = "inline-flex"; }); var ids = []; document.querySelectorAll("[data-story-card]").forEach(function (c) { var id = c.getAttribute("data-id"); if (id) ids.push(id); }); if (!ids.length) return; fetch("/api/state?ids=" + encodeURIComponent(ids.join(",")), { credentials: "same-origin" }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data) return; var serverRead = Object.create(null); (data.read || []).forEach(function (id) { serverRead[id] = 1; readSet[id] = 1; paintCard(id, true); }); (data.bookmarked || []).forEach(function (id) { setBookmarkQuiet(id, true); }); // Migrate device-local reads the account doesn't have yet β but only // once per device. After the first sync the server is authoritative, so // a story the user later marks unread on another device stays unread // instead of being perpetually resurrected from this device's stale // local set on every page load. var MIGRATED_KEY = "pete.readMigrated.v1"; var migrated = false; try { migrated = localStorage.getItem(MIGRATED_KEY) === "1"; } catch (e) {} if (!migrated) { ids.forEach(function (id) { if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true }); }); try { localStorage.setItem(MIGRATED_KEY, "1"); } catch (e) {} } saveRead(readSet); }) .catch(function () {}); } // Reflect read state onto every matching card on the page (a story can appear // in more than one section on the home page). function paintCard(id, on) { document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) { if (on) c.setAttribute("data-read", "1"); else c.removeAttribute("data-read"); }); } function cssEsc(s) { return String(s).replace(/["\\]/g, "\\$&"); } // ---- collecting the page's stories ---------------------------------------- function isVisible(el) { return window.getComputedStyle(el).display !== "none"; } function collect() { var seen = Object.create(null); var out = []; document.querySelectorAll("[data-story-card]").forEach(function (c) { var id = c.getAttribute("data-id"); if (!id || seen[id] || !isVisible(c)) return; var url = c.getAttribute("data-url") || c.getAttribute("href") || ""; if (!/^https?:\/\//i.test(url)) url = ""; seen[id] = true; out.push({ id: id, url: url, headline: c.getAttribute("data-headline") || "", lede: c.getAttribute("data-lede") || "", image: c.getAttribute("data-image") || "", time: c.getAttribute("data-time") || "", source: c.getAttribute("data-source") || "", chTitle: c.getAttribute("data-ch-title") || "", chEmoji: c.getAttribute("data-ch-emoji") || "", chTheme: c.getAttribute("data-ch-theme") || "", paywalled: c.getAttribute("data-paywalled") === "1" }); }); return out; } // ---- rendering ------------------------------------------------------------ function escapeHTML(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } function safeURL(s) { var raw = String(s == null ? "" : s).trim(); return /^https?:\/\//i.test(raw) ? raw : ""; } // Turn stored plain text (paragraphs separated by blank lines) into escaped //
blocks. Single newlines inside a paragraph become spaces. function paragraphs(text) { var blocks = String(text || "").split(/\n{2,}/); var html = ""; for (var i = 0; i < blocks.length; i++) { var t = blocks[i].replace(/\s*\n\s*/g, " ").trim(); if (t) html += "
" + escapeHTML(t) + "
"; } return html; } function eyebrow(it) { var chip = it.chTheme ? '' + (it.chEmoji ? '" : "") + escapeHTML(it.chTitle) + "" : ""; var src = it.source ? '' + escapeHTML(it.source) + "" : ""; var time = it.time ? '' + escapeHTML(it.time) + "" : ""; return '' + (it.paywalled ? "This source is paywalled, so the text above may be partial. " : "") + (href ? 'Read it at the source: ' + escapeHTML(it.source || "original article") + " β." : "") + "
"; articleEl.innerHTML = eyebrow(it) + 'Loading the full storyβ¦
'); } function fillContent(it, data) { var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede); var html = paragraphs(text); if (!html) { html = 'No article text was captured for this story. Open the original to read it.
'; } renderBody(it, html); } function fetchContent(it) { if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; } loadingBody(it); if (inflight) inflight.abort(); var ctrl = new AbortController(); inflight = ctrl; var reqId = it.id; fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" }) .then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); }) .then(function (data) { contentCache[reqId] = data; if (ctrl !== inflight) return; // superseded by a newer navigation if (items[index] && items[index].id === reqId) fillContent(it, data); }) .catch(function (err) { if (err.name === "AbortError") return; if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede }) .finally(function () { if (ctrl === inflight) inflight = null; }); } // ---- related ("you might also like") -------------------------------------- function clearRelated() { if (!relatedEl) return; relatedEl.innerHTML = ""; relatedEl.hidden = true; } function renderRelated(reqId, results) { if (!relatedEl) return; // Ignore a response that arrived after the user moved on. if (!items[index] || String(items[index].id) !== String(reqId)) return; if (!results || !results.length) { clearRelated(); return; } var html = 'That\'s every story on this page. ' + "Head back to browse more, or press β to revisit the last one.
" + "elements being read, for highlight var speakQueue = []; // remaining {el, text} to utter function speakingText() { if (!articleEl) return []; var out = []; var h = articleEl.querySelector(".pete-reader-title"); if (h && h.textContent.trim()) out.push({ el: null, text: h.textContent.trim() }); articleEl.querySelectorAll(".pete-reader-body p").forEach(function (p) { var t = p.textContent.trim(); if (t) out.push({ el: p, text: t }); }); return out; } function clearSpeakHighlight() { speakParas.forEach(function (p) { p.classList.remove("is-speaking"); }); speakParas = []; } function stopSpeak() { if (!TTS_OK) return; try { window.speechSynthesis.cancel(); } catch (e) {} speaking = false; speakQueue = []; clearSpeakHighlight(); if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "π Listen"; } } function speakNext() { if (!speaking) return; var item = speakQueue.shift(); if (!item) { stopSpeak(); return; } clearSpeakHighlight(); if (item.el) { item.el.classList.add("is-speaking"); speakParas.push(item.el); try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {} } var u = new SpeechSynthesisUtterance(item.text); u.onend = function () { if (speaking) speakNext(); }; u.onerror = function () { if (speaking) speakNext(); }; try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); } } function startSpeak() { if (!TTS_OK) return; var parts = speakingText(); if (!parts.length) { toast("Nothing to read yet"); return; } try { window.speechSynthesis.cancel(); } catch (e) {} speaking = true; speakQueue = parts.slice(); if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "βΉ Stop"; } speakNext(); } function toggleSpeak() { if (!TTS_OK) return; if (speaking) stopSpeak(); else startSpeak(); } // ---- open / close --------------------------------------------------------- function openReader() { items = collect(); if (items.length === 0) return; open = true; overlay.classList.remove("hidden"); document.body.classList.add("overflow-hidden"); applyType(); show(firstUnread()); } function closeReader() { open = false; stopSpeak(); toggleTypeMenu(false); if (inflight) { inflight.abort(); inflight = null; } clearRelated(); overlay.classList.add("hidden"); document.body.classList.remove("overflow-hidden"); } // ---- wiring --------------------------------------------------------------- document.addEventListener("DOMContentLoaded", function () { // Dim stories already read on this device. document.querySelectorAll("[data-story-card]").forEach(function (c) { var id = c.getAttribute("data-id"); if (id && isRead(id)) c.setAttribute("data-read", "1"); }); document.querySelectorAll("[data-reader-open]").forEach(function (b) { b.addEventListener("click", function (e) { e.preventDefault(); openReader(); }); }); if (prevBtn) prevBtn.addEventListener("click", prev); if (nextBtn) nextBtn.addEventListener("click", next); if (closeBtn) closeBtn.addEventListener("click", closeReader); if (backdrop) backdrop.addEventListener("click", closeReader); if (readerBookmarkBtn) readerBookmarkBtn.addEventListener("click", function () { var it = items[index]; if (it) setBookmark(it.id, !isBookmarked(it.id)); }); // Read-aloud: signed-in only, and only where Web Speech exists. if (listenBtn) { if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); } else listenBtn.style.display = "none"; } // Share: available to everyone. if (shareBtn) { shareBtn.style.display = ""; shareBtn.addEventListener("click", shareCurrent); } // Text-options popover. if (typeBtn) typeBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleTypeMenu(); }); if (typeMenu) { typeMenu.addEventListener("click", function (e) { var b = e.target.closest("button"); if (!b) return; e.stopPropagation(); if (b.hasAttribute("data-size")) typePrefs.size = b.getAttribute("data-size"); else if (b.hasAttribute("data-font")) typePrefs.font = b.getAttribute("data-font"); else if (b.hasAttribute("data-paper")) typePrefs.paper = b.getAttribute("data-paper"); else return; saveType(); applyType(); }); } // Close the type popover on any outside click. document.addEventListener("click", function (e) { if (!typeMenu || typeMenu.hidden) return; if (e.target.closest("[data-reader-type]")) return; toggleTypeMenu(false); }); applyType(); initUserState(); }); // Bookmark buttons live inside the card's ; intercept so a tap toggles the // bookmark instead of following the link. Delegated so it also covers cards // that are added or removed after load. document.addEventListener("click", function (e) { var btn = e.target.closest && e.target.closest("[data-bookmark-btn]"); if (!btn) return; e.preventDefault(); e.stopPropagation(); var id = btn.getAttribute("data-story-id"); if (id) setBookmark(id, !isBookmarked(id)); }); document.addEventListener("keydown", function (e) { if (e.key !== "Enter" && e.key !== " ") return; var btn = e.target.closest && e.target.closest("[data-bookmark-btn]"); if (!btn) return; e.preventDefault(); var id = btn.getAttribute("data-story-id"); if (id) setBookmark(id, !isBookmarked(id)); }); document.addEventListener("keydown", function (e) { // Enter feed mode with `f` when nothing is focused and no other overlay is up. if (!open && (e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) { var tag = (document.activeElement && document.activeElement.tagName) || ""; if (tag === "INPUT" || tag === "TEXTAREA") return; var searchOpen = document.getElementById("pete-search"); if (searchOpen && !searchOpen.classList.contains("hidden")) return; e.preventDefault(); openReader(); return; } if (!open) return; switch (e.key) { case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break; case "ArrowLeft": case "h": e.preventDefault(); prev(); break; case "Escape": e.preventDefault(); if (typeMenu && !typeMenu.hidden) toggleTypeMenu(false); // esc closes the popover first else closeReader(); break; case "s": case "S": e.preventDefault(); shareCurrent(); break; case "t": case "T": e.preventDefault(); toggleTypeMenu(); break; case "r": case "R": if (TTS_OK) { e.preventDefault(); toggleSpeak(); } break; case "o": case "Enter": { var it = items[index]; var href = it && safeURL(it.url); if (href) { e.preventDefault(); window.open(href, "_blank", "noopener"); } break; } case "u": { var cur = items[index]; if (cur) setRead(cur.id, false); // let the user undo an accidental read break; } case "b": case "B": { if (!SIGNED_IN) break; var it = items[index]; if (it) { e.preventDefault(); setBookmark(it.id, !isBookmarked(it.id)); } break; } } }); })();