mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -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:
8 files changed
+372
-362
No files matched your search
@@ -1,19 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { StarRating } from "@/components/star-rating";
|
import { StarRating } from "@/components/star-rating";
|
||||||
import { StatusButton } from "@/components/status-button";
|
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() {
|
export function TitleActions() {
|
||||||
const {
|
const titleType = useAtomValue(titleTypeAtom);
|
||||||
titleType,
|
const userStatus = useAtomValue(userStatusAtom);
|
||||||
userStatus,
|
const userRating = useAtomValue(userRatingAtom);
|
||||||
userRating,
|
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||||
handleStatusChange,
|
useTitleActions();
|
||||||
handleRating,
|
|
||||||
handleWatchMovie,
|
|
||||||
} = useTitleInteraction();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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 type { Hotkey } from "@tanstack/react-hotkeys";
|
||||||
import { useHotkey } 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 { useRouter } from "next/navigation";
|
||||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
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() {
|
export function TitleKeyboardShortcuts() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const titleType = useAtomValue(titleTypeAtom);
|
||||||
titleType,
|
const userStatus = useAtomValue(userStatusAtom);
|
||||||
userStatus,
|
const { handleStatusChange, handleRating, handleWatchMovie } =
|
||||||
handleStatusChange,
|
useTitleActions();
|
||||||
handleRating,
|
|
||||||
handleWatchMovie,
|
|
||||||
} = useTitleInteraction();
|
|
||||||
|
|
||||||
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
|
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom, {
|
||||||
|
store: getDefaultStore(),
|
||||||
|
});
|
||||||
const enabled = !commandPaletteOpen;
|
const enabled = !commandPaletteOpen;
|
||||||
|
|
||||||
// W: toggle watchlist (add if not in library, remove if in library)
|
// 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,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -21,19 +22,25 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Progress } from "@/components/ui/progress";
|
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() {
|
export function TitleSeasons() {
|
||||||
|
const seasons = useAtomValue(seasonsAtom);
|
||||||
|
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
||||||
|
const userStatus = useAtomValue(userStatusAtom);
|
||||||
|
const watchingEp = useAtomValue(watchingEpAtom);
|
||||||
const {
|
const {
|
||||||
seasons,
|
|
||||||
episodeWatches,
|
|
||||||
userStatus,
|
|
||||||
handleWatchEpisode,
|
handleWatchEpisode,
|
||||||
handleMarkSeason,
|
handleMarkSeason,
|
||||||
handleUnmarkSeason,
|
handleUnmarkSeason,
|
||||||
handleMarkAllWatched,
|
handleMarkAllWatched,
|
||||||
watchingEp,
|
} = useTitleActions();
|
||||||
} = useTitleInteraction();
|
|
||||||
const [openSeason, setOpenSeason] = useState<number | null>(null);
|
const [openSeason, setOpenSeason] = useState<number | null>(null);
|
||||||
const [markAllOpen, setMarkAllOpen] = useState(false);
|
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 { TitleActions } from "./_components/title-actions";
|
||||||
import { TitleAvailability } from "./_components/title-availability";
|
import { TitleAvailability } from "./_components/title-availability";
|
||||||
import { TitleHero } from "./_components/title-hero";
|
import { TitleHero } from "./_components/title-hero";
|
||||||
import { TitleInteractionProvider } from "./_components/title-interaction-provider";
|
|
||||||
import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
|
import { TitleKeyboardShortcuts } from "./_components/title-keyboard-shortcuts";
|
||||||
|
import { TitleProvider } from "./_components/title-provider";
|
||||||
import { TitleRecommendations } from "./_components/title-recommendations";
|
import { TitleRecommendations } from "./_components/title-recommendations";
|
||||||
import { TitleSeasons } from "./_components/title-seasons";
|
import { TitleSeasons } from "./_components/title-seasons";
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export default async function TitleDetailPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-10" style={themeStyle}>
|
<div className="relative space-y-10" style={themeStyle}>
|
||||||
<TitleInteractionProvider
|
<TitleProvider
|
||||||
titleId={title.id}
|
titleId={title.id}
|
||||||
titleType={title.type}
|
titleType={title.type}
|
||||||
titleName={title.title}
|
titleName={title.title}
|
||||||
@@ -93,7 +93,7 @@ export default async function TitleDetailPage({
|
|||||||
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
|
{title.type === "tv" && seasons.length > 0 && <TitleSeasons />}
|
||||||
|
|
||||||
<TitleKeyboardShortcuts />
|
<TitleKeyboardShortcuts />
|
||||||
</TitleInteractionProvider>
|
</TitleProvider>
|
||||||
|
|
||||||
<Suspense fallback={<RecommendationsSkeleton />}>
|
<Suspense fallback={<RecommendationsSkeleton />}>
|
||||||
<TitleRecommendations titleId={title.id} />
|
<TitleRecommendations titleId={title.id} />
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import type { Season } from "@/lib/types/title";
|
||||||
|
|
||||||
|
export const titleIdAtom = atom("");
|
||||||
|
export const titleTypeAtom = atom<"movie" | "tv">("movie");
|
||||||
|
export const titleNameAtom = atom("");
|
||||||
|
export const seasonsAtom = atom<Season[]>([]);
|
||||||
|
export const userStatusAtom = atom<string | null>(null);
|
||||||
|
export const userRatingAtom = atom(0);
|
||||||
|
export const episodeWatchesAtom = atom<string[]>([]);
|
||||||
|
export const watchingEpAtom = atom<string | null>(null);
|
||||||
Reference in new issue
Block a user