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.
276 lines
18 KiB
HTML
276 lines
18 KiB
HTML
{{define "layout"}}<!doctype html>
|
|
<html lang="en" data-phase="{{if .Phase}}{{.Phase}}{{else}}day{{end}}"{{if .Phase}} data-phase-lock{{end}} data-season="{{.Weather.Season}}" data-weather="{{.Weather.Variant}}" data-intensity="{{.Weather.Intensity}}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="/static/css/output.css">
|
|
<link rel="icon" href="/static/img/pete.avif" type="image/avif">
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<meta name="theme-color" content="#fbf3e3">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="Pete">
|
|
<link rel="apple-touch-icon" href="/static/img/apple-touch-icon.png">
|
|
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — all stories" href="/feed.xml">
|
|
<link rel="alternate" type="application/feed+json" title="{{.SiteTitle}} — all stories" href="/feed.json">
|
|
{{if .Active}}{{if ne .Active "bookmarks"}}
|
|
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — {{.Active}}" href="/{{.Active}}/feed.xml">
|
|
{{end}}{{end}}
|
|
<script>
|
|
// Pick a palette phase from the browser clock and update it each minute.
|
|
(function () {
|
|
function phase(h) {
|
|
if (h >= 5 && h < 8) return "dawn";
|
|
if (h >= 8 && h < 17) return "day";
|
|
if (h >= 17 && h < 20) return "dusk";
|
|
return "night";
|
|
}
|
|
function apply() {
|
|
if (document.documentElement.hasAttribute("data-phase-lock")) return;
|
|
document.documentElement.dataset.phase = phase(new Date().getHours());
|
|
}
|
|
apply();
|
|
setInterval(apply, 60000);
|
|
})();
|
|
</script>
|
|
</head>
|
|
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)] transition-colors duration-1000">
|
|
<div class="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--bg-grad)] transition-colors duration-1000"></div>
|
|
<canvas id="pete-weather" class="pointer-events-none fixed inset-0 -z-[5] h-full w-full" aria-hidden="true"></canvas>
|
|
|
|
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
|
|
<div class="flex flex-wrap items-center justify-between gap-3 sm:gap-4">
|
|
<a href="/" class="flex items-center gap-3 group min-w-0">
|
|
<img src="/static/img/pete.avif" alt="Pete" width="48" height="48"
|
|
class="h-12 w-12 shrink-0 rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 object-cover group-hover:-rotate-6 transition-transform">
|
|
<span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span>
|
|
<span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span>
|
|
</a>
|
|
<button type="button" data-settings-trigger
|
|
title="Feed settings"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<circle cx="12" cy="12" r="3"></circle>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
</svg>
|
|
<span class="sr-only">Feed settings</span>
|
|
</button>
|
|
<button type="button" data-reader-open
|
|
title="Feed mode — read one story at a time (press f)"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<path d="M2 4h6a3 3 0 0 1 3 3v13a2.5 2.5 0 0 0-2.5-2.5H2z"></path>
|
|
<path d="M22 4h-6a3 3 0 0 0-3 3v13a2.5 2.5 0 0 1 2.5-2.5H22z"></path>
|
|
</svg>
|
|
<span class="sr-only">Feed mode</span>
|
|
</button>
|
|
{{if .Weather.Variant}}
|
|
<button type="button" data-weather-toggle aria-pressed="true"
|
|
title="Toggle weather animation"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 transition-colors"
|
|
data-weather-star fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
|
<polygon points="12,2.7 14.85,9.27 21.95,10.05 16.6,14.83 18.18,21.8 12,18.13 5.82,21.8 7.4,14.83 2.05,10.05 9.15,9.27"/>
|
|
</svg>
|
|
<span class="sr-only">Toggle weather animation</span>
|
|
</button>
|
|
{{end}}
|
|
<button type="button" data-weather-loc
|
|
title="Set your location"
|
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<span data-weather-chip class="tabular-nums">📍 Weather</span>
|
|
</button>
|
|
<span data-aqi-chip
|
|
class="hidden shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 tabular-nums"></span>
|
|
{{if .AuthEnabled}}
|
|
{{if .User}}
|
|
<a href="/bookmarks" data-bookmarks-link
|
|
title="Your bookmarks"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "bookmarks"}} bg-[color:var(--accent)]/20{{end}}">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<path d="M6 2h12a1 1 0 0 1 1 1v18l-7-4-7 4V3a1 1 0 0 1 1-1z"></path>
|
|
</svg>
|
|
<span class="sr-only">Bookmarks</span>
|
|
</a>
|
|
{{if .IsAdmin}}
|
|
<a href="/status" data-status-link
|
|
title="Source health"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "status"}} bg-[color:var(--accent)]/20{{end}}">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<path d="M3 12h4l2 6 4-14 2 8h6"></path>
|
|
</svg>
|
|
<span class="sr-only">Source health</span>
|
|
</a>
|
|
{{end}}
|
|
<a href="/auth/logout" data-account
|
|
title="Signed in as {{.User.Display}}{{if .User.Email}} · {{.User.Email}}{{end}} — sign out"
|
|
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-white text-xs font-bold">{{.User.Initial}}</span>
|
|
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
|
|
</a>
|
|
{{else}}
|
|
<a href="/auth/login?next={{.Path}}" data-signin
|
|
title="Sign in to sync your preferences"
|
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
|
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
|
<polyline points="10 17 15 12 10 7"></polyline>
|
|
<line x1="15" y1="12" x2="3" y2="12"></line>
|
|
</svg>
|
|
<span class="hidden sm:inline">Sign in</span>
|
|
</a>
|
|
{{end}}
|
|
{{end}}
|
|
<div class="flex items-center gap-2 min-w-0 max-w-full">
|
|
<button type="button" data-search-trigger title="Search (⌘K)"
|
|
class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
|
|
<circle cx="11" cy="11" r="7"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<span class="hidden sm:inline text-[color:var(--ink)]/60 font-semibold">Search</span>
|
|
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
|
|
</button>
|
|
<nav class="pete-channel-nav flex min-w-0 items-center gap-1 sm:gap-2 overflow-x-auto rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
|
|
{{$active := .Active}}
|
|
{{if .User}}
|
|
<a href="/for-you"
|
|
title="Your personalized feed"
|
|
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
|
|
{{if eq $active "for-you"}}bg-[color:var(--accent)] text-[#1c1305] shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
|
<span aria-hidden="true" class="mr-1">✨</span><span class="hidden sm:inline">For you</span>
|
|
</a>
|
|
{{end}}
|
|
{{range .Channels}}
|
|
<a href="/{{.Slug}}"
|
|
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
|
|
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
|
<span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span>
|
|
</a>
|
|
{{end}}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="pete-search" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
|
|
<div class="w-full max-w-2xl rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
|
|
<div class="flex items-center gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
|
|
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
|
|
<input type="search" data-search-input autocomplete="off" spellcheck="false"
|
|
placeholder="Search headlines…"
|
|
class="flex-1 bg-transparent text-lg outline-none placeholder:text-[color:var(--ink)]/40">
|
|
</div>
|
|
<div data-search-list class="max-h-[60vh] overflow-y-auto p-3 space-y-1"></div>
|
|
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs text-[color:var(--ink)]/50">
|
|
<span><kbd class="font-mono">↑↓</kbd> navigate · <kbd class="font-mono">⏎</kbd> open · <kbd class="font-mono">esc</kbd> close</span>
|
|
<span data-search-meta></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="pete-settings" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
|
|
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
|
|
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
|
|
<h2 class="font-display text-lg font-bold">Feed settings</h2>
|
|
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
|
|
</div>
|
|
{{if .PushEnabled}}{{if .User}}
|
|
<div data-push-section hidden class="px-5 pt-4 pb-1">
|
|
<div class="flex items-center justify-between gap-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-bold">🔔 Notifications</div>
|
|
<div data-push-note class="text-xs text-[color:var(--ink)]/60">Get a nudge when new stories land.</div>
|
|
</div>
|
|
<button type="button" data-push-toggle aria-pressed="false"
|
|
class="shrink-0 rounded-full bg-[color:var(--accent)] px-3 py-2 text-sm font-semibold text-[#1c1305] shadow-sm hover:brightness-105 transition disabled:opacity-50 disabled:cursor-not-allowed">
|
|
Turn on notifications
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{{end}}{{end}}
|
|
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
|
|
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
|
|
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
|
|
<button type="button" data-settings-reset class="text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] font-semibold">Enable all</button>
|
|
<span data-settings-meta class="text-[color:var(--ink)]/50"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="pete-reader" class="hidden fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Feed mode reader">
|
|
<div class="pete-reader-backdrop" data-reader-backdrop></div>
|
|
<div class="pete-reader-shell">
|
|
<div class="pete-reader-bar">
|
|
<span class="pete-reader-progress tabular-nums" data-reader-progress>—</span>
|
|
<div class="pete-reader-nav">
|
|
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article">←</button>
|
|
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article">→</button>
|
|
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
|
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open ↗</a>
|
|
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
|
</div>
|
|
</div>
|
|
<div class="pete-reader-scroll" data-reader-scroll>
|
|
<article class="pete-reader-article" data-reader-article></article>
|
|
<div class="pete-reader-related" data-reader-related hidden></div>
|
|
</div>
|
|
<div class="pete-reader-hint">
|
|
<span><kbd>←</kbd> <kbd>→</kbd> navigate · <kbd>o</kbd> open · <kbd>u</kbd> mark unread · <kbd>esc</kbd> close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main class="mx-auto max-w-6xl px-4 pb-24">
|
|
{{block "main" .}}{{end}}
|
|
</main>
|
|
|
|
<footer class="mx-auto max-w-6xl px-4 pb-10 text-center text-sm text-[color:var(--ink)]/50">
|
|
<img src="/static/img/pete.avif" alt="" width="20" height="20"
|
|
class="inline-block h-5 w-5 rounded-full object-cover align-[-4px] mr-1">
|
|
served by <span class="font-semibold">Pete</span> · also a Matrix bot · <span data-clock>—</span>
|
|
</footer>
|
|
|
|
<script>
|
|
// Tiny clock + phase label updater for the header pill / footer.
|
|
(function () {
|
|
function tick() {
|
|
var now = new Date();
|
|
var hh = String(now.getHours()).padStart(2, "0");
|
|
var mm = String(now.getMinutes()).padStart(2, "0");
|
|
var el = document.querySelector("[data-clock]");
|
|
if (el) el.textContent = hh + ":" + mm;
|
|
var label = document.querySelector("[data-phase-label]");
|
|
if (label) label.textContent = document.documentElement.dataset.phase;
|
|
}
|
|
tick();
|
|
setInterval(tick, 30000);
|
|
})();
|
|
</script>
|
|
|
|
<script>
|
|
window.PETE_SOURCES = {{.AllSources}};
|
|
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
|
window.PETE_PREFS = {{.UserPrefs}};
|
|
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
|
</script>
|
|
<script src="/static/js/prefs.js" defer></script>
|
|
<script src="/static/js/weather.js" defer></script>
|
|
<script src="/static/js/weather-forecast.js" defer></script>
|
|
<script src="/static/js/search.js" defer></script>
|
|
<script src="/static/js/settings.js" defer></script>
|
|
<script src="/static/js/reader.js" defer></script>
|
|
<script src="/static/js/pwa.js" defer></script>
|
|
</body>
|
|
</html>{{end}}
|