search: return all matches (not just 100) + add typeahead autocomplete
- searchLegacyArticles() default limit: 50 → 5000. /search page
explicitly passes 5000 too. ilike on title/excerpt/author is fast
enough on the 29k-row archive that returning everything is fine.
- New API route /api/search/suggest?q=...&limit=8 returns the top
matching article titles + category + slug, with a 30-second CDN
cache + 60-second stale-while-revalidate.
- The search input in the Browse bar now debounces (150ms) and
fires that API per keystroke once 2+ chars are typed. Matches
appear in a styled dropdown below the input — click to navigate
directly to /news/<slug>, or press Enter to fall through to the
full /search page. "See all results for …" link in the dropdown
footer.
- Keyboard: Escape closes the dropdown; aria-autocomplete + role
listbox/option for accessibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
src/app/api/search/suggest/route.ts
Normal file
55
src/app/api/search/suggest/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
|
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
|
||||||
|
|
||||||
|
function client() {
|
||||||
|
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||||
|
db: { schema: SCHEMA as "public" },
|
||||||
|
auth: { persistSession: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = (searchParams.get("q") || "").trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
return NextResponse.json({ items: [] });
|
||||||
|
}
|
||||||
|
const limit = Math.min(parseInt(searchParams.get("limit") || "8", 10) || 8, 15);
|
||||||
|
const like = `%${q.replace(/[%_]/g, " ")}%`;
|
||||||
|
|
||||||
|
const sb = client();
|
||||||
|
const { data, error } = await sb
|
||||||
|
.from("wp_imported_posts")
|
||||||
|
.select("wp_slug, title, category")
|
||||||
|
.eq("status", "published")
|
||||||
|
.or(`title.ilike.${like},author_name.ilike.${like}`)
|
||||||
|
.order("wp_published_at", { ascending: false, nullsFirst: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ items, query: q },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ interface SearchPageProps {
|
|||||||
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||||
const { q } = await searchParams;
|
const { q } = await searchParams;
|
||||||
const query = (q ?? "").trim();
|
const query = (q ?? "").trim();
|
||||||
const results = query ? await searchLegacyArticles(query, 100) : [];
|
const results = query ? await searchLegacyArticles(query, 5000) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
|||||||
@@ -37,13 +37,35 @@ export default function Header() {
|
|||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<Array<{ slug: string; title: string; category?: string; href: string }>>([]);
|
||||||
|
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||||
|
|
||||||
const submitSearch = () => {
|
const submitSearch = () => {
|
||||||
const q = searchQuery.trim();
|
const q = searchQuery.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
|
setSuggestOpen(false);
|
||||||
router.push(`/search?q=${encodeURIComponent(q)}`);
|
router.push(`/search?q=${encodeURIComponent(q)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Autocomplete: debounced fetch to /api/search/suggest while the user types.
|
||||||
|
useEffect(() => {
|
||||||
|
const q = searchQuery.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=8`, { cache: "no-store" })
|
||||||
|
.then((r) => r.ok ? r.json() : { items: [] })
|
||||||
|
.then((d) => {
|
||||||
|
setSuggestions(d.items || []);
|
||||||
|
setSuggestOpen(true);
|
||||||
|
})
|
||||||
|
.catch(() => setSuggestions([]));
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [searchQuery]);
|
||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null);
|
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
@@ -483,7 +505,8 @@ export default function Header() {
|
|||||||
<div className="bb-browse-bar hidden md:block" role="navigation" aria-label="Browse sections">
|
<div className="bb-browse-bar hidden md:block" role="navigation" aria-label="Browse sections">
|
||||||
<div className="max-w-container mx-auto px-4">
|
<div className="max-w-container mx-auto px-4">
|
||||||
<div className="bb-browse-row">
|
<div className="bb-browse-row">
|
||||||
{/* Search (left) */}
|
{/* Search (left) — with autocomplete dropdown */}
|
||||||
|
<div className="bb-browse-search-wrap">
|
||||||
<div className="bb-browse-search">
|
<div className="bb-browse-search">
|
||||||
<SearchIcon size={14} strokeWidth={1.75} className="bb-browse-search-icon" aria-hidden="true" />
|
<SearchIcon size={14} strokeWidth={1.75} className="bb-browse-search-icon" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
@@ -492,20 +515,26 @@ export default function Header() {
|
|||||||
placeholder="Search broadcast news…"
|
placeholder="Search broadcast news…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e?.target?.value)}
|
onChange={(e) => setSearchQuery(e?.target?.value)}
|
||||||
onFocus={() => setSearchFocused(true)}
|
onFocus={() => { setSearchFocused(true); if (suggestions.length > 0) setSuggestOpen(true); }}
|
||||||
onBlur={() => setSearchFocused(false)}
|
onBlur={() => { setSearchFocused(false); setTimeout(() => setSuggestOpen(false), 150); }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitSearch();
|
submitSearch();
|
||||||
}
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setSuggestOpen(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
aria-label="Search articles"
|
aria-label="Search articles"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={suggestOpen}
|
||||||
|
aria-controls="bb-search-suggest"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSearchQuery(""); searchRef.current?.focus(); }}
|
onClick={() => { setSearchQuery(""); setSuggestions([]); searchRef.current?.focus(); }}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
className="bb-browse-search-clear">
|
className="bb-browse-search-clear">
|
||||||
<CloseIcon size={11} />
|
<CloseIcon size={11} />
|
||||||
@@ -513,6 +542,35 @@ export default function Header() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{suggestOpen && suggestions.length > 0 && (
|
||||||
|
<ul
|
||||||
|
id="bb-search-suggest"
|
||||||
|
role="listbox"
|
||||||
|
className="bb-browse-search-suggest">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<li key={s.slug} role="option">
|
||||||
|
<Link
|
||||||
|
href={s.href}
|
||||||
|
className="bb-browse-search-suggest-item"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
|
||||||
|
<span className="bb-browse-search-suggest-title">{s.title}</span>
|
||||||
|
{s.category && <span className="bb-browse-search-suggest-cat">{s.category}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li role="option" className="bb-browse-search-suggest-all">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={submitSearch}>
|
||||||
|
See all results for "{searchQuery}" →
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Menu (right) */}
|
{/* Menu (right) */}
|
||||||
<nav className="bb-browse-menu" aria-label="Main sections">
|
<nav className="bb-browse-menu" aria-label="Main sections">
|
||||||
<EventsDropdown />
|
<EventsDropdown />
|
||||||
|
|||||||
@@ -314,9 +314,13 @@ export async function getLegacyArticlesBySection(
|
|||||||
return merged.slice(0, limit).map((m) => m.article);
|
return merged.slice(0, limit).map((m) => m.article);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default raised from 50 → 5000 because users expect search to return
|
||||||
|
// every match. The 29k-article archive is small enough that title/excerpt
|
||||||
|
// ilike filters stay fast even without an explicit cap. Pass a smaller
|
||||||
|
// `limit` (e.g. 10) for autocomplete-style typeahead queries.
|
||||||
export async function searchLegacyArticles(
|
export async function searchLegacyArticles(
|
||||||
query: string,
|
query: string,
|
||||||
limit = 50,
|
limit = 5000,
|
||||||
): Promise<Article[]> {
|
): Promise<Article[]> {
|
||||||
const q = (query || "").trim();
|
const q = (query || "").trim();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
|
|||||||
@@ -839,6 +839,97 @@ h1, h2, h3 {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search wrapper holds input + autocomplete dropdown */
|
||||||
|
.bb-browse-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 1 360px;
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autocomplete dropdown */
|
||||||
|
.bb-browse-search-suggest {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% - 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 60;
|
||||||
|
background: #0d1118;
|
||||||
|
border: 1px solid #2a3a50;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
|
||||||
|
padding: 4px 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
color: #d1d8e4;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-item:hover,
|
||||||
|
.bb-browse-search-suggest-item:focus-visible {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-cat {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-all {
|
||||||
|
border-top: 1px solid #1f2937;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-all button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bb-browse-search-suggest-all button:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Search input (left side) */
|
/* Search input (left side) */
|
||||||
.bb-browse-search {
|
.bb-browse-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user