Add feed/reader mode with full-article capture at ingest
Reader mode presents the stories on a page one at a time in a focused overlay, marking each read as it's shown. Left/right arrows (or the header book button / `f`) page through them; read stories dim on the grid. Read state is device-local in localStorage. Backing this required actually capturing article bodies, which Pete wasn't doing — it kept only the RSS <description> lede and discarded content:encoded: - stories.content column (idempotent migration; old rows fall back to lede) - parser keeps content:encoded as paragraph-preserving text - article fetch already done for paywall detection now also returns its body, so ingest stores the richer of feed-content vs scraped body with no extra request (prefers the archive snapshot body for paywalled stories) - GET /api/article?id= serves the stored text; card queries now select id and expose it as data-id for the reader Tests cover content extraction, the storage round-trip, and the article endpoint + card rendering end to end.
This commit is contained in:
@@ -169,3 +169,185 @@ html[data-phase="night"] {
|
||||
filter: contrast(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
Feed / reader mode. A focused, one-story-at-a-time overlay driven by
|
||||
reader.js. Left/right arrows page through the stories currently on screen,
|
||||
marking each read as it's shown. Styling leans on the phase palette vars so
|
||||
it recolours with day/night like everything else.
|
||||
---------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.pete-reader-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 14, 6, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-backdrop { background: rgba(6, 8, 20, 0.65); }
|
||||
|
||||
.pete-reader-shell {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: max(env(safe-area-inset-top), 0.75rem) 1rem 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pete-reader-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
color: #fff;
|
||||
}
|
||||
.pete-reader-progress {
|
||||
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.pete-reader-nav { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.pete-reader-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.7rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.pete-reader-btn:hover { background: rgba(255, 255, 255, 0.28); transform: translateY(-1px); }
|
||||
.pete-reader-btn:disabled { opacity: 0.35; cursor: default; transform: none; }
|
||||
.pete-reader-btn-open { background: var(--accent); border-color: transparent; color: #1c1305; text-decoration: none; }
|
||||
.pete-reader-btn-open:hover { background: var(--accent); filter: brightness(1.08); }
|
||||
|
||||
.pete-reader-scroll {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: 1.75rem;
|
||||
}
|
||||
|
||||
.pete-reader-article {
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border-radius: 1.75rem;
|
||||
border: 2px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 6px 0 rgba(60, 40, 20, 0.12), 0 16px 32px rgba(60, 40, 20, 0.18);
|
||||
padding: clamp(1.25rem, 4vw, 2.5rem);
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-article { border-color: rgba(255, 255, 255, 0.08); }
|
||||
|
||||
.pete-reader-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.pete-reader-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.15rem 0.65rem;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.pete-reader-source { font-weight: 700; opacity: 0.75; }
|
||||
.pete-reader-time { opacity: 0.55; }
|
||||
|
||||
.pete-reader-title {
|
||||
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: clamp(1.6rem, 4.5vw, 2.4rem);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.pete-reader-hero {
|
||||
width: 100%;
|
||||
border-radius: 1.1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pete-reader-body { font-size: 1.08rem; line-height: 1.75; }
|
||||
.pete-reader-body p { margin-bottom: 1.05em; }
|
||||
.pete-reader-body p:last-child { margin-bottom: 0; }
|
||||
|
||||
.pete-reader-note {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-note { border-color: rgba(255, 255, 255, 0.12); }
|
||||
.pete-reader-note a { color: var(--accent); font-weight: 700; }
|
||||
|
||||
.pete-reader-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
padding: clamp(2rem, 8vw, 4rem) 1rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
.pete-reader-empty-emoji { font-size: 2.5rem; }
|
||||
|
||||
.pete-reader-hint {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.75rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.pete-reader-hint kbd {
|
||||
font-family: ui-monospace, monospace;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pete-reader-hint { display: none; }
|
||||
}
|
||||
|
||||
/* Grid treatment for stories already read in feed mode: dimmed, with a small
|
||||
corner check. Hovering restores full opacity so nothing feels lost. */
|
||||
[data-story-card][data-read="1"] { opacity: 0.5; transition: opacity 0.2s ease; }
|
||||
[data-story-card][data-read="1"]:hover { opacity: 1; }
|
||||
[data-story-card][data-read="1"]::after {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
z-index: 6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(20, 14, 6, 0.72);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
279
internal/web/static/js/reader.js
Normal file
279
internal/web/static/js/reader.js
Normal file
@@ -0,0 +1,279 @@
|
||||
// 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, """).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
|
||||
// <p> 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 += "<p>" + escapeHTML(t) + "</p>";
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function eyebrow(it) {
|
||||
var chip = it.chTheme
|
||||
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.chTheme) + '">' +
|
||||
(it.chEmoji ? '<span aria-hidden="true">' + escapeHTML(it.chEmoji) + "</span>" : "") +
|
||||
escapeHTML(it.chTitle) + "</span>"
|
||||
: "";
|
||||
var src = it.source ? '<span class="pete-reader-source">' + escapeHTML(it.source) + "</span>" : "";
|
||||
var time = it.time ? '<span class="pete-reader-time">' + escapeHTML(it.time) + "</span>" : "";
|
||||
return '<div class="pete-reader-eyebrow">' + chip + src + time + "</div>";
|
||||
}
|
||||
|
||||
function renderBody(it, bodyHTML) {
|
||||
var hero = it.image
|
||||
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
|
||||
: "";
|
||||
var href = safeURL(it.url);
|
||||
var note = '<p class="pete-reader-note">' +
|
||||
(it.paywalled ? "This source is paywalled — the text above may be partial. " : "") +
|
||||
(href ? 'Read it at the source: <a href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
escapeHTML(it.source || "original article") + " ↗</a>." : "") +
|
||||
"</p>";
|
||||
articleEl.innerHTML =
|
||||
eyebrow(it) +
|
||||
'<h1 class="pete-reader-title">' + escapeHTML(it.headline) + "</h1>" +
|
||||
hero +
|
||||
'<div class="pete-reader-body">' + bodyHTML + "</div>" +
|
||||
note;
|
||||
}
|
||||
|
||||
function loadingBody(it) {
|
||||
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
|
||||
}
|
||||
|
||||
function fillContent(it, data) {
|
||||
var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede);
|
||||
var html = paragraphs(text);
|
||||
if (!html) {
|
||||
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
|
||||
}
|
||||
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 =
|
||||
'<div class="pete-reader-empty">' +
|
||||
'<span class="pete-reader-empty-emoji" aria-hidden="true">🎉</span>' +
|
||||
'<h1 class="pete-reader-title" style="margin:0">You\'re all caught up</h1>' +
|
||||
'<p style="opacity:.7;max-width:28rem">That\'s every story on this page. ' +
|
||||
"Head back to browse more, or press ← to revisit the last one.</p>" +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user