mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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>
271 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|