mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
Replace TitleInteractionProvider context with Jotai atoms
Replaces the 335-line React Context provider with a scoped Jotai store, giving consumers granular subscriptions and stable handler callbacks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { StatusButton } from "@/components/status-button";
|
||||
import { useTitleInteraction } from "./title-interaction-provider";
|
||||
import {
|
||||
titleTypeAtom,
|
||||
userRatingAtom,
|
||||
userStatusAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import { useTitleActions } from "./use-title-actions";
|
||||
|
||||
export function TitleActions() {
|
||||
const {
|
||||
titleType,
|
||||
userStatus,
|
||||
userRating,
|
||||
handleStatusChange,
|
||||
handleRating,
|
||||
handleWatchMovie,
|
||||
} = useTitleInteraction();
|
||||
const titleType = useAtomValue(titleTypeAtom);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const userRating = useAtomValue(userRatingAtom);
|
||||
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||
useTitleActions();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Season } from "@/lib/types/title";
|
||||
import {
|
||||
batchWatchEpisodes,
|
||||
markAllWatchedAction,
|
||||
unwatchEpisodeAction,
|
||||
unwatchSeasonAction,
|
||||
updateTitleRating,
|
||||
updateTitleStatus,
|
||||
watchEpisode,
|
||||
watchMovie,
|
||||
watchSeason,
|
||||
} from "./actions";
|
||||
|
||||
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;
|
||||
handleMarkAllWatched: () => 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 === "watchlist" ? "in_progress" : status);
|
||||
try {
|
||||
await updateTitleStatus(titleId, status ? "in_progress" : null);
|
||||
toast.success(status ? "Added to watchlist" : "Removed from library");
|
||||
} catch {
|
||||
setUserStatus(prev);
|
||||
toast.error("Failed to update status");
|
||||
}
|
||||
},
|
||||
[titleId, userStatus],
|
||||
);
|
||||
|
||||
const handleRating = useCallback(
|
||||
async (ratingStars: number) => {
|
||||
const prev = userRating;
|
||||
setUserRating(ratingStars);
|
||||
try {
|
||||
await updateTitleRating(titleId, ratingStars);
|
||||
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 {
|
||||
await watchMovie(titleId);
|
||||
toast.success(`Marked "${titleName}" as watched`);
|
||||
} catch {
|
||||
setUserStatus(prev);
|
||||
toast.error("Failed to mark as watched");
|
||||
}
|
||||
}, [titleId, titleName, userStatus]);
|
||||
|
||||
const handleCatchUp = useCallback(
|
||||
async (episodeIds: string[]) => {
|
||||
setEpisodeWatches((w) => {
|
||||
const set = new Set(w);
|
||||
for (const id of episodeIds) set.add(id);
|
||||
return [...set];
|
||||
});
|
||||
// Check if all episodes are now watched
|
||||
setEpisodeWatches((w) => {
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
if (allEpIds.every((id) => w.includes(id))) {
|
||||
setUserStatus("completed");
|
||||
}
|
||||
return w;
|
||||
});
|
||||
try {
|
||||
await batchWatchEpisodes(episodeIds);
|
||||
toast.success(
|
||||
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to catch up");
|
||||
}
|
||||
},
|
||||
[seasons],
|
||||
);
|
||||
|
||||
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 {
|
||||
await unwatchEpisodeAction(episodeId);
|
||||
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 === null || s === "watchlist" ? "in_progress" : s,
|
||||
);
|
||||
try {
|
||||
await watchEpisode(episodeId);
|
||||
|
||||
// Find unwatched episodes before this one
|
||||
const previousUnwatched: string[] = [];
|
||||
for (const s of seasons) {
|
||||
for (const ep of s.episodes) {
|
||||
if (
|
||||
s.seasonNumber < seasonNum ||
|
||||
(s.seasonNumber === seasonNum && ep.episodeNumber < epNum)
|
||||
) {
|
||||
// Use the latest episodeWatches state
|
||||
if (!episodeWatches.includes(ep.id) && ep.id !== episodeId) {
|
||||
previousUnwatched.push(ep.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousUnwatched.length > 0) {
|
||||
const count = previousUnwatched.length;
|
||||
toast.success(`Watched S${seasonNum} E${epNum}`, {
|
||||
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
|
||||
action: {
|
||||
label: "Catch up",
|
||||
onClick: () => handleCatchUp(previousUnwatched),
|
||||
},
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Watched S${seasonNum} E${epNum}`);
|
||||
}
|
||||
} catch {
|
||||
setEpisodeWatches((w) => w.filter((id) => id !== episodeId));
|
||||
toast.error("Failed to mark episode");
|
||||
}
|
||||
}
|
||||
setWatchingEp(null);
|
||||
},
|
||||
[seasons, episodeWatches, handleCatchUp],
|
||||
);
|
||||
|
||||
const handleMarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
const unwatched = season.episodes.filter(
|
||||
(ep) => !episodeWatches.includes(ep.id),
|
||||
);
|
||||
if (unwatched.length === 0) return;
|
||||
|
||||
const newWatchSet = new Set(episodeWatches);
|
||||
for (const ep of unwatched) newWatchSet.add(ep.id);
|
||||
const newWatches = [...newWatchSet];
|
||||
setEpisodeWatches(newWatches);
|
||||
|
||||
// Optimistically check if all episodes are now watched
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
if (allEpIds.every((id) => newWatchSet.has(id))) {
|
||||
setUserStatus("completed");
|
||||
} else {
|
||||
setUserStatus((s) =>
|
||||
s === null || s === "watchlist" ? "in_progress" : s,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await watchSeason(season.id);
|
||||
toast.success(
|
||||
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to mark some episodes");
|
||||
}
|
||||
},
|
||||
[episodeWatches, seasons],
|
||||
);
|
||||
|
||||
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 {
|
||||
await unwatchSeasonAction(season.id);
|
||||
toast.success(
|
||||
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to unmark some episodes");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMarkAllWatched = useCallback(async () => {
|
||||
const prevStatus = userStatus;
|
||||
const prevWatches = episodeWatches;
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
setEpisodeWatches(allEpIds);
|
||||
setUserStatus("completed");
|
||||
try {
|
||||
await markAllWatchedAction(titleId);
|
||||
toast.success("Marked all episodes as watched");
|
||||
} catch {
|
||||
setUserStatus(prevStatus);
|
||||
setEpisodeWatches(prevWatches);
|
||||
toast.error("Failed to mark all episodes as watched");
|
||||
}
|
||||
}, [titleId, userStatus, episodeWatches, seasons]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
titleId,
|
||||
titleType,
|
||||
titleName,
|
||||
userStatus,
|
||||
userRating,
|
||||
episodeWatches,
|
||||
seasons,
|
||||
handleStatusChange,
|
||||
handleRating,
|
||||
handleWatchMovie,
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
watchingEp,
|
||||
}),
|
||||
[
|
||||
titleId,
|
||||
titleType,
|
||||
titleName,
|
||||
userStatus,
|
||||
userRating,
|
||||
episodeWatches,
|
||||
seasons,
|
||||
handleStatusChange,
|
||||
handleRating,
|
||||
handleWatchMovie,
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
watchingEp,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<TitleInteractionContext.Provider value={value}>
|
||||
{children}
|
||||
</TitleInteractionContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
import type { Hotkey } from "@tanstack/react-hotkeys";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { getDefaultStore, useAtomValue } from "jotai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||
import { useTitleInteraction } from "./title-interaction-provider";
|
||||
import { titleTypeAtom, userStatusAtom } from "@/lib/atoms/title";
|
||||
import { useTitleActions } from "./use-title-actions";
|
||||
|
||||
export function TitleKeyboardShortcuts() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
titleType,
|
||||
userStatus,
|
||||
handleStatusChange,
|
||||
handleRating,
|
||||
handleWatchMovie,
|
||||
} = useTitleInteraction();
|
||||
const titleType = useAtomValue(titleTypeAtom);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||
useTitleActions();
|
||||
|
||||
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
|
||||
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom, {
|
||||
store: getDefaultStore(),
|
||||
});
|
||||
const enabled = !commandPaletteOpen;
|
||||
|
||||
// W: toggle watchlist (add if not in library, remove if in library)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { createStore, Provider } from "jotai";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
titleIdAtom,
|
||||
titleNameAtom,
|
||||
titleTypeAtom,
|
||||
userRatingAtom,
|
||||
userStatusAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import type { Season } from "@/lib/types/title";
|
||||
|
||||
export function TitleProvider({
|
||||
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 [store] = useState(() => {
|
||||
const s = createStore();
|
||||
s.set(titleIdAtom, titleId);
|
||||
s.set(titleTypeAtom, titleType);
|
||||
s.set(titleNameAtom, titleName);
|
||||
s.set(seasonsAtom, seasons);
|
||||
s.set(userStatusAtom, initialStatus);
|
||||
s.set(userRatingAtom, initialRating);
|
||||
s.set(episodeWatchesAtom, initialEpisodeWatches);
|
||||
return s;
|
||||
});
|
||||
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
@@ -21,19 +22,25 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTitleInteraction } from "./title-interaction-provider";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
userStatusAtom,
|
||||
watchingEpAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import { useTitleActions } from "./use-title-actions";
|
||||
|
||||
export function TitleSeasons() {
|
||||
const seasons = useAtomValue(seasonsAtom);
|
||||
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const watchingEp = useAtomValue(watchingEpAtom);
|
||||
const {
|
||||
seasons,
|
||||
episodeWatches,
|
||||
userStatus,
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
watchingEp,
|
||||
} = useTitleInteraction();
|
||||
} = useTitleActions();
|
||||
const [openSeason, setOpenSeason] = useState<number | null>(null);
|
||||
const [markAllOpen, setMarkAllOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useStore } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
episodeWatchesAtom,
|
||||
seasonsAtom,
|
||||
titleIdAtom,
|
||||
titleNameAtom,
|
||||
userRatingAtom,
|
||||
userStatusAtom,
|
||||
watchingEpAtom,
|
||||
} from "@/lib/atoms/title";
|
||||
import type { Season } from "@/lib/types/title";
|
||||
import {
|
||||
batchWatchEpisodes,
|
||||
markAllWatchedAction,
|
||||
unwatchEpisodeAction,
|
||||
unwatchSeasonAction,
|
||||
updateTitleRating,
|
||||
updateTitleStatus,
|
||||
watchEpisode,
|
||||
watchMovie,
|
||||
watchSeason,
|
||||
} from "./actions";
|
||||
|
||||
export function useTitleActions() {
|
||||
const store = useStore();
|
||||
|
||||
const catchUp = useCallback(
|
||||
async (episodeIds: string[]) => {
|
||||
const currentWatches = store.get(episodeWatchesAtom);
|
||||
const newWatchSet = new Set(currentWatches);
|
||||
for (const id of episodeIds) newWatchSet.add(id);
|
||||
store.set(episodeWatchesAtom, [...newWatchSet]);
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
if (allEpIds.every((id) => newWatchSet.has(id))) {
|
||||
store.set(userStatusAtom, "completed");
|
||||
}
|
||||
|
||||
try {
|
||||
await batchWatchEpisodes(episodeIds);
|
||||
toast.success(
|
||||
`Caught up — marked ${episodeIds.length} episode${episodeIds.length > 1 ? "s" : ""} as watched`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to catch up");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: string | null) => {
|
||||
const prev = store.get(userStatusAtom);
|
||||
const titleId = store.get(titleIdAtom);
|
||||
store.set(
|
||||
userStatusAtom,
|
||||
status === "watchlist" ? "in_progress" : status,
|
||||
);
|
||||
try {
|
||||
await updateTitleStatus(titleId, status ? "in_progress" : null);
|
||||
toast.success(status ? "Added to watchlist" : "Removed from library");
|
||||
} catch {
|
||||
store.set(userStatusAtom, prev);
|
||||
toast.error("Failed to update status");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleRating = useCallback(
|
||||
async (ratingStars: number) => {
|
||||
const prev = store.get(userRatingAtom);
|
||||
const titleId = store.get(titleIdAtom);
|
||||
store.set(userRatingAtom, ratingStars);
|
||||
try {
|
||||
await updateTitleRating(titleId, ratingStars);
|
||||
toast.success(
|
||||
ratingStars > 0
|
||||
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
|
||||
: "Rating removed",
|
||||
);
|
||||
} catch {
|
||||
store.set(userRatingAtom, prev);
|
||||
toast.error("Failed to update rating");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleWatchMovie = useCallback(async () => {
|
||||
const prev = store.get(userStatusAtom);
|
||||
const titleId = store.get(titleIdAtom);
|
||||
const titleName = store.get(titleNameAtom);
|
||||
store.set(userStatusAtom, "completed");
|
||||
try {
|
||||
await watchMovie(titleId);
|
||||
toast.success(`Marked "${titleName}" as watched`);
|
||||
} catch {
|
||||
store.set(userStatusAtom, prev);
|
||||
toast.error("Failed to mark as watched");
|
||||
}
|
||||
}, [store]);
|
||||
|
||||
const handleWatchEpisode = useCallback(
|
||||
async (
|
||||
episodeId: string,
|
||||
seasonNum: number,
|
||||
epNum: number,
|
||||
isWatched: boolean,
|
||||
) => {
|
||||
store.set(watchingEpAtom, episodeId);
|
||||
|
||||
if (isWatched) {
|
||||
store.set(
|
||||
episodeWatchesAtom,
|
||||
store.get(episodeWatchesAtom).filter((id) => id !== episodeId),
|
||||
);
|
||||
const status = store.get(userStatusAtom);
|
||||
if (status === "completed") store.set(userStatusAtom, "in_progress");
|
||||
|
||||
try {
|
||||
await unwatchEpisodeAction(episodeId);
|
||||
toast.success(`Unwatched S${seasonNum} E${epNum}`);
|
||||
} catch {
|
||||
const w = store.get(episodeWatchesAtom);
|
||||
if (!w.includes(episodeId))
|
||||
store.set(episodeWatchesAtom, [...w, episodeId]);
|
||||
toast.error("Failed to unmark episode");
|
||||
}
|
||||
} else {
|
||||
const currentWatches = store.get(episodeWatchesAtom);
|
||||
if (!currentWatches.includes(episodeId)) {
|
||||
store.set(episodeWatchesAtom, [...currentWatches, episodeId]);
|
||||
}
|
||||
const status = store.get(userStatusAtom);
|
||||
if (status === null || status === "watchlist") {
|
||||
store.set(userStatusAtom, "in_progress");
|
||||
}
|
||||
|
||||
try {
|
||||
await watchEpisode(episodeId);
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const episodeWatches = store.get(episodeWatchesAtom);
|
||||
const previousUnwatched: string[] = [];
|
||||
for (const s of seasons) {
|
||||
for (const ep of s.episodes) {
|
||||
if (
|
||||
s.seasonNumber < seasonNum ||
|
||||
(s.seasonNumber === seasonNum && ep.episodeNumber < epNum)
|
||||
) {
|
||||
if (!episodeWatches.includes(ep.id) && ep.id !== episodeId) {
|
||||
previousUnwatched.push(ep.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousUnwatched.length > 0) {
|
||||
const count = previousUnwatched.length;
|
||||
toast.success(`Watched S${seasonNum} E${epNum}`, {
|
||||
description: `${count} earlier episode${count > 1 ? "s" : ""} unwatched`,
|
||||
action: {
|
||||
label: "Catch up",
|
||||
onClick: () => catchUp(previousUnwatched),
|
||||
},
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Watched S${seasonNum} E${epNum}`);
|
||||
}
|
||||
} catch {
|
||||
store.set(
|
||||
episodeWatchesAtom,
|
||||
store.get(episodeWatchesAtom).filter((id) => id !== episodeId),
|
||||
);
|
||||
toast.error("Failed to mark episode");
|
||||
}
|
||||
}
|
||||
|
||||
store.set(watchingEpAtom, null);
|
||||
},
|
||||
[store, catchUp],
|
||||
);
|
||||
|
||||
const handleMarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
const episodeWatches = store.get(episodeWatchesAtom);
|
||||
const unwatched = season.episodes.filter(
|
||||
(ep) => !episodeWatches.includes(ep.id),
|
||||
);
|
||||
if (unwatched.length === 0) return;
|
||||
|
||||
const newWatchSet = new Set(episodeWatches);
|
||||
for (const ep of unwatched) newWatchSet.add(ep.id);
|
||||
store.set(episodeWatchesAtom, [...newWatchSet]);
|
||||
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
if (allEpIds.every((id) => newWatchSet.has(id))) {
|
||||
store.set(userStatusAtom, "completed");
|
||||
} else {
|
||||
const status = store.get(userStatusAtom);
|
||||
if (status === null || status === "watchlist") {
|
||||
store.set(userStatusAtom, "in_progress");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await watchSeason(season.id);
|
||||
toast.success(
|
||||
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to mark some episodes");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleUnmarkSeason = useCallback(
|
||||
async (season: Season) => {
|
||||
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
|
||||
store.set(
|
||||
episodeWatchesAtom,
|
||||
store.get(episodeWatchesAtom).filter((id) => !seasonEpIds.has(id)),
|
||||
);
|
||||
const status = store.get(userStatusAtom);
|
||||
if (status === "completed") store.set(userStatusAtom, "in_progress");
|
||||
|
||||
try {
|
||||
await unwatchSeasonAction(season.id);
|
||||
toast.success(
|
||||
`Unwatched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to unmark some episodes");
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const handleMarkAllWatched = useCallback(async () => {
|
||||
const titleId = store.get(titleIdAtom);
|
||||
const prevStatus = store.get(userStatusAtom);
|
||||
const prevWatches = store.get(episodeWatchesAtom);
|
||||
const seasons = store.get(seasonsAtom);
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
store.set(episodeWatchesAtom, allEpIds);
|
||||
store.set(userStatusAtom, "completed");
|
||||
try {
|
||||
await markAllWatchedAction(titleId);
|
||||
toast.success("Marked all episodes as watched");
|
||||
} catch {
|
||||
store.set(userStatusAtom, prevStatus);
|
||||
store.set(episodeWatchesAtom, prevWatches);
|
||||
toast.error("Failed to mark all episodes as watched");
|
||||
}
|
||||
}, [store]);
|
||||
|
||||
return {
|
||||
handleStatusChange,
|
||||
handleRating,
|
||||
handleWatchMovie,
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
};
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import { getTitleThemeStyle } from "@/lib/utils/title-theme";
|
||||
import { TitleActions } from "./_components/title-actions";
|
||||
import { TitleAvailability } from "./_components/title-availability";
|
||||
import { TitleHero } from "./_components/title-hero";
|
||||
import { TitleInteractionProvider } from "./_components/title-interaction-provider";
|
||||
import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
|
||||
import { TitleProvider } from "./_components/title-provider";
|
||||
import { TitleRecommendations } from "./_components/title-recommendations";
|
||||
import { TitleSeasons } from "./_components/title-seasons";
|
||||
|
||||
@@ -77,7 +77,7 @@ export default async function TitleDetailPage({
|
||||
|
||||
return (
|
||||
<div className="relative space-y-10" style={themeStyle}>
|
||||
<TitleInteractionProvider
|
||||
<TitleProvider
|
||||
titleId={title.id}
|
||||
titleType={title.type}
|
||||
titleName={title.title}
|
||||
@@ -93,7 +93,7 @@ export default async function TitleDetailPage({
|
||||
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
|
||||
|
||||
<TitleKeyboardShortcuts />
|
||||
</TitleInteractionProvider>
|
||||
</TitleProvider>
|
||||
|
||||
<Suspense fallback={<RecommendationsSkeleton />}>
|
||||
<TitleRecommendations titleId={title.id} />
|
||||
|
||||
Reference in New Issue
Block a user