Add read-only web UI

Serves Pete's classified-story archive over HTTP alongside the Matrix
bot. Three sections (gaming/tech/politics), Animal-Crossing-vibe Tailwind
templates, day/night palette driven by the visitor's browser clock.
Web port configurable via web.listen_addr and ${PETE_WEB_PORT} in
docker-compose. Tailwind built in a node stage in the Dockerfile so
deployments don't need node at runtime.
This commit is contained in:
prosolis
2026-05-24 21:51:49 -07:00
parent 87906719fa
commit fbd4b84eaf
20 changed files with 1695 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
{{define "card"}}
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
class="group block rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden hover:-translate-y-1 hover:shadow-pete-lg transition">
{{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
<img src="{{.Story.ImageURL}}" alt="" loading="lazy"
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">
<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>
<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>
</a>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "title"}}{{.Channel.Title}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<section class="mt-2 mb-8">
<div class="rounded-3xl bg-theme-{{.Channel.Theme}} text-white 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">{{.Channel.Emoji}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">section</p>
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">{{.Channel.Title}}</h1>
<p class="mt-2 max-w-xl text-white/85">{{.Channel.Blurb}}</p>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">{{.Total}} stories archived</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.Theme}}
{{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-theme-{{.Channel.Theme}} text-white 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">
Pete hasn't catalogued anything for {{.Channel.Title}} yet.
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,44 @@
{{define "title"}}{{.SiteTitle}} — your news feed{{end}}
{{define "main"}}
<section class="mt-2 mb-10 sm:mb-14 text-center">
<h1 class="font-display text-4xl sm:text-6xl font-bold leading-tight">
a friendlier news feed.
</h1>
<p class="mx-auto mt-3 max-w-xl text-base sm:text-lg text-[color:var(--ink)]/70">
Pete reads RSS, sorts stories, and posts them to Matrix.
Here's what he's been reading lately.
</p>
</section>
{{range .Sections}}
<section class="mb-12">
<div class="flex items-end justify-between gap-3 mb-4">
<div>
<h2 class="font-display text-2xl sm:text-3xl font-bold">
<span aria-hidden="true">{{.Channel.Emoji}}</span>
<a href="/{{.Channel.Slug}}" class="hover:underline underline-offset-4 decoration-theme-{{.Channel.Theme}} decoration-4">
{{.Channel.Title}}
</a>
</h2>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">{{.Channel.Blurb}}</p>
</div>
<a href="/{{.Channel.Slug}}" class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold rounded-full bg-theme-{{.Channel.Theme}} text-white px-4 py-2 shadow-pete hover:-translate-y-0.5 transition">
more →
</a>
</div>
{{if .Stories}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{range .Stories}}
{{template "card" dict "Story" . "Theme" $.Channel.Theme}}
{{end}}
</div>
{{else}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-8 text-center text-[color:var(--ink)]/60">
Nothing here yet — Pete's still listening.
</div>
{{end}}
</section>
{{end}}
{{end}}

View File

@@ -0,0 +1,77 @@
{{define "layout"}}<!doctype html>
<html lang="en" data-phase="day">
<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/leaf.svg" type="image/svg+xml">
<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() {
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>
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
<div class="flex items-center justify-between gap-4">
<a href="/" class="flex items-center gap-3 group">
<span class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 text-2xl group-hover:-rotate-6 transition-transform">🌿</span>
<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>
<nav class="flex items-center gap-1 sm:gap-2 rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
{{$active := .Active}}
{{range .Channels}}
<a href="/{{.Slug}}"
class="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>
</header>
<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">
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>
</body>
</html>{{end}}