Add personalization, outbound feeds, and PWA/push to the web UI
A multi-session build turning Pete's read-only web UI into something people return to. Five phases, signed-in features keyed off the OIDC subject; anonymous visitors keep the reverse-chron feed and localStorage-only state. Phase 1 — per-user read + bookmark state: user_story_state table + storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a /bookmarks page; reader.js syncs state server-side for signed-in users. Also hides the Matrix-posting UI when posting.enabled=false (web-only mode). Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0 (content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and per-channel variants; <link rel=alternate> discovery tags. Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail + /for-you page; public /api/related feeds the reader's "You might also like". Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go (RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller; admin-gated /status page behind web.admin_subs. Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and sw.js (app-shell precache, /api/article runtime cache for offline reading, offline fallback, push/notificationclick handlers); PNG icons from pete.avif; pwa.js registers the SW and drives a notifications toggle. Web Push adds webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a digest sender that pings each subscriber "N new stories" past their watermark, honoring disabled-sources and pruning gone endpoints. Tests added beside each new storage/web file; go test ./... and go vet clean.
This commit is contained in:
@@ -20,6 +20,15 @@
|
||||
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;
|
||||
@@ -45,6 +54,102 @@
|
||||
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); });
|
||||
// Push up reads made on this device before the account knew them.
|
||||
ids.forEach(function (id) {
|
||||
if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
|
||||
});
|
||||
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).
|
||||
@@ -173,6 +278,61 @@
|
||||
.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="" 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) {
|
||||
index = i;
|
||||
@@ -186,11 +346,17 @@
|
||||
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;
|
||||
@@ -226,6 +392,7 @@
|
||||
function closeReader() {
|
||||
open = false;
|
||||
if (inflight) { inflight.abort(); inflight = null; }
|
||||
clearRelated();
|
||||
overlay.classList.add("hidden");
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
}
|
||||
@@ -245,6 +412,32 @@
|
||||
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 <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) {
|
||||
@@ -274,6 +467,12 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user