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:
@@ -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)
|
return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name)
|
||||||
}
|
}
|
||||||
if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,23 +202,6 @@ feed_url = "https://e.com/rss"
|
|||||||
tier = 1
|
tier = 1
|
||||||
poll_interval_minutes = 20
|
poll_interval_minutes = 20
|
||||||
enabled = true
|
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
|
|
||||||
`},
|
`},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,6 +348,67 @@ func SetRoundRobinState(lastChannel string, tickAt int64) {
|
|||||||
lastChannel, tickAt)
|
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.
|
// MarshalPlatforms converts a string slice to a JSON array string for storage.
|
||||||
func MarshalPlatforms(platforms []string) string {
|
func MarshalPlatforms(platforms []string) string {
|
||||||
if len(platforms) == 0 {
|
if len(platforms) == 0 {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pete/internal/storage"
|
"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) {
|
func (s *Server) render(w http.ResponseWriter, page string, data any) {
|
||||||
tpl, ok := s.tpls[page]
|
tpl, ok := s.tpls[page]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ var channels = []Channel{
|
|||||||
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
{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: "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: "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."},
|
{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 /{$}", s.handleIndex)
|
||||||
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
||||||
|
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
ch := ch
|
ch := ch
|
||||||
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -58,27 +58,32 @@ html[data-phase="night"] {
|
|||||||
.bg-theme-gaming { background-color: #4caf7d; }
|
.bg-theme-gaming { background-color: #4caf7d; }
|
||||||
.bg-theme-tech { background-color: #5aa9e6; }
|
.bg-theme-tech { background-color: #5aa9e6; }
|
||||||
.bg-theme-politics { background-color: #e07a5f; }
|
.bg-theme-politics { background-color: #e07a5f; }
|
||||||
|
.bg-theme-eu { background-color: #003399; }
|
||||||
.bg-theme-music { background-color: #b079d6; }
|
.bg-theme-music { background-color: #b079d6; }
|
||||||
|
|
||||||
.text-theme-gaming { color: #2d8a5a; }
|
.text-theme-gaming { color: #2d8a5a; }
|
||||||
.text-theme-tech { color: #2f7fb8; }
|
.text-theme-tech { color: #2f7fb8; }
|
||||||
.text-theme-politics { color: #b8523a; }
|
.text-theme-politics { color: #b8523a; }
|
||||||
|
.text-theme-eu { color: #003399; }
|
||||||
.text-theme-music { color: #8a4fb8; }
|
.text-theme-music { color: #8a4fb8; }
|
||||||
|
|
||||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||||
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
||||||
|
.decoration-theme-eu { text-decoration-color: #003399; }
|
||||||
.decoration-theme-music { text-decoration-color: #b079d6; }
|
.decoration-theme-music { text-decoration-color: #b079d6; }
|
||||||
|
|
||||||
.border-theme-gaming { border-color: #4caf7d; }
|
.border-theme-gaming { border-color: #4caf7d; }
|
||||||
.border-theme-tech { border-color: #5aa9e6; }
|
.border-theme-tech { border-color: #5aa9e6; }
|
||||||
.border-theme-politics { border-color: #e07a5f; }
|
.border-theme-politics { border-color: #e07a5f; }
|
||||||
|
.border-theme-eu { border-color: #003399; }
|
||||||
.border-theme-music { border-color: #b079d6; }
|
.border-theme-music { border-color: #b079d6; }
|
||||||
|
|
||||||
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
|
/* "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-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-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-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; }
|
.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 {
|
@keyframes pete-glow-pulse {
|
||||||
|
|||||||
177
internal/web/static/js/search.js
Normal file
177
internal/web/static/js/search.js
Normal file
@@ -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, """)
|
||||||
|
.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
|
||||||
|
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-[color:var(--ink)]/5">
|
||||||
|
<img src="${escapeHTML(r.thumb_url)}" alt="" loading="lazy" decoding="async"
|
||||||
|
class="h-full w-full object-cover">
|
||||||
|
</div>`
|
||||||
|
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
|
||||||
|
const postedRing = r.posted ? ` border-theme-${escapeHTML(r.channel_theme)}` : "";
|
||||||
|
return `
|
||||||
|
<a href="${escapeHTML(r.article_url)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
data-idx="${i}"
|
||||||
|
class="search-result group flex items-start gap-4 rounded-2xl p-3 transition border-2 border-transparent hover:bg-[color:var(--ink)]/5${postedRing}">
|
||||||
|
${thumb}
|
||||||
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-xs flex-wrap">
|
||||||
|
<span class="inline-flex items-center rounded-full bg-theme-${escapeHTML(r.channel_theme)} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
|
||||||
|
<span aria-hidden="true" class="mr-1">${escapeHTML(r.channel_emoji)}</span>${escapeHTML(r.channel_title)}
|
||||||
|
</span>
|
||||||
|
<span class="text-[color:var(--ink)]/60 font-semibold">${escapeHTML(r.source)}</span>
|
||||||
|
<span class="text-[color:var(--ink)]/50">${escapeHTML(r.time_ago)}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-display text-base sm:text-lg font-semibold leading-snug">${escapeHTML(r.headline)}</h3>
|
||||||
|
${r.lede ? `<p class="text-sm text-[color:var(--ink)]/70 line-clamp-2">${escapeHTML(r.lede)}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}).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";
|
||||||
|
})();
|
||||||
@@ -39,19 +39,47 @@
|
|||||||
<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>
|
||||||
<nav class="flex items-center gap-1 sm:gap-2 rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
|
<div class="flex items-center gap-2">
|
||||||
{{$active := .Active}}
|
<button type="button" data-search-trigger title="Search (⌘K)"
|
||||||
{{range .Channels}}
|
class="flex items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
<a href="/{{.Slug}}"
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
class="rounded-full px-3 py-2 text-sm font-semibold transition
|
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
|
||||||
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
<circle cx="11" cy="11" r="7"></circle>
|
||||||
<span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
</a>
|
</svg>
|
||||||
{{end}}
|
<span class="hidden sm:inline text-[color:var(--ink)]/60 font-semibold">Search</span>
|
||||||
</nav>
|
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
<nav class="flex items-center gap-1 sm:gap-2 rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
|
||||||
|
{{$active := .Active}}
|
||||||
|
{{range .Channels}}
|
||||||
|
<a href="/{{.Slug}}"
|
||||||
|
class="rounded-full px-3 py-2 text-sm font-semibold transition
|
||||||
|
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
||||||
|
<span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="pete-search" 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-2xl rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||||||
|
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
|
||||||
|
<input type="search" data-search-input autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="Search headlines…"
|
||||||
|
class="flex-1 bg-transparent text-lg outline-none placeholder:text-[color:var(--ink)]/40">
|
||||||
|
</div>
|
||||||
|
<div data-search-list class="max-h-[60vh] 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 text-[color:var(--ink)]/50">
|
||||||
|
<span><kbd class="font-mono">↑↓</kbd> navigate · <kbd class="font-mono">⏎</kbd> open · <kbd class="font-mono">esc</kbd> close</span>
|
||||||
|
<span data-search-meta></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>
|
||||||
@@ -80,5 +108,6 @@
|
|||||||
</script>
|
</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>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</html>{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user