From cb00f7f3430f541b6d366f59e7bb81dbc97d996c Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Wed, 20 May 2026 06:00:10 +0000 Subject: [PATCH] search: return all matches (not just 100) + add typeahead autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/, 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) --- src/app/api/search/suggest/route.ts | 55 ++++++++++++++ src/app/search/page.tsx | 2 +- src/components/Header.tsx | 112 +++++++++++++++++++++------- src/lib/articles/legacy-source.ts | 6 +- src/styles/tailwind.css | 91 ++++++++++++++++++++++ 5 files changed, 237 insertions(+), 29 deletions(-) create mode 100644 src/app/api/search/suggest/route.ts diff --git a/src/app/api/search/suggest/route.ts b/src/app/api/search/suggest/route.ts new file mode 100644 index 0000000..a882c77 --- /dev/null +++ b/src/app/api/search/suggest/route.ts @@ -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", + }, + }, + ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 2d7d8d1..9feae52 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -23,7 +23,7 @@ interface SearchPageProps { export default async function SearchPage({ searchParams }: SearchPageProps) { const { q } = await searchParams; const query = (q ?? "").trim(); - const results = query ? await searchLegacyArticles(query, 100) : []; + const results = query ? await searchLegacyArticles(query, 5000) : []; return (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0396fb6..51af282 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -37,13 +37,35 @@ export default function Header() { const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchFocused, setSearchFocused] = useState(false); + const [suggestions, setSuggestions] = useState>([]); + const [suggestOpen, setSuggestOpen] = useState(false); const submitSearch = () => { const q = searchQuery.trim(); if (!q) return; setMobileOpen(false); + setSuggestOpen(false); 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(null); const [currentUserEmail, setCurrentUserEmail] = useState(null); const [isAdmin, setIsAdmin] = useState(false); @@ -483,33 +505,69 @@ export default function Header() {
- {/* Search (left) */} -
-