|
| 1 | +"use client" |
| 2 | + |
| 3 | +import React, { useState, useEffect, useCallback, useMemo } from "react" |
| 4 | +import { motion, AnimatePresence } from "framer-motion" |
| 5 | +import { |
| 6 | + IconSearch, |
| 7 | + IconX, |
| 8 | + IconLoader, |
| 9 | + IconChecklist, |
| 10 | + IconMessage, |
| 11 | + IconBrain, |
| 12 | + IconFileText, |
| 13 | + IconArrowRight |
| 14 | +} from "@tabler/icons-react" |
| 15 | +import { cn } from "@utils/cn" |
| 16 | +import { useRouter } from "next/navigation" |
| 17 | +import { formatDistanceToNow, parseISO } from "date-fns" |
| 18 | + |
| 19 | +const useDebounce = (value, delay) => { |
| 20 | + const [debouncedValue, setDebouncedValue] = useState(value) |
| 21 | + useEffect(() => { |
| 22 | + const handler = setTimeout(() => { |
| 23 | + setDebouncedValue(value) |
| 24 | + }, delay) |
| 25 | + return () => { |
| 26 | + clearTimeout(handler) |
| 27 | + } |
| 28 | + }, [value, delay]) |
| 29 | + return debouncedValue |
| 30 | +} |
| 31 | + |
| 32 | +const ResultItem = ({ item }) => { |
| 33 | + const router = useRouter() |
| 34 | + const handleClick = () => { |
| 35 | + if (item.type === "task") { |
| 36 | + router.push(`/tasks?taskId=${item.task_id}`) |
| 37 | + } else if (item.type === "chat") { |
| 38 | + router.push(`/chat`) // Future enhancement: scroll to message |
| 39 | + } else if (item.type === "memory") { |
| 40 | + router.push(`/memories`) // Future enhancement: open memory detail |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + const icons = { |
| 45 | + task: <IconChecklist size={18} className="text-blue-400" />, |
| 46 | + chat: <IconMessage size={18} className="text-green-400" />, |
| 47 | + memory: <IconBrain size={18} className="text-yellow-400" /> |
| 48 | + } |
| 49 | + |
| 50 | + const title = |
| 51 | + item.type === "task" |
| 52 | + ? item.name |
| 53 | + : item.type === "chat" |
| 54 | + ? item.content |
| 55 | + : item.content |
| 56 | + |
| 57 | + const timestamp = item.timestamp || item.created_at |
| 58 | + |
| 59 | + return ( |
| 60 | + <button |
| 61 | + onClick={handleClick} |
| 62 | + className="w-full text-left p-3 rounded-lg hover:bg-neutral-700/50 transition-colors flex items-center gap-4" |
| 63 | + > |
| 64 | + <div className="flex-shrink-0">{icons[item.type]}</div> |
| 65 | + <div className="flex-1 overflow-hidden"> |
| 66 | + <p className="text-sm text-neutral-100 truncate font-medium"> |
| 67 | + {title} |
| 68 | + </p> |
| 69 | + <p className="text-xs text-neutral-400 mt-1"> |
| 70 | + {item.type.charAt(0).toUpperCase() + item.type.slice(1)} |
| 71 | + {timestamp && |
| 72 | + ` • ${formatDistanceToNow(parseISO(timestamp), { addSuffix: true })}`} |
| 73 | + </p> |
| 74 | + </div> |
| 75 | + <IconArrowRight |
| 76 | + size={16} |
| 77 | + className="text-neutral-500 flex-shrink-0" |
| 78 | + /> |
| 79 | + </button> |
| 80 | + ) |
| 81 | +} |
| 82 | + |
| 83 | +export default function GlobalSearch({ onClose }) { |
| 84 | + const [query, setQuery] = useState("") |
| 85 | + const [activeFilter, setActiveFilter] = useState("All") |
| 86 | + const [results, setResults] = useState([]) |
| 87 | + const [isLoading, setIsLoading] = useState(false) |
| 88 | + const debouncedQuery = useDebounce(query, 300) |
| 89 | + |
| 90 | + const filters = ["All", "Tasks", "Chats", "Memories"] |
| 91 | + |
| 92 | + const fetchResults = useCallback(async (searchQuery) => { |
| 93 | + if (searchQuery.length < 3) { |
| 94 | + setResults([]) |
| 95 | + return |
| 96 | + } |
| 97 | + setIsLoading(true) |
| 98 | + try { |
| 99 | + const response = await fetch( |
| 100 | + `/api/search?q=${encodeURIComponent(searchQuery)}` |
| 101 | + ) |
| 102 | + if (!response.ok) { |
| 103 | + throw new Error("Search failed") |
| 104 | + } |
| 105 | + const data = await response.json() |
| 106 | + setResults(data.results || []) |
| 107 | + } catch (error) { |
| 108 | + console.error("Search error:", error) |
| 109 | + setResults([]) |
| 110 | + } finally { |
| 111 | + setIsLoading(false) |
| 112 | + } |
| 113 | + }, []) |
| 114 | + |
| 115 | + useEffect(() => { |
| 116 | + fetchResults(debouncedQuery) |
| 117 | + }, [debouncedQuery, fetchResults]) |
| 118 | + |
| 119 | + const filteredResults = useMemo(() => { |
| 120 | + if (activeFilter === "All") { |
| 121 | + return results |
| 122 | + } |
| 123 | + const filterType = activeFilter.slice(0, -1).toLowerCase() // Tasks -> task |
| 124 | + return results.filter((r) => r.type === filterType) |
| 125 | + }, [results, activeFilter]) |
| 126 | + |
| 127 | + return ( |
| 128 | + <motion.div |
| 129 | + initial={{ opacity: 0 }} |
| 130 | + animate={{ opacity: 1 }} |
| 131 | + exit={{ opacity: 0 }} |
| 132 | + className="fixed inset-0 bg-black/70 backdrop-blur-md z-[60] flex items-start justify-center p-4 pt-[15vh] md:pt-[20vh]" |
| 133 | + onClick={onClose} |
| 134 | + > |
| 135 | + <motion.div |
| 136 | + initial={{ scale: 0.95, y: -20 }} |
| 137 | + animate={{ scale: 1, y: 0 }} |
| 138 | + exit={{ scale: 0.95, y: -20 }} |
| 139 | + transition={{ duration: 0.2, ease: "easeInOut" }} |
| 140 | + onClick={(e) => e.stopPropagation()} |
| 141 | + className="relative bg-neutral-900/80 backdrop-blur-xl p-4 rounded-2xl shadow-2xl w-full max-w-2xl border border-neutral-700 flex flex-col" |
| 142 | + > |
| 143 | + {/* Search Input */} |
| 144 | + <div className="relative flex-shrink-0"> |
| 145 | + <IconSearch |
| 146 | + className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500" |
| 147 | + size={20} |
| 148 | + /> |
| 149 | + <input |
| 150 | + type="text" |
| 151 | + value={query} |
| 152 | + onChange={(e) => setQuery(e.target.value)} |
| 153 | + placeholder="Search tasks, chats, and memories..." |
| 154 | + className="w-full bg-neutral-800/50 border border-neutral-700 rounded-lg pl-12 pr-10 py-3 text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange" |
| 155 | + autoFocus |
| 156 | + /> |
| 157 | + <button |
| 158 | + onClick={onClose} |
| 159 | + className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-full hover:bg-neutral-700" |
| 160 | + > |
| 161 | + <IconX size={18} /> |
| 162 | + </button> |
| 163 | + </div> |
| 164 | + |
| 165 | + {/* Filters */} |
| 166 | + <div className="flex flex-wrap gap-2 mt-4 px-1 flex-shrink-0"> |
| 167 | + {filters.map((filter) => ( |
| 168 | + <button |
| 169 | + key={filter} |
| 170 | + onClick={() => setActiveFilter(filter)} |
| 171 | + className={cn( |
| 172 | + "px-3 py-1.5 rounded-full text-sm font-medium transition-colors", |
| 173 | + activeFilter === filter |
| 174 | + ? "bg-brand-orange text-black" |
| 175 | + : "bg-neutral-800 text-neutral-300 hover:bg-neutral-700" |
| 176 | + )} |
| 177 | + > |
| 178 | + {filter} |
| 179 | + </button> |
| 180 | + ))} |
| 181 | + </div> |
| 182 | + |
| 183 | + {/* Results */} |
| 184 | + <div className="mt-4 pt-4 border-t border-neutral-800 min-h-[200px] max-h-[50vh] overflow-y-auto custom-scrollbar"> |
| 185 | + <AnimatePresence mode="wait"> |
| 186 | + {isLoading ? ( |
| 187 | + <motion.div |
| 188 | + key="loader" |
| 189 | + initial={{ opacity: 0 }} |
| 190 | + animate={{ opacity: 1 }} |
| 191 | + exit={{ opacity: 0 }} |
| 192 | + className="flex justify-center items-center h-full p-8" |
| 193 | + > |
| 194 | + <IconLoader className="animate-spin text-neutral-500" /> |
| 195 | + </motion.div> |
| 196 | + ) : filteredResults.length > 0 ? ( |
| 197 | + <motion.div |
| 198 | + key="results" |
| 199 | + initial={{ opacity: 0 }} |
| 200 | + animate={{ opacity: 1 }} |
| 201 | + exit={{ opacity: 0 }} |
| 202 | + className="space-y-1" |
| 203 | + > |
| 204 | + {filteredResults.map((item) => ( |
| 205 | + <ResultItem |
| 206 | + key={`${item.type}-${item.task_id || item.message_id || item.id}`} |
| 207 | + item={item} |
| 208 | + /> |
| 209 | + ))} |
| 210 | + </motion.div> |
| 211 | + ) : ( |
| 212 | + <motion.div |
| 213 | + key="empty" |
| 214 | + initial={{ opacity: 0 }} |
| 215 | + animate={{ opacity: 1 }} |
| 216 | + exit={{ opacity: 0 }} |
| 217 | + className="flex flex-col justify-center items-center text-center h-full p-8 text-neutral-500" |
| 218 | + > |
| 219 | + <IconFileText size={32} className="mb-2" /> |
| 220 | + <p className="font-medium text-neutral-400"> |
| 221 | + {debouncedQuery.length < 3 |
| 222 | + ? "Start typing to search" |
| 223 | + : `No results for "${debouncedQuery}"`} |
| 224 | + </p> |
| 225 | + <p className="text-sm"> |
| 226 | + {debouncedQuery.length < 3 |
| 227 | + ? "Search requires at least 3 characters." |
| 228 | + : "Try a different search term."} |
| 229 | + </p> |
| 230 | + </motion.div> |
| 231 | + )} |
| 232 | + </AnimatePresence> |
| 233 | + </div> |
| 234 | + </motion.div> |
| 235 | + </motion.div> |
| 236 | + ) |
| 237 | +} |
0 commit comments