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) */}
-
-
-
setSearchQuery(e?.target?.value)}
- onFocus={() => setSearchFocused(true)}
- onBlur={() => setSearchFocused(false)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- submitSearch();
- }
- }}
- aria-label="Search articles"
- />
- {searchQuery && (
-
+ {/* Search (left) — with autocomplete dropdown */}
+
+
+
+ setSearchQuery(e?.target?.value)}
+ onFocus={() => { setSearchFocused(true); if (suggestions.length > 0) setSuggestOpen(true); }}
+ onBlur={() => { setSearchFocused(false); setTimeout(() => setSuggestOpen(false), 150); }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ submitSearch();
+ }
+ if (e.key === "Escape") {
+ setSuggestOpen(false);
+ }
+ }}
+ aria-label="Search articles"
+ aria-autocomplete="list"
+ aria-expanded={suggestOpen}
+ aria-controls="bb-search-suggest"
+ />
+ {searchQuery && (
+
+ )}
+
+
+ {suggestOpen && suggestions.length > 0 && (
+
+ {suggestions.map((s) => (
+ -
+ e.preventDefault()}
+ onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
+ {s.title}
+ {s.category && {s.category}}
+
+
+ ))}
+ -
+
+
+
)}
diff --git a/src/lib/articles/legacy-source.ts b/src/lib/articles/legacy-source.ts
index 0b832a2..27a3bf1 100644
--- a/src/lib/articles/legacy-source.ts
+++ b/src/lib/articles/legacy-source.ts
@@ -314,9 +314,13 @@ export async function getLegacyArticlesBySection(
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(
query: string,
- limit = 50,
+ limit = 5000,
): Promise
{
const q = (query || "").trim();
if (!q) return [];
diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css
index ce80aaf..2dc808e 100644
--- a/src/styles/tailwind.css
+++ b/src/styles/tailwind.css
@@ -839,6 +839,97 @@ h1, h2, h3 {
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) */
.bb-browse-search {
position: relative;