// 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 '
' + chip + src + time + "
"; } function renderBody(it, bodyHTML) { var hero = it.image ? '' : ""; var href = safeURL(it.url); var note = '

' + (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) + '

' + escapeHTML(it.headline) + "

" + hero + '
' + bodyHTML + "
" + note; } function loadingBody(it) { renderBody(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 = '' + '"; relatedEl.innerHTML = html; relatedEl.hidden = false; } function fetchRelated(it) { if (!relatedEl) return; clearRelated(); var reqId = it.id; if (relatedCache[reqId]) { renderRelated(reqId, relatedCache[reqId]); return; } fetch("/api/related?id=" + encodeURIComponent(it.id), { credentials: "same-origin" }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { var results = (data && data.results) || []; relatedCache[reqId] = results; renderRelated(reqId, results); }) .catch(function () {}); } // ---- navigation ----------------------------------------------------------- function show(i) { index = i; if (index >= items.length) { renderDone(); return; } var it = items[index]; progressEl.textContent = (index + 1) + " / " + items.length; var href = safeURL(it.url); if (href) { linkEl.href = href; linkEl.style.display = ""; } else linkEl.style.display = "none"; prevBtn.disabled = index === 0; nextBtn.disabled = false; nextBtn.textContent = index === items.length - 1 ? "done βœ“" : "β†’"; if (scrollEl) scrollEl.scrollTop = 0; if (readerBookmarkBtn) { readerBookmarkBtn.style.display = SIGNED_IN ? "" : "none"; applyReaderBookmark(isBookmarked(it.id)); } fetchContent(it); fetchRelated(it); setRead(it.id, true); // presenting a story marks it read } function renderDone() { clearRelated(); progressEl.textContent = items.length + " / " + items.length; linkEl.style.display = "none"; prevBtn.disabled = items.length === 0; nextBtn.disabled = true; nextBtn.textContent = "β†’"; if (scrollEl) scrollEl.scrollTop = 0; articleEl.innerHTML = '
' + '' + '

You\'re all caught up

' + '

That\'s every story on this page. ' + "Head back to browse more, or press ← to revisit the last one.

" + "
"; } function next() { if (index < items.length) show(index + 1); } function prev() { if (index > 0) show(index - 1); } function firstUnread() { for (var i = 0; i < items.length; i++) if (!isRead(items[i].id)) return i; return 0; } // ---- open / close --------------------------------------------------------- function openReader() { items = collect(); if (items.length === 0) return; open = true; overlay.classList.remove("hidden"); document.body.classList.add("overflow-hidden"); show(firstUnread()); } function closeReader() { open = 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)); }); 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(); closeReader(); 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; } } }); })();