From a15025089d2ec0e1da6c6b617df638d45d39a2e1 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 26 May 2026 17:15:46 -0700 Subject: [PATCH] Add per-feed visibility settings panel Visitors can hide individual feeds via a gear-icon panel in the header. Preferences live in localStorage; the server ships the full source list (name + channel) as window.PETE_SOURCES so the panel lists every feed, grouped by channel, regardless of what's on the current page. --- internal/web/handlers.go | 22 +++-- internal/web/server.go | 24 ++++- internal/web/static/js/settings.js | 144 +++++++++++++++++++++++++++++ internal/web/templates/_card.html | 1 + internal/web/templates/layout.html | 27 ++++++ main.go | 4 +- 6 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 internal/web/static/js/settings.js diff --git a/internal/web/handlers.go b/internal/web/handlers.go index ec01b8f..ef335bf 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -43,11 +43,12 @@ func toView(s storage.Story) StoryView { } type pageData struct { - SiteTitle string - Channels []Channel - Active string // slug of active channel, "" for landing - Weather Weather - Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS + SiteTitle string + Channels []Channel + Active string // slug of active channel, "" for landing + Weather Weather + Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS + AllSources template.JS // JSON array of {name, channel} for the settings panel } type channelPage struct { @@ -78,10 +79,15 @@ type channelStat struct { } func (s *Server) base() pageData { + srcJSON, err := json.Marshal(s.sources) + if err != nil { + srcJSON = []byte("[]") + } return pageData{ - SiteTitle: s.cfg.SiteTitle, - Channels: channels, - Weather: currentWeather(time.Now()), + SiteTitle: s.cfg.SiteTitle, + Channels: channels, + Weather: currentWeather(time.Now()), + AllSources: template.JS(srcJSON), } } diff --git a/internal/web/server.go b/internal/web/server.go index 344f12e..af9c730 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -40,17 +40,24 @@ var channels = []Channel{ {Slug: "foss", Title: "FOSS", Theme: "foss", Emoji: "🐧", Blurb: "Kernel, distros, and the wider free and open source software world."}, } +// SourceInfo is the trimmed view of a configured feed for the settings panel. +type SourceInfo struct { + Name string `json:"name"` + Channel string `json:"channel"` +} + // 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") + cfg config.WebConfig + sources []SourceInfo + 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) { +func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) { pages := []string{"index", "channel", "weather"} tpls := make(map[string]*template.Template, len(pages)) for _, p := range pages { @@ -64,7 +71,14 @@ func New(cfg config.WebConfig) (*Server, error) { } tpls[p] = t } - s := &Server{cfg: cfg, tpls: tpls} + infos := make([]SourceInfo, 0, len(sources)) + for _, sc := range sources { + if !sc.Enabled || sc.Name == "" { + continue + } + infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute}) + } + s := &Server{cfg: cfg, sources: infos, tpls: tpls} mux := http.NewServeMux() staticSub, err := fs.Sub(staticFS, "static") diff --git a/internal/web/static/js/settings.js b/internal/web/static/js/settings.js new file mode 100644 index 0000000..ab46b84 --- /dev/null +++ b/internal/web/static/js/settings.js @@ -0,0 +1,144 @@ +// Per-feed visibility, stored in localStorage. Server still ships every story; +// we just hide cards whose data-source is in the disabled set. +(function () { + var KEY = "pete.disabledSources.v1"; + + function load() { + try { + var raw = localStorage.getItem(KEY); + if (!raw) return {}; + var obj = JSON.parse(raw); + return obj && typeof obj === "object" ? obj : {}; + } catch (e) { return {}; } + } + function save(set) { + try { localStorage.setItem(KEY, JSON.stringify(set)); } catch (e) {} + } + + var disabled = load(); + + function apply() { + var cards = document.querySelectorAll("[data-story-card]"); + cards.forEach(function (c) { + var src = c.getAttribute("data-source") || ""; + c.style.display = disabled[src] ? "none" : ""; + }); + } + + function knownSources() { + // Prefer the server-provided full list (grouped by channel below). + var server = Array.isArray(window.PETE_SOURCES) ? window.PETE_SOURCES : []; + if (server.length > 0) { + var copy = server.slice(); + // Surface any persisted-disabled sources that aren't in the config anymore. + var present = Object.create(null); + copy.forEach(function (s) { present[s.name] = true; }); + Object.keys(disabled).forEach(function (n) { + if (!present[n]) copy.push({ name: n, channel: "" }); + }); + copy.sort(function (a, b) { + var ca = (a.channel || "~").toLowerCase(); + var cb = (b.channel || "~").toLowerCase(); + if (ca !== cb) return ca.localeCompare(cb); + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + return copy; + } + // Fallback: derive from DOM if the server didn't ship a list. + var seen = Object.create(null); + document.querySelectorAll("[data-story-card]").forEach(function (c) { + var src = c.getAttribute("data-source"); + var ch = c.getAttribute("data-channel") || ""; + if (src && !seen[src]) seen[src] = { name: src, channel: ch }; + }); + Object.keys(disabled).forEach(function (s) { + if (!seen[s]) seen[s] = { name: s, channel: "" }; + }); + return Object.keys(seen).map(function (k) { return seen[k]; }).sort(function (a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + } + + function render() { + var list = document.querySelector("[data-settings-list]"); + var meta = document.querySelector("[data-settings-meta]"); + if (!list) return; + var sources = knownSources(); + list.innerHTML = ""; + if (sources.length === 0) { + list.innerHTML = '
No feeds configured.
'; + } else { + var lastChannel = null; + sources.forEach(function (src) { + if (src.channel !== lastChannel) { + lastChannel = src.channel; + var hdr = document.createElement("div"); + hdr.className = "px-3 pt-3 pb-1 text-[11px] uppercase tracking-wider text-[color:var(--ink)]/40 font-bold"; + hdr.textContent = src.channel || "other"; + list.appendChild(hdr); + } + var id = "pete-feed-" + btoa(src.name).replace(/=/g, ""); + var row = document.createElement("label"); + row.setAttribute("for", id); + row.className = "flex items-center justify-between gap-3 rounded-2xl px-3 py-2 hover:bg-[color:var(--ink)]/5 cursor-pointer"; + var nameEl = document.createElement("span"); + nameEl.className = "text-sm font-semibold truncate"; + nameEl.textContent = src.name; + var box = document.createElement("input"); + box.type = "checkbox"; + box.id = id; + box.className = "h-4 w-4 shrink-0 accent-[color:var(--accent)]"; + box.checked = !disabled[src.name]; + box.addEventListener("change", function () { + if (box.checked) delete disabled[src.name]; + else disabled[src.name] = true; + save(disabled); + apply(); + updateMeta(); + }); + row.appendChild(nameEl); + row.appendChild(box); + list.appendChild(row); + }); + } + updateMeta(); + + function updateMeta() { + if (!meta) return; + var off = Object.keys(disabled).length; + meta.textContent = off === 0 ? "All feeds enabled" : off + " hidden"; + } + } + + function open() { + render(); + var dlg = document.getElementById("pete-settings"); + if (dlg) dlg.classList.remove("hidden"); + } + function close() { + var dlg = document.getElementById("pete-settings"); + if (dlg) dlg.classList.add("hidden"); + } + + document.addEventListener("DOMContentLoaded", function () { + apply(); + var trigger = document.querySelector("[data-settings-trigger]"); + if (trigger) trigger.addEventListener("click", open); + var closeBtn = document.querySelector("[data-settings-close]"); + if (closeBtn) closeBtn.addEventListener("click", close); + var dlg = document.getElementById("pete-settings"); + if (dlg) dlg.addEventListener("click", function (e) { + if (e.target === dlg) close(); + }); + var reset = document.querySelector("[data-settings-reset]"); + if (reset) reset.addEventListener("click", function () { + disabled = {}; + save(disabled); + apply(); + render(); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") close(); + }); + }); +})(); diff --git a/internal/web/templates/_card.html b/internal/web/templates/_card.html index 484c2c6..d739a19 100644 --- a/internal/web/templates/_card.html +++ b/internal/web/templates/_card.html @@ -1,5 +1,6 @@ {{define "card"}} {{if .Story.ImageURL}}Uncheck a feed to hide its stories. Saved in this browser.
+ +