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:
170
internal/web/handlers.go
Normal file
170
internal/web/handlers.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
const pageSize = 24
|
||||
|
||||
// StoryView is the trimmed-down record used in templates.
|
||||
type StoryView struct {
|
||||
Headline string
|
||||
Lede string
|
||||
ImageURL string
|
||||
ArticleURL string
|
||||
Source string
|
||||
SeenAt time.Time
|
||||
}
|
||||
|
||||
func toView(s storage.Story) StoryView {
|
||||
return StoryView{
|
||||
Headline: s.Headline,
|
||||
Lede: s.Lede,
|
||||
ImageURL: s.ImageURL,
|
||||
ArticleURL: s.ArticleURL,
|
||||
Source: s.Source,
|
||||
SeenAt: time.Unix(s.SeenAt, 0),
|
||||
}
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
SiteTitle string
|
||||
Channels []Channel
|
||||
Active string // slug of active channel, "" for landing
|
||||
}
|
||||
|
||||
type channelPage struct {
|
||||
pageData
|
||||
Channel Channel
|
||||
Stories []StoryView
|
||||
Page int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Total int
|
||||
}
|
||||
|
||||
type indexPage struct {
|
||||
pageData
|
||||
Sections []indexSection
|
||||
}
|
||||
|
||||
type indexSection struct {
|
||||
Channel Channel
|
||||
Stories []StoryView // top 3 per channel
|
||||
}
|
||||
|
||||
func (s *Server) base() pageData {
|
||||
return pageData{
|
||||
SiteTitle: s.cfg.SiteTitle,
|
||||
Channels: channels,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
||||
sections := make([]indexSection, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
rows, err := storage.ListClassifiedByChannel(ch.Slug, 4, 0)
|
||||
if err != nil {
|
||||
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
views := make([]StoryView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
views = append(views, toView(r))
|
||||
}
|
||||
sections = append(sections, indexSection{Channel: ch, Stories: views})
|
||||
}
|
||||
data := indexPage{pageData: s.base(), Sections: sections}
|
||||
s.render(w, "index", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
|
||||
page := 1
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
if n, err := strconv.Atoi(p); err == nil && n > 0 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
rows, err := storage.ListClassifiedByChannel(ch.Slug, pageSize+1, offset)
|
||||
if err != nil {
|
||||
slog.Error("web: list failed", "channel", ch.Slug, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasNext := len(rows) > pageSize
|
||||
if hasNext {
|
||||
rows = rows[:pageSize]
|
||||
}
|
||||
views := make([]StoryView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
views = append(views, toView(r))
|
||||
}
|
||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||
|
||||
base := s.base()
|
||||
base.Active = ch.Slug
|
||||
data := channelPage{
|
||||
pageData: base,
|
||||
Channel: ch,
|
||||
Stories: views,
|
||||
Page: page,
|
||||
HasPrev: page > 1,
|
||||
HasNext: hasNext,
|
||||
PrevURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page-1),
|
||||
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
|
||||
Total: total,
|
||||
}
|
||||
s.render(w, "channel", data)
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, page string, data any) {
|
||||
tpl, ok := s.tpls[page]
|
||||
if !ok {
|
||||
http.Error(w, "unknown page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
slog.Error("web: template render failed", "page", page, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"dict": func(pairs ...any) map[string]any {
|
||||
m := make(map[string]any, len(pairs)/2)
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
key, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[key] = pairs[i+1]
|
||||
}
|
||||
return m
|
||||
},
|
||||
"timeAgo": func(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
case d < 7*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
||||
default:
|
||||
return t.Format("Jan 2")
|
||||
}
|
||||
},
|
||||
}
|
||||
104
internal/web/server.go
Normal file
104
internal/web/server.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Package web serves Pete's read-only news interface.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/config"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
// Channel describes one section of the site.
|
||||
type Channel struct {
|
||||
Slug string // "gaming"
|
||||
Title string // "Gaming"
|
||||
Theme string // tailwind theme key: "gaming" | "tech" | "politics"
|
||||
Emoji string
|
||||
Blurb string
|
||||
}
|
||||
|
||||
// channels in display order. Add a new entry here to add a section.
|
||||
var channels = []Channel{
|
||||
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
||||
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
|
||||
{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
|
||||
}
|
||||
|
||||
// Server is the HTTP server for Pete's web UI.
|
||||
type Server struct {
|
||||
cfg config.WebConfig
|
||||
srv *http.Server
|
||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||
}
|
||||
|
||||
// New builds the server. Templates are parsed once at startup. Each page
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
func New(cfg config.WebConfig) (*Server, error) {
|
||||
pages := []string{"index", "channel"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
"templates/layout.html",
|
||||
"templates/_card.html",
|
||||
"templates/"+p+".html",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tpls[p] = t
|
||||
}
|
||||
s := &Server{cfg: cfg, tpls: tpls}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||
for _, ch := range channels {
|
||||
ch := ch
|
||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleChannel(w, r, ch)
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start runs the HTTP server and blocks until ctx is canceled.
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
slog.Info("web server listening", "addr", s.cfg.ListenAddr)
|
||||
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("web server crashed", "err", err)
|
||||
}
|
||||
}
|
||||
80
internal/web/static/css/input.css
Normal file
80
internal/web/static/css/input.css
Normal file
@@ -0,0 +1,80 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
Day/night palette. JS sets data-phase={dawn|day|dusk|night} on <html>.
|
||||
Each phase drives a small set of CSS vars; the page transitions smoothly
|
||||
thanks to the `transition-colors duration-1000` on <body>.
|
||||
---------------------------------------------------------------------------- */
|
||||
|
||||
:root,
|
||||
html[data-phase="day"] {
|
||||
--bg: #fff7e4; /* warm cream */
|
||||
--bg-grad: #ffeec2; /* soft sun wash */
|
||||
--card: #ffffff;
|
||||
--ink: #3a2e1f;
|
||||
--accent: #f2a541; /* sunshine yellow */
|
||||
}
|
||||
|
||||
html[data-phase="dawn"] {
|
||||
--bg: #ffe7d6;
|
||||
--bg-grad: #ffc9c9;
|
||||
--card: #fff4ea;
|
||||
--ink: #4a2e2a;
|
||||
--accent: #ff8a65;
|
||||
}
|
||||
|
||||
html[data-phase="dusk"] {
|
||||
--bg: #ffd6a8;
|
||||
--bg-grad: #f7a07e;
|
||||
--card: #fff1de;
|
||||
--ink: #3d2417;
|
||||
--accent: #e6553a;
|
||||
}
|
||||
|
||||
html[data-phase="night"] {
|
||||
--bg: #1a1f3a;
|
||||
--bg-grad: #2a3358;
|
||||
--card: #2d365a;
|
||||
--ink: #f1ecd8; /* moonlight */
|
||||
--accent: #f9d976; /* lantern */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body { font-family: "Nunito", system-ui, sans-serif; }
|
||||
html { scroll-behavior: smooth; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.font-display { font-family: "Fredoka", "Nunito", system-ui, sans-serif; letter-spacing: -0.01em; }
|
||||
|
||||
/* Soft signage shadow — Animal Crossing wooden plank vibe. */
|
||||
.shadow-pete { box-shadow: 0 4px 0 rgba(60,40,20,0.10), 0 8px 24px rgba(60,40,20,0.08); }
|
||||
.shadow-pete-lg { box-shadow: 0 6px 0 rgba(60,40,20,0.12), 0 16px 32px rgba(60,40,20,0.12); }
|
||||
|
||||
/* Per-channel theme colors. Kept as plain utilities so Tailwind doesn't
|
||||
need to know to safelist them. */
|
||||
.bg-theme-gaming { background-color: #4caf7d; }
|
||||
.bg-theme-tech { background-color: #5aa9e6; }
|
||||
.bg-theme-politics { background-color: #e07a5f; }
|
||||
|
||||
.text-theme-gaming { color: #2d8a5a; }
|
||||
.text-theme-tech { color: #2f7fb8; }
|
||||
.text-theme-politics { color: #b8523a; }
|
||||
|
||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
||||
|
||||
.border-theme-gaming { border-color: #4caf7d; }
|
||||
.border-theme-tech { border-color: #5aa9e6; }
|
||||
.border-theme-politics { border-color: #e07a5f; }
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
1
internal/web/static/css/output.css
Normal file
1
internal/web/static/css/output.css
Normal file
File diff suppressed because one or more lines are too long
4
internal/web/static/img/leaf.svg
Normal file
4
internal/web/static/img/leaf.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path d="M48 8C28 8 12 22 12 42c0 6 2 12 6 16 4-12 14-22 26-26-10 8-16 18-18 30 16 0 30-14 30-34 0-8-4-16-8-20z" fill="#6db73c" stroke="#3f6e1f" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M22 52c8-12 18-20 30-26" stroke="#3f6e1f" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
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