git-subtree-dir: manga-site git-subtree-split: f2ef775f7095dc2b107b576cd4053593e89dd887
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import Link from "next/link";
|
|
|
|
type SearchResult = {
|
|
id: number;
|
|
title: string;
|
|
slug: string;
|
|
coverUrl: string;
|
|
};
|
|
|
|
export function SearchBar() {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
function handleSearch(value: string) {
|
|
setQuery(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (value.trim().length < 2) {
|
|
setResults([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
debounceRef.current = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/search?q=${encodeURIComponent(value.trim())}`
|
|
);
|
|
const data = await res.json();
|
|
setResults(data);
|
|
setOpen(true);
|
|
} catch {
|
|
setResults([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} className="relative flex-1 max-w-md">
|
|
<div className="relative">
|
|
<svg
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
onFocus={() => results.length > 0 && setOpen(true)}
|
|
placeholder="Search manga..."
|
|
className="w-full pl-10 pr-4 py-2 text-sm bg-surface border border-border rounded-xl focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-muted transition-colors"
|
|
suppressHydrationWarning
|
|
/>
|
|
{loading && (
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{open && results.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-surface border border-border rounded-xl shadow-2xl overflow-hidden z-50">
|
|
{results.map((manga) => (
|
|
<Link
|
|
key={manga.id}
|
|
href={`/manga/${manga.slug}`}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
setQuery("");
|
|
}}
|
|
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover transition-colors"
|
|
>
|
|
<img
|
|
src={manga.coverUrl}
|
|
alt={manga.title}
|
|
className="w-10 h-14 rounded object-cover bg-card"
|
|
/>
|
|
<span className="text-sm font-medium truncate">
|
|
{manga.title}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{open && query.trim().length >= 2 && results.length === 0 && !loading && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-surface border border-border rounded-xl shadow-2xl p-4 z-50">
|
|
<p className="text-sm text-muted text-center">No results found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|