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 + ? `
${escapeHTML(r.lede)}
` : ""} +