"use client"; import { IconDeviceTv, IconHome, IconKeyboard, IconMovie, IconSearch, } from "@tabler/icons-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useKeyboard } from "@/components/keyboard-provider"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from "@/components/ui/command"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useDebounce } from "@/hooks/use-debounce"; 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))); } export function CommandPalette() { const router = useRouter(); const { commandPaletteOpen, setCommandPaletteOpen, setHelpOpen, registerShortcut, } = useKeyboard(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [importing, setImporting] = useState(null); const [recentSearches, setRecentSearches] = useState([]); const debouncedQuery = useDebounce(query, 300); // Register global shortcuts useEffect(() => { registerShortcut("cmd-palette-slash", { keys: ["/"], description: "Search", action: () => setCommandPaletteOpen(true), scope: "Global", }); registerShortcut("cmd-palette-help", { keys: ["?"], description: "Keyboard shortcuts", action: () => setHelpOpen(true), scope: "Global", }); registerShortcut("nav-home", { keys: ["g", "h"], description: "Go to dashboard", action: () => router.push("/dashboard"), scope: "Navigation", }); registerShortcut("nav-search", { keys: ["g", "s"], description: "Go to search", action: () => router.push("/search"), scope: "Navigation", }); }, [registerShortcut, setCommandPaletteOpen, setHelpOpen, router]); // 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( async (result: SearchResult) => { setImporting(result.tmdbId); try { const res = await fetch("/api/titles/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tmdbId: result.tmdbId, type: result.type }), }); const title = await res.json(); if (title.id) { setCommandPaletteOpen(false); router.push(`/titles/${title.id}`); } } finally { setImporting(null); } }, [router, setCommandPaletteOpen], ); const handleRecentSearch = useCallback((q: string) => { setQuery(q); }, []); 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)} disabled={importing === r.tmdbId} 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)} )}
{importing === r.tmdbId && (
)} ))} )} {!hasQuery && ( <> {recentSearches.length > 0 && ( {recentSearches.map((q) => ( handleRecentSearch(q)} > {q} ))} )} {recentSearches.length > 0 && } { setCommandPaletteOpen(false); router.push("/dashboard"); }} > Go to Dashboard G H { setCommandPaletteOpen(false); router.push("/search"); }} > Go to Search G S { setCommandPaletteOpen(false); setHelpOpen(true); }} > Keyboard Shortcuts ? )}
); }