File size: 4,600 Bytes
61aec16
 
 
 
 
 
d212ba6
61aec16
 
 
 
 
 
 
d212ba6
61aec16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d212ba6
61aec16
 
 
 
d212ba6
61aec16
 
 
d212ba6
 
 
 
61aec16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d212ba6
 
61aec16
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/**
 * T079: Search bar with debounced queries and dropdown results
 */
import { useState, useEffect, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import { Button } from '@/components/ui/button';
import { SearchResultSkeleton } from '@/components/SearchResultSkeleton';
import { searchNotes } from '@/services/api';
import type { SearchResult } from '@/types/search';

interface SearchBarProps {
  onSelectNote: (path: string) => void;
}

export function SearchBar({ onSelectNote }: SearchBarProps) {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isOpen, setIsOpen] = useState(false);

  // Debounce search query (300ms)
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  // Execute search when debounced query changes
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults([]);
      setIsOpen(false);
      return;
    }

    const performSearch = async () => {
      setIsLoading(true);
      try {
        const searchResults = await searchNotes(debouncedQuery);
        setResults(searchResults);
        setIsOpen(searchResults.length > 0);
      } catch (error) {
        console.error('Search error:', error);
        setResults([]);
        setIsOpen(false);
      } finally {
        setIsLoading(false);
      }
    };

    performSearch();
  }, [debouncedQuery]);

  const handleSelectResult = useCallback(
    (path: string) => {
      onSelectNote(path);
      setQuery('');
      setResults([]);
      setIsOpen(false);
    },
    [onSelectNote]
  );

  const handleClear = useCallback(() => {
    setQuery('');
    setResults([]);
    setIsOpen(false);
  }, []);

  return (
    <div className="relative w-full">
      <div className="relative">
        <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
        <Input
          type="text"
          placeholder="Search notes..."
          className="pl-8 pr-8"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onFocus={() => {
            if (results.length > 0) {
              setIsOpen(true);
            }
          }}
        />
        {query && (
          <Button
            variant="ghost"
            size="sm"
            className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
            onClick={handleClear}
          >
            <X className="h-4 w-4" />
          </Button>
        )}
      </div>

      {isOpen && results.length > 0 && (
        <div className="absolute top-full left-0 right-0 mt-1 z-50 animate-slide-in-down">
          <div className="bg-popover border border-border rounded-md shadow-md max-h-[400px] overflow-auto">
            <Command>
              <CommandList>
                <CommandGroup heading={`${results.length} result${results.length !== 1 ? 's' : ''}`}>
                  {results.map((result, index) => (
                    <CommandItem
                      key={result.note_path}
                      onSelect={() => handleSelectResult(result.note_path)}
                      className={cn(
                        "cursor-pointer",
                        index < 5 && `animate-stagger-${index + 1}`
                      )}
                    >
                      <div className="flex flex-col gap-1 w-full">
                        <div className="font-medium">{result.title}</div>
                        <div className="text-xs text-muted-foreground line-clamp-2">
                          {result.snippet}
                        </div>
                        <div className="text-xs text-muted-foreground">
                          {result.note_path}
                        </div>
                      </div>
                    </CommandItem>
                  ))}
                </CommandGroup>
              </CommandList>
            </Command>
          </div>
        </div>
      )}

      {isLoading && (
        <div className="absolute top-full left-0 right-0 mt-1 z-50">
          <div className="bg-popover border border-border rounded-md shadow-md p-3">
            <SearchResultSkeleton />
          </div>
        </div>
      )}
    </div>
  );
}