Convert dashboard and title pages to server components with granular Suspense

Replace fully client-side dashboard and title detail pages with server
component orchestrators that fetch data directly via service functions,
eliminating extra network round-trips through API routes. Each section
streams independently through its own Suspense boundary.

- Extract getUserStats(), getTitleWithChildren(), getRecommendationsForTitle()
  into service layer; update getNewAvailableFeed() to include tmdbId/voteAverage
- Split dashboard into server sections (stats, continue watching, library,
  recommendations) with client children for animations
- Split title page into server hero + client interaction provider with shared
  context for optimistic mutations across actions and seasons
- Add generateMetadata with OG tags, server-side TMDB ID resolution via
  redirect(), loading.tsx and not-found.tsx for both pages
- Add per-section skeleton components, fix ContinueWatchingSkeleton dimensions
- Remove 6 unused API routes (feed/*, titles/[id] GET, titles/[id]/recommendations)
- Remove components/stats-summary.tsx, add lib/types/title.ts for shared types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 15:42:42 -05:00
co-authored by Claude Opus 4.6
parent 081f53beac
commit c1867a4f23
33 changed files with 1804 additions and 1654 deletions
@@ -0,0 +1,52 @@
"use client";
import { motion } from "motion/react";
import { TitleCard } from "@/components/title-card";
import type { RecommendedTitle } from "@/lib/types/title";
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 function RecommendationsGrid({
recommendations,
}: {
recommendations: RecommendedTitle[];
}) {
return (
<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>
);
}
@@ -0,0 +1,40 @@
"use client";
import { IconPlayerPlay } from "@tabler/icons-react";
import { StarRating } from "@/components/star-rating";
import { StatusButton } from "@/components/status-button";
import { useTitleInteraction } from "./title-interaction-provider";
export function TitleActions() {
const {
titleType,
userStatus,
userRating,
handleStatusChange,
handleRating,
handleWatchMovie,
} = useTitleInteraction();
return (
<div className="flex flex-wrap items-center gap-3">
<StatusButton
currentStatus={userStatus ?? null}
onChange={handleStatusChange}
/>
{titleType === "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={userRating ?? 0} onChange={handleRating} />
</div>
</div>
);
}
@@ -0,0 +1,88 @@
"use client";
import Image from "next/image";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { AvailabilityOffer } from "@/lib/types/title";
const offerLabels: Record<string, string> = {
flatrate: "Stream",
rent: "Rent",
buy: "Buy",
free: "Free",
ads: "With Ads",
};
function ProviderBadge({
name,
logoPath,
}: {
name: string;
logoPath: string | null;
}) {
return (
<Tooltip>
<TooltipTrigger 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">
{logoPath ? (
<Image
src={logoPath}
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>
)}
</TooltipTrigger>
<TooltipContent className="bg-popover px-2 py-1 text-[10px] font-medium text-popover-foreground shadow-md [&>:last-child]:bg-popover [&>:last-child]:fill-popover">
{name}
</TooltipContent>
</Tooltip>
);
}
export function TitleAvailability({
availability,
}: {
availability: AvailabilityOffer[];
}) {
const availByType: Record<string, AvailabilityOffer[]> = {};
for (const offer of availability) {
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
availByType[offer.offerType].push(offer);
}
if (Object.keys(availByType).length === 0) return null;
return (
<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>
);
}
@@ -0,0 +1,165 @@
import Image from "next/image";
import type { ReactNode } from "react";
import { TmdbLogo } from "@/components/tmdb-logo";
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
export function TitleHero({
title,
actions,
children,
}: {
title: ResolvedTitle;
actions: ReactNode;
children?: ReactNode;
}) {
const dateStr = title.releaseDate ?? title.firstAirDate;
const year = dateStr?.slice(0, 4);
const palette = title.colorPalette;
return (
<>
{/* Backdrop hero */}
{title.backdropPath && (
<div className="relative -mt-6 ml-[calc(-50vw+50%)] mr-[calc(-50vw+50%)] h-80 overflow-hidden sm:h-[28rem]">
<Image
src={title.backdropPath}
alt=""
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-background/30" />
<div className="absolute inset-0 bg-gradient-to-r from-background/90 via-background/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-b from-background/50 via-transparent to-transparent" />
<div className="absolute inset-0 bg-background/15" />
{palette?.darkMuted && (
<div
className="absolute inset-0 opacity-40 mix-blend-multiply"
style={{
background: `radial-gradient(ellipse at 25% 85%, ${palette.darkMuted} 0%, transparent 65%)`,
}}
/>
)}
{palette?.vibrant && (
<div
className="absolute inset-0 opacity-[0.08]"
style={{
background: `radial-gradient(ellipse at 50% 70%, ${palette.vibrant} 0%, transparent 55%)`,
}}
/>
)}
<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>
)}
{/* Ambient glow orbs */}
<AmbientGlow palette={palette} />
{/* Title header */}
<div
className={`flex flex-row gap-4 sm:gap-8 ${title.backdropPath ? "-mt-32 relative z-10" : ""}`}
>
{title.posterPath && (
<div className="shrink-0">
<div
className="overflow-hidden rounded-xl sm:rounded-2xl ring-1 ring-foreground/5 shadow-2xl transition-shadow duration-500"
style={{
boxShadow: palette?.darkVibrant
? `0 25px 60px -12px ${palette.darkVibrant}50, 0 12px 28px -8px rgba(0,0,0,0.5)`
: "0 25px 50px -12px rgba(0,0,0,0.5)",
}}
>
<Image
src={title.posterPath}
alt={title.title}
width={220}
height={330}
className="h-auto w-[120px] sm:w-[220px]"
priority
/>
</div>
</div>
)}
<div className="flex-1 space-y-5">
<div>
<h1 className="font-display text-2xl 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="inline-flex h-5 items-center rounded border border-border/50 px-2 text-xs">
{title.status}
</span>
)}
<a
href={`https://www.themoviedb.org/${title.type === "movie" ? "movie" : "tv"}/${title.tmdbId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-5 items-center rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
</div>
</div>
{title.overview && (
<p className="max-w-2xl leading-relaxed text-muted-foreground">
{title.overview}
</p>
)}
{actions}
{children}
</div>
</div>
</>
);
}
function AmbientGlow({ palette }: { palette: ColorPalette | null }) {
if (!palette) return null;
return (
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[800px] overflow-hidden">
{palette.vibrant && (
<div
className="absolute -left-32 top-16 h-[500px] w-[500px] rounded-full opacity-[0.07] blur-[120px]"
style={{ background: palette.vibrant }}
/>
)}
{palette.darkMuted && (
<div
className="absolute -right-24 top-48 h-[400px] w-[600px] rounded-full opacity-[0.05] blur-[140px]"
style={{ background: palette.darkMuted }}
/>
)}
{palette.muted && (
<div
className="absolute left-1/3 top-[500px] h-[300px] w-[400px] rounded-full opacity-[0.04] blur-[100px]"
style={{ background: palette.muted }}
/>
)}
</div>
);
}
@@ -0,0 +1,270 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { toast } from "sonner";
import type { Season } from "@/lib/types/title";
interface TitleInteractionState {
titleId: string;
titleType: "movie" | "tv";
titleName: string;
userStatus: string | null;
userRating: number;
episodeWatches: string[];
seasons: Season[];
handleStatusChange: (status: string | null) => void;
handleRating: (ratingStars: number) => void;
handleWatchMovie: () => void;
handleWatchEpisode: (
episodeId: string,
seasonNum: number,
epNum: number,
isWatched: boolean,
) => void;
handleMarkSeason: (season: Season) => void;
handleUnmarkSeason: (season: Season) => void;
watchingEp: string | null;
}
const TitleInteractionContext = createContext<TitleInteractionState | null>(
null,
);
export function useTitleInteraction() {
const ctx = useContext(TitleInteractionContext);
if (!ctx)
throw new Error(
"useTitleInteraction must be used within TitleInteractionProvider",
);
return ctx;
}
export function TitleInteractionProvider({
titleId,
titleType,
titleName,
initialStatus,
initialRating,
initialEpisodeWatches,
seasons,
children,
}: {
titleId: string;
titleType: "movie" | "tv";
titleName: string;
initialStatus: string | null;
initialRating: number;
initialEpisodeWatches: string[];
seasons: Season[];
children: React.ReactNode;
}) {
const [userStatus, setUserStatus] = useState(initialStatus);
const [userRating, setUserRating] = useState(initialRating);
const [episodeWatches, setEpisodeWatches] = useState(initialEpisodeWatches);
const [watchingEp, setWatchingEp] = useState<string | null>(null);
const handleStatusChange = useCallback(
async (status: string | null) => {
const prev = userStatus;
setUserStatus(status);
try {
const res = await fetch(`/api/titles/${titleId}/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 {
setUserStatus(prev);
toast.error("Failed to update status");
}
},
[titleId, userStatus],
);
const handleRating = useCallback(
async (ratingStars: number) => {
const prev = userRating;
setUserRating(ratingStars);
try {
const res = await fetch(`/api/titles/${titleId}/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 {
setUserRating(prev);
toast.error("Failed to update rating");
}
},
[titleId, userRating],
);
const handleWatchMovie = useCallback(async () => {
const prev = userStatus;
setUserStatus("completed");
try {
const res = await fetch(`/api/movies/${titleId}/watch`, {
method: "POST",
});
if (!res.ok) throw new Error();
toast.success(`Marked "${titleName}" as watched`);
} catch {
setUserStatus(prev);
toast.error("Failed to mark as watched");
}
}, [titleId, titleName, userStatus]);
const handleWatchEpisode = useCallback(
async (
episodeId: string,
seasonNum: number,
epNum: number,
isWatched: boolean,
) => {
setWatchingEp(episodeId);
if (isWatched) {
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
setUserStatus((s) => (s === "completed" ? "in_progress" : s));
try {
const res = await fetch(`/api/episodes/${episodeId}/watch`, {
method: "DELETE",
});
if (!res.ok) throw new Error();
toast.success(`Unwatched S${seasonNum} E${epNum}`);
} catch {
setEpisodeWatches((w) =>
w.includes(episodeId) ? w : [...w, episodeId],
);
toast.error("Failed to unmark episode");
}
} else {
setEpisodeWatches((w) =>
w.includes(episodeId) ? w : [...w, episodeId],
);
setUserStatus((s) => s ?? "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 {
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
toast.error("Failed to mark episode");
}
}
setWatchingEp(null);
},
[],
);
const handleMarkSeason = useCallback(
async (season: Season) => {
const unwatched = season.episodes.filter(
(ep) => !episodeWatches.includes(ep.id),
);
if (unwatched.length === 0) return;
setEpisodeWatches((w) => {
const set = new Set(w);
for (const ep of unwatched) set.add(ep.id);
return [...set];
});
try {
const res = await fetch(`/api/seasons/${season.id}/watch`, {
method: "POST",
});
if (!res.ok) throw new Error();
toast.success(
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
} catch {
toast.error("Failed to mark some episodes");
}
},
[episodeWatches],
);
const handleUnmarkSeason = useCallback(async (season: Season) => {
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
setEpisodeWatches((w) => w.filter((id) => !seasonEpIds.has(id)));
setUserStatus((s) => (s === "completed" ? "in_progress" : s));
try {
const res = await fetch(`/api/seasons/${season.id}/watch`, {
method: "DELETE",
});
if (!res.ok) throw new Error();
toast.success(
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
} catch {
toast.error("Failed to unmark some episodes");
}
}, []);
const value = useMemo(
() => ({
titleId,
titleType,
titleName,
userStatus,
userRating,
episodeWatches,
seasons,
handleStatusChange,
handleRating,
handleWatchMovie,
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
watchingEp,
}),
[
titleId,
titleType,
titleName,
userStatus,
userRating,
episodeWatches,
seasons,
handleStatusChange,
handleRating,
handleWatchMovie,
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
watchingEp,
],
);
return (
<TitleInteractionContext.Provider value={value}>
{children}
</TitleInteractionContext.Provider>
);
}
@@ -0,0 +1,64 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { useRegisterShortcut } from "@/hooks/use-register-shortcut";
import { useTitleInteraction } from "./title-interaction-provider";
const statusCycle = ["watchlist", "in_progress", "completed"] as const;
export function TitleKeyboardShortcuts() {
const router = useRouter();
const {
titleType,
userStatus,
handleStatusChange,
handleRating,
handleWatchMovie,
} = useTitleInteraction();
const nextStatus = useMemo(() => {
const currentIdx = statusCycle.indexOf(
userStatus as (typeof statusCycle)[number],
);
return currentIdx === statusCycle.length - 1
? null
: statusCycle[currentIdx + 1];
}, [userStatus]);
useRegisterShortcut("title-cycle-status", {
keys: ["w"],
description: "Cycle status",
action: () => handleStatusChange(nextStatus),
scope: "Title",
});
useRegisterShortcut("title-mark-watched", {
keys: ["m"],
description: "Mark watched",
action: () => {
if (titleType === "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",
});
}
return null;
}
@@ -0,0 +1,21 @@
import { getRecommendationsForTitle } from "@/lib/services/discovery";
import type { RecommendedTitle } from "@/lib/types/title";
import { RecommendationsGrid } from "./recommendations-grid";
export async function TitleRecommendations({ titleId }: { titleId: string }) {
const recs = await getRecommendationsForTitle(titleId);
if (recs.length === 0) return null;
const recommendations: RecommendedTitle[] = recs.map((r) => ({
id: r.id,
tmdbId: r.tmdbId,
type: r.type,
title: r.title,
posterPath: r.posterPath,
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
}));
return <RecommendationsGrid recommendations={recommendations} />;
}
@@ -0,0 +1,206 @@
"use client";
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useState } from "react";
import { Progress } from "@/components/ui/progress";
import { useTitleInteraction } from "./title-interaction-provider";
export function TitleSeasons() {
const {
seasons,
episodeWatches,
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,
watchingEp,
} = useTitleInteraction();
const [openSeason, setOpenSeason] = useState<number | null>(null);
return (
<div className="space-y-3">
<h2 className="font-display text-2xl tracking-tight">Seasons</h2>
<div className="space-y-2">
{seasons.map((season) => {
const isOpen = openSeason === season.seasonNumber;
const watchedCount = season.episodes.filter((ep) =>
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"
>
{/* biome-ignore lint/a11y/useSemanticElements: contains nested buttons */}
<div
role="button"
tabIndex={0}
onClick={() =>
setOpenSeason(isOpen ? null : season.seasonNumber)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpenSeason(isOpen ? null : season.seasonNumber);
}
}}
className="flex w-full cursor-pointer 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 && (
<>
<span className="text-xs tabular-nums text-muted-foreground sm:hidden">
{Math.round(progressPercent)}%
</span>
<div className="hidden w-24 sm:block">
<Progress value={progressPercent} />
</div>
</>
)}
{totalCount > 0 && 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>
)}
{totalCount > 0 && watchedCount === totalCount && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleUnmarkSeason(season);
}}
className="rounded-md px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
Unmark all
</button>
)}
{isOpen ? (
<IconChevronUp
size={16}
className="text-muted-foreground"
/>
) : (
<IconChevronDown
size={16}
className="text-muted-foreground"
/>
)}
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="overflow-hidden border-t border-border/50"
>
{season.episodes.map((ep) => {
const isWatched = episodeWatches.includes(ep.id);
const { stillPath } = ep;
return (
<div
key={ep.id}
className={`flex gap-3 border-b border-border/30 px-4 py-3 last:border-b-0 transition-colors ${isWatched ? "opacity-60" : ""}`}
>
<button
type="button"
onClick={() =>
handleWatchEpisode(
ep.id,
season.seasonNumber,
ep.episodeNumber,
isWatched,
)
}
disabled={watchingEp === ep.id}
className={`mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 transition-all ${
isWatched
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40 bg-muted-foreground/5 hover:border-primary/70 hover:bg-primary/10"
}`}
>
{isWatched && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 500,
damping: 15,
}}
>
<IconCheck size={14} />
</motion.div>
)}
</button>
{stillPath && (
<div className="hidden h-14 w-24 shrink-0 overflow-hidden rounded-md bg-muted sm:block">
<Image
src={stillPath}
alt={ep.name ?? ""}
width={300}
height={169}
className="h-full w-full object-cover"
/>
</div>
)}
<div className="min-w-0 flex-1">
<p className="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>
<p className="text-xs text-muted-foreground">
{ep.airDate ?? ""}
{ep.airDate && ep.runtimeMinutes ? " · " : ""}
{ep.runtimeMinutes ? `${ep.runtimeMinutes}m` : ""}
</p>
{ep.overview && (
<p className="mt-1 hidden line-clamp-2 text-xs leading-relaxed text-muted-foreground/70 sm:block">
{ep.overview}
</p>
)}
</div>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
);
}