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.
This commit is contained in:
@@ -21,6 +21,10 @@
|
||||
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
|
||||
|
||||
@@ -30,6 +34,11 @@
|
||||
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;
|
||||
@@ -238,7 +247,7 @@
|
||||
|
||||
function renderBody(it, bodyHTML) {
|
||||
var hero = it.image
|
||||
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
|
||||
? '<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">' +
|
||||
@@ -312,7 +321,7 @@
|
||||
escapeHTML(it.channel_title) + "</span>"
|
||||
: "";
|
||||
var thumb = it.thumb_url
|
||||
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="" loading="lazy" decoding="async">'
|
||||
? '<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 +
|
||||
@@ -345,6 +354,7 @@
|
||||
|
||||
// ---- 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];
|
||||
@@ -366,6 +376,7 @@
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
stopSpeak();
|
||||
clearRelated();
|
||||
progressEl.textContent = items.length + " / " + items.length;
|
||||
linkEl.style.display = "none";
|
||||
@@ -390,6 +401,143 @@
|
||||
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();
|
||||
@@ -397,10 +545,13 @@
|
||||
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");
|
||||
@@ -427,6 +578,37 @@
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -465,7 +647,14 @@
|
||||
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 "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);
|
||||
|
||||
Reference in New Issue
Block a user