From bddd15f7d11e4c55772739494161422e03d8c5dd Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 24 May 2026 23:07:17 -0700 Subject: [PATCH] Web UI: music channel, posted-to-Matrix glow, redesigned index, AVIF thumbnails - Add music as a fourth channel (nav + theme color) - Glowing themed border on cards that have been posted to Matrix - Replace per-channel index sections with: "Pete just posted" strip, channel dashboard (last post, 24h count, totals), unified latest feed - /img proxy: SSRF-guarded thumbnail re-encoder that resizes to 800px and runs avifenc -q 45, cached under data/img-cache --- go.mod | 1 + go.sum | 2 + internal/storage/queries.go | 78 ++++++++++- internal/storage/types.go | 1 + internal/web/handlers.go | 89 ++++++++++--- internal/web/server.go | 2 + internal/web/static/css/input.css | 15 +++ internal/web/static/css/output.css | 2 +- internal/web/templates/_card.html | 13 +- internal/web/templates/index.html | 83 +++++++++--- internal/web/thumbs.go | 199 +++++++++++++++++++++++++++++ 11 files changed, 438 insertions(+), 47 deletions(-) create mode 100644 internal/web/thumbs.go diff --git a/go.mod b/go.mod index 3ce0076..f308086 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( go.mau.fi/util v0.9.9 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/image v0.41.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/go.sum b/go.sum index c68a026..9209f41 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= diff --git a/internal/storage/queries.go b/internal/storage/queries.go index 8c7254e..70e05a7 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -220,22 +220,76 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) { // channels (_discarded, _duplicate) are excluded. func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) { rows, err := Get().Query( - `SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at - FROM stories - WHERE classified = 1 - AND channel = ? - ORDER BY seen_at DESC + `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at, + EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted + FROM stories s + WHERE s.classified = 1 + AND s.channel = ? + ORDER BY s.seen_at DESC LIMIT ? OFFSET ?`, channel, limit, offset) if err != nil { return nil, err } defer rows.Close() var out []Story + for rows.Next() { + var s Story + if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt, &s.Posted); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// ListAllClassified returns up to `limit` classified stories across all real +// channels, newest first. Sentinel channels are excluded. +func ListAllClassified(limit, offset int) ([]Story, error) { + rows, err := Get().Query( + `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at, + EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted + FROM stories s + WHERE s.classified = 1 + AND s.channel IS NOT NULL + AND s.channel NOT IN ('_discarded', '_duplicate') + ORDER BY s.seen_at DESC + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Story + for rows.Next() { + var s Story + if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt, &s.Posted); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// ListRecentlyPosted returns up to `limit` stories that have been posted to +// Matrix, ordered by post time (newest first). Posted is always true. +func ListRecentlyPosted(limit int) ([]Story, error) { + rows, err := Get().Query( + `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.seen_at + FROM stories s + JOIN post_log p ON p.guid = s.guid + GROUP BY s.guid + ORDER BY MAX(p.posted_at) DESC + LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Story for rows.Next() { var s Story if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil { return nil, err } + s.Posted = true out = append(out, s) } return out, rows.Err() @@ -250,6 +304,20 @@ func CountClassifiedByChannel(channel string) (int, error) { return n, err } +// IsKnownImageURL reports whether any story has this exact image_url. Used +// by the thumbnail proxy to guard against arbitrary URL fetches. +func IsKnownImageURL(url string) bool { + if url == "" { + return false + } + var n int + if err := Get().QueryRow(`SELECT COUNT(*) FROM stories WHERE image_url = ? LIMIT 1`, url).Scan(&n); err != nil { + slog.Error("IsKnownImageURL query failed", "err", err) + return false + } + return n > 0 +} + // GetRoundRobinState returns the last channel posted by the round-robin // scheduler and the timestamp of that tick. Empty string + 0 if no state yet. func GetRoundRobinState() (lastChannel string, lastTickAt int64, err error) { diff --git a/internal/storage/types.go b/internal/storage/types.go index 88f1a22..6455792 100644 --- a/internal/storage/types.go +++ b/internal/storage/types.go @@ -15,4 +15,5 @@ type Story struct { Channel string Classified bool SeenAt int64 + Posted bool // true if this story has been posted to Matrix (joined from post_log) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 460c5a6..070b225 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -21,6 +21,8 @@ type StoryView struct { ArticleURL string Source string SeenAt time.Time + Posted bool + Channel string // channel slug; also the theme key } func toView(s storage.Story) StoryView { @@ -31,6 +33,8 @@ func toView(s storage.Story) StoryView { ArticleURL: s.ArticleURL, Source: s.Source, SeenAt: time.Unix(s.SeenAt, 0), + Posted: s.Posted, + Channel: s.Channel, } } @@ -54,12 +58,17 @@ type channelPage struct { type indexPage struct { pageData - Sections []indexSection + JustPosted []StoryView + Stats []channelStat + Latest []StoryView } -type indexSection struct { - Channel Channel - Stories []StoryView // top 3 per channel +type channelStat struct { + Channel Channel + LastPostedAt time.Time // zero if never posted + HasLastPosted bool + PostsToday int + Total int } func (s *Server) base() pageData { @@ -70,21 +79,56 @@ func (s *Server) base() pageData { } 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}) + const ( + justPostedLimit = 6 + latestLimit = 16 + ) + + postedRows, err := storage.ListRecentlyPosted(justPostedLimit) + if err != nil { + slog.Error("web: list recently posted failed", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + justPosted := make([]StoryView, 0, len(postedRows)) + for _, r := range postedRows { + justPosted = append(justPosted, toView(r)) + } + + latestRows, err := storage.ListAllClassified(latestLimit, 0) + if err != nil { + slog.Error("web: list all classified failed", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + latest := make([]StoryView, 0, len(latestRows)) + for _, r := range latestRows { + latest = append(latest, toView(r)) + } + + dayStart := time.Now().Add(-24 * time.Hour).Unix() + stats := make([]channelStat, 0, len(channels)) + for _, ch := range channels { + total, _ := storage.CountClassifiedByChannel(ch.Slug) + lastTs := storage.GetLastPostTime(ch.Slug) + stat := channelStat{ + Channel: ch, + PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart), + Total: total, + } + if lastTs > 0 { + stat.LastPostedAt = time.Unix(lastTs, 0) + stat.HasLastPosted = true + } + stats = append(stats, stat) + } + + data := indexPage{ + pageData: s.base(), + JustPosted: justPosted, + Stats: stats, + Latest: latest, } - data := indexPage{pageData: s.base(), Sections: sections} s.render(w, "index", data) } @@ -141,6 +185,15 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) { } var funcs = template.FuncMap{ + "thumb": thumbURL, + "channel": func(slug string) Channel { + for _, ch := range channels { + if ch.Slug == slug { + return ch + } + } + return Channel{Slug: slug, Title: slug, Theme: slug} + }, "dict": func(pairs ...any) map[string]any { m := make(map[string]any, len(pairs)/2) for i := 0; i+1 < len(pairs); i += 2 { diff --git a/internal/web/server.go b/internal/web/server.go index 1ffd5a3..9369715 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -34,6 +34,7 @@ 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."}, + {Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."}, } // Server is the HTTP server for Pete's web UI. @@ -68,6 +69,7 @@ func New(cfg config.WebConfig) (*Server, error) { return nil, err } mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) + mux.HandleFunc("GET /img/{hash}.avif", s.handleImg) mux.HandleFunc("GET /{$}", s.handleIndex) for _, ch := range channels { diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index 32d4838..8237efb 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -58,18 +58,33 @@ html[data-phase="night"] { .bg-theme-gaming { background-color: #4caf7d; } .bg-theme-tech { background-color: #5aa9e6; } .bg-theme-politics { background-color: #e07a5f; } + .bg-theme-music { background-color: #b079d6; } .text-theme-gaming { color: #2d8a5a; } .text-theme-tech { color: #2f7fb8; } .text-theme-politics { color: #b8523a; } + .text-theme-music { color: #8a4fb8; } .decoration-theme-gaming { text-decoration-color: #4caf7d; } .decoration-theme-tech { text-decoration-color: #5aa9e6; } .decoration-theme-politics { text-decoration-color: #e07a5f; } + .decoration-theme-music { text-decoration-color: #b079d6; } .border-theme-gaming { border-color: #4caf7d; } .border-theme-tech { border-color: #5aa9e6; } .border-theme-politics { border-color: #e07a5f; } + .border-theme-music { border-color: #b079d6; } + + /* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */ + .glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; } + .glow-theme-tech { box-shadow: 0 0 0 2px rgba(90,169,230,0.35), 0 0 24px 4px rgba(90,169,230,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; } + .glow-theme-politics { box-shadow: 0 0 0 2px rgba(224,122,95,0.35), 0 0 24px 4px rgba(224,122,95,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; } + .glow-theme-music { box-shadow: 0 0 0 2px rgba(176,121,214,0.35), 0 0 24px 4px rgba(176,121,214,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; } + + @keyframes pete-glow-pulse { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.08); } + } .line-clamp-3 { display: -webkit-box; diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index f21eee3..2765adc 100644 --- a/internal/web/static/css/output.css +++ b/internal/web/static/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.mx-auto{margin-left:auto;margin-right:auto}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-5{height:1.25rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-5{width:1.25rem}.w-full{width:100%}.max-w-6xl{max-width:72rem}.max-w-xl{max-width:36rem}.-translate-y-0{--tw-translate-y:-0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.border-2{border-width:2px}.border-dashed{border-style:dashed}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-8{padding-top:2rem}.text-center{text-align:center}.align-\[-4px\]{vertical-align:-4px}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-20{opacity:.2}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.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}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}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;--accent:#f9d976}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:mb-14{margin-bottom:3.5rem}.sm\:inline{display:inline}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.mx-auto{margin-left:auto;margin-right:auto}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-5{height:1.25rem}.h-9{height:2.25rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-5{width:1.25rem}.w-9{width:2.25rem}.w-full{width:100%}.max-w-6xl{max-width:72rem}.max-w-xl{max-width:36rem}.-translate-y-0{--tw-translate-y:-0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.border-2{border-width:2px}.border-dashed{border-style:dashed}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-8{padding-top:2rem}.text-center{text-align:center}.align-\[-4px\]{vertical-align:-4px}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.opacity-20{opacity:.2}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-music{background-color:#b079d6}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-music{color:#8a4fb8}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-music{text-decoration-color:#b079d6}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-music{border-color:#b079d6}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-music,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}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;--accent:#f9d976}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} \ No newline at end of file diff --git a/internal/web/templates/_card.html b/internal/web/templates/_card.html index 4174fdd..81f1819 100644 --- a/internal/web/templates/_card.html +++ b/internal/web/templates/_card.html @@ -1,17 +1,24 @@ {{define "card"}} + class="group 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"> {{if .Story.ImageURL}}
-
{{end}}
-
+
+ {{if .ShowChannel}}{{$ch := channel .Story.Channel}} + + {{$ch.Title}} + + {{.Story.Source}} + {{else}} {{.Story.Source}} + {{end}} {{timeAgo .Story.SeenAt}}

diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 7721cf0..3853c4e 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -1,37 +1,81 @@ {{define "title"}}{{.SiteTitle}} — your news feed{{end}} {{define "main"}} -
+

a friendlier news feed.

Pete reads RSS, sorts stories, and posts them to Matrix. - Here's what he's been reading lately. + Glowing cards are the ones he's posted.

-{{range .Sections}} +{{if .JustPosted}} +
+
+

+ Pete just posted +

+ most recent posts to Matrix +
+
+ {{range .JustPosted}} + {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}} + {{end}} +
+
+{{end}} + +
+
+

