import { msg } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { IconDeviceTv, IconHome, IconKeyboard, IconMovie, IconSearch, IconUser, IconX, } from "@tabler/icons-react"; import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys"; import { skipToken, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useRef, 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, MAX_RECENT, recentSearchesAtom, } from "@/lib/atoms/command-palette"; import { orpc } from "@/lib/orpc/client"; import { i18n } from "@sofa/i18n"; // Static shortcut descriptions for the help dialog, pre-grouped by scope. // TanStack's HotkeyManager/SequenceManager handle all actual key listening. const SHORTCUT_GROUPS = [ { scope: msg`Global`, shortcuts: [ { description: msg`Search`, keys: ["/"] }, { description: msg`Keyboard shortcuts`, keys: ["?"] }, ], }, { scope: msg`Navigation`, shortcuts: [ { description: msg`Go to dashboard`, keys: ["g", "h"] }, { description: msg`Go to explore`, keys: ["g", "e"] }, { description: msg`Go to upcoming`, keys: ["g", "u"] }, { description: msg`Go to settings`, keys: ["g", "s"] }, ], }, { scope: msg`Title`, shortcuts: [ { description: msg`Cycle status`, keys: ["w"] }, { description: msg`Mark watched`, keys: ["m"] }, { description: msg`Go back`, keys: ["Escape"] }, { description: msg`Rate 1 star`, keys: ["1"] }, { description: msg`Rate 2 stars`, keys: ["2"] }, { description: msg`Rate 3 stars`, keys: ["3"] }, { description: msg`Rate 4 stars`, keys: ["4"] }, { description: msg`Rate 5 stars`, keys: ["5"] }, ], }, ]; interface SearchResult { id?: string; tmdbId: number; type: "movie" | "tv" | "person"; title: string; posterPath: string | null; profilePath: string | null; releaseDate: string | null; voteAverage: number | null; knownFor: string[] | null; knownForDepartment: string | null; } export function CommandPalette() { const { t } = useLingui(); const navigate = useNavigate(); 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 trimmedQuery = debouncedQuery.trim(); const { data: searchData, isLoading: loading } = useQuery( orpc.discover.search.queryOptions({ input: trimmedQuery ? { query: trimmedQuery } : skipToken, }), ); const results: SearchResult[] = searchData?.results?.slice(0, 8) ?? []; const enabled = !commandPaletteOpen; const handleOpenChange = useCallback( (open: boolean | ((prev: boolean) => boolean)) => { setCommandPaletteOpen((prev) => { const next = typeof open === "function" ? open(prev) : open; if (next) setQuery(""); return next; }); }, [setCommandPaletteOpen], ); useHotkey("Mod+K", () => handleOpenChange((prev) => !prev)); useHotkey("/", () => handleOpenChange(true), { enabled }); useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled }); useHotkeySequence( ["G", "H"], () => { void navigate({ to: "/dashboard" }); }, { enabled, timeout: 500 }, ); useHotkeySequence( ["G", "E"], () => { void navigate({ to: "/explore" }); }, { enabled, timeout: 500 }, ); useHotkeySequence( ["G", "U"], () => { void navigate({ to: "/upcoming" }); }, { enabled, timeout: 500 }, ); useHotkeySequence( ["G", "S"], () => { void navigate({ to: "/settings" }); }, { enabled, timeout: 500 }, ); // 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) => { if (!result.id) return; setCommandPaletteOpen(false); if (result.type === "person") { void navigate({ to: "/people/$id", params: { id: result.id } }); } else { void navigate({ to: "/titles/$id", params: { id: result.id } }); } }, [setCommandPaletteOpen, navigate], ); 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) => (
))}
)} {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 === "movie" ? t`movie` : r.type === "tv" ? t`tv` : t`person`} {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); void navigate({ to: "/dashboard" }); }} > Go to Dashboard G H { setCommandPaletteOpen(false); void navigate({ to: "/explore" }); }} > Go to Explore G E { setCommandPaletteOpen(false); setHelpOpen(true); }} > Keyboard Shortcuts ? )}
Keyboard Shortcuts
{SHORTCUT_GROUPS.map((group) => (

{i18n._(group.scope)}

{group.shortcuts.map((item) => (
{i18n._(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(); }