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,92 @@
"use client";
import { IconPlayerPlay } from "@tabler/icons-react";
import Image from "next/image";
import Link from "next/link";
export interface ContinueWatchingItemProps {
title: {
id: string;
title: string;
backdropPath: string | null;
};
nextEpisode: {
seasonNumber: number;
episodeNumber: number;
name: string | null;
stillPath: string | null;
} | null;
totalEpisodes: number;
watchedEpisodes: number;
}
export function ContinueWatchingCard({
item,
}: {
item: ContinueWatchingItemProps;
}) {
const stillUrl =
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress =
item.totalEpisodes > 0
? (item.watchedEpisodes / item.totalEpisodes) * 100
: 0;
return (
<Link
href={`/titles/${item.title.id}`}
className="group relative w-[calc(100vw-3rem)] shrink-0 overflow-hidden rounded-xl border border-border/30 bg-card/50 transition-all hover:border-primary/20 hover:shadow-lg hover:shadow-black/25 sm:w-72"
>
<div className="relative aspect-video overflow-hidden bg-muted">
{stillUrl ? (
<Image
src={stillUrl}
alt={item.nextEpisode?.name ?? item.title.title}
width={500}
height={281}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
<IconPlayerPlay size={32} className="text-muted-foreground/30" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{item.nextEpisode && (
<div className="absolute bottom-2.5 left-3 right-3">
<p className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-primary">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
Up next
</p>
<p className="mt-0.5 truncate text-sm font-medium text-white">
<span className="font-mono text-xs text-white/60">
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
</span>{" "}
{item.nextEpisode.name}
</p>
</div>
)}
</div>
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title.title}</p>
<p className="text-xs text-muted-foreground">
{item.watchedEpisodes}/{item.totalEpisodes} episodes
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<IconPlayerPlay size={14} />
</div>
</div>
{progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
</Link>
);
}
@@ -0,0 +1,43 @@
"use client";
import { motion } from "motion/react";
import {
ContinueWatchingCard,
type ContinueWatchingItemProps,
} from "./continue-watching-card";
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 ContinueWatchingList({
items,
}: {
items: ContinueWatchingItemProps[];
}) {
return (
<motion.div
className="feed-scroll -mx-4 flex gap-4 overflow-x-auto px-4 pb-2 sm:-mx-0 sm:px-0"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{items.map((item) => (
<motion.div key={item.title.id} variants={staggerItem}>
<ContinueWatchingCard item={item} />
</motion.div>
))}
</motion.div>
);
}
@@ -0,0 +1,42 @@
import { IconPlayerPlay } from "@tabler/icons-react";
import { getContinueWatchingFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import type { ContinueWatchingItemProps } from "./continue-watching-card";
import { ContinueWatchingList } from "./continue-watching-list";
import { FeedSection } from "./feed-section";
export async function ContinueWatchingSection({ userId }: { userId: string }) {
const feed = await getContinueWatchingFeed(userId);
if (feed.length === 0) return null;
const items: ContinueWatchingItemProps[] = feed.map((item) => ({
title: {
id: item.title.id,
title: item.title.title,
backdropPath: tmdbImageUrl(item.title.backdropPath, "w1280"),
},
nextEpisode: item.nextEpisode
? {
seasonNumber: item.nextEpisode.seasonNumber,
episodeNumber: item.nextEpisode.episodeNumber,
name: item.nextEpisode.name,
stillPath: tmdbImageUrl(
item.nextEpisode.stillPath,
"w1280",
"stills",
),
}
: null,
totalEpisodes: item.totalEpisodes,
watchedEpisodes: item.watchedEpisodes,
}));
return (
<FeedSection
title="Continue Watching"
icon={<IconPlayerPlay size={20} className="text-primary" />}
>
<ContinueWatchingList items={items} />
</FeedSection>
);
}
@@ -0,0 +1,23 @@
"use client";
import type { ReactNode } from "react";
export function FeedSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="font-display text-xl tracking-tight">{title}</h2>
</div>
{children}
</section>
);
}
@@ -0,0 +1,29 @@
import { IconSparkles } from "@tabler/icons-react";
import { getNewAvailableFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FeedSection } from "./feed-section";
import { TitleGrid } from "./title-grid";
export async function LibrarySection({ userId }: { userId: string }) {
const feed = await getNewAvailableFeed(userId);
if (feed.length === 0) return null;
const items = feed.slice(0, 10).map((t) => ({
id: t.titleId,
tmdbId: t.tmdbId,
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "w500"),
releaseDate: t.releaseDate ?? t.firstAirDate,
voteAverage: t.voteAverage,
}));
return (
<FeedSection
title="In Your Library"
icon={<IconSparkles size={20} className="text-primary" />}
>
<TitleGrid items={items} />
</FeedSection>
);
}
@@ -0,0 +1,32 @@
import { IconSparkles } from "@tabler/icons-react";
import { getRecommendationsFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import { FeedSection } from "./feed-section";
import { TitleGrid } from "./title-grid";
export async function RecommendationsSection({ userId }: { userId: string }) {
const feed = await getRecommendationsFeed(userId);
if (feed.length === 0) return null;
const items = feed
.filter((item) => !!item)
.slice(0, 10)
.map((t) => ({
id: t.id,
tmdbId: t.tmdbId,
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "w500"),
releaseDate: t.releaseDate ?? t.firstAirDate,
voteAverage: t.voteAverage,
}));
return (
<FeedSection
title="Recommended for You"
icon={<IconSparkles size={20} className="text-primary" />}
>
<TitleGrid items={items} />
</FeedSection>
);
}
@@ -0,0 +1,85 @@
"use client";
import {
IconCheck,
IconLibrary,
IconMovie,
IconPlayerPlay,
} from "@tabler/icons-react";
import { motion } from "motion/react";
import type { DashboardStats } from "@/lib/services/discovery";
const statDefs = [
{
key: "moviesThisMonth" as const,
label: "Movies This Month",
icon: IconMovie,
color: "text-primary",
bgColor: "bg-primary/10",
},
{
key: "episodesThisWeek" as const,
label: "Episodes This Week",
icon: IconPlayerPlay,
color: "text-status-watching",
bgColor: "bg-status-watching/10",
},
{
key: "librarySize" as const,
label: "In Library",
icon: IconLibrary,
color: "text-status-watchlist",
bgColor: "bg-status-watchlist/10",
},
{
key: "completed" as const,
label: "Completed",
icon: IconCheck,
color: "text-status-completed",
bgColor: "bg-status-completed/10",
},
];
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
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">
<div
className={`flex h-6 w-6 items-center justify-center rounded-md ${def.bgColor}`}
>
<Icon size={13} className={def.color} />
</div>
<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 tabular-nums tracking-tight ${def.color}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.08 + 0.2 }}
>
{value}
</motion.p>
</motion.div>
);
})}
</div>
);
}
@@ -0,0 +1,39 @@
import { IconDeviceTv } from "@tabler/icons-react";
import Link from "next/link";
import { getUserStats } from "@/lib/services/discovery";
import { StatsDisplay } from "./stats-display";
export async function StatsSection({ userId }: { userId: string }) {
const stats = await getUserStats(userId);
const isEmpty =
stats.moviesThisMonth === 0 &&
stats.episodesThisWeek === 0 &&
stats.librarySize === 0 &&
stats.completed === 0;
return (
<>
<StatsDisplay stats={stats} />
{isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center">
<div className="animate-gentle-float rounded-full bg-primary/10 p-4">
<IconDeviceTv size={32} className="text-primary" />
</div>
<div className="space-y-1">
<p className="font-medium">Your library is empty</p>
<p className="text-sm text-muted-foreground">
Search for movies and TV shows to start tracking
</p>
</div>
<Link
href="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
>
Start exploring
</Link>
</div>
)}
</>
);
}
@@ -0,0 +1,54 @@
"use client";
import { motion } from "motion/react";
import { TitleCard } from "@/components/title-card";
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 },
},
};
interface TitleGridItem {
id: string;
tmdbId: number;
type: string;
title: string;
posterPath: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
}
export function TitleGrid({ items }: { items: TitleGridItem[] }) {
return (
<motion.div
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{items.map((t) => (
<motion.div key={t.id} variants={staggerItem}>
<TitleCard
id={t.id}
tmdbId={t.tmdbId}
type={t.type}
title={t.title}
posterPath={t.posterPath}
releaseDate={t.releaseDate}
voteAverage={t.voteAverage}
/>
</motion.div>
))}
</motion.div>
);
}
@@ -0,0 +1,12 @@
export function WelcomeHeader({ name }: { name?: string | null }) {
return (
<div>
<h1 className="font-display text-3xl tracking-tight">
Welcome back{name ? `, ${name}` : ""}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Here&apos;s what&apos;s happening with your library
</p>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardSkeleton } from "@/components/skeletons";
export default function DashboardLoading() {
return <DashboardSkeleton />;
}
+30 -324
View File
@@ -1,334 +1,40 @@
"use client";
import { headers } from "next/headers";
import { Suspense } from "react";
import {
IconDeviceTv,
IconPlayerPlay,
IconSparkles,
} from "@tabler/icons-react";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
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";
ContinueWatchingSectionSkeleton,
StatsSectionSkeleton,
TitleGridSectionSkeleton,
} from "@/components/skeletons";
import { auth } from "@/lib/auth/server";
import { ContinueWatchingSection } from "./_components/continue-watching-section";
import { LibrarySection } from "./_components/library-section";
import { RecommendationsSection } from "./_components/recommendations-section";
import { StatsSection } from "./_components/stats-section";
import { WelcomeHeader } from "./_components/welcome-header";
interface ContinueWatchingItem {
title: {
id: string;
title: string;
posterPath: string | null;
backdropPath: string | null;
type: string;
};
nextEpisode: {
id: string;
seasonNumber: number;
episodeNumber: number;
name: string | null;
stillPath: string | null;
overview: string | null;
} | null;
totalEpisodes: number;
watchedEpisodes: number;
}
interface FeedTitle {
id?: string;
titleId?: string;
title: string;
type: string;
tmdbId?: number;
posterPath: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
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 } = useSession();
const [continueWatching, setContinueWatching] = useState<
ContinueWatchingItem[]
>([]);
const [newAvailable, setNewAvailable] = useState<FeedTitle[]>([]);
const [recommendations, setRecommendations] = useState<FeedTitle[]>([]);
const [loading, setLoading] = useState(true);
const fetchFeeds = useCallback(async () => {
setLoading(true);
try {
const [cwRes, naRes, recRes] = await Promise.all([
fetch("/api/feed/continue-watching"),
fetch("/api/feed/new-available"),
fetch("/api/feed/recommendations"),
]);
if (cwRes.ok) setContinueWatching(await cwRes.json());
if (naRes.ok) setNewAvailable(await naRes.json());
if (recRes.ok) setRecommendations(await recRes.json());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchFeeds();
}, [fetchFeeds]);
if (loading) {
return <DashboardSkeleton />;
}
const isEmpty =
continueWatching.length === 0 &&
newAvailable.length === 0 &&
recommendations.length === 0;
export default async function DashboardPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return (
<motion.div
className="space-y-10"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.15 } },
}}
>
<motion.div variants={sectionVariants}>
<h1 className="font-display text-3xl tracking-tight">
Welcome back{session?.user?.name ? `, ${session.user.name}` : ""}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Here&apos;s what&apos;s happening with your library
</p>
</motion.div>
<div className="space-y-10">
<WelcomeHeader name={session.user.name} />
<motion.div variants={sectionVariants}>
<StatsSummary />
</motion.div>
<Suspense fallback={<StatsSectionSkeleton />}>
<StatsSection userId={session.user.id} />
</Suspense>
{isEmpty && (
<motion.div
variants={sectionVariants}
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border/50 py-16 text-center"
>
<div className="animate-gentle-float rounded-full bg-primary/10 p-4">
<IconDeviceTv size={32} className="text-primary" />
</div>
<div className="space-y-1">
<p className="font-medium">Your library is empty</p>
<p className="text-sm text-muted-foreground">
Search for movies and TV shows to start tracking
</p>
</div>
<Link
href="/explore"
className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
>
Start exploring
</Link>
</motion.div>
)}
<Suspense fallback={<ContinueWatchingSectionSkeleton />}>
<ContinueWatchingSection userId={session.user.id} />
</Suspense>
{/* Continue Watching */}
{continueWatching.length > 0 && (
<motion.div variants={sectionVariants}>
<FeedSection
title="Continue Watching"
icon={<IconPlayerPlay size={20} className="text-primary" />}
>
<motion.div
className="feed-scroll -mx-4 flex gap-4 overflow-x-auto px-4 pb-2 sm:-mx-0 sm:px-0"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{continueWatching.map((item) => (
<motion.div key={item.title.id} variants={staggerItem}>
<ContinueWatchingCard item={item} />
</motion.div>
))}
</motion.div>
</FeedSection>
</motion.div>
)}
<Suspense fallback={<TitleGridSectionSkeleton />}>
<LibrarySection userId={session.user.id} />
</Suspense>
{/* In Your Library */}
{newAvailable.length > 0 && (
<motion.div variants={sectionVariants}>
<FeedSection
title="In Your Library"
icon={<IconSparkles size={20} className="text-primary" />}
>
<motion.div
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{newAvailable.slice(0, 10).map((t) => (
<motion.div key={t.titleId ?? t.id} variants={staggerItem}>
<TitleCard
id={t.titleId ?? t.id}
tmdbId={t.tmdbId ?? 0}
type={t.type}
title={t.title}
posterPath={t.posterPath}
releaseDate={t.releaseDate ?? t.firstAirDate}
voteAverage={t.voteAverage}
/>
</motion.div>
))}
</motion.div>
</FeedSection>
</motion.div>
)}
{/* Recommendations */}
{recommendations.length > 0 && (
<motion.div variants={sectionVariants}>
<FeedSection
title="Recommended for You"
icon={<IconSparkles size={20} className="text-primary" />}
>
<motion.div
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
variants={staggerContainer}
initial="hidden"
animate="visible"
>
{recommendations.slice(0, 10).map((t) => (
<motion.div key={t.id} variants={staggerItem}>
<TitleCard
id={t.id}
tmdbId={t.tmdbId ?? 0}
type={t.type}
title={t.title}
posterPath={t.posterPath}
releaseDate={t.releaseDate ?? t.firstAirDate}
voteAverage={t.voteAverage}
/>
</motion.div>
))}
</motion.div>
</FeedSection>
</motion.div>
)}
</motion.div>
);
}
function FeedSection({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2">
{icon}
<h2 className="font-display text-xl tracking-tight">{title}</h2>
</div>
{children}
</section>
);
}
function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const stillUrl =
item.nextEpisode?.stillPath ?? item.title.backdropPath ?? null;
const progress =
item.totalEpisodes > 0
? (item.watchedEpisodes / item.totalEpisodes) * 100
: 0;
return (
<Link
href={`/titles/${item.title.id}`}
className="group relative w-[calc(100vw-3rem)] shrink-0 overflow-hidden rounded-xl border border-border/30 bg-card/50 transition-all hover:border-primary/20 hover:shadow-lg hover:shadow-black/25 sm:w-72"
>
{/* Episode still / backdrop image */}
<div className="relative aspect-video overflow-hidden bg-muted">
{stillUrl ? (
<Image
src={stillUrl}
alt={item.nextEpisode?.name ?? item.title.title}
width={500}
height={281}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-card via-secondary to-muted">
<IconPlayerPlay size={32} className="text-muted-foreground/30" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{/* Episode label overlay */}
{item.nextEpisode && (
<div className="absolute bottom-2.5 left-3 right-3">
<p className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-primary">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
Up next
</p>
<p className="mt-0.5 truncate text-sm font-medium text-white">
<span className="font-mono text-xs text-white/60">
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
</span>{" "}
{item.nextEpisode.name}
</p>
</div>
)}
</div>
{/* Bottom bar with title + progress */}
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title.title}</p>
<p className="text-xs text-muted-foreground">
{item.watchedEpisodes}/{item.totalEpisodes} episodes
</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<IconPlayerPlay size={14} />
</div>
</div>
{/* Progress bar */}
{progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
</Link>
<Suspense fallback={<TitleGridSectionSkeleton />}>
<RecommendationsSection userId={session.user.id} />
</Suspense>
</div>
);
}