From 418027b4699f5f985fa73dd60e489e6c52e8b3db Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Wed, 20 May 2026 16:22:04 +0000 Subject: [PATCH] search: real autocomplete previews + clearer placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLACEHOLDER: "Search broadcast news…" → "Search articles, gear, vendors". Drops the weird ellipsis and the "we're a broadcast site so 'broadcast news' is redundant" feel. API (/api/search/suggest): - Match expanded from {title, author_name} to {title, excerpt, content, author_name} — surfaces hits when the phrase lives deep in the article body. - Returns a snippet object {text, matchStart, matchEnd} sliced from the first hit (HTML-stripped), with ~80 chars of context on either side and "…" markers when truncated. - Returns matched_in: 'title' | 'excerpt' | 'content' | 'author' so the UI can show "match in article body" / "match in byline". - Returns date + author_name on each result. UI dropdown (Header.tsx): - Each suggestion now renders three rows: 1) Title (bold) + category chip 2) Snippet with the matched substring wrapped in 3) Author byline + date + (if match wasn't in title) a tag showing where the match occurred - Vertical stacking with a colored left-border on hover so the active suggestion is visible. CSS: - Stack item layout, snippet with -webkit-line-clamp:2 so long matches don't overflow. - background uses the accent blue at 28% so the highlight reads against the dark card. --- src/app/api/search/suggest/route.ts | 98 ++++++++++++++++++++++++++--- src/components/Header.tsx | 63 ++++++++++++++----- src/styles/tailwind.css | 67 ++++++++++++++++++-- 3 files changed, 201 insertions(+), 27 deletions(-) diff --git a/src/app/api/search/suggest/route.ts b/src/app/api/search/suggest/route.ts index a882c77..03fa835 100644 --- a/src/app/api/search/suggest/route.ts +++ b/src/app/api/search/suggest/route.ts @@ -15,6 +15,37 @@ function client() { }); } +// Extract a short snippet around the first match of `needle` inside the +// (HTML-stripped) `haystack`. Returns the substring + the absolute offset +// of the match within the snippet, so the caller can highlight it. +function buildSnippet( + haystack: string, + needle: string, + radius = 80, +): { text: string; matchStart: number; matchEnd: number } | null { + if (!haystack || !needle) return null; + const stripped = haystack + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/\s+/g, " ") + .trim(); + const idx = stripped.toLowerCase().indexOf(needle.toLowerCase()); + if (idx === -1) return null; + const start = Math.max(0, idx - radius); + const end = Math.min(stripped.length, idx + needle.length + radius); + const prefix = start > 0 ? "… " : ""; + const suffix = end < stripped.length ? " …" : ""; + const text = prefix + stripped.slice(start, end) + suffix; + return { + text, + matchStart: prefix.length + (idx - start), + matchEnd: prefix.length + (idx - start) + needle.length, + }; +} + export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = (searchParams.get("q") || "").trim(); @@ -25,11 +56,21 @@ export async function GET(req: Request) { const like = `%${q.replace(/[%_]/g, " ")}%`; const sb = client(); + // Match against title, excerpt, content, AND author name (was just + // title+author). Content match is what surfaces hits when the phrase + // lives deep in the article body. const { data, error } = await sb .from("wp_imported_posts") - .select("wp_slug, title, category") + .select("wp_slug, title, excerpt, content, category, wp_published_at, author_name") .eq("status", "published") - .or(`title.ilike.${like},author_name.ilike.${like}`) + .or( + [ + `title.ilike.${like}`, + `excerpt.ilike.${like}`, + `content.ilike.${like}`, + `author_name.ilike.${like}`, + ].join(","), + ) .order("wp_published_at", { ascending: false, nullsFirst: false }) .limit(limit); @@ -37,12 +78,53 @@ export async function GET(req: Request) { return NextResponse.json({ items: [], error: error.message }, { status: 500 }); } - const items = (data || []).map((r: any) => ({ - slug: r.wp_slug, - title: r.title, - category: r.category, - href: `/news/${r.wp_slug}`, - })); + // Build each item with title, category, date, and a snippet that + // shows WHERE the match happened. Snippet source priority: + // title → excerpt → content → author_name. + const items = (data || []).map((r: any) => { + let snippet: { text: string; matchStart: number; matchEnd: number } | null = null; + let matchedIn: "title" | "excerpt" | "content" | "author" | null = null; + + const titleHit = buildSnippet(r.title || "", q, 30); + if (titleHit) { + snippet = titleHit; + matchedIn = "title"; + } else { + const exHit = buildSnippet(r.excerpt || "", q, 70); + if (exHit) { + snippet = exHit; + matchedIn = "excerpt"; + } else { + const ctHit = buildSnippet(r.content || "", q, 80); + if (ctHit) { + snippet = ctHit; + matchedIn = "content"; + } else if ((r.author_name || "").toLowerCase().includes(q.toLowerCase())) { + const a = r.author_name || ""; + const i = a.toLowerCase().indexOf(q.toLowerCase()); + snippet = { text: a, matchStart: i, matchEnd: i + q.length }; + matchedIn = "author"; + } + } + } + + return { + slug: r.wp_slug, + title: r.title, + category: r.category, + date: r.wp_published_at + ? new Date(r.wp_published_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : null, + author_name: r.author_name || null, + snippet, + matched_in: matchedIn, + href: `/news/${r.wp_slug}`, + }; + }); return NextResponse.json( { items, query: q }, diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 51af282..e0612ed 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -37,7 +37,18 @@ export default function Header() { const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchFocused, setSearchFocused] = useState(false); - const [suggestions, setSuggestions] = useState>([]); + const [suggestions, setSuggestions] = useState< + Array<{ + slug: string; + title: string; + category?: string; + date?: string | null; + author_name?: string | null; + snippet?: { text: string; matchStart: number; matchEnd: number } | null; + matched_in?: "title" | "excerpt" | "content" | "author" | null; + href: string; + }> + >([]); const [suggestOpen, setSuggestOpen] = useState(false); const submitSearch = () => { @@ -512,7 +523,7 @@ export default function Header() { setSearchQuery(e?.target?.value)} onFocus={() => { setSearchFocused(true); if (suggestions.length > 0) setSuggestOpen(true); }} @@ -547,18 +558,42 @@ export default function Header() { id="bb-search-suggest" role="listbox" className="bb-browse-search-suggest"> - {suggestions.map((s) => ( -
  • - e.preventDefault()} - onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}> - {s.title} - {s.category && {s.category}} - -
  • - ))} + {suggestions.map((s) => { + const snippet = s.snippet; + const beforeMatch = snippet?.text.slice(0, snippet.matchStart) || ""; + const matchText = snippet?.text.slice(snippet.matchStart, snippet.matchEnd) || ""; + const afterMatch = snippet?.text.slice(snippet.matchEnd) || ""; + return ( +
  • + e.preventDefault()} + onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}> +
    + {s.title} + {s.category && {s.category}} +
    + {snippet && s.matched_in !== "title" && ( +
    + {beforeMatch} + {matchText} + {afterMatch} +
    + )} + {(s.date || s.author_name) && ( +
    + {s.author_name && {s.author_name}} + {s.author_name && s.date && ·} + {s.date && } + {s.matched_in === "author" && match in byline} + {s.matched_in === "content" && match in article body} +
    + )} + +
  • + ); + })}