mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
- Move dashboard page to app/(pages)/dashboard, redirect / to /dashboard for authenticated users, update all nav/breadcrumb/auth hrefs - Mark all episodes watched when a TV show status is set to "completed" (markAllEpisodesWatched skips already-watched episodes) - Refactor KeyboardProvider to store shortcuts in a ref instead of state, eliminating unnecessary re-renders; use react-hotkeys-hook for Cmd+K so it works reliably in form inputs - Fix useRegisterShortcut: re-register on every render (stable ref read) with a separate cleanup effect, removing brittle key memoization - Search page: silently navigate to title detail on import instead of showing "Added to library" toast
737 lines
24 KiB
TypeScript
737 lines
24 KiB
TypeScript
"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<Title | null>(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>
|
|
);
|
|
}
|