From b6aaad243faefdd680ffea59fc1bba82fe023b76 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Fri, 27 Feb 2026 15:30:35 -0500 Subject: [PATCH] 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 --- app/(pages)/layout.tsx | 23 +- app/(pages)/page.tsx | 207 ++++++++---- app/(pages)/search/page.tsx | 48 +-- app/(pages)/titles/[id]/page.tsx | 470 +++++++++++++++++++++------- app/api/feed/stats/route.ts | 75 +++++ app/globals.css | 128 ++++++-- app/page.tsx | 68 +++- components/auth-form.tsx | 67 +++- components/command-palette.tsx | 306 ++++++++++++++++++ components/keyboard-help-dialog.tsx | 77 +++++ components/keyboard-provider.tsx | 144 +++++++++ components/nav-bar.tsx | 100 +++--- components/search-autocomplete.tsx | 204 ++++++++++++ components/skeletons.tsx | 91 ++++++ components/star-rating.tsx | 17 +- components/stats-summary.tsx | 91 ++++++ components/status-button.tsx | 127 +++++--- components/title-card.tsx | 17 +- hooks/use-debounce.ts | 12 + hooks/use-register-shortcut.ts | 14 + lib/services/discovery.ts | 9 + package.json | 1 + pnpm-lock.yaml | 60 ++++ 23 files changed, 2017 insertions(+), 339 deletions(-) create mode 100644 app/api/feed/stats/route.ts create mode 100644 components/command-palette.tsx create mode 100644 components/keyboard-help-dialog.tsx create mode 100644 components/keyboard-provider.tsx create mode 100644 components/search-autocomplete.tsx create mode 100644 components/skeletons.tsx create mode 100644 components/stats-summary.tsx create mode 100644 hooks/use-debounce.ts create mode 100644 hooks/use-register-shortcut.ts diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index f6ccb4e..9c603c9 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -1,4 +1,10 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { KeyboardHelpDialog } from "@/components/keyboard-help-dialog"; +import { KeyboardProvider } from "@/components/keyboard-provider"; import { NavBar } from "@/components/nav-bar"; +import { Toaster } from "@/components/ui/sonner"; export default function PagesLayout({ children, @@ -6,9 +12,18 @@ export default function PagesLayout({ children: React.ReactNode; }) { return ( -
- -
{children}
-
+ +
+ + {/* Ambient glow */} +
+
+ {children} +
+
+ + + + ); } diff --git a/app/(pages)/page.tsx b/app/(pages)/page.tsx index d90c2fe..69fff29 100644 --- a/app/(pages)/page.tsx +++ b/app/(pages)/page.tsx @@ -5,10 +5,13 @@ import { IconPlayerPlay, IconSparkles, } from "@tabler/icons-react"; +import { motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import { DashboardSkeleton } from "@/components/skeletons"; +import { StatsSummary } from "@/components/stats-summary"; import { TitleCard } from "@/components/title-card"; import { useSession } from "@/lib/auth/client"; @@ -25,6 +28,8 @@ interface ContinueWatchingItem { episodeNumber: number; name: string | null; } | null; + totalEpisodes: number; + watchedEpisodes: number; } interface FeedTitle { @@ -39,6 +44,30 @@ interface FeedTitle { voteAverage?: number | null; } +const staggerContainer = { + hidden: {}, + visible: { transition: { staggerChildren: 0.05 } }, +}; + +const staggerItem = { + hidden: { opacity: 0, y: 12, scale: 0.98 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, +}; + +const sectionVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { type: "spring" as const, stiffness: 200, damping: 24 }, + }, +}; + export default function DashboardPage() { const { data: session, isPending } = useSession(); const router = useRouter(); @@ -75,11 +104,7 @@ export default function DashboardPage() { }, [session, isPending, router, fetchFeeds]); if (isPending || loading) { - return ( -
-
-
- ); + return ; } const isEmpty = @@ -88,19 +113,34 @@ export default function DashboardPage() { recommendations.length === 0; return ( -
-
+ +

Welcome back{session?.user?.name ? `, ${session.user.name}` : ""}

Here's what's happening with your library

-
+ + + + + {isEmpty && ( -
-
+ +
@@ -115,69 +155,94 @@ export default function DashboardPage() { > Start searching -
+
)} {/* Continue Watching */} {continueWatching.length > 0 && ( - } - > -
- {continueWatching.map((item) => ( - - ))} -
-
+ + } + > + + {continueWatching.map((item) => ( + + + + ))} + + + )} - {/* New on Streaming */} + {/* In Your Library */} {newAvailable.length > 0 && ( - } - > -
- {newAvailable.slice(0, 10).map((t) => ( - - ))} -
-
+ + } + > + + {newAvailable.slice(0, 10).map((t) => ( + + + + ))} + + + )} {/* Recommendations */} {recommendations.length > 0 && ( - } - > -
- {recommendations.slice(0, 10).map((t) => ( - - ))} -
-
+ + } + > + + {recommendations.slice(0, 10).map((t) => ( + + + + ))} + + + )} -
+ ); } @@ -205,13 +270,17 @@ function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) { const posterUrl = item.title.posterPath ? `https://image.tmdb.org/t/p/w300${item.title.posterPath}` : null; + const progress = + item.totalEpisodes > 0 + ? (item.watchedEpisodes / item.totalEpisodes) * 100 + : 0; return ( -
+
{posterUrl ? ( )} + {/* Progress bar at bottom of poster */} + {progress > 0 && ( +
+
+
+ )}

@@ -235,7 +313,8 @@ function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) { S{item.nextEpisode.seasonNumber} E{item.nextEpisode.episodeNumber}

)} -

