mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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:
@@ -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's what's happening with your library
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardSkeleton } from "@/components/skeletons";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
+30
-324
@@ -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's what'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TitleDetailSkeleton } from "@/components/skeletons";
|
||||
|
||||
export default function TitleDetailLoading() {
|
||||
return <TitleDetailSkeleton />;
|
||||
}
|
||||
@@ -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're looking for doesn'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
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user