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.
This commit is contained in:
@@ -43,11 +43,12 @@ func toView(s storage.Story) StoryView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
SiteTitle string
|
SiteTitle string
|
||||||
Channels []Channel
|
Channels []Channel
|
||||||
Active string // slug of active channel, "" for landing
|
Active string // slug of active channel, "" for landing
|
||||||
Weather Weather
|
Weather Weather
|
||||||
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
|
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 {
|
type channelPage struct {
|
||||||
@@ -78,10 +79,15 @@ type channelStat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) base() pageData {
|
func (s *Server) base() pageData {
|
||||||
|
srcJSON, err := json.Marshal(s.sources)
|
||||||
|
if err != nil {
|
||||||
|
srcJSON = []byte("[]")
|
||||||
|
}
|
||||||
return pageData{
|
return pageData{
|
||||||
SiteTitle: s.cfg.SiteTitle,
|
SiteTitle: s.cfg.SiteTitle,
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
Weather: currentWeather(time.Now()),
|
Weather: currentWeather(time.Now()),
|
||||||
|
AllSources: template.JS(srcJSON),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."},
|
{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.
|
// Server is the HTTP server for Pete's web UI.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.WebConfig
|
cfg config.WebConfig
|
||||||
srv *http.Server
|
sources []SourceInfo
|
||||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
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
|
// New builds the server. Templates are parsed once at startup. Each page
|
||||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||||
// `main` define collisions between pages.
|
// `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"}
|
pages := []string{"index", "channel", "weather"}
|
||||||
tpls := make(map[string]*template.Template, len(pages))
|
tpls := make(map[string]*template.Template, len(pages))
|
||||||
for _, p := range pages {
|
for _, p := range pages {
|
||||||
@@ -64,7 +71,14 @@ func New(cfg config.WebConfig) (*Server, error) {
|
|||||||
}
|
}
|
||||||
tpls[p] = t
|
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()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
|
|||||||
144
internal/web/static/js/settings.js
Normal file
144
internal/web/static/js/settings.js
Normal file
@@ -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 = '<p class="px-2 py-4 text-sm text-center text-[color:var(--ink)]/50">No feeds configured.</p>';
|
||||||
|
} 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{{define "card"}}
|
{{define "card"}}
|
||||||
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
|
<a href="{{.Story.ArticleURL}}" target="_blank" rel="noopener noreferrer"
|
||||||
|
data-story-card data-source="{{.Story.Source}}" data-channel="{{.Story.Channel}}"
|
||||||
class="group relative 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">
|
class="group relative 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}}
|
{{if .Story.ImageURL}}
|
||||||
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
|
||||||
|
|||||||
@@ -39,6 +39,16 @@
|
|||||||
<span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</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>
|
<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>
|
</a>
|
||||||
|
<button type="button" data-settings-trigger
|
||||||
|
title="Feed settings"
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Feed settings</span>
|
||||||
|
</button>
|
||||||
{{if .Weather.Variant}}
|
{{if .Weather.Variant}}
|
||||||
<button type="button" data-weather-toggle aria-pressed="true"
|
<button type="button" data-weather-toggle aria-pressed="true"
|
||||||
title="Toggle weather animation"
|
title="Toggle weather animation"
|
||||||
@@ -91,6 +101,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pete-settings" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
|
||||||
|
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||||||
|
<h2 class="font-display text-lg font-bold">Feed settings</h2>
|
||||||
|
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
|
||||||
|
</div>
|
||||||
|
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. Saved in this browser.</p>
|
||||||
|
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
|
||||||
|
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
|
||||||
|
<button type="button" data-settings-reset class="text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] font-semibold">Enable all</button>
|
||||||
|
<span data-settings-meta class="text-[color:var(--ink)]/50"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="mx-auto max-w-6xl px-4 pb-24">
|
<main class="mx-auto max-w-6xl px-4 pb-24">
|
||||||
{{block "main" .}}{{end}}
|
{{block "main" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
@@ -118,7 +143,9 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>window.PETE_SOURCES = {{.AllSources}};</script>
|
||||||
<script src="/static/js/weather.js" defer></script>
|
<script src="/static/js/weather.js" defer></script>
|
||||||
<script src="/static/js/search.js" defer></script>
|
<script src="/static/js/search.js" defer></script>
|
||||||
|
<script src="/static/js/settings.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -200,7 +200,7 @@ func main() {
|
|||||||
|
|
||||||
// Start the read-only web UI alongside the Matrix bot.
|
// Start the read-only web UI alongside the Matrix bot.
|
||||||
if cfg.Web.Enabled {
|
if cfg.Web.Enabled {
|
||||||
ws, err := web.New(cfg.Web)
|
ws, err := web.New(cfg.Web, cfg.Sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("web server init failed", "err", err)
|
slog.Error("web server init failed", "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -246,7 +246,7 @@ func runLocal(cfg *config.Config) {
|
|||||||
poller.Start(ctx)
|
poller.Start(ctx)
|
||||||
slog.Info("local: pollers started")
|
slog.Info("local: pollers started")
|
||||||
|
|
||||||
ws, err := web.New(cfg.Web)
|
ws, err := web.New(cfg.Web, cfg.Sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("local: web server init failed", "err", err)
|
slog.Error("local: web server init failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user