Compare commits

...

2 Commits

Author SHA1 Message Date
prosolis
b617d403b7 Exclude !post from the daily cap
Manual !post overrides were counted toward daily_cap_total, so a few
forced posts could starve the round-robin rotation for the rest of the
day. Tag forced rows in post_log and skip them in CountAllPostsInWindow
so the cap only meters the auto-rotation.
2026-05-26 17:15:53 -07:00
prosolis
a15025089d 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.
2026-05-26 17:15:46 -07:00
13 changed files with 237 additions and 34 deletions

View File

@@ -109,7 +109,7 @@ func (q *Queue) ForcePost(channel string) bool {
slog.Info("force-posting story on demand", slog.Info("force-posting story on demand",
"guid", item.GUID, "channel", channel) "guid", item.GUID, "channel", channel)
return q.postItem(item) return q.postItem(item, true)
} }
func (q *Queue) drain() { func (q *Queue) drain() {
@@ -194,21 +194,23 @@ func (q *Queue) drainChannel(channel string) {
q.queues[channel] = items[1:] q.queues[channel] = items[1:]
q.mu.Unlock() q.mu.Unlock()
q.postItem(item) q.postItem(item, false)
} }
// PostNow sends a story immediately, bypassing the in-memory queue and all // PostNow sends a story immediately, bypassing the in-memory queue and all
// pacing limits. Last-mile canonical-URL dedup still applies. Used by !post // pacing limits. Last-mile canonical-URL dedup still applies. Used by !post
// to satisfy on-demand requests with stories pulled directly from storage. // to satisfy on-demand requests with stories pulled directly from storage.
func (q *Queue) PostNow(item QueueItem) { func (q *Queue) PostNow(item QueueItem) {
q.postItem(item) q.postItem(item, true)
} }
// postItem returns true when a Matrix event was actually sent, false on // postItem returns true when a Matrix event was actually sent, false on
// any non-success path (dedup-skip, transport failure with or without // any non-success path (dedup-skip, transport failure with or without
// retry, dead-letter). ForcePost uses the return value to decide whether // retry, dead-letter). ForcePost uses the return value to decide whether
// to acknowledge the user-initiated !post or fall back to a DB lookup. // to acknowledge the user-initiated !post or fall back to a DB lookup.
func (q *Queue) postItem(item QueueItem) bool { // forced=true marks the resulting post_log row so it's excluded from the
// global daily cap (manual overrides shouldn't steal the rotation budget).
func (q *Queue) postItem(item QueueItem, forced bool) bool {
// Last-mile dedup: if this canonical URL was already posted to this channel // Last-mile dedup: if this canonical URL was already posted to this channel
// within the cooldown window, drop silently. Catches "same article, different // within the cooldown window, drop silently. Catches "same article, different
// GUID across feeds" and any race where two items slipped past ingest dedup. // GUID across feeds" and any race where two items slipped past ingest dedup.
@@ -264,7 +266,7 @@ func (q *Queue) postItem(item QueueItem) bool {
} }
// Record in post log (INSERT OR IGNORE via unique index) // Record in post log (INSERT OR IGNORE via unique index)
storage.InsertPostLog(item.GUID, item.Channel, string(eventID), canonical) storage.InsertPostLog(item.GUID, item.Channel, string(eventID), canonical, forced)
slog.Info("story posted", slog.Info("story posted",
"guid", item.GUID, "guid", item.GUID,

View File

@@ -22,7 +22,7 @@ func TestHandleReaction_KnownPost(t *testing.T) {
setupTrackerTestDB(t) setupTrackerTestDB(t)
// Insert a post log entry // Insert a post log entry
storage.InsertPostLog("story-1", "tech", "$post1:example.org", "") storage.InsertPostLog("story-1", "tech", "$post1:example.org", "", false)
// Handle a reaction to that post // Handle a reaction to that post
HandleReaction( HandleReaction(
@@ -63,7 +63,7 @@ func TestHandleReaction_UnknownPost(t *testing.T) {
func TestHandleReaction_DuplicateIgnored(t *testing.T) { func TestHandleReaction_DuplicateIgnored(t *testing.T) {
setupTrackerTestDB(t) setupTrackerTestDB(t)
storage.InsertPostLog("story-1", "tech", "$post1:example.org", "") storage.InsertPostLog("story-1", "tech", "$post1:example.org", "", false)
// Send same reaction twice // Send same reaction twice
HandleReaction( HandleReaction(

View File

@@ -156,7 +156,7 @@ func TestTick_AlreadyPostedExcluded(t *testing.T) {
setupTestDB(t) setupTestDB(t)
insertPostable(t, "t-posted", "GuardianTech", "tech", 200) insertPostable(t, "t-posted", "GuardianTech", "tech", 200)
insertPostable(t, "t-fresh", "GuardianTech", "tech", 100) insertPostable(t, "t-fresh", "GuardianTech", "tech", 100)
storage.InsertPostLog("t-posted", "tech", "$evt1", "https://example.com/t-posted") storage.InsertPostLog("t-posted", "tech", "$evt1", "https://example.com/t-posted", false)
fq := &fakeQueue{} fq := &fakeQueue{}
rr := New([]string{"tech"}, 4, fq) rr := New([]string{"tech"}, 4, fq)

View File

@@ -83,6 +83,7 @@ func runMigrations(d *sql.DB) error {
addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0") addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(d, "stories", "published_at", "INTEGER") addColumnIfMissing(d, "stories", "published_at", "INTEGER")
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT") addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT") addColumnIfMissing(d, "round_robin_state", "last_channel", "TEXT")
// FTS5 virtual tables don't support IF NOT EXISTS reliably. // FTS5 virtual tables don't support IF NOT EXISTS reliably.

View File

@@ -122,10 +122,16 @@ func GetStoryByGUID(guid string) (*Story, error) {
// InsertPostLog records that a story was posted to a channel. // InsertPostLog records that a story was posted to a channel.
// Uses OR IGNORE to prevent duplicate posts for the same story+channel. // Uses OR IGNORE to prevent duplicate posts for the same story+channel.
func InsertPostLog(guid, channel, eventID, urlCanonical string) { // forced=true marks the row as a manual !post override so it doesn't count
// against the global daily cap.
func InsertPostLog(guid, channel, eventID, urlCanonical string, forced bool) {
f := 0
if forced {
f = 1
}
exec("insert post_log", exec("insert post_log",
`INSERT OR IGNORE INTO post_log (guid, channel, event_id, url_canonical, posted_at) VALUES (?, ?, ?, ?, ?)`, `INSERT OR IGNORE INTO post_log (guid, channel, event_id, url_canonical, posted_at, forced) VALUES (?, ?, ?, ?, ?, ?)`,
guid, channel, eventID, nullIfEmpty(urlCanonical), nowUnix()) guid, channel, eventID, nullIfEmpty(urlCanonical), nowUnix(), f)
} }
// GetLastPostTime returns the unix timestamp of the most recent post to a channel. // GetLastPostTime returns the unix timestamp of the most recent post to a channel.
@@ -139,10 +145,11 @@ func GetLastPostTime(channel string) int64 {
} }
// CountAllPostsInWindow counts posts across ALL channels within a time window. // CountAllPostsInWindow counts posts across ALL channels within a time window.
// Used for the global daily cap. // Used for the global daily cap. Excludes forced (!post) entries so manual
// overrides don't eat into the auto-rotation budget.
func CountAllPostsInWindow(windowStart int64) int { func CountAllPostsInWindow(windowStart int64) int {
var count int var count int
if err := Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE posted_at >= ?`, windowStart).Scan(&count); err != nil { if err := Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE posted_at >= ? AND forced = 0`, windowStart).Scan(&count); err != nil {
slog.Error("CountAllPostsInWindow query failed", "err", err) slog.Error("CountAllPostsInWindow query failed", "err", err)
return 1<<31 - 1 // fail closed: huge number prevents posting return 1<<31 - 1 // fail closed: huge number prevents posting
} }

View File

@@ -25,7 +25,8 @@ CREATE TABLE IF NOT EXISTS post_log (
channel TEXT NOT NULL, channel TEXT NOT NULL,
event_id TEXT, event_id TEXT,
url_canonical TEXT, url_canonical TEXT,
posted_at INTEGER NOT NULL posted_at INTEGER NOT NULL,
forced INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS round_robin_state ( CREATE TABLE IF NOT EXISTS round_robin_state (

View File

@@ -98,8 +98,8 @@ func TestMarkClassified(t *testing.T) {
func TestPostLogAndLookup(t *testing.T) { func TestPostLogAndLookup(t *testing.T) {
setupTestDB(t) setupTestDB(t)
InsertPostLog("story-1", "tech", "$event1:example.org", "") InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
InsertPostLog("story-2", "politics", "$event2:example.org", "") InsertPostLog("story-2", "politics", "$event2:example.org", "", false)
guid, channel, found := LookupPostGUID("$event1:example.org") guid, channel, found := LookupPostGUID("$event1:example.org")
if !found { if !found {
@@ -193,9 +193,9 @@ func TestMarshalUnmarshalPlatforms(t *testing.T) {
func TestInsertPostLog_DuplicateIgnored(t *testing.T) { func TestInsertPostLog_DuplicateIgnored(t *testing.T) {
setupTestDB(t) setupTestDB(t)
InsertPostLog("story-1", "tech", "$event1:example.org", "") InsertPostLog("story-1", "tech", "$event1:example.org", "", false)
// Same guid+channel should be silently ignored // Same guid+channel should be silently ignored
InsertPostLog("story-1", "tech", "$event1-retry:example.org", "") InsertPostLog("story-1", "tech", "$event1-retry:example.org", "", false)
var count int var count int
Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ? AND channel = ?`, "story-1", "tech").Scan(&count) Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ? AND channel = ?`, "story-1", "tech").Scan(&count)
@@ -204,7 +204,7 @@ func TestInsertPostLog_DuplicateIgnored(t *testing.T) {
} }
// Different channel should be allowed // Different channel should be allowed
InsertPostLog("story-1", "politics", "$event2:example.org", "") InsertPostLog("story-1", "politics", "$event2:example.org", "", false)
Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ?`, "story-1").Scan(&count) Get().QueryRow(`SELECT COUNT(*) FROM post_log WHERE guid = ?`, "story-1").Scan(&count)
if count != 2 { if count != 2 {
t.Errorf("expected 2 post_log entries across channels, got %d", count) t.Errorf("expected 2 post_log entries across channels, got %d", count)

View File

@@ -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),
} }
} }

View File

@@ -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")

View 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();
});
});
})();

View File

@@ -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">

View File

@@ -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}}

View File

@@ -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)