+ Channels +

+ last 24 hours +
+
+
+
-
-

- - - {{.Channel.Title}} - -

-

{{.Channel.Blurb}}

-
- +

+ Latest across the feed +

+ newest first, all channels
- - {{if .Stories}} + {{if .Latest}}
- {{range .Stories}} - {{template "card" dict "Story" . "Theme" $.Channel.Theme}} + {{range .Latest}} + {{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}} {{end}}
{{else}} @@ -41,4 +85,3 @@ {{end}}
{{end}} -{{end}} diff --git a/internal/web/thumbs.go b/internal/web/thumbs.go new file mode 100644 index 0000000..add2326 --- /dev/null +++ b/internal/web/thumbs.go @@ -0,0 +1,199 @@ +package web + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" + + "pete/internal/storage" +) + +const ( + thumbWidth = 800 + thumbMaxBytes = 12 << 20 // 12 MiB cap on source download + thumbFetchTimeout = 12 * time.Second + thumbCacheDir = "./data/img-cache" + thumbQuality = "45" + thumbSpeed = "8" +) + +var ( + thumbClient = &http.Client{Timeout: thumbFetchTimeout} + + thumbMu sync.Mutex + thumbInFlight = map[string]*sync.WaitGroup{} +) + +func thumbKey(u string) string { + sum := sha256.Sum256([]byte(u)) + return hex.EncodeToString(sum[:16]) +} + +// thumbURL maps a source image URL to the /img proxy path. Empty input → "". +func thumbURL(src string) string { + if src == "" { + return "" + } + return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src) +} + +func (s *Server) handleImg(w http.ResponseWriter, r *http.Request) { + hash := r.PathValue("hash") + src := r.URL.Query().Get("u") + if src == "" || thumbKey(src) != hash { + http.NotFound(w, r) + return + } + if !(strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://")) { + http.NotFound(w, r) + return + } + if !storage.IsKnownImageURL(src) { + http.NotFound(w, r) + return + } + + if err := os.MkdirAll(thumbCacheDir, 0o755); err != nil { + http.Error(w, "cache dir", http.StatusInternalServerError) + return + } + cachePath := filepath.Join(thumbCacheDir, hash+".avif") + if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 { + serveAvif(w, r, cachePath) + return + } + + // Single-flight: collapse concurrent requests for the same image. + thumbMu.Lock() + wg, busy := thumbInFlight[hash] + if !busy { + wg = &sync.WaitGroup{} + wg.Add(1) + thumbInFlight[hash] = wg + } + thumbMu.Unlock() + + if busy { + wg.Wait() + if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 { + serveAvif(w, r, cachePath) + return + } + http.Redirect(w, r, src, http.StatusFound) + return + } + defer func() { + thumbMu.Lock() + delete(thumbInFlight, hash) + thumbMu.Unlock() + wg.Done() + }() + + if err := buildAvifThumb(src, cachePath); err != nil { + slog.Warn("thumb build failed", "url", src, "err", err) + http.Redirect(w, r, src, http.StatusFound) + return + } + serveAvif(w, r, cachePath) +} + +func serveAvif(w http.ResponseWriter, r *http.Request, path string) { + w.Header().Set("Content-Type", "image/avif") + w.Header().Set("Cache-Control", "public, max-age=2592000, immutable") + http.ServeFile(w, r, path) +} + +// buildAvifThumb downloads src, decodes + resizes in Go, then shells out to +// avifenc to produce a compact AVIF at outPath. +func buildAvifThumb(src, outPath string) error { + req, err := http.NewRequest(http.MethodGet, src, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "Pete-Thumb/1.0 (+https://pete.example)") + resp, err := thumbClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upstream %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, thumbMaxBytes+1)) + if err != nil { + return err + } + if len(body) > thumbMaxBytes { + return fmt.Errorf("image too big (>%d bytes)", thumbMaxBytes) + } + + src2, _, err := image.Decode(bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("decode: %w", err) + } + b := src2.Bounds() + srcW, srcH := b.Dx(), b.Dy() + if srcW <= 0 || srcH <= 0 { + return fmt.Errorf("empty image") + } + dstW, dstH := srcW, srcH + if srcW > thumbWidth { + dstW = thumbWidth + dstH = int(float64(srcH) * float64(thumbWidth) / float64(srcW)) + if dstH < 1 { + dstH = 1 + } + } + var resized image.Image = src2 + if dstW != srcW || dstH != srcH { + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src2, b, draw.Over, nil) + resized = dst + } + + tmp, err := os.CreateTemp("", "pete-thumb-*.png") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := png.Encode(tmp, resized); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + + // Encode to a sibling temp file then atomically rename so partial outputs + // never appear in the cache. + stagedOut := outPath + ".tmp" + cmd := exec.Command("avifenc", + "-q", thumbQuality, + "-s", thumbSpeed, + tmpPath, stagedOut, + ) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(stagedOut) + return fmt.Errorf("avifenc: %w: %s", err, strings.TrimSpace(string(out))) + } + return os.Rename(stagedOut, outPath) +}