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
+24 -24
View File
@@ -3,7 +3,8 @@
import { IconDeviceTv, IconMovie } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { SearchBar } from "@/components/search-bar";
import { toast } from "sonner";
import { SearchAutocomplete } from "@/components/search-autocomplete";
import { TitleCard } from "@/components/title-card";
interface SearchResult {
@@ -24,19 +25,21 @@ export default function SearchPage() {
const [importing, setImporting] = useState<number | null>(null);
const [searched, setSearched] = useState(false);
const handleSearch = useCallback(async (query: string) => {
setLoading(true);
setSearched(true);
try {
const res = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data.results ?? []);
} finally {
setLoading(false);
}
const handleResults = useCallback((res: SearchResult[]) => {
setResults(res);
if (res.length > 0) setSearched(true);
}, []);
async function handleImport(tmdbId: number, type: "movie" | "tv") {
const handleLoading = useCallback((l: boolean) => {
setLoading(l);
if (l) setSearched(true);
}, []);
async function handleImport(
tmdbId: number,
type: "movie" | "tv",
title: string,
) {
setImporting(tmdbId);
try {
const res = await fetch("/api/titles/import", {
@@ -44,10 +47,13 @@ export default function SearchPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId, type }),
});
const title = await res.json();
if (title.id) {
router.push(`/titles/${title.id}`);
const data = await res.json();
if (data.id) {
toast.success(`Added "${title}" to library`);
router.push(`/titles/${data.id}`);
}
} catch {
toast.error("Failed to import title");
} finally {
setImporting(null);
}
@@ -62,13 +68,7 @@ export default function SearchPage() {
</p>
</div>
<SearchBar onSearch={handleSearch} />
{loading && (
<div className="flex items-center justify-center py-16">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
<SearchAutocomplete onResults={handleResults} onLoading={handleLoading} />
{!loading && searched && results.length === 0 && (
<div className="flex flex-col items-center gap-3 py-16 text-center">
@@ -91,10 +91,10 @@ export default function SearchPage() {
posterPath={r.posterPath}
releaseDate={r.releaseDate}
voteAverage={r.voteAverage}
onImport={() => handleImport(r.tmdbId, r.type)}
onImport={() => handleImport(r.tmdbId, r.type, r.title)}
/>
{importing === r.tmdbId && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80 backdrop-blur-sm">
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-background/80 backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm font-medium text-primary">