components/NavSearch.tsx
Rama main · réplica de lectura del repositorio original.
'use client';
import { search, SearchResult } from '@/app/(authenticated)/lib/actions';
import { truncateWord } from '@/utils/text';
import {
MagnifyingGlassIcon,
DocumentTextIcon,
Squares2X2Icon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useTranslations } from 'next-intl';
export default function NavSearch() {
const t = useTranslations('NavSearch');
const [open, setOpen] = useState(false);
const searchInput = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const [results, setResults] = useState<SearchResult[]>([]);
const [query, setQuery] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
const DEBOUNCE_MS = 350;
if (!query) {
setResults([]);
return;
}
let cancelled = false;
const handle = setTimeout(async () => {
try {
setLoading(true);
const res = await search(query);
if (!cancelled) setResults(res);
} catch (e) {
console.error(e);
if (!cancelled) setResults([]);
} finally {
setLoading(false);
}
}, DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [query]);
useEffect(() => {
if (open) {
setTimeout(() => searchInput.current?.focus(), 100);
} else {
setQuery('');
setResults([]);
}
}, [open]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.altKey && e.key === 'k') {
e.preventDefault();
setOpen(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleKeyDown = (key: string) => {
if (!resultsRef.current) return;
const anchors = Array.from(
resultsRef.current.querySelectorAll<HTMLAnchorElement>('a'),
);
const active = document.activeElement as HTMLAnchorElement | null;
const currentIndex = anchors.findIndex((a) => a === active);
if (key === 'ArrowDown') {
const nextIndex =
currentIndex < anchors.length - 1 ? currentIndex + 1 : 0;
anchors[nextIndex]?.focus();
} else if (key === 'ArrowUp') {
const prevIndex =
currentIndex > 0 ? currentIndex - 1 : anchors.length - 1;
anchors[prevIndex]?.focus();
} else if (key === 'Backspace') {
searchInput.current?.focus();
}
};
return (
<>
<button
onClick={() => setOpen(true)}
type="button"
className="flex items-center justify-center md:justify-start gap-2 p-2 md:px-4 md:py-2 text-sm text-muted-foreground hover:text-foreground md:bg-muted md:border border-transparent md:border-border hover:bg-accent rounded-full md:rounded-xl transition-all duration-300"
>
<MagnifyingGlassIcon className="h-5 w-5 md:h-4 md:w-4" />
<span className="hidden md:inline font-medium">{t('trigger')}</span>
<div className="hidden md:flex items-center gap-1 ml-4 text-[10px] font-bold uppercase tracking-widest opacity-60">
<kbd className="min-w-[2rem] h-5 inline-flex items-center justify-center px-1 bg-background border border-border rounded-md tracking-normal leading-none">
Alt
</kbd>
<span>+</span>
<kbd className="w-5 h-5 inline-flex items-center justify-center bg-background border border-border rounded-md tracking-normal leading-none">
K
</kbd>
</div>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-w-2xl top-[20%] translate-y-0 pt-6"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
</DialogHeader>
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInput}
type="text"
className="pl-10"
placeholder={t('placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e.key)}
/>
</div>
<div
ref={resultsRef}
role="presentation"
className="max-h-[400px] overflow-y-auto pr-2 custom-scrollbar space-y-1"
onKeyDown={(e) => handleKeyDown(e.key)}
>
{results.length === 0 ? (
loading ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/70">
<div className="animate-spin h-8 w-8 border-2 border-[#000000] border-t-transparent rounded-full mb-3" />
<span className="text-xs font-bold uppercase tracking-widest">
{t('searching')}
</span>
</div>
) : query ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/70">
<span className="text-sm">{t('noResults', { query })}</span>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/70">
<MagnifyingGlassIcon className="h-10 w-10 mb-4 opacity-20" />
<span className="text-sm">{t('emptyState')}</span>
</div>
)
) : (
<div className="flex flex-col gap-1">
{results.map((item, index) => (
<Link
key={index}
href={item.url}
className="flex flex-col gap-1 px-4 py-3 hover:bg-muted border border-transparent hover:border-border rounded-xl transition-all duration-200 group focus:bg-muted focus:border-border outline-none"
onClick={() => setOpen(false)}
>
<div className="flex items-center gap-3">
{item.type === 'note' ? (
<div className="p-2 bg-orange-50 rounded-lg group-hover:bg-orange-100 transition-colors">
<DocumentTextIcon className="h-4 w-4 text-orange-600" />
</div>
) : (
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<Squares2X2Icon className="h-4 w-4 text-blue-600" />
</div>
)}
<div className="flex flex-col">
<span className="text-[14px] font-bold text-foreground line-clamp-1">
{truncateWord(item.title, 80)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/70">
{item.type === 'note' ? t('noteType') : t('boardType')}
</span>
</div>
</div>
{item.matchContent && (
<p className="text-[12px] text-muted-foreground leading-relaxed ml-11 line-clamp-2 italic">
{item.matchContent}
</p>
)}
</Link>
))}
</div>
)}
</div>
{results.length > 0 && (
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-muted-foreground/70 pt-4 border-t border-border mt-2">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<kbd className="w-5 h-5 inline-flex items-center justify-center bg-muted border border-border rounded text-[10px] tracking-normal leading-none">
↑↓
</kbd>
{t('navigate')}
</span>
<span className="flex items-center gap-1.5">
<kbd className="min-w-[2.5rem] h-5 inline-flex items-center justify-center px-1 bg-muted border border-border rounded text-[10px] tracking-normal leading-none">
Enter
</kbd>
{t('open')}
</span>
</div>
<span>{t('results', { count: results.length })}</span>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}