122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState, useRef, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { blogPosts } from "@/data/blog";
|
|
|
|
/**
|
|
* Client-side blog search. The site is a static export, so there is no search
|
|
* server. We filter the known posts in the browser and link straight to the
|
|
* matching /blog/[slug] routes.
|
|
*/
|
|
export default function BlogSearch() {
|
|
const [query, setQuery] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const results = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
return blogPosts
|
|
.filter(
|
|
(p) =>
|
|
p.title.toLowerCase().includes(q) ||
|
|
p.category.toLowerCase().includes(q) ||
|
|
p.excerpt.toLowerCase().includes(q)
|
|
)
|
|
.slice(0, 6);
|
|
}, [query]);
|
|
|
|
// Close the results panel on outside click.
|
|
useEffect(() => {
|
|
function onDocClick(e: MouseEvent) {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(e.target as Node)
|
|
) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", onDocClick);
|
|
return () => document.removeEventListener("mousedown", onDocClick);
|
|
}, []);
|
|
|
|
const showPanel = open && query.trim().length > 0;
|
|
|
|
return (
|
|
<div className="dm-blog-search" ref={containerRef}>
|
|
<form
|
|
role="search"
|
|
className="dm-blog-search-form"
|
|
onSubmit={(e) => e.preventDefault()}
|
|
>
|
|
<label htmlFor="dm-blog-search-input" className="dm-sr-only">
|
|
Search articles
|
|
</label>
|
|
<input
|
|
id="dm-blog-search-input"
|
|
type="search"
|
|
className="dm-blog-search-input"
|
|
placeholder="Search articles…"
|
|
value={query}
|
|
autoComplete="off"
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
aria-controls="dm-blog-search-results"
|
|
/>
|
|
<span className="dm-blog-search-icon" aria-hidden="true">
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
</span>
|
|
</form>
|
|
|
|
{showPanel && (
|
|
<div
|
|
id="dm-blog-search-results"
|
|
className="dm-blog-search-results"
|
|
role="listbox"
|
|
>
|
|
{results.length === 0 ? (
|
|
<p className="dm-blog-search-empty">
|
|
No articles match “{query.trim()}”.
|
|
</p>
|
|
) : (
|
|
<ul>
|
|
{results.map((p) => (
|
|
<li key={p.slug} role="option" aria-selected="false">
|
|
<Link
|
|
href={`/blog/${p.slug}`}
|
|
className="dm-blog-search-result"
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<span className="dm-blog-search-result-cat">
|
|
{p.category}
|
|
</span>
|
|
<span className="dm-blog-search-result-title">
|
|
{p.title}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|