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:
25
internal/web/templates/_card.html
Normal file
25
internal/web/templates/_card.html
Normal 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}}
|
||||
37
internal/web/templates/channel.html
Normal file
37
internal/web/templates/channel.html
Normal 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}}
|
||||
44
internal/web/templates/index.html
Normal file
44
internal/web/templates/index.html
Normal 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}}
|
||||
77
internal/web/templates/layout.html
Normal file
77
internal/web/templates/layout.html
Normal 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}}
|
||||
Reference in New Issue
Block a user