mission-control/components/layout/quick-search.tsx

279 lines
8.8 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Search,
LayoutDashboard,
Activity,
Calendar,
Kanban,
FolderKanban,
FileText,
Wrench,
Target,
ExternalLink,
CheckCircle2,
Clock,
AlertCircle,
Timer,
Circle,
} from "lucide-react";
import { siteUrls } from "@/lib/config/sites";
// Search result type from API - matches SearchableResult interface
type SearchResultType = "task" | "project" | "document" | "sprint";
interface SearchResult {
id: string;
type: SearchResultType;
title: string;
snippet?: string; // Brief preview text (replaces subtitle/description)
url: string; // Deep link to full view
icon: string;
status?: string; // For visual badges
color?: string; // For project/task colors
}
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",
};
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" />;
}
}
export function QuickSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const router = useRouter();
// Debounced search
useEffect(() => {
if (!open || query.length < 2) {
setResults([]);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data.results || []);
} catch (err) {
console.error("Search error:", err);
setResults([]);
} finally {
setLoading(false);
}
}, 150); // 150ms debounce
return () => clearTimeout(timer);
}, [query, open]);
// 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("");
command();
}, []);
const handleResultSelect = (result: SearchResult) => {
runCommand(() => {
if (result.url?.startsWith("http")) {
window.open(result.url, "_blank");
} else if (result.url) {
router.push(result.url);
}
});
};
// 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
);
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}>
<CommandInput
placeholder="Search tasks, projects, sprints, documents..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
{loading ? "Searching..." : query.length < 2 ? "Type to search..." : "No results found."}
</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) => (
<CommandItem
key={`${result.type}-${result.id}`}
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">{result.title}</span>
{result.snippet && (
<span className="text-xs text-muted-foreground truncate">
{result.snippet}
</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>
</>
);
}