// 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 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 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 β 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.
" + "