diff --git a/components/search/SearchOverlay.tsx b/components/search/SearchOverlay.tsx index d58d9e9..bd1b000 100644 --- a/components/search/SearchOverlay.tsx +++ b/components/search/SearchOverlay.tsx @@ -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([]) + const [activeIndex, setActiveIndex] = useState(0) + const [loading, setLoading] = useState(false) + const inputRef = useRef(null) + const debounceRef = useRef | 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 ( +
+
+ +
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + className="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-disabled outline-none" + /> + {query && ( + + )} +
+ + {results.length > 0 && ( +
    + {results.map((article, i) => ( +
  • + +
  • + ))} +
+ )} + + {loading && ( +

Searching…

+ )} + + {!loading && query.trim() && results.length === 0 && ( +

No results for "{query}"

+ )} + +
+ ↑↓ navigate + ↵ open + esc close +
+
+
+ ) }