"use client"; import { IconCheck, IconChevronDown, IconChevronUp, IconPlayerPlay, } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Image from "next/image"; 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; episodeNumber: number; name: string | null; overview: string | null; airDate: string | null; runtimeMinutes: number | null; } interface Season { id: string; seasonNumber: number; name: string | null; episodes: Episode[]; } interface AvailabilityOffer { providerId: number; providerName: string; logoPath: string | null; offerType: string; } interface RecommendedTitle { id: string; tmdbId: number; type: "movie" | "tv"; title: string; posterPath: string | null; releaseDate: string | null; firstAirDate: string | null; voteAverage: number | null; } interface Title { id: string; tmdbId: number; type: "movie" | "tv"; title: string; originalTitle: string | null; overview: string | null; releaseDate: string | null; firstAirDate: string | null; posterPath: string | null; backdropPath: string | null; popularity: number | null; voteAverage: number | null; voteCount: number | null; status: string | null; seasons: Season[]; availability: AvailabilityOffer[]; userStatus?: string | null; userRating?: number | null; 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[]>( [], ); const [loading, setLoading] = useState(true); const [openSeason, setOpenSeason] = useState<number | null>(null); const [watchingEp, setWatchingEp] = useState<string | null>(null); const fetchTitle = useCallback(async () => { setLoading(true); try { const [titleRes, statusRes] = await Promise.all([ fetch(`/api/titles/${id}`), fetch(`/api/titles/${id}/status`), ]); const titleData = await titleRes.json(); let statusData = { status: null, rating: null, episodeWatches: [] }; if (statusRes.ok) { statusData = await statusRes.json(); } setTitle({ ...titleData, userStatus: statusData.status, userRating: statusData.rating, episodeWatches: statusData.episodeWatches ?? [], }); } finally { setLoading(false); } }, [id]); const fetchRecommendations = useCallback(async () => { try { const res = await fetch(`/api/titles/${id}/recommendations`); if (res.ok) { const data = await res.json(); setRecommendations(data ?? []); } } catch { // silent } }, [id]); useEffect(() => { fetchTitle(); 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 <TitleDetailSkeleton />; } if (!title) { return ( <p className="py-24 text-center text-muted-foreground">Title not found</p> ); } const posterUrl = title.posterPath ? `https://image.tmdb.org/t/p/w500${title.posterPath}` : null; const backdropUrl = title.backdropPath ? `https://image.tmdb.org/t/p/w1280${title.backdropPath}` : null; const dateStr = title.releaseDate ?? title.firstAirDate; const year = dateStr?.slice(0, 4); async function handleMarkSeason(season: Season) { const unwatched = season.episodes.filter( (ep) => !title?.episodeWatches?.includes(ep.id), ); if (unwatched.length === 0) return; // Optimistic update setTitle((t) => { if (!t) return t; const watches = [...(t.episodeWatches ?? [])]; for (const ep of unwatched) { if (!watches.includes(ep.id)) watches.push(ep.id); } return { ...t, episodeWatches: watches }; }); 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 const availByType: Record<string, AvailabilityOffer[]> = {}; for (const offer of title.availability ?? []) { if (!availByType[offer.offerType]) availByType[offer.offerType] = []; availByType[offer.offerType].push(offer); } const offerLabels: Record<string, string> = { flatrate: "Stream", rent: "Rent", buy: "Buy", free: "Free", ads: "With Ads", }; return ( <div className="space-y-10"> {/* Breadcrumb */} <Breadcrumb className="relative z-20"> <BreadcrumbList> <BreadcrumbItem> <BreadcrumbLink render={<Link href="/dashboard" />}> Home </BreadcrumbLink> </BreadcrumbItem> <BreadcrumbSeparator /> <BreadcrumbItem> <BreadcrumbPage>{title.title}</BreadcrumbPage> </BreadcrumbItem> </BreadcrumbList> </Breadcrumb> {/* Backdrop hero */} {backdropUrl && ( <div className="relative -mx-4 -mt-4 h-80 overflow-hidden sm:-mx-6 sm:h-[28rem]"> <Image src={backdropUrl} alt="" fill 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 */} <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-2xl ring-1 ring-foreground/5 shadow-2xl shadow-black/50"> <Image src={posterUrl} alt={title.title} width={220} height={330} className="h-auto w-[180px] sm:w-[220px]" priority /> </div> </div> )} <div className="flex-1 space-y-5"> <div> <h1 className="font-display text-4xl tracking-tight sm:text-5xl"> {title.title} </h1> <div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> <span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-primary"> {title.type} </span> {year && <span>{year}</span>} {title.voteAverage != null && title.voteAverage > 0 && ( <span className="flex items-center gap-1 text-primary"> ★ {title.voteAverage.toFixed(1)} {title.voteCount != null && ( <span className="text-muted-foreground"> ({title.voteCount.toLocaleString()}) </span> )} </span> )} {title.status && ( <span className="rounded border border-border/50 px-2 py-0.5 text-xs"> {title.status} </span> )} </div> </div> {title.overview && ( <p className="max-w-2xl leading-relaxed text-muted-foreground"> {title.overview} </p> )} {/* Actions */} <div className="flex flex-wrap items-center gap-3"> <StatusButton currentStatus={title.userStatus ?? null} onChange={handleStatusChange} /> {title.type === "movie" && ( <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 active:scale-[0.97] hover:shadow-md hover:shadow-primary/20" > <IconPlayerPlay size={15} /> Mark Watched </button> )} <div className="flex items-center gap-2"> <span className="text-xs text-muted-foreground">Rate:</span> <StarRating value={title.userRating ?? 0} onChange={handleRating} /> </div> </div> {/* Availability */} {Object.keys(availByType).length > 0 && ( <div className="space-y-3"> <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> Where to Watch </h3> <div className="flex flex-wrap gap-4"> {Object.entries(availByType).map(([type, offers]) => ( <div key={type} className="space-y-1.5"> <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60"> {offerLabels[type] ?? type} </span> <div className="flex gap-1.5"> {offers.map((offer) => ( <ProviderBadge key={offer.providerId} name={offer.providerName} logoPath={offer.logoPath} /> ))} </div> </div> ))} </div> </div> )} </div> </motion.div> {/* Seasons & Episodes (TV) */} {title.type === "tv" && title.seasons.length > 0 && ( <div className="space-y-3"> <h2 className="font-display text-2xl tracking-tight">Seasons</h2> <div className="space-y-2"> {title.seasons.map((season) => { const isOpen = openSeason === season.seasonNumber; const watchedCount = season.episodes.filter((ep) => 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-xl border border-border/50 bg-card/50" > <button type="button" onClick={() => setOpenSeason(isOpen ? null : season.seasonNumber) } className="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-accent/50" > <div className="flex items-center gap-3"> <span className="font-medium"> {season.name ?? `Season ${season.seasonNumber}`} </span> <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} className="text-muted-foreground" /> ) : ( <IconChevronDown size={16} className="text-muted-foreground" /> )} </div> </button> <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" > <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> ); })} </motion.div> )} </AnimatePresence> </div> ); })} </div> </div> )} {/* Recommendations */} {recommendations.length > 0 && ( <div className="space-y-4"> <h2 className="font-display text-2xl tracking-tight">Recommended</h2> <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) => ( <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> ))} </motion.div> </div> )} </div> ); } function ProviderBadge({ name, logoPath, }: { name: string; logoPath: string | null; }) { const logoUrl = logoPath ? `https://image.tmdb.org/t/p/w92${logoPath}` : null; return ( <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card transition-transform hover:scale-105" title={name} > {logoUrl ? ( <Image src={logoUrl} alt={name} width={40} height={40} className="h-full w-full object-cover" /> ) : ( <span className="text-[8px] font-medium text-muted-foreground"> {name.slice(0, 2)} </span> )} </div> ); }