"use client"; import { IconDeviceTv, IconHome, IconKeyboard, IconMovie, IconSearch, IconUser, 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, useRef, useState } from "react"; import { useProgress } from "@/components/navigation-progress"; 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 { useSearch } from "@/hooks/use-search"; import { resolvePerson } from "@/lib/actions/people"; import { resolveTitle } from "@/lib/actions/titles"; import { commandPaletteOpenAtom, helpOpenAtom, MAX_RECENT, recentSearchesAtom, } from "@/lib/atoms/command-palette"; // Static shortcut descriptions for the help dialog. // TanStack's HotkeyManager/SequenceManager handle all actual key listening. const SHORTCUT_DESCRIPTIONS = [ { scope: "Global", description: "Search", keys: ["/"] }, { scope: "Global", description: "Keyboard shortcuts", keys: ["?"] }, { scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] }, { scope: "Navigation", description: "Go to explore", keys: ["g", "e"] }, { scope: "Title", description: "Cycle status", keys: ["w"] }, { scope: "Title", description: "Mark watched", keys: ["m"] }, { scope: "Title", description: "Go back", keys: ["Escape"] }, { scope: "Title", description: "Rate 1 star", keys: ["1"] }, { scope: "Title", description: "Rate 2 stars", keys: ["2"] }, { scope: "Title", description: "Rate 3 stars", keys: ["3"] }, { scope: "Title", description: "Rate 4 stars", keys: ["4"] }, { scope: "Title", description: "Rate 5 stars", keys: ["5"] }, ] as const; const groupedShortcuts: Record< string, { description: string; keys: readonly string[] }[] > = {}; for (const entry of SHORTCUT_DESCRIPTIONS) { if (!groupedShortcuts[entry.scope]) groupedShortcuts[entry.scope] = []; groupedShortcuts[entry.scope].push(entry); } type SearchResult = ReturnType["results"][number]; export function CommandPalette() { const router = useRouter(); const progress = useProgress(); const [commandPaletteOpen, setCommandPaletteOpen] = useAtom( commandPaletteOpenAtom, ); const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom); const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom); const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, 300); const { results, isLoading: loading } = useSearch(debouncedQuery); const enabled = !commandPaletteOpen; useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev)); useHotkey("/", () => setCommandPaletteOpen(true), { enabled }); useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled }); useHotkeySequence( ["G", "H"], () => { progress.start(); router.push("/dashboard"); }, { enabled, timeout: 500 }, ); useHotkeySequence( ["G", "E"], () => { progress.start(); router.push("/explore"); }, { enabled, timeout: 500 }, ); // Reset query when palette opens useEffect(() => { if (commandPaletteOpen) { setQuery(""); } }, [commandPaletteOpen]); // Save to recent searches after user stops typing for a while const saveTimerRef = useRef>(null); useEffect(() => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); const trimmed = debouncedQuery.trim(); if (trimmed && results.length > 0) { saveTimerRef.current = setTimeout(() => { setRecentSearches((prev) => { const filtered = prev.filter((q) => q !== trimmed); return [trimmed, ...filtered].slice(0, MAX_RECENT); }); }, 2000); } return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; }, [debouncedQuery, results.length, setRecentSearches]); const handleSelect = useCallback( (result: SearchResult) => { setCommandPaletteOpen(false); progress.start(); if (result.type === "person") { void resolvePerson(result.tmdbId).then((id) => { if (id) router.push(`/people/${id}`); }); } else { void resolveTitle(result.tmdbId, result.type).then((id) => { if (id) router.push(`/titles/${id}`); }); } }, [router, setCommandPaletteOpen, progress], ); const handleRecentSearch = useCallback((q: string) => { setQuery(q); }, []); const handleRemoveRecent = useCallback( (q: string) => { setRecentSearches((prev) => prev.filter((s) => s !== q)); }, [setRecentSearches], ); const handleClearRecent = useCallback(() => { setRecentSearches([]); }, [setRecentSearches]); const hasQuery = query.trim().length > 0; 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.type === "person" ? (
{r.profilePath ? ( {r.title} ) : (
)}
) : (
{r.posterPath ? ( {r.title} ) : (
?
)}
)}

{r.title}

{r.type === "person" ? ( ) : r.type === "movie" ? ( ) : ( )} {r.type} {r.type !== "person" && r.releaseDate && ( {r.releaseDate.slice(0, 4)} )} {r.type === "person" && r.knownFor && r.knownFor.length > 0 && ( {r.knownFor.join(", ")} )}
))}
)} {!hasQuery && ( <> {recentSearches.length > 0 && ( Recent Searches } > {recentSearches.map((q) => ( handleRecentSearch(q)} className="group" > {q} ))} )} {recentSearches.length > 0 && } { setCommandPaletteOpen(false); progress.start(); router.push("/dashboard"); }} > Go to Dashboard G H { setCommandPaletteOpen(false); progress.start(); router.push("/explore"); }} > Go to Explore G E { setCommandPaletteOpen(false); setHelpOpen(true); }} > Keyboard Shortcuts ? )}
Keyboard Shortcuts
{Object.entries(groupedShortcuts).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(); }