mission-control/components/layout/quick-search.tsx
OpenClaw Bot 0092b318c2 feat(search): Enhance Mission Control search functionality
- Add search result highlighting with match positions
- Implement advanced filters (type, status, date range)
- Add relevance scoring algorithm with recency/status boosts
- Add search history with localStorage persistence
- Create useSearch and useSearchHistory hooks
- Add filter UI with popover component
- Improve visual feedback and status icons

Task: 56ae2be4-fcf1-403a-87fb-ea9de966f456
2026-02-25 15:28:47 -06:00

679 lines
22 KiB
TypeScript

"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<SearchResultType, React.ElementType> = {
task: Kanban,
project: FolderKanban,
document: FileText,
sprint: Timer,
};
// Type labels
const typeLabels: Record<SearchResultType, string> = {
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 <CheckCircle2 className="w-4 h-4 text-green-500" />;
case "in-progress":
return <Clock className="w-4 h-4 text-blue-500" />;
case "open":
case "todo":
return <Circle className="w-4 h-4 text-muted-foreground" />;
default:
return <AlertCircle className="w-4 h-4 text-muted-foreground" />;
}
}
// Highlight text component
function HighlightedText({
text,
matches,
className
}: {
text: string;
matches: Array<{ start: number; end: number }>;
className?: string;
}) {
if (!matches || matches.length === 0) {
return <span className={className}>{text}</span>;
}
const parts: React.ReactNode[] = [];
let lastEnd = 0;
matches.forEach((match, index) => {
// Add text before match
if (match.start > lastEnd) {
parts.push(
<span key={`text-${index}`}>{text.substring(lastEnd, match.start)}</span>
);
}
// Add highlighted match
parts.push(
<mark
key={`mark-${index}`}
className="bg-yellow-200 dark:bg-yellow-900/50 text-inherit rounded px-0.5"
>
{text.substring(match.start, match.end)}
</mark>
);
lastEnd = match.end;
});
// Add remaining text
if (lastEnd < text.length) {
parts.push(<span key="text-end">{text.substring(lastEnd)}</span>);
}
return <span className={className}>{parts}</span>;
}
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<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<SearchFilters>({});
const [showFilters, setShowFilters] = useState(false);
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [showHistory, setShowHistory] = useState(false);
const inputRef = useRef<HTMLInputElement>(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<SearchResultType, SearchResult[]>);
// 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 */}
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground bg-muted hover:bg-muted/80 rounded-md border border-border/50 transition-colors"
aria-label="Open search (⌘K)"
>
<Search className="w-4 h-4" aria-hidden="true" />
<span className="hidden sm:inline">Search...</span>
<kbd className="hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-background px-1.5 font-mono text-[10px] font-medium text-muted-foreground" aria-hidden="true">
<span className="text-xs"></span>K
</kbd>
</button>
{/* Command Dialog */}
<CommandDialog open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-2 px-3 border-b">
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
<CommandInput
ref={inputRef}
placeholder="Search tasks, projects, sprints, documents..."
value={query}
onValueChange={setQuery}
className="flex-1 border-0"
/>
{/* Filter Button */}
<Popover open={showFilters} onOpenChange={setShowFilters}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1",
activeFilterCount > 0 && "bg-primary/10 text-primary"
)}
>
<Filter className="w-4 h-4" />
{activeFilterCount > 0 && (
<Badge variant="secondary" className="h-5 px-1 text-xs">
{activeFilterCount}
</Badge>
)}
<ChevronDown className="w-3 h-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Filters</h4>
{activeFilterCount > 0 && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear all
</Button>
)}
</div>
{/* Type Filters */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Type</label>
<div className="flex flex-wrap gap-2">
{typeFilterOptions.map(option => {
const Icon = option.icon;
const isActive = filters.types?.includes(option.value);
return (
<Button
key={option.value}
variant={isActive ? "default" : "outline"}
size="sm"
onClick={() => toggleTypeFilter(option.value)}
className="h-7 gap-1"
>
<Icon className="w-3 h-3" />
{option.label}
</Button>
);
})}
</div>
</div>
{/* Status Filters */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Status</label>
<div className="flex flex-wrap gap-2">
{statusFilterOptions.map(option => {
const isActive = filters.status?.includes(option.value);
return (
<Button
key={option.value}
variant={isActive ? "default" : "outline"}
size="sm"
onClick={() => toggleStatusFilter(option.value)}
className="h-7"
>
{option.label}
</Button>
);
})}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<CommandList>
{/* Search History */}
{showHistory && searchHistory.length > 0 && !loading && (
<CommandGroup heading="Recent Searches">
{searchHistory.slice(0, 5).map((item, index) => (
<CommandItem
key={index}
value={`history-${item.query}`}
onSelect={() => handleHistorySelect(item)}
className="group"
>
<History className="w-4 h-4 mr-2 text-muted-foreground" />
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{item.query}</span>
{item.filters && (
<span className="text-xs text-muted-foreground">
{item.filters.types?.map(t => typeLabels[t]).join(", ")}
{item.filters.status && `${item.filters.status.join(", ")}`}
</span>
)}
</div>
{item.resultCount !== undefined && (
<span className="text-xs text-muted-foreground ml-2">
{item.resultCount} results
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 ml-2"
onClick={(e) => {
e.stopPropagation();
removeHistoryItem(index);
}}
>
<X className="w-3 h-3" />
</Button>
</CommandItem>
))}
<CommandItem
onSelect={clearHistory}
className="text-muted-foreground"
>
<X className="w-4 h-4 mr-2" />
Clear search history
</CommandItem>
</CommandGroup>
)}
<CommandEmpty>
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : query.length < 2 ? (
"Type to search..."
) : (
<div className="py-6 text-center">
<p>No results found{activeFilterCount > 0 ? " with current filters" : ""}</p>
{activeFilterCount > 0 && (
<Button
variant="link"
size="sm"
onClick={clearFilters}
className="mt-2"
>
Clear filters
</Button>
)}
</div>
)}
</CommandEmpty>
{/* Show search results when query exists */}
{query.length >= 2 && activeTypes.length > 0 && (
<>
{activeTypes.map((type) => {
const Icon = typeIcons[type];
const typeResults = groupedResults[type];
return (
<CommandGroup key={type} heading={`${typeLabels[type]}s (${typeResults.length})`}>
{typeResults.slice(0, 5).map((result) => {
const titleHighlight = getTitleHighlight(result);
const snippetHighlight = getSnippetHighlight(result);
return (
<CommandItem
key={`${result.type}-${result.id}`}
value={`${result.title} ${result.snippet ?? ""} ${result.status ?? ""} ${typeLabels[result.type]}`}
keywords={[query]}
onSelect={() => handleResultSelect(result)}
>
{result.type === "task" ? (
getStatusIcon(result.status)
) : result.type === "project" && result.color ? (
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: result.color }}
/>
) : (
<Icon className="w-4 h-4 mr-2" />
)}
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">
<HighlightedText
text={titleHighlight.text}
matches={titleHighlight.matches}
/>
</span>
{snippetHighlight && (
<span className="text-xs text-muted-foreground truncate">
<HighlightedText
text={snippetHighlight.text}
matches={snippetHighlight.matches}
/>
</span>
)}
</div>
{result.status && result.type !== "task" && (
<span className="text-xs text-muted-foreground ml-2 capitalize">
{result.status}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
);
})}
<CommandSeparator />
</>
)}
{/* Navigation */}
<CommandGroup heading="Navigation">
{navItems.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.href}
onSelect={() => runCommand(() => router.push(item.href))}
>
<Icon className="w-4 h-4 mr-2" />
<span>{item.name}</span>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
{/* Quick Links */}
<CommandGroup heading="Quick Links">
{quickLinks.map((link) => {
const Icon = link.icon;
return (
<CommandItem
key={link.url}
onSelect={() => runCommand(() => window.open(link.url, "_blank"))}
>
<Icon className="w-4 h-4 mr-2" />
<span>{link.name}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
</>
);
}