"use client"; import { IconDeviceTv, IconHome, IconKeyboard, IconMovie, IconSearch, IconX, } from "@tabler/icons-react"; import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys"; import { useAtom } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from "@/components/ui/command"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Kbd } from "@/components/ui/kbd"; import { Skeleton } from "@/components/ui/skeleton"; import { useDebounce } from "@/hooks/use-debounce"; import { commandPaletteOpenAtom, helpOpenAtom, } from "@/lib/atoms/command-palette"; import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts"; interface SearchResult { tmdbId: number; type: "movie" | "tv"; title: string; posterPath: string | null; releaseDate: string | null; voteAverage: number; } const RECENT_KEY = "cp:recent-searches"; const MAX_RECENT = 5; function getRecentSearches(): string[] { if (typeof window === "undefined") return []; try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); } catch { return []; } } function addRecentSearch(query: string) { const recent = getRecentSearches().filter((q) => q !== query); recent.unshift(query); localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT))); } function removeRecentSearch(query: string) { const recent = getRecentSearches().filter((q) => q !== query); localStorage.setItem(RECENT_KEY, JSON.stringify(recent)); } function clearRecentSearches() { localStorage.removeItem(RECENT_KEY); } export function CommandPalette() { const router = useRouter(); const [commandPaletteOpen, setCommandPaletteOpen] = useAtom( commandPaletteOpenAtom, ); const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const debouncedQuery = useDebounce(query, 300); const enabled = !commandPaletteOpen; useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev)); useHotkey("/", () => setCommandPaletteOpen(true), { enabled }); useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled }); useHotkeySequence(["G", "H"], () => router.push("/dashboard"), { enabled, timeout: 500, }); useHotkeySequence(["G", "E"], () => router.push("/explore"), { enabled, timeout: 500, }); // Load recent searches when palette opens useEffect(() => { if (commandPaletteOpen) { setRecentSearches(getRecentSearches()); setQuery(""); setResults([]); } }, [commandPaletteOpen]); // Search TMDB useEffect(() => { if (!debouncedQuery.trim()) { setResults([]); return; } let cancelled = false; setLoading(true); fetch(`/api/search?query=${encodeURIComponent(debouncedQuery)}`) .then((r) => r.json()) .then((data) => { if (!cancelled) { setResults((data.results ?? []).slice(0, 8)); addRecentSearch(debouncedQuery.trim()); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [debouncedQuery]); const handleSelect = useCallback( (result: SearchResult) => { setCommandPaletteOpen(false); router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); }, [router, setCommandPaletteOpen], ); const handleRecentSearch = useCallback((q: string) => { setQuery(q); }, []); const handleRemoveRecent = useCallback((q: string) => { removeRecentSearch(q); setRecentSearches((prev) => prev.filter((s) => s !== q)); }, []); const handleClearRecent = useCallback(() => { clearRecentSearches(); setRecentSearches([]); }, []); const hasQuery = query.trim().length > 0; // Group shortcuts by scope for help dialog const grouped: Record< string, { description: string; keys: readonly string[] }[] > = {}; for (const entry of SHORTCUT_DESCRIPTIONS) { if (!grouped[entry.scope]) grouped[entry.scope] = []; grouped[entry.scope].push(entry); } return ( <> Command Palette Search for movies, TV shows, or run commands {hasQuery && loading && (
{Array.from({ length: 3 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
))}
)} {hasQuery && !loading && results.length === 0 && ( No results found. )} {hasQuery && !loading && results.length > 0 && ( {results.map((r) => ( handleSelect(r)} className="flex items-center gap-3 py-2" >
{r.posterPath ? ( {r.title} ) : (
?
)}

{r.title}

{r.type === "movie" ? ( ) : ( )} {r.type} {r.releaseDate && ( {r.releaseDate.slice(0, 4)} )}
))}
)} {!hasQuery && ( <> {recentSearches.length > 0 && ( Recent Searches } > {recentSearches.map((q) => ( handleRecentSearch(q)} className="group" > {q} ))} )} {recentSearches.length > 0 && } { setCommandPaletteOpen(false); router.push("/dashboard"); }} > Go to Dashboard G H { setCommandPaletteOpen(false); router.push("/explore"); }} > Go to Explore G E { setCommandPaletteOpen(false); setHelpOpen(true); }} > Keyboard Shortcuts ? )}
Keyboard Shortcuts
{Object.entries(grouped).map(([scope, items]) => (

{scope}

{items.map((item) => (
{item.description}
{item.keys.map((key, i) => ( {i > 0 && ( then )} {formatKey(key)} ))}
))}
))}
); } function formatKey(key: string): string { const map: Record = { " ": "Space", Escape: "Esc", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", }; return map[key] ?? key.toUpperCase(); }