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:
@@ -330,6 +330,62 @@ html[data-phase="night"] {
|
||||
.pete-reader-hint { display: none; }
|
||||
}
|
||||
|
||||
/* "You might also like" rail, shown under the article in feed mode. Lives
|
||||
inside the reader's scroll area, below the article card. */
|
||||
.pete-reader-related { width: 100%; margin: 0.85rem auto 0; }
|
||||
.pete-reader-related-title {
|
||||
font-family: "Fredoka", "Nunito", system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: #fff;
|
||||
margin: 0 0 0.6rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.pete-reader-related-grid { display: grid; gap: 0.6rem; }
|
||||
.pete-reader-related-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border-radius: 1rem;
|
||||
border: 2px solid rgba(0, 0, 0, 0.06);
|
||||
padding: 0.6rem;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pete-reader-related-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(60, 40, 20, 0.16);
|
||||
}
|
||||
html[data-phase="night"] .pete-reader-related-card { border-color: rgba(255, 255, 255, 0.08); }
|
||||
.pete-reader-related-thumb {
|
||||
width: 4.5rem;
|
||||
height: 3.25rem;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.pete-reader-related-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.pete-reader-related-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.pete-reader-related-source { font-weight: 700; opacity: 0.7; }
|
||||
.pete-reader-related-headline {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
font-size: 0.92rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
internal/web/static/img/apple-touch-icon.png
Normal file
BIN
internal/web/static/img/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
internal/web/static/img/icon-192.png
Normal file
BIN
internal/web/static/img/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
internal/web/static/img/icon-512.png
Normal file
BIN
internal/web/static/img/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
BIN
internal/web/static/img/maskable-512.png
Normal file
BIN
internal/web/static/img/maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
116
internal/web/static/js/pwa.js
Normal file
116
internal/web/static/js/pwa.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// PWA glue: register the service worker, and (for signed-in users on a
|
||||
// push-enabled server) drive the notification opt-in toggle in the settings
|
||||
// panel. Anonymous visitors still get the offline reader — only the push
|
||||
// controls are gated behind sign-in + a configured VAPID key.
|
||||
(function () {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
var CFG = window.PETE_PUSH || null; // { enabled, publicKey } or null
|
||||
var reg = null;
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").then(function (r) {
|
||||
reg = r;
|
||||
if (canPush()) initPushUI();
|
||||
}).catch(function () {});
|
||||
|
||||
function canPush() {
|
||||
return !!(CFG && CFG.enabled && CFG.publicKey && window.PETE_USER &&
|
||||
"PushManager" in window && "Notification" in window);
|
||||
}
|
||||
|
||||
// ---- push subscription ----------------------------------------------------
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
var padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
var raw = atob(base64);
|
||||
var out = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function currentSub() {
|
||||
if (!reg) return Promise.resolve(null);
|
||||
return reg.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
return reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(CFG.publicKey),
|
||||
}).then(function (sub) {
|
||||
return fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
credentials: "same-origin",
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error("subscribe rejected");
|
||||
return sub;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
return currentSub().then(function (sub) {
|
||||
if (!sub) return;
|
||||
var endpoint = sub.endpoint;
|
||||
return sub.unsubscribe().then(function () {
|
||||
return fetch("/api/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: endpoint }),
|
||||
credentials: "same-origin",
|
||||
}).catch(function () {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- settings-panel toggle ------------------------------------------------
|
||||
function initPushUI() {
|
||||
var slot = document.querySelector("[data-push-section]");
|
||||
if (!slot) return;
|
||||
slot.hidden = false;
|
||||
|
||||
var btn = slot.querySelector("[data-push-toggle]");
|
||||
var note = slot.querySelector("[data-push-note]");
|
||||
if (!btn) return;
|
||||
var busy = false;
|
||||
|
||||
function paint(on, text) {
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
btn.textContent = on ? "Notifications on" : "Turn on notifications";
|
||||
if (note && text != null) note.textContent = text;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (Notification.permission === "denied") {
|
||||
btn.disabled = true;
|
||||
paint(false, "Notifications are blocked in your browser settings.");
|
||||
return;
|
||||
}
|
||||
currentSub().then(function (sub) {
|
||||
paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land.");
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
btn.disabled = true;
|
||||
currentSub().then(function (sub) {
|
||||
if (sub) return unsubscribe().then(function () { paint(false, "Notifications off."); });
|
||||
return Notification.requestPermission().then(function (perm) {
|
||||
if (perm !== "granted") { paint(false, "Permission denied."); return; }
|
||||
return subscribe().then(function () { paint(true, "You're all set — new stories will nudge you."); });
|
||||
});
|
||||
}).catch(function () {
|
||||
paint(false, "Something went wrong. Try again.");
|
||||
}).finally(function () {
|
||||
busy = false;
|
||||
btn.disabled = Notification.permission === "denied";
|
||||
});
|
||||
});
|
||||
|
||||
refresh();
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
22
internal/web/static/manifest.webmanifest
Normal file
22
internal/web/static/manifest.webmanifest
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Pete — friendly news",
|
||||
"short_name": "Pete",
|
||||
"description": "A calm, read-one-at-a-time news reader. Bookmarks, a personalized feed, and offline reading.",
|
||||
"id": "/",
|
||||
"start_url": "/?source=pwa",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#fbf3e3",
|
||||
"theme_color": "#fbf3e3",
|
||||
"categories": ["news", "productivity"],
|
||||
"icons": [
|
||||
{ "src": "/static/img/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/img/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/img/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "For you", "short_name": "For you", "url": "/for-you", "description": "Your personalized feed" },
|
||||
{ "name": "Bookmarks", "short_name": "Saved", "url": "/bookmarks", "description": "Stories you saved" }
|
||||
]
|
||||
}
|
||||
198
internal/web/static/sw.js
Normal file
198
internal/web/static/sw.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// Pete's service worker: an installable-PWA shell, an offline reader, and the
|
||||
// Web Push receiver. Served from the root (/sw.js) so its scope is the whole
|
||||
// origin — it can intercept navigations and /api/article the same as any page.
|
||||
//
|
||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||
// drops every cache that doesn't match the current version.
|
||||
var CACHE_VERSION = "v1";
|
||||
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||
|
||||
// App shell: the static assets every page needs. Versioned by URL content only
|
||||
// loosely, so we lean on network-first for HTML and cache-first for these.
|
||||
var SHELL_ASSETS = [
|
||||
"/static/css/output.css",
|
||||
"/static/js/prefs.js",
|
||||
"/static/js/weather.js",
|
||||
"/static/js/weather-forecast.js",
|
||||
"/static/js/search.js",
|
||||
"/static/js/settings.js",
|
||||
"/static/js/reader.js",
|
||||
"/static/js/pwa.js",
|
||||
"/static/img/pete.avif",
|
||||
"/static/img/icon-192.png",
|
||||
"/static/img/icon-512.png",
|
||||
];
|
||||
|
||||
// How many visited-article responses to keep for offline reading before the
|
||||
// oldest are evicted. Reader articles are small JSON blobs.
|
||||
var RUNTIME_MAX = 60;
|
||||
|
||||
self.addEventListener("install", function (event) {
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE).then(function (cache) {
|
||||
// addAll is atomic-ish: if one asset 404s the whole install fails, so keep
|
||||
// this list to assets we know are served. Individual failures are tolerated
|
||||
// by falling back to per-asset puts.
|
||||
return Promise.all(
|
||||
SHELL_ASSETS.map(function (url) {
|
||||
return cache.add(url).catch(function () {});
|
||||
})
|
||||
);
|
||||
}).then(function () {
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(
|
||||
keys.map(function (k) {
|
||||
if (k !== SHELL_CACHE && k !== RUNTIME_CACHE) return caches.delete(k);
|
||||
})
|
||||
);
|
||||
}).then(function () {
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// trimCache evicts the oldest entries once a runtime cache passes its cap.
|
||||
function trimCache(cacheName, max) {
|
||||
caches.open(cacheName).then(function (cache) {
|
||||
cache.keys().then(function (keys) {
|
||||
if (keys.length <= max) return;
|
||||
for (var i = 0; i < keys.length - max; i++) cache.delete(keys[i]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// A minimal offline page for navigations we have nothing cached for.
|
||||
function offlineFallback() {
|
||||
return new Response(
|
||||
"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>" +
|
||||
"<title>Offline · Pete</title>" +
|
||||
"<div style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;text-align:center;color:#3a2f1a\">" +
|
||||
"<div style=font-size:3rem>🦆</div>" +
|
||||
"<h1 style=font-size:1.4rem>You're offline</h1>" +
|
||||
"<p style=opacity:.7>Pete can't reach the news right now. Articles you've already opened are still readable — head back and try one of those.</p>" +
|
||||
"</div>",
|
||||
{ headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", function (event) {
|
||||
var req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
|
||||
var url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // never touch cross-origin
|
||||
|
||||
// Visited articles: network-first so text stays fresh, but cache every success
|
||||
// so the reader still works offline for stories the user has already opened.
|
||||
if (url.pathname === "/api/article") {
|
||||
event.respondWith(
|
||||
fetch(req).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
var copy = res.clone();
|
||||
caches.open(RUNTIME_CACHE).then(function (cache) {
|
||||
cache.put(req, copy);
|
||||
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}).catch(function () {
|
||||
return caches.match(req).then(function (hit) {
|
||||
return hit || new Response(JSON.stringify({ error: "offline" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first (they're versioned by deploy), fill the cache on
|
||||
// first miss so a later offline visit has them.
|
||||
if (url.pathname.indexOf("/static/") === 0) {
|
||||
event.respondWith(
|
||||
caches.match(req).then(function (hit) {
|
||||
return hit || fetch(req).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
var copy = res.clone();
|
||||
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||
}
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Page navigations: network-first, fall back to a cached copy of the same page,
|
||||
// then to the offline card. Successful HTML is cached so revisits work offline.
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(req).then(function (res) {
|
||||
if (res && res.ok) {
|
||||
var copy = res.clone();
|
||||
caches.open(RUNTIME_CACHE).then(function (cache) {
|
||||
cache.put(req, copy);
|
||||
trimCache(RUNTIME_CACHE, RUNTIME_MAX);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}).catch(function () {
|
||||
return caches.match(req).then(function (hit) {
|
||||
return hit || offlineFallback();
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else (other /api/* calls): straight to the network. These are
|
||||
// personalized/stateful and must not be served stale.
|
||||
});
|
||||
|
||||
// ---- Web Push -------------------------------------------------------------
|
||||
// The server sends a JSON payload {title, body, url, tag}. Missing fields fall
|
||||
// back to sensible defaults so a malformed push still shows something useful.
|
||||
self.addEventListener("push", function (event) {
|
||||
var data = {};
|
||||
if (event.data) {
|
||||
try { data = event.data.json(); } catch (e) { data = { body: event.data.text() }; }
|
||||
}
|
||||
var title = data.title || "Pete";
|
||||
var options = {
|
||||
body: data.body || "New stories are waiting.",
|
||||
icon: "/static/img/icon-192.png",
|
||||
badge: "/static/img/icon-192.png",
|
||||
tag: data.tag || "pete-digest",
|
||||
renotify: true,
|
||||
data: { url: data.url || "/" },
|
||||
};
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", function (event) {
|
||||
event.notification.close();
|
||||
var target = (event.notification.data && event.notification.data.url) || "/";
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clients) {
|
||||
for (var i = 0; i < clients.length; i++) {
|
||||
var c = clients[i];
|
||||
// Focus an existing Pete tab and route it to the target if we can.
|
||||
if ("focus" in c) {
|
||||
c.focus();
|
||||
if ("navigate" in c && target !== "/") { try { c.navigate(target); } catch (e) {} }
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(target);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user