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>
);
}
@@ -7,14 +7,7 @@ import {
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;
}
import type { DashboardStats } from "@/lib/services/discovery";
const statDefs = [
{
@@ -47,18 +40,7 @@ const statDefs = [
},
];
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;
export function StatsDisplay({ stats }: { stats: DashboardStats }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{statDefs.map((def, i) => {
@@ -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>
);
}
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { TitleDetailSkeleton } from "@/components/skeletons";
export default function TitleDetailLoading() {
return <TitleDetailSkeleton />;
}
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link";
export default function TitleNotFound() {
return (
<div className="flex flex-col items-center gap-4 py-24 text-center">
<h1 className="font-display text-4xl tracking-tight">Title not found</h1>
<p className="text-muted-foreground">
The title you&apos;re looking for doesn&apos;t exist or may have been
removed.
</p>
<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"
>
Explore titles
</Link>
</div>
);
}
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { getContinueWatchingFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export async function GET() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const feed = await getContinueWatchingFeed(session.user.id);
return NextResponse.json(
feed.map((item) => ({
...item,
title: {
...item.title,
posterPath: tmdbImageUrl(item.title.posterPath, "w500"),
backdropPath: tmdbImageUrl(item.title.backdropPath, "w1280"),
},
nextEpisode: item.nextEpisode
? {
...item.nextEpisode,
stillPath: tmdbImageUrl(
item.nextEpisode.stillPath,
"w1280",
"stills",
),
}
: null,
})),
);
}
-23
View File
@@ -1,23 +0,0 @@
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { getNewAvailableFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export async function GET(req: NextRequest) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
const feed = await getNewAvailableFeed(session.user.id, days);
return NextResponse.json(
feed.map((item) => ({
...item,
posterPath: tmdbImageUrl(item.posterPath, "w500"),
})),
);
}
-24
View File
@@ -1,24 +0,0 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { getRecommendationsFeed } from "@/lib/services/discovery";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export async function GET() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const feed = await getRecommendationsFeed(session.user.id);
return NextResponse.json(
feed
.filter((item) => !!item)
.map((item) => ({
...item,
posterPath: tmdbImageUrl(item.posterPath, "w500"),
backdropPath: tmdbImageUrl(item.backdropPath, "w1280"),
})),
);
}
-75
View File
@@ -1,75 +0,0 @@
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] = await 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] = await 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] = await db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
const [completedCount] = await 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,
});
}
@@ -1,52 +0,0 @@
import { eq } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { titleRecommendations, titles } from "@/lib/db/schema";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const title = await db.select().from(titles).where(eq(titles.id, id)).get();
if (!title)
return NextResponse.json({ error: "Title not found" }, { status: 404 });
const recs = await db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, id))
.orderBy(titleRecommendations.rank)
.all();
const results = (
await Promise.all(
recs.map(async (rec) => {
const recTitle = await db
.select()
.from(titles)
.where(eq(titles.id, rec.recommendedTitleId))
.get();
return recTitle
? { ...recTitle, source: rec.source, rank: rec.rank }
: null;
}),
)
).filter(Boolean);
return NextResponse.json(
results
.filter((r) => !!r)
.map((r) => ({
...r,
posterPath: tmdbImageUrl(r.posterPath, "w500"),
backdropPath: tmdbImageUrl(r.backdropPath, "w1280"),
})),
);
}
-145
View File
@@ -1,145 +0,0 @@
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
import {
extractAndStoreColors,
parseColorPalette,
} from "@/lib/services/colors";
import { refreshTvChildren } from "@/lib/services/metadata";
import { getMovieDetails, getTvDetails } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
let title = await db.select().from(titles).where(eq(titles.id, id)).get();
if (!title)
return NextResponse.json({ error: "Title not found" }, { status: 404 });
// If this is a shell TV title (created by recommendations with no episode data),
// fetch the full details and episodes now.
if (title.type === "tv" && !title.lastFetchedAt) {
try {
const show = await getTvDetails(title.tmdbId);
await db
.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
lastFetchedAt: new Date(),
})
.where(eq(titles.id, id))
.run();
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
} catch {
// Continue with whatever data we have
}
}
// If this is a shell movie title (created by recommendations with no full data),
// fetch the full details now.
if (title.type === "movie" && !title.lastFetchedAt) {
try {
const movie = await getMovieDetails(title.tmdbId);
await db
.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
lastFetchedAt: new Date(),
})
.where(eq(titles.id, id))
.run();
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
} catch {
// Continue with whatever data we have
}
}
let titleSeasons: Array<{
id: string;
seasonNumber: number;
name: string | null;
overview: string | null;
posterPath: string | null;
airDate: string | null;
episodes: Array<{
id: string;
episodeNumber: number;
name: string | null;
overview: string | null;
stillPath: string | null;
airDate: string | null;
runtimeMinutes: number | null;
}>;
}> = [];
if (title.type === "tv") {
const seasonRows = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
.orderBy(seasons.seasonNumber)
.all();
titleSeasons = await Promise.all(
seasonRows.map(async (s) => ({
...s,
episodes: await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.orderBy(episodes.episodeNumber)
.all(),
})),
);
}
const availability = await db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, title.id))
.all();
// Lazy color extraction: if no palette yet, fire-and-forget for next load
if (!title.colorPalette && title.posterPath) {
extractAndStoreColors(title.id, title.posterPath).catch(() => {});
}
return NextResponse.json({
...title,
posterPath: tmdbImageUrl(title.posterPath, "w500"),
backdropPath: tmdbImageUrl(title.backdropPath, "w1280"),
colorPalette: parseColorPalette(title.colorPalette),
seasons: titleSeasons.map((s) => ({
...s,
posterPath: tmdbImageUrl(s.posterPath, "w500"),
episodes: s.episodes.map((ep) => ({
...ep,
stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"),
})),
})),
availability: availability.map((a) => ({
...a,
logoPath: tmdbImageUrl(a.logoPath, "w92"),
})),
});
}
+67 -34
View File
@@ -12,12 +12,14 @@ export function TitleCardSkeleton() {
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 className="w-[calc(100vw-3rem)] shrink-0 overflow-hidden rounded-xl border border-border/30 bg-card/50 sm:w-72">
<Skeleton className="aspect-video w-full rounded-none" />
<div className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
</div>
</div>
);
@@ -27,6 +29,62 @@ export function StatCardSkeleton() {
return <Skeleton className="h-24 w-full rounded-xl" />;
}
export function StatsSectionSkeleton() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
);
}
export function ContinueWatchingSectionSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-40" />
<div className="flex gap-4 overflow-hidden">
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
<ContinueWatchingSkeleton />
</div>
</div>
);
}
export function TitleGridSectionSkeleton() {
return (
<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>
);
}
export function RecommendationsSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
<TitleCardSkeleton />
</div>
</div>
);
}
export function TitleDetailSkeleton() {
return (
<div className="space-y-10">
@@ -58,34 +116,9 @@ export function DashboardSkeleton() {
<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>
<StatsSectionSkeleton />
<ContinueWatchingSectionSkeleton />
<TitleGridSectionSkeleton />
</div>
);
}
+112
View File
@@ -7,9 +7,73 @@ import {
titleRecommendations,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@/lib/db/schema";
import { tmdbImageUrl } from "@/lib/tmdb/image";
export interface DashboardStats {
moviesThisMonth: number;
episodesThisWeek: number;
librarySize: number;
completed: number;
}
export async function getUserStats(userId: string): Promise<DashboardStats> {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
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] = await 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] = await db
.select({ count: sql<number>`count(*)` })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
sql`${userEpisodeWatches.watchedAt} >= ${Math.floor(weekStart.getTime() / 1000)}`,
),
)
.all();
const [librarySizeRow] = await db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
const [completedCount] = await db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "completed"),
),
)
.all();
return {
moviesThisMonth: moviesThisMonth?.count ?? 0,
episodesThisWeek: episodesThisWeek?.count ?? 0,
librarySize: librarySizeRow?.count ?? 0,
completed: completedCount?.count ?? 0,
};
}
export interface ContinueWatchingItem {
title: {
@@ -154,9 +218,11 @@ export async function getNewAvailableFeed(userId: string, days = 14) {
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
})
.from(titles)
@@ -263,3 +329,49 @@ export async function getRecommendationsFeed(userId: string) {
)
).filter(Boolean);
}
export async function getRecommendationsForTitle(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return [];
const recs = await db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.orderBy(titleRecommendations.rank)
.all();
const results = (
await Promise.all(
recs.map(async (rec) => {
const recTitle = await db
.select()
.from(titles)
.where(eq(titles.id, rec.recommendedTitleId))
.get();
return recTitle
? { ...recTitle, source: rec.source, rank: rec.rank }
: null;
}),
)
).filter((r): r is NonNullable<typeof r> => r !== null);
return results.map((r) => ({
id: r.id,
tmdbId: r.tmdbId,
type: r.type as "movie" | "tv",
title: r.title,
posterPath: tmdbImageUrl(r.posterPath, "w500"),
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
}));
}
+148 -1
View File
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
availabilityOffers,
episodes,
seasons,
titleRecommendations,
@@ -13,8 +14,15 @@ import {
getTvDetails,
getTvSeasonDetails,
} from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import type {
AvailabilityOffer,
Episode,
ResolvedTitle,
Season,
} from "@/lib/types/title";
import { refreshAvailability } from "./availability";
import { extractAndStoreColors } from "./colors";
import { extractAndStoreColors, parseColorPalette } from "./colors";
import {
cacheEpisodeStills,
cacheImagesForTitle,
@@ -445,6 +453,145 @@ export async function refreshRecommendations(titleId: string) {
}
}
export async function getTitleWithChildren(id: string): Promise<{
title: ResolvedTitle;
seasons: Season[];
availability: AvailabilityOffer[];
} | null> {
let title = await db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return null;
// If this is a shell TV title, fetch full details now
if (title.type === "tv" && !title.lastFetchedAt) {
try {
const show = await getTvDetails(title.tmdbId);
await db
.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
lastFetchedAt: new Date(),
})
.where(eq(titles.id, id))
.run();
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
} catch {
// Continue with whatever data we have
}
}
// If this is a shell movie title, fetch full details now
if (title.type === "movie" && !title.lastFetchedAt) {
try {
const movie = await getMovieDetails(title.tmdbId);
await db
.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
lastFetchedAt: new Date(),
})
.where(eq(titles.id, id))
.run();
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
} catch {
// Continue with whatever data we have
}
}
let titleSeasons: Season[] = [];
if (title.type === "tv") {
const seasonRows = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
.orderBy(seasons.seasonNumber)
.all();
titleSeasons = await Promise.all(
seasonRows.map(async (s) => ({
id: s.id,
seasonNumber: s.seasonNumber,
name: s.name,
episodes: (
await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.orderBy(episodes.episodeNumber)
.all()
).map(
(ep): Episode => ({
id: ep.id,
episodeNumber: ep.episodeNumber,
name: ep.name,
overview: ep.overview,
stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"),
airDate: ep.airDate,
runtimeMinutes: ep.runtimeMinutes,
}),
),
})),
);
}
const availability = (
await db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, title.id))
.all()
).map(
(a): AvailabilityOffer => ({
providerId: a.providerId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "w92"),
offerType: a.offerType,
}),
);
// Lazy color extraction
if (!title.colorPalette && title.posterPath) {
extractAndStoreColors(title.id, title.posterPath).catch(() => {});
}
const resolvedTitle: ResolvedTitle = {
id: title.id,
tmdbId: title.tmdbId,
type: title.type as "movie" | "tv",
title: title.title,
originalTitle: title.originalTitle,
overview: title.overview,
releaseDate: title.releaseDate,
firstAirDate: title.firstAirDate,
posterPath: tmdbImageUrl(title.posterPath, "w500"),
backdropPath: tmdbImageUrl(title.backdropPath, "w1280"),
popularity: title.popularity,
voteAverage: title.voteAverage,
voteCount: title.voteCount,
status: title.status,
colorPalette: parseColorPalette(title.colorPalette),
};
return { title: resolvedTitle, seasons: titleSeasons, availability };
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+61
View File
@@ -0,0 +1,61 @@
export interface Episode {
id: string;
episodeNumber: number;
name: string | null;
overview: string | null;
stillPath: string | null;
airDate: string | null;
runtimeMinutes: number | null;
}
export interface Season {
id: string;
seasonNumber: number;
name: string | null;
episodes: Episode[];
}
export interface AvailabilityOffer {
providerId: number;
providerName: string;
logoPath: string | null;
offerType: string;
}
export interface RecommendedTitle {
id: string;
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
firstAirDate: string | null;
voteAverage: number | null;
}
export interface ColorPalette {
vibrant: string | null;
darkVibrant: string | null;
lightVibrant: string | null;
muted: string | null;
darkMuted: string | null;
lightMuted: string | null;
}
export interface ResolvedTitle {
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;
colorPalette: ColorPalette | null;
}