Files

169 lines
5.4 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Search, X } from 'lucide-react'
import type { Article } from '@/lib/types'
export default function SearchOverlay() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<Article[]>([])
const [activeIndex, setActiveIndex] = useState(0)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const router = useRouter()
const close = useCallback(() => {
setOpen(false)
setQuery('')
setResults([])
setActiveIndex(0)
}, [])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(true)
}
if (e.key === 'Escape') close()
}
function onOpenSearch() {
setOpen(true)
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('kotobane:open-search', onOpenSearch)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('kotobane:open-search', onOpenSearch)
}
}, [close])
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
'filter[status][_eq]': 'published',
'filter[_or][0][title][_icontains]': query.trim(),
'filter[_or][1][excerpt][_icontains]': query.trim(),
limit: '8',
fields: 'id,title,slug,category.slug,category.name',
access_token: process.env.NEXT_PUBLIC_DIRECTUS_TOKEN ?? '',
})
const res = await fetch(
`${process.env.NEXT_PUBLIC_DIRECTUS_URL}/items/articles?${params}`
)
const data = await res.json()
setResults(data.data ?? [])
setActiveIndex(0)
} finally {
setLoading(false)
}
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query])
function navigate(article: Article) {
router.push(`/${article.category.slug}/${article.slug}`)
close()
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter' && results[activeIndex]) {
navigate(results[activeIndex])
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
onClick={close}
>
<div className="absolute inset-0 bg-black/60" />
<div
className="relative w-full max-w-[560px] mx-4 bg-bg-elevated border border-border rounded-lg shadow-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
<Search size={16} className="text-text-muted shrink-0" />
<input
ref={inputRef}
type="text"
placeholder="Search articles…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-disabled outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="text-text-muted hover:text-text-secondary"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
{results.length > 0 && (
<ul>
{results.map((article, i) => (
<li key={article.id}>
<button
onClick={() => navigate(article)}
className={`w-full text-left px-4 py-3 flex flex-col gap-0.5 transition-colors ${
i === activeIndex ? 'bg-bg-card' : 'hover:bg-bg-card'
}`}
>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-widest">
{article.category.name}
</span>
<span className="text-sm text-text-primary">{article.title}</span>
</button>
</li>
))}
</ul>
)}
{loading && (
<p className="px-4 py-3 text-sm text-text-muted">Searching</p>
)}
{!loading && query.trim() && results.length === 0 && (
<p className="px-4 py-3 text-sm text-text-muted">No results for &quot;{query}&quot;</p>
)}
<div className="flex items-center gap-4 px-4 py-2 border-t border-border">
<span className="text-[10px] text-text-disabled"> navigate</span>
<span className="text-[10px] text-text-disabled"> open</span>
<span className="text-[10px] text-text-disabled">esc close</span>
</div>
</div>
</div>
)
}