feat: Cmd+K search overlay with debounced Directus query
This commit is contained in:
@@ -1,4 +1,167 @@
|
||||
'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() {
|
||||
return null
|
||||
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({
|
||||
search: query.trim(),
|
||||
'filter[status][_eq]': 'published',
|
||||
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 "{query}"</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user