+

+ Up next

diff --git a/app/(pages)/search/page.tsx b/app/(pages)/search/page.tsx index 5fcbda6..2ad378b 100644 --- a/app/(pages)/search/page.tsx +++ b/app/(pages)/search/page.tsx @@ -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(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() {

- - - {loading && ( -
-
-
- )} + {!loading && searched && results.length === 0 && (
@@ -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 && ( -
+
diff --git a/app/(pages)/titles/[id]/page.tsx b/app/(pages)/titles/[id]/page.tsx index 4f510d7..67ec545 100644 --- a/app/(pages)/titles/[id]/page.tsx +++ b/app/(pages)/titles/[id]/page.tsx @@ -6,12 +6,26 @@ import { IconChevronUp, IconPlayerPlay, } from "@tabler/icons-react"; +import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; -import { useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { TitleDetailSkeleton } from "@/components/skeletons"; import { StarRating } from "@/components/star-rating"; import { StatusButton } from "@/components/status-button"; import { TitleCard } from "@/components/title-card"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Progress } from "@/components/ui/progress"; +import { useRegisterShortcut } from "@/hooks/use-register-shortcut"; interface Episode { id: string; @@ -69,8 +83,24 @@ interface Title { episodeWatches?: string[]; } +const staggerContainer = { + hidden: {}, + visible: { transition: { staggerChildren: 0.05 } }, +}; + +const staggerItem = { + hidden: { opacity: 0, y: 12, scale: 0.98 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, +}; + export default function TitleDetailPage() { const { id } = useParams<{ id: string }>(); + const router = useRouter(); const [title, setTitle] = useState(null); const [recommendations, setRecommendations] = useState<RecommendedTitle[]>( [], @@ -119,12 +149,170 @@ export default function TitleDetailPage() { fetchRecommendations(); }, [fetchTitle, fetchRecommendations]); + // Keyboard shortcuts + const statusCycle = useMemo( + () => ["watchlist", "in_progress", "completed"] as const, + [], + ); + + const handleStatusChange = useCallback( + async (status: string | null) => { + // Optimistic update + setTitle((t) => (t ? { ...t, userStatus: status } : t)); + try { + const res = await fetch(`/api/titles/${id}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!res.ok) throw new Error(); + const label = + status === "watchlist" + ? "Added to watchlist" + : status === "in_progress" + ? "Marked as watching" + : status === "completed" + ? "Marked as completed" + : "Removed from list"; + toast.success(label); + } catch { + // Revert + setTitle((t) => + t ? { ...t, userStatus: title?.userStatus ?? null } : t, + ); + toast.error("Failed to update status"); + } + }, + [id, title?.userStatus], + ); + + const handleRating = useCallback( + async (ratingStars: number) => { + const prev = title?.userRating ?? 0; + // Optimistic update + setTitle((t) => (t ? { ...t, userRating: ratingStars } : t)); + try { + const res = await fetch(`/api/titles/${id}/rating`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ratingStars }), + }); + if (!res.ok) throw new Error(); + toast.success( + ratingStars > 0 + ? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}` + : "Rating removed", + ); + } catch { + setTitle((t) => (t ? { ...t, userRating: prev } : t)); + toast.error("Failed to update rating"); + } + }, + [id, title?.userRating], + ); + + const handleWatchMovie = useCallback(async () => { + setTitle((t) => (t ? { ...t, userStatus: "completed" } : t)); + try { + const res = await fetch(`/api/movies/${id}/watch`, { method: "POST" }); + if (!res.ok) throw new Error(); + toast.success(`Marked "${title?.title}" as watched`); + } catch { + setTitle((t) => + t ? { ...t, userStatus: title?.userStatus ?? null } : t, + ); + toast.error("Failed to mark as watched"); + } + }, [id, title?.title, title?.userStatus]); + + const handleWatchEpisode = useCallback( + async (episodeId: string, seasonNum: number, epNum: number) => { + setWatchingEp(episodeId); + // Optimistic update + setTitle((t) => { + if (!t) return t; + const watches = [...(t.episodeWatches ?? [])]; + if (!watches.includes(episodeId)) watches.push(episodeId); + return { + ...t, + episodeWatches: watches, + userStatus: t.userStatus ?? "in_progress", + }; + }); + try { + const res = await fetch(`/api/episodes/${episodeId}/watch`, { + method: "POST", + }); + if (!res.ok) throw new Error(); + toast.success(`Watched S${seasonNum} E${epNum}`); + } catch { + // Revert + setTitle((t) => { + if (!t) return t; + return { + ...t, + episodeWatches: (t.episodeWatches ?? []).filter( + (w) => w !== episodeId, + ), + }; + }); + toast.error("Failed to mark episode"); + } + setWatchingEp(null); + }, + [], + ); + + // Page keyboard shortcuts + useRegisterShortcut("title-cycle-status", { + keys: ["w"], + description: "Cycle status", + action: () => { + if (!title) return; + const currentIdx = statusCycle.indexOf( + title.userStatus as (typeof statusCycle)[number], + ); + const nextStatus = + currentIdx === statusCycle.length - 1 + ? null + : statusCycle[currentIdx + 1]; + handleStatusChange(nextStatus); + }, + scope: "Title", + }); + + useRegisterShortcut("title-mark-watched", { + keys: ["m"], + description: "Mark watched", + action: () => { + if (!title) return; + if (title.type === "movie") { + handleWatchMovie(); + } + }, + scope: "Title", + }); + + useRegisterShortcut("title-escape", { + keys: ["Escape"], + description: "Go back", + action: () => router.back(), + scope: "Title", + }); + + // Rating shortcuts 1-5 + for (const n of [1, 2, 3, 4, 5]) { + // biome-ignore lint/correctness/useHookAtTopLevel: loop is stable + useRegisterShortcut(`title-rate-${n}`, { + keys: [String(n)], + description: `Rate ${n} star${n > 1 ? "s" : ""}`, + action: () => handleRating(n), + scope: "Title", + }); + } + if (loading) { - return ( - <div className="flex items-center justify-center py-24"> - <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" /> - </div> - ); + return <TitleDetailSkeleton />; } if (!title) { return ( @@ -141,43 +329,34 @@ export default function TitleDetailPage() { const dateStr = title.releaseDate ?? title.firstAirDate; const year = dateStr?.slice(0, 4); - async function handleStatusChange(status: string | null) { - await fetch(`/api/titles/${id}/status`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), - }); - setTitle((t) => (t ? { ...t, userStatus: status } : t)); - } + async function handleMarkSeason(season: Season) { + const unwatched = season.episodes.filter( + (ep) => !title?.episodeWatches?.includes(ep.id), + ); + if (unwatched.length === 0) return; - async function handleRating(ratingStars: number) { - await fetch(`/api/titles/${id}/rating`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ratingStars }), - }); - setTitle((t) => (t ? { ...t, userRating: ratingStars } : t)); - } - - async function handleWatchMovie() { - await fetch(`/api/movies/${id}/watch`, { method: "POST" }); - setTitle((t) => (t ? { ...t, userStatus: "completed" } : t)); - } - - async function handleWatchEpisode(episodeId: string) { - setWatchingEp(episodeId); - await fetch(`/api/episodes/${episodeId}/watch`, { method: "POST" }); + // Optimistic update setTitle((t) => { if (!t) return t; const watches = [...(t.episodeWatches ?? [])]; - if (!watches.includes(episodeId)) watches.push(episodeId); - return { - ...t, - episodeWatches: watches, - userStatus: t.userStatus ?? "in_progress", - }; + for (const ep of unwatched) { + if (!watches.includes(ep.id)) watches.push(ep.id); + } + return { ...t, episodeWatches: watches }; }); - setWatchingEp(null); + + try { + await Promise.all( + unwatched.map((ep) => + fetch(`/api/episodes/${ep.id}/watch`, { method: "POST" }), + ), + ); + toast.success( + `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`, + ); + } catch { + toast.error("Failed to mark some episodes"); + } } // Group availability by offerType @@ -197,9 +376,22 @@ export default function TitleDetailPage() { return ( <div className="space-y-10"> + {/* Breadcrumb */} + <Breadcrumb className="relative z-20"> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink render={<Link href="/" />}>Home</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage>{title.title}</BreadcrumbPage> + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + {/* Backdrop hero */} {backdropUrl && ( - <div className="relative -mx-4 -mt-6 h-72 overflow-hidden sm:h-96"> + <div className="relative -mx-4 -mt-4 h-80 overflow-hidden sm:-mx-6 sm:h-[28rem]"> <Image src={backdropUrl} alt="" @@ -207,18 +399,30 @@ export default function TitleDetailPage() { className="object-cover" priority /> + {/* Three-layer gradient */} <div className="absolute inset-0 bg-gradient-to-t from-background via-background/60 to-background/20" /> <div className="absolute inset-0 bg-gradient-to-r from-background/80 to-transparent" /> + <div className="absolute inset-0 bg-gradient-to-b from-background/40 via-transparent to-transparent" /> + {/* Film grain overlay */} + <div + className="pointer-events-none absolute inset-0 opacity-[0.03]" + style={{ + backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`, + }} + /> </div> )} {/* Title header */} - <div + <motion.div className={`flex flex-col gap-8 sm:flex-row ${backdropUrl ? "-mt-32 relative z-10" : ""}`} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ type: "spring" as const, stiffness: 200, damping: 24 }} > {posterUrl && ( <div className="shrink-0"> - <div className="overflow-hidden rounded-xl shadow-2xl shadow-black/40"> + <div className="overflow-hidden rounded-2xl ring-1 ring-foreground/5 shadow-2xl shadow-black/50"> <Image src={posterUrl} alt={title.title} @@ -275,7 +479,7 @@ export default function TitleDetailPage() { <button type="button" onClick={handleWatchMovie} - className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20" + className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20" > <IconPlayerPlay size={15} /> Mark Watched @@ -317,7 +521,7 @@ export default function TitleDetailPage() { </div> )} </div> - </div> + </motion.div> {/* Seasons & Episodes (TV) */} {title.type === "tv" && title.seasons.length > 0 && ( @@ -330,11 +534,13 @@ export default function TitleDetailPage() { title.episodeWatches?.includes(ep.id), ).length; const totalCount = season.episodes.length; + const progressPercent = + totalCount > 0 ? (watchedCount / totalCount) * 100 : 0; return ( <div key={season.id} - className="overflow-hidden rounded-lg border border-border/50 bg-card/50" + className="overflow-hidden rounded-xl border border-border/50 bg-card/50" > <button type="button" @@ -347,16 +553,28 @@ export default function TitleDetailPage() { <span className="font-medium"> {season.name ?? `Season ${season.seasonNumber}`} </span> - {watchedCount > 0 && ( - <span className="text-xs text-primary"> - {watchedCount}/{totalCount} - </span> - )} - </div> - <div className="flex items-center gap-2"> - <span className="text-xs text-muted-foreground"> - {totalCount} ep + <span className="font-mono text-xs text-muted-foreground"> + {watchedCount}/{totalCount} </span> + </div> + <div className="flex items-center gap-3"> + {totalCount > 0 && ( + <div className="hidden w-24 sm:block"> + <Progress value={progressPercent} /> + </div> + )} + {watchedCount < totalCount && ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + handleMarkSeason(season); + }} + className="rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-primary transition-colors hover:bg-primary/10" + > + Mark all + </button> + )} {isOpen ? ( <IconChevronUp size={16} @@ -371,50 +589,82 @@ export default function TitleDetailPage() { </div> </button> - {isOpen && ( - <div className="border-t border-border/50"> - {season.episodes.map((ep) => { - const isWatched = title.episodeWatches?.includes(ep.id); - return ( - <div - key={ep.id} - className="flex items-center gap-3 border-b border-border/30 px-4 py-3 last:border-b-0" - > - <button - type="button" - onClick={() => handleWatchEpisode(ep.id)} - disabled={watchingEp === ep.id} - className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-all ${ - isWatched - ? "border-primary bg-primary text-primary-foreground" - : "border-border/50 hover:border-primary/50 hover:bg-primary/5" - }`} + <AnimatePresence> + {isOpen && ( + <motion.div + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ + type: "spring" as const, + stiffness: 300, + damping: 30, + }} + className="overflow-hidden border-t border-border/50" + > + {season.episodes.map((ep) => { + const isWatched = title.episodeWatches?.includes( + ep.id, + ); + return ( + <div + key={ep.id} + className="flex items-center gap-3 border-b border-border/30 px-4 py-3 last:border-b-0" > - {isWatched && <IconCheck size={14} />} - </button> - <div className="min-w-0 flex-1"> - <p className="truncate text-sm"> - <span className="font-mono text-xs text-muted-foreground"> - E{String(ep.episodeNumber).padStart(2, "0")} - </span>{" "} - <span className="font-medium"> - {ep.name ?? "Untitled"} - </span> - </p> - {ep.airDate && ( - <p className="text-xs text-muted-foreground"> - {ep.airDate} - {ep.runtimeMinutes - ? ` · ${ep.runtimeMinutes}m` - : ""} + <button + type="button" + onClick={() => + handleWatchEpisode( + ep.id, + season.seasonNumber, + ep.episodeNumber, + ) + } + disabled={watchingEp === ep.id} + className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-all ${ + isWatched + ? "border-primary bg-primary text-primary-foreground" + : "border-border/50 hover:border-primary/50 hover:bg-primary/5" + }`} + > + {isWatched && ( + <motion.div + initial={{ scale: 0 }} + animate={{ scale: 1 }} + transition={{ + type: "spring" as const, + stiffness: 500, + damping: 15, + }} + > + <IconCheck size={14} /> + </motion.div> + )} + </button> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm"> + <span className="font-mono text-xs text-muted-foreground"> + E{String(ep.episodeNumber).padStart(2, "0")} + </span>{" "} + <span className="font-medium"> + {ep.name ?? "Untitled"} + </span> </p> - )} + {ep.airDate && ( + <p className="text-xs text-muted-foreground"> + {ep.airDate} + {ep.runtimeMinutes + ? ` · ${ep.runtimeMinutes}m` + : ""} + </p> + )} + </div> </div> - </div> - ); - })} - </div> - )} + ); + })} + </motion.div> + )} + </AnimatePresence> </div> ); })} @@ -426,20 +676,26 @@ export default function TitleDetailPage() { {recommendations.length > 0 && ( <div className="space-y-4"> <h2 className="font-display text-2xl tracking-tight">Recommended</h2> - <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"> + <motion.div + className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6" + variants={staggerContainer} + initial="hidden" + animate="visible" + > {recommendations.slice(0, 12).map((rec) => ( - <TitleCard - key={rec.id} - id={rec.id} - tmdbId={rec.tmdbId} - type={rec.type} - title={rec.title} - posterPath={rec.posterPath} - releaseDate={rec.releaseDate ?? rec.firstAirDate} - voteAverage={rec.voteAverage} - /> + <motion.div key={rec.id} variants={staggerItem}> + <TitleCard + id={rec.id} + tmdbId={rec.tmdbId} + type={rec.type} + title={rec.title} + posterPath={rec.posterPath} + releaseDate={rec.releaseDate ?? rec.firstAirDate} + voteAverage={rec.voteAverage} + /> + </motion.div> ))} - </div> + </motion.div> </div> )} </div> diff --git a/app/api/feed/stats/route.ts b/app/api/feed/stats/route.ts new file mode 100644 index 0000000..a23fe14 --- /dev/null +++ b/app/api/feed/stats/route.ts @@ -0,0 +1,75 @@ +import { and, eq, sql } from "drizzle-orm"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { db } from "@/lib/db/client"; +import { + userEpisodeWatches, + userMovieWatches, + userTitleStatus, +} from "@/lib/db/schema"; + +export async function GET() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const userId = session.user.id; + const now = new Date(); + + // Start of current month + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + // Start of current week (Monday) + const dayOfWeek = now.getDay(); + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); + weekStart.setHours(0, 0, 0, 0); + + const [moviesThisMonth] = db + .select({ count: sql<number>`count(*)` }) + .from(userMovieWatches) + .where( + and( + eq(userMovieWatches.userId, userId), + sql`${userMovieWatches.watchedAt} >= ${Math.floor(monthStart.getTime() / 1000)}`, + ), + ) + .all(); + + const [episodesThisWeek] = db + .select({ count: sql<number>`count(*)` }) + .from(userEpisodeWatches) + .where( + and( + eq(userEpisodeWatches.userId, userId), + sql`${userEpisodeWatches.watchedAt} >= ${Math.floor(weekStart.getTime() / 1000)}`, + ), + ) + .all(); + + const [librarySize] = db + .select({ count: sql<number>`count(*)` }) + .from(userTitleStatus) + .where(eq(userTitleStatus.userId, userId)) + .all(); + + const [completedCount] = db + .select({ count: sql<number>`count(*)` }) + .from(userTitleStatus) + .where( + and( + eq(userTitleStatus.userId, userId), + eq(userTitleStatus.status, "completed"), + ), + ) + .all(); + + return NextResponse.json({ + moviesThisMonth: moviesThisMonth?.count ?? 0, + episodesThisWeek: episodesThisWeek?.count ?? 0, + librarySize: librarySize?.count ?? 0, + completed: completedCount?.count ?? 0, + }); +} diff --git a/app/globals.css b/app/globals.css index dad75b1..5184d12 100644 --- a/app/globals.css +++ b/app/globals.css @@ -46,42 +46,48 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); + --color-status-watchlist: var(--status-watchlist); + --color-status-watching: var(--status-watching); + --color-status-completed: var(--status-completed); } -/* Dark cinema — always dark */ +/* Dark cinema — always dark, warm indigo base */ :root { - --background: oklch(0.12 0.005 250); + --background: oklch(0.12 0.008 270); --foreground: oklch(0.93 0.01 80); - --card: oklch(0.16 0.005 250); + --card: oklch(0.16 0.008 270); --card-foreground: oklch(0.93 0.01 80); - --popover: oklch(0.18 0.005 250); + --popover: oklch(0.18 0.008 270); --popover-foreground: oklch(0.93 0.01 80); - --primary: oklch(0.82 0.12 70); - --primary-foreground: oklch(0.12 0.005 250); - --secondary: oklch(0.2 0.005 250); + --primary: oklch(0.8 0.14 65); + --primary-foreground: oklch(0.12 0.008 270); + --secondary: oklch(0.2 0.008 270); --secondary-foreground: oklch(0.85 0.02 80); - --muted: oklch(0.2 0.005 250); + --muted: oklch(0.2 0.008 270); --muted-foreground: oklch(0.6 0.02 80); - --accent: oklch(0.22 0.008 250); + --accent: oklch(0.22 0.01 270); --accent-foreground: oklch(0.93 0.01 80); --destructive: oklch(0.65 0.2 25); - --border: oklch(1 0 0 / 8%); + --border: oklch(0.8 0.02 65 / 8%); --input: oklch(1 0 0 / 10%); - --ring: oklch(0.82 0.12 70); - --chart-1: oklch(0.82 0.12 70); + --ring: oklch(0.8 0.14 65); + --chart-1: oklch(0.8 0.14 65); --chart-2: oklch(0.65 0.15 30); --chart-3: oklch(0.55 0.12 260); --chart-4: oklch(0.72 0.1 160); --chart-5: oklch(0.6 0.15 310); - --radius: 0.5rem; - --sidebar: oklch(0.14 0.005 250); + --radius: 0.625rem; + --sidebar: oklch(0.14 0.008 270); --sidebar-foreground: oklch(0.93 0.01 80); - --sidebar-primary: oklch(0.82 0.12 70); - --sidebar-primary-foreground: oklch(0.12 0.005 250); - --sidebar-accent: oklch(0.2 0.005 250); + --sidebar-primary: oklch(0.8 0.14 65); + --sidebar-primary-foreground: oklch(0.12 0.008 270); + --sidebar-accent: oklch(0.2 0.008 270); --sidebar-accent-foreground: oklch(0.93 0.01 80); - --sidebar-border: oklch(1 0 0 / 8%); - --sidebar-ring: oklch(0.82 0.12 70); + --sidebar-border: oklch(0.8 0.02 65 / 8%); + --sidebar-ring: oklch(0.8 0.14 65); + --status-watchlist: oklch(0.72 0.12 220); + --status-watching: oklch(0.78 0.14 65); + --status-completed: oklch(0.75 0.14 155); } @layer base { @@ -93,6 +99,79 @@ } } +/* Film grain texture overlay */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + opacity: 0.015; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); +} + +/* Custom keyframe animations */ +@keyframes card-enter { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes check-pop { + 0% { + transform: scale(0); + } + 60% { + transform: scale(1.15); + } + 100% { + transform: scale(1); + } +} + +@keyframes star-fill { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.25); + } + 100% { + transform: scale(1); + } +} + +@keyframes gentle-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} + +/* Warm skeleton pulse override */ +@keyframes warm-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +[data-slot="skeleton"] { + animation: warm-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + background: oklch(0.22 0.015 270); +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; @@ -117,3 +196,14 @@ .feed-scroll::-webkit-scrollbar { display: none; } + +/* Utility animations */ +.animate-gentle-float { + animation: gentle-float 3s ease-in-out infinite; +} +.animate-check-pop { + animation: check-pop 0.3s ease-out; +} +.animate-star-fill { + animation: star-fill 0.3s ease-out; +} diff --git a/app/page.tsx b/app/page.tsx index 2bc1f07..92efc73 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { motion } from "motion/react"; import Link from "next/link"; export default function Home() { @@ -12,24 +15,71 @@ export default function Home() { /> {/* Warm primary glow */} - <div className="pointer-events-none absolute left-1/2 top-1/3 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/5 blur-[120px]" /> + <motion.div + className="pointer-events-none absolute left-1/2 top-1/3 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/5 blur-[120px]" + animate={{ opacity: [0.4, 0.7, 0.4] }} + transition={{ + duration: 6, + repeat: Number.POSITIVE_INFINITY, + ease: "easeInOut", + }} + /> <main className="relative z-10 flex flex-col items-center gap-10 px-6 text-center"> <div className="space-y-4"> - <p className="text-sm font-medium uppercase tracking-[0.3em] text-primary"> + <motion.p + className="text-sm font-medium uppercase tracking-[0.3em] text-primary" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + type: "spring" as const, + stiffness: 200, + damping: 20, + }} + > Self-hosted movie & TV tracker - </p> - <h1 className="font-display text-6xl tracking-tight sm:text-7xl md:text-8xl"> + </motion.p> + <motion.h1 + className="font-display text-6xl tracking-tight sm:text-7xl md:text-8xl" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + type: "spring" as const, + stiffness: 200, + damping: 20, + delay: 0.1, + }} + > Couch Potato - </h1> - <p className="mx-auto max-w-md text-lg leading-relaxed text-muted-foreground"> + </motion.h1> + <motion.p + className="mx-auto max-w-md text-lg leading-relaxed text-muted-foreground" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + type: "spring" as const, + stiffness: 200, + damping: 20, + delay: 0.2, + }} + > Track what you watch. Know what's next. <br /> Your library, your data, your rules. - </p> + </motion.p> </div> - <div className="flex gap-4"> + <motion.div + className="flex gap-4" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + type: "spring" as const, + stiffness: 200, + damping: 20, + delay: 0.35, + }} + > <Link href="/login" className="group relative inline-flex h-12 items-center justify-center overflow-hidden rounded-lg bg-primary px-8 font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20" @@ -43,7 +93,7 @@ export default function Home() { > Register </Link> - </div> + </motion.div> </main> {/* Bottom fade */} diff --git a/components/auth-form.tsx b/components/auth-form.tsx index d259159..5d00112 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -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> ); } diff --git a/components/command-palette.tsx b/components/command-palette.tsx new file mode 100644 index 0000000..d529f47 --- /dev/null +++ b/components/command-palette.tsx @@ -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> + ); +} diff --git a/components/keyboard-help-dialog.tsx b/components/keyboard-help-dialog.tsx new file mode 100644 index 0000000..8eb35e6 --- /dev/null +++ b/components/keyboard-help-dialog.tsx @@ -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(); +} diff --git a/components/keyboard-provider.tsx b/components/keyboard-provider.tsx new file mode 100644 index 0000000..708f730 --- /dev/null +++ b/components/keyboard-provider.tsx @@ -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> + ); +} diff --git a/components/nav-bar.tsx b/components/nav-bar.tsx index 7c5399a..61f5473 100644 --- a/components/nav-bar.tsx +++ b/components/nav-bar.tsx @@ -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> - ); -} diff --git a/components/search-autocomplete.tsx b/components/search-autocomplete.tsx new file mode 100644 index 0000000..7394b67 --- /dev/null +++ b/components/search-autocomplete.tsx @@ -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> + ); +} diff --git a/components/skeletons.tsx b/components/skeletons.tsx new file mode 100644 index 0000000..4aebfba --- /dev/null +++ b/components/skeletons.tsx @@ -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> + ); +} diff --git a/components/star-rating.tsx b/components/star-rating.tsx index 57e70f9..1d36952 100644 --- a/components/star-rating.tsx +++ b/components/star-rating.tsx @@ -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> diff --git a/components/stats-summary.tsx b/components/stats-summary.tsx new file mode 100644 index 0000000..0e4c7c8 --- /dev/null +++ b/components/stats-summary.tsx @@ -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> + ); +} diff --git a/components/status-button.tsx b/components/status-button.tsx index e63d3c2..ad4536f 100644 --- a/components/status-button.tsx +++ b/components/status-button.tsx @@ -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> ); } diff --git a/components/title-card.tsx b/components/title-card.tsx index c4826c2..7f4f329 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -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) { diff --git a/hooks/use-debounce.ts b/hooks/use-debounce.ts new file mode 100644 index 0000000..7bb0e18 --- /dev/null +++ b/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce<T>(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/hooks/use-register-shortcut.ts b/hooks/use-register-shortcut.ts new file mode 100644 index 0000000..1293ad2 --- /dev/null +++ b/hooks/use-register-shortcut.ts @@ -0,0 +1,14 @@ +import { useEffect, useMemo } from "react"; +import { type ShortcutDef, useKeyboard } from "@/components/keyboard-provider"; + +export function useRegisterShortcut(id: string, def: ShortcutDef) { + const { registerShortcut, unregisterShortcut } = useKeyboard(); + const keysKey = def.keys.join(","); + // biome-ignore lint/correctness/useExhaustiveDependencies: stable memoization on keys/description + const stableDef = useMemo(() => def, [keysKey]); + + useEffect(() => { + registerShortcut(id, stableDef); + return () => unregisterShortcut(id); + }, [id, registerShortcut, unregisterShortcut, stableDef]); +} diff --git a/lib/services/discovery.ts b/lib/services/discovery.ts index 4ffd7d7..af4a05f 100644 --- a/lib/services/discovery.ts +++ b/lib/services/discovery.ts @@ -25,6 +25,8 @@ export interface ContinueWatchingItem { name: string | null; } | null; lastWatchedAt: Date | null; + totalEpisodes: number; + watchedEpisodes: number; } export function getContinueWatchingFeed( @@ -67,6 +69,8 @@ export function getContinueWatchingFeed( // Find first unwatched episode let nextEpisode: ContinueWatchingItem["nextEpisode"] = null; let lastWatchedAt: Date | null = null; + let totalEpisodes = 0; + let watchedEpisodes = 0; // Get most recent watch for this show for (const s of titleSeasons) { @@ -77,6 +81,8 @@ export function getContinueWatchingFeed( .orderBy(episodes.episodeNumber) .all(); + totalEpisodes += eps.length; + for (const ep of eps) { const watch = db .select() @@ -90,6 +96,7 @@ export function getContinueWatchingFeed( .get(); if (watch) { + watchedEpisodes++; if (!lastWatchedAt || watch.watchedAt > lastWatchedAt) { lastWatchedAt = watch.watchedAt; } @@ -116,6 +123,8 @@ export function getContinueWatchingFeed( }, nextEpisode, lastWatchedAt, + totalEpisodes, + watchedEpisodes, }); } } diff --git a/package.json b/package.json index 1afdcdd..d3aaa96 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "embla-carousel-react": "^8.6.0", + "motion": "^12.34.3", "next": "16.1.6", "react": "19.2.4", "react-day-picker": "^9.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf8516..e7c98e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.4) + motion: + specifier: ^12.34.3 + version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2049,6 +2052,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.34.3: + resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2467,6 +2484,26 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + motion-dom@12.34.3: + resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + + motion@12.34.3: + resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4802,6 +4839,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.34.3 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -5106,6 +5152,20 @@ snapshots: mkdirp-classic@0.5.3: {} + motion-dom@12.34.3: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + ms@2.1.3: {} msw@2.12.10(@types/node@25.3.2)(typescript@5.9.3):