"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Search, LayoutDashboard, Activity, Calendar, Kanban, FolderKanban, FileText, Wrench, Target, ExternalLink, CheckCircle2, Clock, AlertCircle, Timer, Circle, Filter, History, X, ChevronDown, } from "lucide-react"; import { siteUrls } from "@/lib/config/sites"; import { cn } from "@/lib/utils"; // Search result type from API - matches SearchableResult interface type SearchResultType = "task" | "project" | "document" | "sprint"; interface SearchResultHighlight { field: string; text: string; matches: Array<{ start: number; end: number }>; } interface SearchResult { id: string; type: SearchResultType; title: string; snippet?: string; url: string; icon: string; status?: string; color?: string; highlights?: SearchResultHighlight[]; score: number; updatedAt?: string; } interface SearchFilters { types?: SearchResultType[]; status?: string[]; } interface SearchHistoryItem { query: string; filters?: SearchFilters; timestamp: number; resultCount?: number; } const navItems = [ { name: "Dashboard", href: "/", icon: LayoutDashboard, shortcut: "D" }, { name: "Activity", href: "/activity", icon: Activity, shortcut: "A" }, { name: "Calendar", href: "/calendar", icon: Calendar, shortcut: "C" }, { name: "Tasks", href: "/tasks", icon: Kanban, shortcut: "T" }, { name: "Projects", href: "/projects", icon: FolderKanban, shortcut: "P" }, { name: "Documents", href: "/documents", icon: FileText, shortcut: "D" }, { name: "Tools", href: "/tools", icon: Wrench, shortcut: "O" }, { name: "Mission", href: "/mission", icon: Target, shortcut: "M" }, ]; const quickLinks = [ { name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink }, { name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink }, { name: "Gitea", url: siteUrls.gitea, icon: ExternalLink }, ]; // Icon mapping for search result types const typeIcons: Record = { task: Kanban, project: FolderKanban, document: FileText, sprint: Timer, }; // Type labels const typeLabels: Record = { task: "Task", project: "Project", document: "Document", sprint: "Sprint", }; // Available filters const typeFilterOptions: { value: SearchResultType; label: string; icon: React.ElementType }[] = [ { value: "task", label: "Tasks", icon: Kanban }, { value: "project", label: "Projects", icon: FolderKanban }, { value: "document", label: "Documents", icon: FileText }, { value: "sprint", label: "Sprints", icon: Timer }, ]; const statusFilterOptions = [ { value: "open", label: "Open" }, { value: "in-progress", label: "In Progress" }, { value: "done", label: "Done" }, { value: "todo", label: "Todo" }, { value: "completed", label: "Completed" }, ]; function getStatusIcon(status?: string) { switch (status) { case "done": case "completed": return ; case "in-progress": return ; case "open": case "todo": return ; default: return ; } } // Highlight text component function HighlightedText({ text, matches, className }: { text: string; matches: Array<{ start: number; end: number }>; className?: string; }) { if (!matches || matches.length === 0) { return {text}; } const parts: React.ReactNode[] = []; let lastEnd = 0; matches.forEach((match, index) => { // Add text before match if (match.start > lastEnd) { parts.push( {text.substring(lastEnd, match.start)} ); } // Add highlighted match parts.push( {text.substring(match.start, match.end)} ); lastEnd = match.end; }); // Add remaining text if (lastEnd < text.length) { parts.push({text.substring(lastEnd)}); } return {parts}; } const SEARCH_HISTORY_KEY = "mission-control-search-history"; const MAX_HISTORY_ITEMS = 10; export function QuickSearch() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [filters, setFilters] = useState({}); const [showFilters, setShowFilters] = useState(false); const [searchHistory, setSearchHistory] = useState([]); const [showHistory, setShowHistory] = useState(false); const inputRef = useRef(null); const router = useRouter(); // Load search history from localStorage useEffect(() => { try { const saved = localStorage.getItem(SEARCH_HISTORY_KEY); if (saved) { const parsed = JSON.parse(saved); // Filter out items older than 30 days const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; setSearchHistory(parsed.filter((item: SearchHistoryItem) => item.timestamp > thirtyDaysAgo)); } } catch (e) { console.error("Failed to load search history:", e); } }, []); // Save search history to localStorage const saveToHistory = useCallback((query: string, resultCount: number, filters?: SearchFilters) => { if (!query.trim()) return; try { const newItem: SearchHistoryItem = { query: query.trim(), filters: Object.keys(filters || {}).length > 0 ? filters : undefined, timestamp: Date.now(), resultCount, }; setSearchHistory(prev => { // Remove duplicate queries const filtered = prev.filter(item => item.query.toLowerCase() !== query.toLowerCase()); // Add new item at the beginning const updated = [newItem, ...filtered].slice(0, MAX_HISTORY_ITEMS); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); return updated; }); } catch (e) { console.error("Failed to save search history:", e); } }, []); // Clear search history const clearHistory = useCallback(() => { localStorage.removeItem(SEARCH_HISTORY_KEY); setSearchHistory([]); }, []); // Remove single history item const removeHistoryItem = useCallback((index: number) => { setSearchHistory(prev => { const updated = prev.filter((_, i) => i !== index); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); return updated; }); }, []); // Debounced search useEffect(() => { if (!open || query.length < 2) { setResults([]); setLoading(false); setShowHistory(query.length === 0); return; } setShowHistory(false); setLoading(true); setResults([]); let cancelled = false; const timer = setTimeout(async () => { try { const params = new URLSearchParams(); params.set("q", query); if (filters.types?.length) params.set("types", filters.types.join(",")); if (filters.status?.length) params.set("status", filters.status.join(",")); const res = await fetch(`/api/search?${params.toString()}`); if (!res.ok) { throw new Error(`Search request failed with status ${res.status}`); } const data = (await res.json()) as { results?: SearchResult[]; total?: number }; if (!cancelled) { setResults(Array.isArray(data.results) ? data.results : []); // Save to history if we got results if (data.total && data.total > 0) { saveToHistory(query, data.total, filters); } } } catch (err) { console.error("Search error:", err); if (!cancelled) { setResults([]); } } finally { if (!cancelled) { setLoading(false); } } }, 150); // 150ms debounce return () => { cancelled = true; clearTimeout(timer); }; }, [query, open, filters, saveToHistory]); // Keyboard shortcut useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((open) => !open); } }; document.addEventListener("keydown", down); return () => document.removeEventListener("keydown", down); }, []); const runCommand = useCallback((command: () => void) => { setOpen(false); setQuery(""); setShowHistory(false); command(); }, []); const handleResultSelect = (result: SearchResult) => { runCommand(() => { if (result.url?.startsWith("http")) { window.open(result.url, "_blank"); } else if (result.url) { router.push(result.url); } }); }; const handleHistorySelect = (item: SearchHistoryItem) => { setQuery(item.query); if (item.filters) { setFilters(item.filters); } setShowHistory(false); // Focus input after selection inputRef.current?.focus(); }; const toggleTypeFilter = (type: SearchResultType) => { setFilters(prev => ({ ...prev, types: prev.types?.includes(type) ? prev.types.filter(t => t !== type) : [...(prev.types || []), type] })); }; const toggleStatusFilter = (status: string) => { setFilters(prev => ({ ...prev, status: prev.status?.includes(status) ? prev.status.filter(s => s !== status) : [...(prev.status || []), status] })); }; const clearFilters = () => { setFilters({}); }; const activeFilterCount = (filters.types?.length || 0) + (filters.status?.length || 0); // Group results by type const groupedResults = results.reduce((acc, result) => { if (!acc[result.type]) acc[result.type] = []; acc[result.type].push(result); return acc; }, {} as Record); // Get ordered list of types that have results const activeTypes = (Object.keys(groupedResults) as SearchResultType[]).filter( (type) => groupedResults[type]?.length > 0 ); // Get title highlight for a result const getTitleHighlight = (result: SearchResult) => { const titleHighlight = result.highlights?.find(h => h.field === "title"); if (titleHighlight && titleHighlight.matches.length > 0) { return titleHighlight; } return { text: result.title, matches: [] }; }; // Get snippet highlight for a result const getSnippetHighlight = (result: SearchResult) => { const snippetField = result.type === "sprint" ? "goal" : result.type === "document" ? "content" : "description"; return result.highlights?.find(h => h.field === snippetField); }; return ( <> {/* Search Button Trigger */} {/* Command Dialog */}
{/* Filter Button */}

