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
+50 -17
View File
@@ -1,10 +1,20 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { signIn, signUp } from "@/lib/auth/client";
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
},
};
export function AuthForm({ mode }: { mode: "login" | "register" }) {
const router = useRouter();
const [name, setName] = useState("");
@@ -48,7 +58,12 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
{/* Subtle glow behind card */}
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
<div className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 p-8 backdrop-blur-sm">
<motion.div
className="relative space-y-8 rounded-xl border border-border/50 bg-card/80 p-8 backdrop-blur-sm"
initial={{ opacity: 0, y: 20, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring" as const, stiffness: 200, damping: 20 }}
>
<div className="space-y-2 text-center">
<Link
href="/"
@@ -64,9 +79,18 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
}}
>
{isRegister && (
<div className="space-y-1.5">
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="name"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
@@ -82,10 +106,10 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="Your name"
/>
</div>
</motion.div>
)}
<div className="space-y-1.5">
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="email"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
@@ -101,9 +125,9 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="you@example.com"
/>
</div>
</motion.div>
<div className="space-y-1.5">
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="password"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
@@ -120,22 +144,31 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="Min 8 characters"
/>
</div>
</motion.div>
{error && (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</motion.div>
)}
</AnimatePresence>
<button
<motion.button
type="submit"
disabled={loading}
variants={fieldVariants}
whileTap={{ scale: 0.98 }}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? "Loading..." : isRegister ? "Create account" : "Sign in"}
</button>
</form>
</motion.button>
</motion.form>
<p className="text-center text-sm text-muted-foreground">
{isRegister ? (
@@ -160,7 +193,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
</>
)}
</p>
</div>
</motion.div>
</div>
);
}
+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>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useKeyboard } from "@/components/keyboard-provider";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
export function KeyboardHelpDialog() {
const { shortcuts, helpOpen, setHelpOpen } = useKeyboard();
// Group shortcuts by scope
const grouped: Record<string, { description: string; keys: string[] }[]> = {};
for (const def of shortcuts.values()) {
const scope = def.scope ?? "Global";
if (!grouped[scope]) grouped[scope] = [];
grouped[scope].push({ description: def.description, keys: def.keys });
}
return (
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{Object.entries(grouped).map(([scope, items]) => (
<div key={scope} className="space-y-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{scope}
</h3>
<div className="space-y-1">
{items.map((item) => (
<div
key={item.description}
className="flex items-center justify-between rounded-md px-2 py-1.5"
>
<span className="text-xs text-foreground">
{item.description}
</span>
<div className="flex items-center gap-1">
{item.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && (
<span className="text-[10px] text-muted-foreground">
then
</span>
)}
<Kbd>{formatKey(key)}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
function formatKey(key: string): string {
const map: Record<string, string> = {
" ": "Space",
Escape: "Esc",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
};
return map[key] ?? key.toUpperCase();
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
export interface ShortcutDef {
keys: string[];
description: string;
action: () => void;
scope?: string;
}
interface KeyboardContextValue {
shortcuts: Map<string, ShortcutDef>;
registerShortcut: (id: string, def: ShortcutDef) => void;
unregisterShortcut: (id: string) => void;
commandPaletteOpen: boolean;
setCommandPaletteOpen: (open: boolean) => void;
helpOpen: boolean;
setHelpOpen: (open: boolean) => void;
}
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
export function useKeyboard() {
const ctx = useContext(KeyboardContext);
if (!ctx) throw new Error("useKeyboard must be used within KeyboardProvider");
return ctx;
}
export function KeyboardProvider({ children }: { children: React.ReactNode }) {
const [shortcuts, setShortcuts] = useState<Map<string, ShortcutDef>>(
() => new Map(),
);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const pendingKeyRef = useRef<string | null>(null);
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const registerShortcut = useCallback((id: string, def: ShortcutDef) => {
setShortcuts((prev) => {
const next = new Map(prev);
next.set(id, def);
return next;
});
}, []);
const unregisterShortcut = useCallback((id: string) => {
setShortcuts((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
}, []);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
const isInput =
tagName === "input" ||
tagName === "textarea" ||
target.isContentEditable;
// Cmd+K / Ctrl+K always works
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setCommandPaletteOpen((prev) => !prev);
return;
}
// Don't fire shortcuts when typing in inputs (except Escape)
if (isInput && e.key !== "Escape") return;
// Don't fire when command palette is open
if (commandPaletteOpen && e.key !== "Escape") return;
// Check for two-key combos
if (pendingKeyRef.current) {
const comboKey = `${pendingKeyRef.current}+${e.key}`;
pendingKeyRef.current = null;
if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
for (const def of shortcuts.values()) {
if (
def.keys.length === 2 &&
def.keys[0] === comboKey.split("+")[0] &&
def.keys[1] === comboKey.split("+")[1]
) {
e.preventDefault();
def.action();
return;
}
}
// If no combo matched, fall through to single-key check
}
// Check for single-key shortcuts
for (const def of shortcuts.values()) {
if (def.keys.length === 1 && def.keys[0] === e.key) {
e.preventDefault();
def.action();
return;
}
// Start combo sequence
if (def.keys.length === 2 && def.keys[0] === e.key) {
e.preventDefault();
pendingKeyRef.current = e.key;
pendingTimerRef.current = setTimeout(() => {
pendingKeyRef.current = null;
}, 500);
return;
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [shortcuts, commandPaletteOpen]);
return (
<KeyboardContext.Provider
value={{
shortcuts,
registerShortcut,
unregisterShortcut,
commandPaletteOpen,
setCommandPaletteOpen,
helpOpen,
setHelpOpen,
}}
>
{children}
</KeyboardContext.Provider>
);
}
+60 -40
View File
@@ -1,28 +1,59 @@
"use client";
import { IconLogout, IconSearch } from "@tabler/icons-react";
import { motion } from "motion/react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useKeyboard } from "@/components/keyboard-provider";
import { Kbd } from "@/components/ui/kbd";
import { signOut, useSession } from "@/lib/auth/client";
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/search", label: "Search" },
] as const;
export function NavBar() {
const { data: session } = useSession();
const router = useRouter();
const pathname = usePathname();
const { setCommandPaletteOpen } = useKeyboard();
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-8">
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-6">
<Link href="/" className="font-display text-xl tracking-tight">
Couch Potato
</Link>
{session?.user && (
<div className="hidden items-center gap-1 sm:flex">
<NavLink href="/search" active={pathname === "/search"}>
<IconSearch size={16} />
<span>Search</span>
</NavLink>
{navLinks.map((link) => {
const isActive =
link.href === "/"
? pathname === "/"
: pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
{isActive && (
<motion.div
layoutId="nav-indicator"
className="absolute inset-x-2 -bottom-[11px] h-0.5 rounded-full bg-primary"
transition={{
type: "spring",
stiffness: 380,
damping: 30,
}}
/>
)}
</Link>
);
})}
</div>
)}
</div>
@@ -30,20 +61,32 @@ export function NavBar() {
<div className="flex items-center gap-3">
{session?.user ? (
<>
<span className="text-sm text-muted-foreground">
{session.user.name}
</span>
{/* Search trigger pill */}
<button
type="button"
onClick={async () => {
await signOut();
router.push("/");
router.refresh();
}}
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setCommandPaletteOpen(true)}
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/20 hover:bg-card sm:inline-flex"
>
<IconLogout size={15} />
<IconSearch size={14} />
<span>Search...</span>
<Kbd className="ml-1">K</Kbd>
</button>
<div className="flex items-center gap-2 rounded-lg border border-border/30 px-2.5 py-1">
<span className="text-sm text-muted-foreground">
{session.user.name}
</span>
<button
type="button"
onClick={async () => {
await signOut();
router.push("/");
router.refresh();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<IconLogout size={14} />
</button>
</div>
</>
) : (
<>
@@ -55,7 +98,7 @@ export function NavBar() {
</Link>
<Link
href="/register"
className="inline-flex h-8 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
className="inline-flex h-8 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
>
Register
</Link>
@@ -66,26 +109,3 @@ export function NavBar() {
</header>
);
}
function NavLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors ${
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{children}
</Link>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import { IconSearch } from "@tabler/icons-react";
import { Command as CommandPrimitive } from "cmdk";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "@/hooks/use-debounce";
interface SearchResult {
tmdbId: number;
type: "movie" | "tv";
title: string;
overview: string;
releaseDate: string | null;
posterPath: string | null;
popularity: number;
voteAverage: number;
}
interface SearchAutocompleteProps {
onResults?: (results: SearchResult[]) => void;
onLoading?: (loading: boolean) => void;
}
export function SearchAutocomplete({
onResults,
onLoading,
}: SearchAutocompleteProps) {
const router = useRouter();
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<"all" | "movie" | "tv">("all");
const [importing, setImporting] = useState<number | null>(null);
const debouncedQuery = useDebounce(query, 300);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
onResults?.([]);
return;
}
let cancelled = false;
setLoading(true);
onLoading?.(true);
const typeParam = filter !== "all" ? `&type=${filter}` : "";
fetch(`/api/search?query=${encodeURIComponent(debouncedQuery)}${typeParam}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) {
const res = data.results ?? [];
setResults(res);
onResults?.(res);
setOpen(res.length > 0);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
onLoading?.(false);
}
});
return () => {
cancelled = true;
};
}, [debouncedQuery, filter, onResults, onLoading]);
const handleImport = 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) {
toast.success(`Added "${result.title}" to library`);
router.push(`/titles/${title.id}`);
}
} catch {
toast.error("Failed to import title");
} finally {
setImporting(null);
}
},
[router],
);
return (
<div className="space-y-3">
{/* Filter tabs */}
<div className="flex gap-1">
{(["all", "movie", "tv"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setFilter(t)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
filter === t
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{t === "all" ? "All" : t === "movie" ? "Movies" : "TV Shows"}
</button>
))}
</div>
<div className="relative">
<CommandPrimitive shouldFilter={false} className="w-full">
<div className="relative">
<IconSearch
size={18}
className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<CommandPrimitive.Input
ref={inputRef}
placeholder="Search movies & TV shows..."
value={query}
onValueChange={(val) => {
setQuery(val);
if (val.trim()) setOpen(true);
}}
onFocus={() => {
if (results.length > 0) setOpen(true);
}}
onBlur={() => {
// Delay to allow click on result
setTimeout(() => setOpen(false), 200);
}}
className="flex h-13 w-full rounded-xl border border-border/50 bg-card/50 pl-11 pr-4 text-base backdrop-blur-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
{loading && (
<div className="absolute right-4 top-1/2 -translate-y-1/2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
</div>
{open && results.length > 0 && (
<CommandPrimitive.List className="absolute z-30 mt-2 max-h-96 w-full overflow-y-auto rounded-xl border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl">
{results.map((r) => (
<CommandPrimitive.Item
key={`${r.type}-${r.tmdbId}`}
value={`${r.type}-${r.tmdbId}`}
onSelect={() => handleImport(r)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm data-[selected=true]:bg-accent"
>
<div className="h-[60px] w-10 shrink-0 overflow-hidden rounded-md bg-muted">
{r.posterPath ? (
<Image
src={`https://image.tmdb.org/t/p/w92${r.posterPath}`}
alt={r.title}
width={40}
height={60}
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">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{r.title}</p>
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
{r.type}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.releaseDate && (
<span>{r.releaseDate.slice(0, 4)}</span>
)}
{r.voteAverage > 0 && (
<span className="text-primary">
{r.voteAverage.toFixed(1)}
</span>
)}
</div>
{r.overview && (
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground/70">
{r.overview}
</p>
)}
</div>
{importing === r.tmdbId && (
<div className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</CommandPrimitive.Item>
))}
</CommandPrimitive.List>
)}
</CommandPrimitive>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { Skeleton } from "@/components/ui/skeleton";
export function TitleCardSkeleton() {
return (
<div className="space-y-2">
<Skeleton className="aspect-[2/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
);
}
export function ContinueWatchingSkeleton() {
return (
<div className="flex w-56 shrink-0 gap-3 rounded-xl border border-border/30 bg-card/50 p-3">
<Skeleton className="h-20 w-14 shrink-0 rounded-md" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
</div>
);
}
export function StatCardSkeleton() {
return <Skeleton className="h-24 w-full rounded-xl" />;
}
export function TitleDetailSkeleton() {
return (
<div className="space-y-10">
<Skeleton className="-mx-4 -mt-6 h-72 sm:h-96" />
<div className="flex flex-col gap-8 sm:flex-row">
<Skeleton className="h-[330px] w-[220px] shrink-0 rounded-xl" />
<div className="flex-1 space-y-5">
<Skeleton className="h-12 w-2/3" />
<Skeleton className="h-5 w-1/3" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<div className="flex gap-3">
<Skeleton className="h-9 w-32 rounded-lg" />
<Skeleton className="h-9 w-32 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
export function DashboardSkeleton() {
return (
<div className="space-y-10">
<div>
<Skeleton className="h-9 w-64" />
<Skeleton className="mt-2 h-4 w-48" />
</div>
{/* Stats row */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
{/* Continue watching */}
<div className="space-y-4">
<Skeleton className="h-6 w-40" />
<div className="flex gap-4">
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
</div>
</div>
{/* Grid */}
<div className="space-y-4">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
</div>
</div>
</div>
);
}
+14 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { IconStar, IconStarFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useState } from "react";
interface StarRatingProps {
@@ -21,19 +22,29 @@ export function StarRating({ value, onChange }: StarRatingProps) {
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= (hover || value);
return (
<button
<motion.button
key={star}
type="button"
onClick={() => onChange(star === value ? 0 : star)}
onMouseEnter={() => setHover(star)}
className="p-0.5 transition-transform hover:scale-110"
className="p-0.5"
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }}
animate={
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
}
transition={{
type: "spring" as const,
stiffness: 400,
damping: 15,
}}
>
{filled ? (
<IconStarFilled size={18} className="text-primary" />
) : (
<IconStar size={18} className="text-muted-foreground/30" />
)}
</button>
</motion.button>
);
})}
</div>
+91
View File
@@ -0,0 +1,91 @@
"use client";
import {
IconCheck,
IconLibrary,
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
interface Stats {
moviesThisMonth: number;
episodesThisWeek: number;
librarySize: number;
completed: number;
}
const statDefs = [
{
key: "moviesThisMonth" as const,
label: "Movies This Month",
icon: IconMovie,
},
{
key: "episodesThisWeek" as const,
label: "Episodes This Week",
icon: IconPlayerPlay,
},
{
key: "librarySize" as const,
label: "In Library",
icon: IconLibrary,
},
{
key: "completed" as const,
label: "Completed",
icon: IconCheck,
},
];
export function StatsSummary() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
fetch("/api/feed/stats")
.then((r) => r.json())
.then((data) => setStats(data))
.catch(() => {});
}, []);
if (!stats) return null;
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{statDefs.map((def, i) => {
const Icon = def.icon;
const value = stats[def.key];
return (
<motion.div
key={def.key}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: i * 0.08,
}}
className="rounded-xl border border-border/30 bg-card/50 p-4"
>
<div className="flex items-center gap-2">
<Icon size={14} className="text-primary" />
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{def.label}
</span>
</div>
<motion.p
className="mt-2 font-display text-2xl tracking-tight"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.08 + 0.2 }}
>
{value}
</motion.p>
</motion.div>
);
})}
</div>
);
}
+80 -47
View File
@@ -7,12 +7,31 @@ import {
IconPlus,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
const statuses = [
{ value: "watchlist", label: "Watchlist", icon: IconBookmark },
{ value: "in_progress", label: "Watching", icon: IconPlayerPlay },
{ value: "completed", label: "Completed", icon: IconCheck },
{
value: "watchlist",
label: "Watchlist",
icon: IconBookmark,
colorClass:
"border-status-watchlist/30 bg-status-watchlist/10 text-status-watchlist hover:bg-status-watchlist/15",
},
{
value: "in_progress",
label: "Watching",
icon: IconPlayerPlay,
colorClass:
"border-status-watching/30 bg-status-watching/10 text-status-watching hover:bg-status-watching/15",
},
{
value: "completed",
label: "Completed",
icon: IconCheck,
colorClass:
"border-status-completed/30 bg-status-completed/10 text-status-completed hover:bg-status-completed/15",
},
] as const;
interface StatusButtonProps {
@@ -42,9 +61,9 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
<button
type="button"
onClick={() => setOpen(!open)}
className={`inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition-all ${
className={`inline-flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition-all active:scale-[0.97] ${
current
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/15"
? current.colorClass
: "border-border/50 hover:border-primary/30 hover:bg-primary/5"
}`}
>
@@ -52,48 +71,62 @@ export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
{current ? current.label : "Add to List"}
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1.5 w-44 overflow-hidden rounded-lg border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl">
{statuses.map((s) => {
const Icon = s.icon;
return (
<button
key={s.value}
type="button"
onClick={() => {
onChange(s.value === currentStatus ? null : s.value);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
s.value === currentStatus ? "text-primary" : "text-foreground"
}`}
>
<Icon size={15} />
{s.label}
{s.value === currentStatus && (
<IconCheck size={13} className="ml-auto" />
)}
</button>
);
})}
{currentStatus && (
<>
<div className="my-1 border-t border-border/50" />
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-destructive transition-colors hover:bg-accent"
>
<IconX size={15} />
Remove
</button>
</>
)}
</div>
)}
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -4 }}
transition={{
type: "spring" as const,
stiffness: 500,
damping: 30,
}}
className="absolute left-0 top-full z-20 mt-1.5 w-44 overflow-hidden rounded-xl border border-border/50 bg-popover/95 p-1 shadow-xl shadow-black/30 backdrop-blur-xl"
>
{statuses.map((s) => {
const Icon = s.icon;
return (
<button
key={s.value}
type="button"
onClick={() => {
onChange(s.value === currentStatus ? null : s.value);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-accent ${
s.value === currentStatus
? "text-primary"
: "text-foreground"
}`}
>
<Icon size={15} />
{s.label}
{s.value === currentStatus && (
<IconCheck size={13} className="ml-auto" />
)}
</button>
);
})}
{currentStatus && (
<>
<div className="my-1 border-t border-border/50" />
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-destructive transition-colors hover:bg-accent"
>
<IconX size={15} />
Remove
</button>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+12 -5
View File
@@ -1,3 +1,6 @@
"use client";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
@@ -29,22 +32,26 @@ export function TitleCard({
: null;
const content = (
<div className="group relative overflow-hidden rounded-lg transition-transform duration-200 hover:scale-[1.02]">
<div className="aspect-[2/3] overflow-hidden rounded-lg bg-card">
<motion.div
className="group relative overflow-hidden rounded-xl ring-1 ring-foreground/5 transition-shadow hover:ring-primary/20 hover:shadow-lg hover:shadow-black/25"
whileHover={{ scale: 1.02 }}
transition={{ type: "spring" as const, stiffness: 400, damping: 25 }}
>
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-card">
{posterUrl ? (
<Image
src={posterUrl}
alt={title}
width={300}
height={450}
className="h-full w-full object-cover transition-all duration-300 group-hover:brightness-110"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card to-muted text-sm text-muted-foreground">
No poster
</div>
)}
{/* Overlay gradient */}
{/* Hover gradient overlay with metadata */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div>
<div className="mt-2 space-y-0.5">
@@ -59,7 +66,7 @@ export function TitleCard({
)}
</div>
</div>
</div>
</motion.div>
);
if (href || id) {