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:
2026-03-03 19:09:57 -05:00
co-authored by Claude Opus 4.6
parent d9b408128b
commit 6a9c9f22c6
8 changed files with 372 additions and 362 deletions
@@ -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,
};
}