UX overhaul: motion animations, command palette, warm cinema theme

Add premium "Late Night Screening Room" experience with warm indigo/amber
palette, framer-motion spring animations, Cmd+K command palette with TMDB
search, keyboard shortcuts (G H, G S, ?, W, M, 1-5), sonner toasts with
optimistic updates, skeleton loading states, stats dashboard, search
autocomplete with filter tabs, cinematic backdrop with film grain, season
progress bars, and staggered card reveal animations throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 15:30:35 -05:00
co-authored by Claude Opus 4.6
parent 095b64ab42
commit b6aaad243f
23 changed files with 2017 additions and 339 deletions
+306
View File
@@ -0,0 +1,306 @@
"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<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<number | null>(null);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
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("/"),
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 (
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle>
<DialogDescription>
Search for movies, TV shows, or run commands
</DialogDescription>
</DialogHeader>
<DialogContent
className="top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-lg"
showCloseButton={false}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search movies & TV shows..."
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-80">
{hasQuery && loading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={`skel-${i}`} className="flex items-center gap-3">
<Skeleton className="h-12 w-8 shrink-0 rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
)}
{hasQuery && !loading && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{hasQuery && !loading && results.length > 0 && (
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
disabled={importing === r.tmdbId}
className="flex items-center gap-3 py-2"
>
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
{r.posterPath ? (
<Image
src={`https://image.tmdb.org/t/p/w92${r.posterPath}`}
alt={r.title}
width={32}
height={48}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
?
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{r.title}</p>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{r.type === "movie" ? (
<IconMovie size={11} />
) : (
<IconDeviceTv size={11} />
)}
<span className="uppercase">{r.type}</span>
{r.releaseDate && (
<span>{r.releaseDate.slice(0, 4)}</span>
)}
</div>
</div>
{importing === r.tmdbId && (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</CommandItem>
))}
</CommandGroup>
)}
{!hasQuery && (
<>
{recentSearches.length > 0 && (
<CommandGroup heading="Recent Searches">
{recentSearches.map((q) => (
<CommandItem
key={q}
onSelect={() => handleRecentSearch(q)}
>
<IconSearch
size={14}
className="text-muted-foreground"
/>
{q}
</CommandItem>
))}
</CommandGroup>
)}
{recentSearches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Quick Actions">
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
router.push("/");
}}
>
<IconHome size={14} />
Go to Dashboard
<CommandShortcut>G H</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
router.push("/search");
}}
>
<IconSearch size={14} />
Go to Search
<CommandShortcut>G S</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
setHelpOpen(true);
}}
>
<IconKeyboard size={14} />
Keyboard Shortcuts
<CommandShortcut>?</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}