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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user