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:
@@ -11,6 +11,13 @@
|
||||
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"
|
||||
|
||||
37
internal/web/templates/bookmarks.html
Normal file
37
internal/web/templates/bookmarks.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "title"}}Bookmarks — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--accent)] text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">🔖</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-70">saved for later</p>
|
||||
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">Bookmarks</h1>
|
||||
<p class="mt-2 max-w-xl opacity-80">Stories you've tucked away. Tap the bookmark on any card to add or remove one.</p>
|
||||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">{{.Total}} saved</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Stories}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{{range .Stories}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<nav class="mt-10 flex items-center justify-between">
|
||||
{{if .HasPrev}}
|
||||
<a href="{{.PrevURL}}" class="rounded-full bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
<span class="text-sm text-[color:var(--ink)]/60">page {{.Page}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="{{.NextURL}}" class="rounded-full bg-[color:var(--accent)] text-[#1c1305] px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
</nav>
|
||||
{{else}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
|
||||
Nothing saved yet. Browse the feed and tap the 🔖 on a story to keep it here.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
26
internal/web/templates/for-you.html
Normal file
26
internal/web/templates/for-you.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "title"}}For you — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--accent)] text-[#1c1305] p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">✨</div>
|
||||
<div class="relative">
|
||||
<p class="text-sm uppercase tracking-[0.2em] opacity-70">picked for you</p>
|
||||
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">For you</h1>
|
||||
<p class="mt-2 max-w-xl opacity-80">Fresh stories weighted toward the channels and sources you read and bookmark most. The more you read, the sharper this gets.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Stories}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{{range .Stories}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
|
||||
Nothing to tune yet. Read a few stories or bookmark the ones you like, and Pete will start building a feed around them.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -6,14 +6,34 @@
|
||||
a friendlier news feed.
|
||||
</h1>
|
||||
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70">
|
||||
{{if .PostingEnabled}}
|
||||
Pete reads RSS, sorts stories, and posts them to Matrix.
|
||||
Glowing cards are the ones he's posted.
|
||||
{{else}}
|
||||
Pete reads RSS and sorts stories into channels, fresh from his feeds.
|
||||
{{end}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section data-weather-card class="mb-10 empty:hidden"></section>
|
||||
|
||||
{{if .JustPosted}}
|
||||
{{if .ForYou}}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
<span aria-hidden="true">✨</span> For you
|
||||
</h2>
|
||||
<a href="/for-you" class="text-xs font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">more →</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{{range .ForYou}}
|
||||
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if and .PostingEnabled .JustPosted}}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-end justify-between gap-3 mb-4">
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
@@ -34,7 +54,7 @@
|
||||
<h2 class="font-display text-xl sm:text-2xl font-bold">
|
||||
<span aria-hidden="true">📊</span> Channels
|
||||
</h2>
|
||||
<span class="text-xs text-[color:var(--ink)]/50">last 24 hours</span>
|
||||
<span class="text-xs text-[color:var(--ink)]/50">{{if .PostingEnabled}}last 24 hours{{else}}what Pete's tracking{{end}}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{{range .Stats}}
|
||||
@@ -47,6 +67,7 @@
|
||||
<span class="font-display text-lg font-semibold">{{.Channel.Title}}</span>
|
||||
</div>
|
||||
<dl class="mt-3 space-y-1 text-xs text-[color:var(--ink)]/70">
|
||||
{{if $.PostingEnabled}}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>last post</dt>
|
||||
<dd class="font-semibold text-[color:var(--ink)]">
|
||||
@@ -57,6 +78,7 @@
|
||||
<dt>posted 24h</dt>
|
||||
<dd class="font-semibold text-[color:var(--ink)]">{{.PostsToday}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>stories</dt>
|
||||
<dd class="font-semibold text-[color:var(--ink)]">{{.Total}}</dd>
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
<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 () {
|
||||
@@ -79,6 +90,26 @@
|
||||
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">
|
||||
@@ -112,6 +143,14 @@
|
||||
</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
|
||||
@@ -146,6 +185,20 @@
|
||||
<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">
|
||||
@@ -163,12 +216,14 @@
|
||||
<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>
|
||||
@@ -207,6 +262,7 @@
|
||||
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>
|
||||
@@ -214,5 +270,6 @@
|
||||
<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}}
|
||||
|
||||
80
internal/web/templates/status.html
Normal file
80
internal/web/templates/status.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{{define "title"}}Source health — {{.SiteTitle}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<section class="mt-2 mb-8">
|
||||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-[color:var(--ink)]/50">owner view</p>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-1">Source health</h1>
|
||||
<p class="mt-2 max-w-2xl text-[color:var(--ink)]/70">
|
||||
Per-feed poll status and content stats. Rows that are currently failing float to the top.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/5 px-3 py-1 text-sm font-semibold tabular-nums">
|
||||
{{len .Sources}} sources
|
||||
</span>
|
||||
{{if .DegradedCnt}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-3 py-1 text-sm font-semibold tabular-nums">
|
||||
⚠ {{.DegradedCnt}} degraded
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-3 py-1 text-sm font-semibold">
|
||||
✓ all healthy
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="overflow-x-auto rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete">
|
||||
<table class="w-full min-w-[52rem] text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[color:var(--ink)]/50 uppercase text-xs tracking-wider border-b-2 border-[color:var(--ink)]/10">
|
||||
<th class="px-4 py-3 font-semibold">Source</th>
|
||||
<th class="px-4 py-3 font-semibold">Channel</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Last poll</th>
|
||||
<th class="px-4 py-3 font-semibold">Last success</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Items</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Stories</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Paywall</th>
|
||||
<th class="px-4 py-3 font-semibold">Last story</th>
|
||||
<th class="px-4 py-3 font-semibold">Last posted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Sources}}
|
||||
<tr class="border-b border-[color:var(--ink)]/5 last:border-0 align-top">
|
||||
<td class="px-4 py-3 font-semibold whitespace-nowrap">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{.Channel}}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{{if .NeverRun}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-[color:var(--ink)]/10 px-2.5 py-1 text-xs font-semibold">◌ idle</span>
|
||||
{{else if .Healthy}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-2.5 py-1 text-xs font-semibold">● ok</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 text-red-700 dark:text-red-300 px-2.5 py-1 text-xs font-semibold"
|
||||
title="{{.LastError}}">✕ {{.Failures}} fail{{if ne .Failures 1}}s{{end}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{.LastItemCount}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums" title="{{.Classified}} classified of {{.Total}}">{{.Total}}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums {{if gt .PaywallRate 50}}text-amber-600 dark:text-amber-400 font-semibold{{end}}">
|
||||
{{if .Total}}{{.PaywallRate}}%{{else}}—{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-[color:var(--ink)]/70 whitespace-nowrap">{{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}</td>
|
||||
</tr>
|
||||
{{if .LastError}}{{if not .Healthy}}
|
||||
<tr class="border-b border-[color:var(--ink)]/5">
|
||||
<td colspan="10" class="px-4 pb-3 -mt-1 text-xs text-red-600 dark:text-red-400 font-mono break-all">{{.LastError}}</td>
|
||||
</tr>
|
||||
{{end}}{{end}}
|
||||
{{else}}
|
||||
<tr><td colspan="10" class="px-4 py-8 text-center text-[color:var(--ink)]/50">No sources configured.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user