Filters

{activeFilterCount > 0 && ( )}
{/* Type Filters */}
{typeFilterOptions.map(option => { const Icon = option.icon; const isActive = filters.types?.includes(option.value); return ( ); })}
{/* Status Filters */}
{statusFilterOptions.map(option => { const isActive = filters.status?.includes(option.value); return ( ); })}
{/* Search History */} {showHistory && searchHistory.length > 0 && !loading && ( {searchHistory.slice(0, 5).map((item, index) => ( handleHistorySelect(item)} className="group" >
{item.query} {item.filters && ( {item.filters.types?.map(t => typeLabels[t]).join(", ")} {item.filters.status && ` • ${item.filters.status.join(", ")}`} )}
{item.resultCount !== undefined && ( {item.resultCount} results )}
))} Clear search history
)} {loading ? (
) : query.length < 2 ? ( "Type to search..." ) : (

No results found{activeFilterCount > 0 ? " with current filters" : ""}

{activeFilterCount > 0 && ( )}
)} {/* Show search results when query exists */} {query.length >= 2 && activeTypes.length > 0 && ( <> {activeTypes.map((type) => { const Icon = typeIcons[type]; const typeResults = groupedResults[type]; return ( {typeResults.slice(0, 5).map((result) => { const titleHighlight = getTitleHighlight(result); const snippetHighlight = getSnippetHighlight(result); return ( handleResultSelect(result)} > {result.type === "task" ? ( getStatusIcon(result.status) ) : result.type === "project" && result.color ? (
) : ( )}
{snippetHighlight && ( )}
{result.status && result.type !== "task" && ( {result.status} )} ); })} ); })} )} {/* Navigation */} {navItems.map((item) => { const Icon = item.icon; return ( runCommand(() => router.push(item.href))} > {item.name} ); })} {/* Quick Links */} {quickLinks.map((link) => { const Icon = link.icon; return ( runCommand(() => window.open(link.url, "_blank"))} > {link.name} ); })} ); }