mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user