Files
sofa/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx
T
jakeandClaude Opus 4.6 c1867a4f23 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>
2026-03-02 15:42:42 -05:00

271 lines
7.1 KiB
TypeScript

"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>
);
}