From ec1f130ed5ace4f54c0d1cf30045327a5338fcd1 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 25 May 2026 11:23:52 -0700 Subject: [PATCH] Add full-text search, EU channel theme, and web-only sources Search: new FTS5-backed SearchStories query, /search JSON endpoint, client-side overlay (search.js) wired into the layout header. EU channel gets its own theme color (#003399) across bg/text/border/glow classes. Sources can now route to non-Matrix channels without validation error (web-only mode); a warning still flags typos. --- internal/config/config.go | 5 +- internal/config/config_test.go | 17 --- internal/storage/queries.go | 62 ++++++++++ internal/web/handlers.go | 74 ++++++++++++ internal/web/server.go | 2 + internal/web/static/css/input.css | 5 + internal/web/static/js/search.js | 177 +++++++++++++++++++++++++++++ internal/web/templates/layout.html | 49 ++++++-- 8 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 internal/web/static/js/search.js diff --git a/internal/config/config.go b/internal/config/config.go index adc0933..6f0169f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -140,7 +140,10 @@ func (c *Config) validate() error { return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name) } if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok { - return fmt.Errorf("sources[%d] (%s): direct_route %q is not a configured matrix.channels key", i, s.Name, s.DirectRoute) + // Not a Matrix channel — treated as web-only (stories visible in the + // UI but never posted). Warn so typos still surface. + slog.Warn("source routes to non-Matrix channel (web-only)", + "source", s.Name, "direct_route", s.DirectRoute) } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e76c49a..57c95e5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -202,23 +202,6 @@ feed_url = "https://e.com/rss" tier = 1 poll_interval_minutes = 20 enabled = true -`}, - {"direct_route not in channels", ` -[matrix] -homeserver = "https://h" -user_id = "@p:h" -password = "pw" -[matrix.channels] -tech = "!t:e" -[storage] -db_path = "/tmp/t.db" -[[sources]] -name = "S" -feed_url = "https://e.com/rss" -tier = 1 -poll_interval_minutes = 20 -direct_route = "gaming" -enabled = true `}, } diff --git a/internal/storage/queries.go b/internal/storage/queries.go index b9e53d5..f615368 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "log/slog" + "strings" "time" ) @@ -347,6 +348,67 @@ func SetRoundRobinState(lastChannel string, tickAt int64) { lastChannel, tickAt) } +// SearchStories runs an FTS5 query against headline + lede and returns the +// best-ranked classified stories (real channels only), newest-ish first via +// bm25. The user query is tokenized and each token becomes a prefix match; +// FTS5 special characters are stripped so user input cannot break syntax. +func SearchStories(query string, limit int) ([]Story, error) { + fts := buildFTSQuery(query) + if fts == "" { + return nil, nil + } + rows, err := Get().Query( + `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, + EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted + FROM stories_fts f + JOIN stories s ON s.id = f.rowid + WHERE f.stories_fts MATCH ? + AND s.classified = 1 + AND s.channel IS NOT NULL + AND s.channel NOT IN ('_discarded', '_duplicate') + ORDER BY bm25(stories_fts) ASC, s.seen_at DESC + LIMIT ?`, fts, 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.Paywalled, &s.SeenAt, &s.Posted); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func buildFTSQuery(raw string) string { + var tokens []string + var cur strings.Builder + flush := func() { + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + } + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + cur.WriteRune(r) + case r > 127: + cur.WriteRune(r) + default: + flush() + } + } + flush() + for i, t := range tokens { + tokens[i] = "\"" + t + "\"*" + } + return strings.Join(tokens, " ") +} + // MarshalPlatforms converts a string slice to a JSON array string for storage. func MarshalPlatforms(platforms []string) string { if len(platforms) == 0 { diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 2363fcd..ec01b8f 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,11 +1,13 @@ package web import ( + "encoding/json" "fmt" "html/template" "log/slog" "net/http" "strconv" + "strings" "time" "pete/internal/storage" @@ -237,6 +239,78 @@ func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) { }) } +type searchResult struct { + Headline string `json:"headline"` + Lede string `json:"lede,omitempty"` + ArticleURL string `json:"article_url"` + ThumbURL string `json:"thumb_url,omitempty"` + Source string `json:"source"` + Channel string `json:"channel"` + ChannelTitle string `json:"channel_title"` + ChannelTheme string `json:"channel_theme"` + ChannelEmoji string `json:"channel_emoji"` + TimeAgo string `json:"time_ago"` + Posted bool `json:"posted"` +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if q == "" { + _, _ = w.Write([]byte(`{"results":[]}`)) + return + } + const searchLimit = 12 + rows, err := storage.SearchStories(q, searchLimit) + if err != nil { + slog.Error("web: search failed", "q", q, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + channelByName := make(map[string]Channel, len(channels)) + for _, ch := range channels { + channelByName[ch.Slug] = ch + } + results := make([]searchResult, 0, len(rows)) + for _, row := range rows { + ch, ok := channelByName[row.Channel] + if !ok { + ch = Channel{Slug: row.Channel, Title: row.Channel, Theme: row.Channel} + } + results = append(results, searchResult{ + Headline: row.Headline, + Lede: row.Lede, + ArticleURL: row.ArticleURL, + ThumbURL: thumbURL(row.ImageURL), + Source: row.Source, + Channel: ch.Slug, + ChannelTitle: ch.Title, + ChannelTheme: ch.Theme, + ChannelEmoji: ch.Emoji, + TimeAgo: shortTimeAgo(time.Unix(row.SeenAt, 0)), + Posted: row.Posted, + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": results}) +} + +func shortTimeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours())/24) + default: + return t.Format("Jan 2") + } +} + func (s *Server) render(w http.ResponseWriter, page string, data any) { tpl, ok := s.tpls[page] if !ok { diff --git a/internal/web/server.go b/internal/web/server.go index 979ebdd..a56936b 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: "eu", Title: "EU", Theme: "eu", Emoji: "🇪🇺", Blurb: "Portugal and the wider European beat. Read-only — these stories don't post to Matrix."}, {Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."}, } @@ -73,6 +74,7 @@ func New(cfg config.WebConfig) (*Server, error) { mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /weather", s.handleWeatherDemo) + mux.HandleFunc("GET /api/search", s.handleSearch) for _, ch := range channels { ch := ch mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index c6d191b..1272576 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -58,27 +58,32 @@ html[data-phase="night"] { .bg-theme-gaming { background-color: #4caf7d; } .bg-theme-tech { background-color: #5aa9e6; } .bg-theme-politics { background-color: #e07a5f; } + .bg-theme-eu { background-color: #003399; } .bg-theme-music { background-color: #b079d6; } .text-theme-gaming { color: #2d8a5a; } .text-theme-tech { color: #2f7fb8; } .text-theme-politics { color: #b8523a; } + .text-theme-eu { color: #003399; } .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-eu { text-decoration-color: #003399; } .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-eu { border-color: #003399; } .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-eu { box-shadow: 0 0 0 2px rgba(0,51,153,0.35), 0 0 24px 4px rgba(0,51,153,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 { diff --git a/internal/web/static/js/search.js b/internal/web/static/js/search.js new file mode 100644 index 0000000..58d4c0f --- /dev/null +++ b/internal/web/static/js/search.js @@ -0,0 +1,177 @@ +// Cmd+K / Ctrl+K command palette. Hits /api/search and renders editorial-card +// results. Vanilla JS, no deps; styles come from output.css (Tailwind). +(function () { + const overlay = document.getElementById("pete-search"); + if (!overlay) return; + const input = overlay.querySelector("[data-search-input]"); + const list = overlay.querySelector("[data-search-list]"); + const meta = overlay.querySelector("[data-search-meta]"); + + let activeIndex = -1; + let items = []; + let inflight = null; + let debounceTimer = 0; + let lastQuery = ""; + + function open() { + if (!overlay.classList.contains("hidden")) return; + overlay.classList.remove("hidden"); + document.body.classList.add("overflow-hidden"); + requestAnimationFrame(() => input.focus()); + } + + function close() { + overlay.classList.add("hidden"); + document.body.classList.remove("overflow-hidden"); + input.value = ""; + list.innerHTML = ""; + meta.textContent = ""; + items = []; + activeIndex = -1; + lastQuery = ""; + } + + function escapeHTML(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function render(results) { + items = results || []; + activeIndex = items.length > 0 ? 0 : -1; + if (items.length === 0) { + list.innerHTML = ""; + return; + } + const html = items.map((r, i) => { + const thumb = r.thumb_url + ? `` + : ``; + const postedRing = r.posted ? ` border-theme-${escapeHTML(r.channel_theme)}` : ""; + return ` + + ${thumb} +
+
+ + ${escapeHTML(r.channel_title)} + + ${escapeHTML(r.source)} + ${escapeHTML(r.time_ago)} +
+

${escapeHTML(r.headline)}

+ ${r.lede ? `

${escapeHTML(r.lede)}

` : ""} +
+
`; + }).join(""); + list.innerHTML = html; + paintActive(); + } + + function paintActive() { + const nodes = list.querySelectorAll(".search-result"); + nodes.forEach((n, i) => { + if (i === activeIndex) { + n.classList.add("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15"); + n.scrollIntoView({ block: "nearest" }); + } else { + n.classList.remove("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15"); + } + }); + } + + async function runQuery(q) { + if (q === lastQuery) return; + lastQuery = q; + if (!q) { + render([]); + meta.textContent = "type to search · esc to close"; + return; + } + if (inflight) inflight.abort(); + const ctrl = new AbortController(); + inflight = ctrl; + meta.textContent = "searching…"; + try { + const res = await fetch("/api/search?q=" + encodeURIComponent(q), { signal: ctrl.signal }); + if (!res.ok) throw new Error("search failed: " + res.status); + const data = await res.json(); + if (ctrl !== inflight) return; + render(data.results || []); + const n = (data.results || []).length; + meta.textContent = n === 0 ? "no matches" : `${n} result${n === 1 ? "" : "s"}`; + } catch (err) { + if (err.name === "AbortError") return; + console.error(err); + meta.textContent = "search error"; + } finally { + if (ctrl === inflight) inflight = null; + } + } + + input.addEventListener("input", () => { + clearTimeout(debounceTimer); + const q = input.value.trim(); + debounceTimer = window.setTimeout(() => runQuery(q), 120); + }); + + input.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (items.length === 0) return; + activeIndex = (activeIndex + 1) % items.length; + paintActive(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (items.length === 0) return; + activeIndex = (activeIndex - 1 + items.length) % items.length; + paintActive(); + } else if (e.key === "Enter") { + if (activeIndex >= 0 && items[activeIndex]) { + e.preventDefault(); + window.open(items[activeIndex].article_url, "_blank", "noopener"); + } + } + }); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + + document.addEventListener("keydown", (e) => { + const isK = e.key === "k" || e.key === "K"; + if ((e.metaKey || e.ctrlKey) && isK) { + e.preventDefault(); + if (overlay.classList.contains("hidden")) open(); + else close(); + return; + } + if (e.key === "Escape" && !overlay.classList.contains("hidden")) { + close(); + } + if (e.key === "/" && overlay.classList.contains("hidden")) { + const tag = (document.activeElement && document.activeElement.tagName) || ""; + if (tag === "INPUT" || tag === "TEXTAREA") return; + e.preventDefault(); + open(); + } + }); + + document.querySelectorAll("[data-search-trigger]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + open(); + }); + }); + + meta.textContent = "type to search · esc to close"; +})(); diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 1147e8d..80b0b1e 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -39,19 +39,47 @@ {{.SiteTitle}} - +
+ + +
+ +
{{block "main" .}}{{end}}
@@ -80,5 +108,6 @@ + {{end}}