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.
53 lines
3.0 KiB
HTML
53 lines
3.0 KiB
HTML
{{define "card"}}{{$ch := channel .Story.Channel}}
|
|
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
|
|
data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}"
|
|
data-id="{{.Story.ID}}"
|
|
data-headline="{{.Story.Headline}}"
|
|
data-lede="{{.Story.Lede}}"
|
|
data-url="{{.Story.ArticleURL}}"
|
|
data-image="{{if .Story.ImageURL}}{{thumb .Story.ImageURL}}{{end}}"
|
|
data-time="{{timeAgo .Story.SeenAt}}"
|
|
data-ch-title="{{$ch.Title}}" data-ch-emoji="{{$ch.Emoji}}" data-ch-theme="{{$ch.Theme}}"
|
|
data-posted="{{if .Story.Posted}}1{{end}}"
|
|
data-paywalled="{{if .Story.Paywalled}}1{{end}}"
|
|
class="group relative block rounded-3xl bg-[color:var(--card)] border-2 {{if .Story.Posted}}border-theme-{{.Theme}} glow-theme-{{.Theme}}{{else}}border-[color:var(--ink)]/10{{end}} shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
|
|
<span role="button" tabindex="0" data-bookmark-btn data-story-id="{{.Story.ID}}"
|
|
aria-label="Bookmark this story" aria-pressed="false"
|
|
style="display:none;position:absolute;top:.6rem;left:.6rem;z-index:6;height:1.9rem;width:1.9rem;align-items:center;justify-content:center;border-radius:9999px;background:rgba(20,14,6,.62);color:#fff;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)">
|
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M6 2h12a1 1 0 0 1 1 1v18l-7-4-7 4V3a1 1 0 0 1 1-1z"></path>
|
|
</svg>
|
|
</span>
|
|
{{if .Story.ImageURL}}
|
|
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
|
<img src="{{thumb .Story.ImageURL}}" alt="" loading="lazy" decoding="async"
|
|
class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
|
|
</div>
|
|
{{end}}
|
|
<div class="p-4 space-y-2">
|
|
<div class="flex items-center gap-2 text-xs flex-wrap">
|
|
{{if .ShowChannel}}{{$ch := channel .Story.Channel}}
|
|
<span class="inline-flex items-center rounded-full bg-theme-{{$ch.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
|
|
<span aria-hidden="true" class="mr-1">{{$ch.Emoji}}</span>{{$ch.Title}}
|
|
</span>
|
|
<span class="text-[color:var(--ink)]/60 font-semibold">{{.Story.Source}}</span>
|
|
{{else}}
|
|
<span class="inline-flex items-center rounded-full bg-theme-{{.Theme}} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
|
|
{{.Story.Source}}
|
|
</span>
|
|
{{end}}
|
|
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
|
|
</div>
|
|
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">
|
|
{{.Story.Headline}}
|
|
</h3>
|
|
{{if .Story.Lede}}
|
|
<p class="text-sm text-[color:var(--ink)]/70 line-clamp-3">{{.Story.Lede}}</p>
|
|
{{end}}
|
|
</div>
|
|
{{if .Story.Paywalled}}
|
|
<div class="paywall-stamp" aria-label="Paywalled article"></div>
|
|
{{end}}
|
|
</a>
|
|
{{end}}
|