175 lines
6.6 KiB
TypeScript
175 lines
6.6 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from '@tanstack/react-router';
|
|
import { Search, User, Folder, Clock, X, History, ChevronDown } from 'lucide-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '../lib/api';
|
|
|
|
interface SearchResult {
|
|
id: string;
|
|
type: 'customer' | 'project' | 'time-entry';
|
|
label: string;
|
|
subtitle?: string;
|
|
}
|
|
|
|
export default function SearchBar() {
|
|
const [query, setQuery] = useState('');
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [history, setHistory] = useState<string[]>([]);
|
|
const [limit, setLimit] = useState(10);
|
|
const navigate = useNavigate();
|
|
|
|
useEffect(() => {
|
|
const saved = localStorage.getItem('search_history');
|
|
if (saved) {
|
|
try {
|
|
setHistory(JSON.parse(saved));
|
|
} catch (e) {
|
|
console.error('Failed to parse search history');
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const addToHistory = (term: string) => {
|
|
if (!term.trim()) return;
|
|
const updated = [term, ...history.filter((item) => item !== term)].slice(0, 10);
|
|
setHistory(updated);
|
|
localStorage.setItem('search_history', JSON.stringify(updated));
|
|
};
|
|
|
|
const { data: results, isLoading } = useQuery({
|
|
queryKey: ['search', query, limit],
|
|
queryFn: async () => {
|
|
if (query.length >= 2) {
|
|
addToHistory(query);
|
|
}
|
|
return api.search(query, limit);
|
|
},
|
|
enabled: query.length >= 2,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setIsOpen(false);
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
setIsOpen(true);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
const handleSelect = (result: SearchResult) => {
|
|
const paths: Record<SearchResult['type'], string> = {
|
|
customer: `/customers/${result.id}`,
|
|
project: `/projects/${result.id}`,
|
|
'time-entry': `/time-entries/${result.id}`,
|
|
};
|
|
navigate({ to: paths[result.type] });
|
|
setQuery('');
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleHistoryClick = (term: string) => {
|
|
setQuery(term);
|
|
setIsOpen(true);
|
|
};
|
|
|
|
const getIcon = (type: SearchResult['type']) => {
|
|
switch (type) {
|
|
case 'customer': return <User className="w-4 h-4 text-purple-500" />;
|
|
case 'project': return <Folder className="w-4 h-4 text-orange-500" />;
|
|
case 'time-entry': return <Clock className="w-4 h-4 text-green-500" />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-64">
|
|
<div className="relative group">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400 group-focus-within:text-zinc-600 dark:group-focus-within:text-zinc-300" />
|
|
<input
|
|
type="text"
|
|
className="w-full pl-9 pr-4 py-1.5 bg-zinc-100 dark:bg-zinc-800 border border-transparent focus:border-zinc-300 dark:focus:border-zinc-700 rounded-md text-sm outline-none transition-all placeholder-zinc-500 dark:placeholder-zinc-400"
|
|
placeholder="Search... (⌘K)"
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setLimit(10);
|
|
setIsOpen(true);
|
|
}}
|
|
onFocus={() => setIsOpen(true)}
|
|
/>
|
|
{query && (
|
|
<button
|
|
onClick={() => {
|
|
setQuery('');
|
|
setLimit(10);
|
|
}}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-xl z-50 overflow-hidden">
|
|
{query.length === 0 ? (
|
|
<div className="p-1">
|
|
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 flex items-center gap-1">
|
|
<History className="w-3 h-3" /> Recent Searches
|
|
</div>
|
|
{history.length > 0 ? (
|
|
history.map((term) => (
|
|
<button
|
|
key={term}
|
|
onClick={() => handleHistoryClick(term)}
|
|
className="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-600 dark:text-zinc-300 transition-colors"
|
|
>
|
|
{term}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="px-2 py-2 text-xs text-zinc-400 text-center">No recent searches</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="p-1">
|
|
{isLoading ? (
|
|
<div className="px-2 py-4 text-xs text-zinc-400 text-center animate-pulse">Searching...</div>
|
|
) : results && results.length > 0 ? (
|
|
<>
|
|
{results.map((result: SearchResult) => (
|
|
<button
|
|
key={result.id}
|
|
onClick={() => handleSelect(result)}
|
|
className="w-full flex items-center gap-3 px-2 py-2 text-sm rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-200 transition-colors"
|
|
>
|
|
<div className="p-1.5 bg-zinc-100 dark:bg-zinc-800 rounded-md">
|
|
{getIcon(result.type)}
|
|
</div>
|
|
<div className="flex flex-col items-start overflow-hidden">
|
|
<span className="font-medium truncate w-full text-left">{result.label}</span>
|
|
{result.subtitle && <span className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate w-full text-left">{result.subtitle}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
{results.length >= limit && (
|
|
<button
|
|
onClick={() => setLimit(prev => prev + 10)}
|
|
className="w-full flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
|
|
>
|
|
<ChevronDown className="w-3 h-3" /> More...
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="px-2 py-4 text-xs text-zinc-400 text-center">No results found</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |