// 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 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); } // 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 — 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; }); } // ---- 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; fetchContent(it); setRead(it.id, true); // presenting a story marks it read } function renderDone() { 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; } 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); }); 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; } } }); })();