Files
Pete/internal/web/static/js/reader.js
prosolis 616055a704 Add per-story views, trending, and reader reading-experience upgrades
Surface read counts and sharpen the reader:
- story_views table + RecordStoryView on /api/article (background,
  filter-guarded); "Popular this week" home rail via TrendingStories;
  read-count badge and reading-time chip decorated onto every listing
- reader: signed-in-only read-aloud (TTS), native share/copy, and an
  Aa typography popover (size/serif/sepia) persisted per device
- real alt text on card/reader/related/search images; time-of-day Pete
  greeting on the home hero
- harden exec() to skip (not panic) on a nil DB so background writes
  can't crash on a closed handle

Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
2026-07-07 22:17:23 -07:00

678 lines
27 KiB
JavaScript

// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
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="' + escapeHTML(it.headline) + '" loading="lazy" decoding="async">'
: "";
var href = safeURL(it.url);
var note = '<p class="pete-reader-note">' +
(it.paywalled ? "This source is paywalled, so 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; });
}
// ---- 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 = '<h2 class="pete-reader-related-title">You might also like</h2>' +
'<div class="pete-reader-related-grid">';
for (var i = 0; i < results.length; i++) {
var it = results[i];
var href = safeURL(it.article_url);
if (!href) continue;
var chip = it.channel_theme
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.channel_theme) + '">' +
(it.channel_emoji ? '<span aria-hidden="true">' + escapeHTML(it.channel_emoji) + "</span>" : "") +
escapeHTML(it.channel_title) + "</span>"
: "";
var thumb = it.thumb_url
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="' + escapeHTML(it.headline || "") + '" loading="lazy" decoding="async">'
: '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
thumb +
'<div class="pete-reader-related-meta">' +
'<div class="pete-reader-related-eyebrow">' + chip +
(it.source ? '<span class="pete-reader-related-source">' + escapeHTML(it.source) + "</span>" : "") +
"</div>" +
'<div class="pete-reader-related-headline">' + escapeHTML(it.headline) + "</div>" +
"</div></a>";
}
html += "</div>";
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) {
stopSpeak(); // never keep reading a story the user navigated away from
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() {
stopSpeak();
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 =
'<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;
}
// ---- typography (Aa popover) ----------------------------------------------
// Per-device reading preferences: text size, sans/serif, default/sepia paper.
// Kept in localStorage (like the read set) rather than the synced prefs blob,
// since they're a device-local reading-comfort choice.
var TYPE_KEY = "pete.reader.type.v1";
var SIZES = ["s", "m", "l", "xl"];
var typePrefs = { size: "m", font: "sans", paper: "default" };
(function loadType() {
try {
var raw = JSON.parse(localStorage.getItem(TYPE_KEY) || "{}");
if (raw && typeof raw === "object") {
if (SIZES.indexOf(raw.size) >= 0) typePrefs.size = raw.size;
if (raw.font === "serif" || raw.font === "sans") typePrefs.font = raw.font;
if (raw.paper === "sepia" || raw.paper === "default") typePrefs.paper = raw.paper;
}
} catch (e) {}
})();
function saveType() {
try { localStorage.setItem(TYPE_KEY, JSON.stringify(typePrefs)); } catch (e) {}
}
function applyType() {
if (!scrollEl) return;
SIZES.forEach(function (s) { scrollEl.classList.remove("is-size-" + s); });
scrollEl.classList.add("is-size-" + typePrefs.size);
scrollEl.classList.toggle("is-serif", typePrefs.font === "serif");
scrollEl.classList.toggle("is-sepia", typePrefs.paper === "sepia");
paintType();
}
function paintType() {
if (!typeMenu) return;
typeMenu.querySelectorAll("[data-size]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-size") === typePrefs.size);
});
typeMenu.querySelectorAll("[data-font]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-font") === typePrefs.font);
});
typeMenu.querySelectorAll("[data-paper]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-paper") === typePrefs.paper);
});
}
function toggleTypeMenu(force) {
if (!typeMenu || !typeBtn) return;
var openNow = force != null ? force : typeMenu.hidden;
typeMenu.hidden = !openNow;
typeBtn.setAttribute("aria-expanded", openNow ? "true" : "false");
}
// ---- share ----------------------------------------------------------------
var toastEl = null, toastTimer = null;
function toast(msg) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "pete-reader-toast";
overlay.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.classList.add("is-shown");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-shown"); }, 1800);
}
function shareCurrent() {
var it = items[index];
if (!it) return;
var url = safeURL(it.url);
if (!url) { toast("No link to share"); return; }
var data = { title: it.headline || "Pete", text: it.headline || "", url: url };
if (navigator.share) {
navigator.share(data).catch(function () {});
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(
function () { toast("Link copied ✓"); },
function () { toast("Couldn't copy link"); }
);
return;
}
window.open(url, "_blank", "noopener");
}
// ---- read aloud (signed-in only) ------------------------------------------
var speaking = false;
var speakParas = []; // <p> 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 <a>; 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;
}
}
});
})();