From 553fe98013a7d09b4e499d60f238ba1ece6daefc Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 12 Jul 2026 16:53:52 -0400 Subject: [PATCH] feat: add "Mark Unwatched" toggle for movies on web and native (#52) - Replace the static "Mark Watched" button with a toggle that shows "Mark Unwatched" (destructive style) when a movie is already completed - Add `handleUnwatchMovie` to web `useTitleActions` with optimistic update (reverts status to `in_watchlist` on error) - Add `unwatchMovie` mutation to native `useTitleActions` hook and `titleActions` imperative helper - Update poster-card and upcoming-row context menus to show the correct action based on current watch status - Wire the `M` keyboard shortcut on the title detail page to unwatch when the movie is already marked watched - Add i18n strings for "Mark Unwatched", "Marked as unwatched", and "Failed to mark as unwatched" --- apps/native/src/app/title/[id].tsx | 40 +++++++++++++++---- .../src/components/dashboard/upcoming-row.tsx | 9 ++++- apps/native/src/components/ui/poster-card.tsx | 9 ++++- apps/native/src/hooks/use-title-actions.ts | 12 ++++++ apps/native/src/lib/title-actions.ts | 14 +++++++ .../src/components/titles/title-actions.tsx | 24 ++++++++--- .../titles/title-keyboard-shortcuts.tsx | 11 ++++- .../components/titles/use-title-actions.ts | 14 +++++++ packages/i18n/src/po/en.po | 31 ++++++++++++-- 9 files changed, 143 insertions(+), 21 deletions(-) diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index 739e500..81f7263 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -12,6 +12,7 @@ import { IconStarFilled, IconThumbUp, IconUsers, + IconX, } from "@tabler/icons-react-native"; import { useQuery } from "@tanstack/react-query"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; @@ -98,22 +99,27 @@ export default function TitleDetailScreen() { const { back } = useRouter(); const useAutomaticInsets = process.env.EXPO_OS === "ios"; - const [titleAccent, mutedForeground, titleAccentForeground] = useCSSVariable([ + const [titleAccent, mutedForeground, titleAccentForeground, destructiveColor] = useCSSVariable([ "--color-title-accent", "--color-muted-foreground", "--color-title-accent-foreground", - ]) as [string, string, string]; + "--color-destructive", + ]) as [string, string, string, string]; const detail = useQuery(orpc.titles.get.queryOptions({ input: { id } })); const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } })); const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } })); - const { updateStatus, updateRating, watchMovie } = useTitleActions({ + const { updateStatus, updateRating, watchMovie, unwatchMovie } = useTitleActions({ toasts: { watchMovie: () => { const name = title?.title; return name ? t`Marked "${name}" as watched` : t`Marked as watched`; }, + unwatchMovie: () => { + const name = title?.title; + return name ? t`Marked "${name}" as unwatched` : t`Marked as unwatched`; + }, }, }); @@ -147,6 +153,7 @@ export default function TitleDetailScreen() { () => new Set(userInfo.data?.episodeWatches ?? []), [userInfo.data?.episodeWatches], ); + const isMovieWatched = userInfo.data?.status === "completed"; const recItems = useMemo( () => @@ -413,17 +420,34 @@ export default function TitleDetailScreen() { updateStatus.mutate({ id, status: null }); } }} - isPending={updateStatus.isPending || watchMovie.isPending} + isPending={updateStatus.isPending || watchMovie.isPending || unwatchMovie.isPending} /> {title.type === "movie" && ( watchMovie.mutate({ scope: "movie", ids: [id] })} - disabled={watchMovie.isPending} - className="bg-title-accent flex-row items-center gap-1.5 rounded-lg px-4 py-2" + onPress={() => { + if (isMovieWatched) { + unwatchMovie.mutate({ scope: "movie", ids: [id] }); + } else { + watchMovie.mutate({ scope: "movie", ids: [id] }); + } + }} + disabled={watchMovie.isPending || unwatchMovie.isPending} + className={ + isMovieWatched + ? "border-destructive/20 bg-destructive/10 flex-row items-center gap-1.5 rounded-lg border px-4 py-2" + : "bg-title-accent flex-row items-center gap-1.5 rounded-lg px-4 py-2" + } > - {watchMovie.isPending ? ( + {watchMovie.isPending || unwatchMovie.isPending ? ( + ) : isMovieWatched ? ( + <> + + + Mark Unwatched + + ) : ( <> diff --git a/apps/native/src/components/dashboard/upcoming-row.tsx b/apps/native/src/components/dashboard/upcoming-row.tsx index 27270a6..4d7b7cb 100644 --- a/apps/native/src/components/dashboard/upcoming-row.tsx +++ b/apps/native/src/components/dashboard/upcoming-row.tsx @@ -126,7 +126,14 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) { {rowContent} - {item.titleType === "movie" && ( + {item.titleType === "movie" && item.userStatus === "completed" && ( + titleActions.unwatchMovie(item.titleId, item.titleName)} + /> + )} + {item.titleType === "movie" && item.userStatus !== "completed" && ( )} - {type === "movie" && ( + {type === "movie" && userStatus === "completed" && ( + titleActions.unwatchMovie(id, title)} + /> + )} + {type === "movie" && userStatus !== "completed" && ( ; watchMovie?: ToastOverride; + unwatchMovie?: ToastOverride; updateRating?: ToastOverride<{ id: string; stars: number }>; watchEpisode?: ToastOverride; unwatchEpisode?: ToastOverride; @@ -67,6 +68,16 @@ export function useTitleActions(options?: UseTitleActionsOptions) { }), ); + const unwatchMovie = useMutation( + orpc.tracking.unwatch.mutationOptions({ + onSuccess: (_data, input) => { + toast.success(resolveToast(toastOverrides?.unwatchMovie, t`Marked as unwatched`, input)); + invalidateTitleQueries(); + }, + onError: () => toast.error(t`Failed to mark as unwatched`), + }), + ); + const updateRating = useMutation( orpc.tracking.rate.mutationOptions({ onSuccess: (_data, input) => { @@ -115,6 +126,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) { return { updateStatus, watchMovie, + unwatchMovie, updateRating, watchEpisode, unwatchEpisode, diff --git a/apps/native/src/lib/title-actions.ts b/apps/native/src/lib/title-actions.ts index 0be3717..d648af8 100644 --- a/apps/native/src/lib/title-actions.ts +++ b/apps/native/src/lib/title-actions.ts @@ -55,6 +55,20 @@ export const titleActions = { } }, + async unwatchMovie(id: string, titleName?: string) { + try { + await client.tracking.unwatch({ scope: "movie", ids: [id] }); + toast.success( + titleName + ? i18n._(msg`Marked "${titleName}" as unwatched`) + : i18n._(msg`Marked as unwatched`), + ); + invalidateTitleQueries(); + } catch { + toast.error(i18n._(msg`Failed to mark as unwatched`)); + } + }, + async removeFromLibrary(id: string) { try { await client.tracking.updateStatus({ id, status: null }); diff --git a/apps/web/src/components/titles/title-actions.tsx b/apps/web/src/components/titles/title-actions.tsx index 01041cc..901e52b 100644 --- a/apps/web/src/components/titles/title-actions.tsx +++ b/apps/web/src/components/titles/title-actions.tsx @@ -1,5 +1,5 @@ import { Trans } from "@lingui/react/macro"; -import { IconCheck } from "@tabler/icons-react"; +import { IconCheck, IconX } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -12,19 +12,31 @@ import { useTitleActions } from "./use-title-actions"; export function TitleActions() { const { titleType } = useTitleContext(); const { userStatus, userRating } = useTitleUserInfo(); - const { handleStatusChange, handleRating, handleWatchMovie } = useTitleActions(); + const { handleStatusChange, handleRating, handleWatchMovie, handleUnwatchMovie } = + useTitleActions(); + const isMovieWatched = userStatus === "completed"; return (
{titleType === "movie" && ( )} diff --git a/apps/web/src/components/titles/title-keyboard-shortcuts.tsx b/apps/web/src/components/titles/title-keyboard-shortcuts.tsx index d11833d..3d6f3e2 100644 --- a/apps/web/src/components/titles/title-keyboard-shortcuts.tsx +++ b/apps/web/src/components/titles/title-keyboard-shortcuts.tsx @@ -9,7 +9,8 @@ import { useTitleActions } from "./use-title-actions"; export function TitleKeyboardShortcuts() { const { titleType } = useTitleContext(); const { userStatus } = useTitleUserInfo(); - const { handleStatusChange, handleRating, handleWatchMovie } = useTitleActions(); + const { handleStatusChange, handleRating, handleWatchMovie, handleUnwatchMovie } = + useTitleActions(); const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom); const enabled = !commandPaletteOpen; @@ -21,7 +22,13 @@ export function TitleKeyboardShortcuts() { useHotkey( "M", () => { - if (titleType === "movie") handleWatchMovie(); + if (titleType === "movie") { + if (userStatus === "completed") { + handleUnwatchMovie(); + } else { + handleWatchMovie(); + } + } }, { enabled }, ); diff --git a/apps/web/src/components/titles/use-title-actions.ts b/apps/web/src/components/titles/use-title-actions.ts index 3390204..f67a44c 100644 --- a/apps/web/src/components/titles/use-title-actions.ts +++ b/apps/web/src/components/titles/use-title-actions.ts @@ -135,6 +135,19 @@ export function useTitleActions() { } }, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, watch, t]); + const handleUnwatchMovie = useCallback(async () => { + await queryClient.cancelQueries({ queryKey: userInfoKey }); + const prevStatus = getUserInfo().status; + setUserInfo((old) => ({ ...old, status: "in_watchlist" })); + try { + await unwatch({ scope: "movie", ids: [titleId] }); + toast.success(t`Marked "${titleName}" as unwatched`); + } catch { + setUserInfo((old) => ({ ...old, status: prevStatus })); + toast.error(t`Failed to mark as unwatched`); + } + }, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, unwatch, t]); + const handleWatchEpisode = useCallback( async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => { await queryClient.cancelQueries({ queryKey: userInfoKey }); @@ -352,6 +365,7 @@ export function useTitleActions() { handleStatusChange, handleRating, handleWatchMovie, + handleUnwatchMovie, handleWatchEpisode, handleMarkSeason, handleUnmarkSeason, diff --git a/packages/i18n/src/po/en.po b/packages/i18n/src/po/en.po index e272a39..dfa44e3 100644 --- a/packages/i18n/src/po/en.po +++ b/packages/i18n/src/po/en.po @@ -346,7 +346,6 @@ msgstr "" msgid "Added \"{titleName}\" to watchlist" msgstr "" -#: apps/native/src/hooks/use-title-actions.ts #: apps/native/src/hooks/use-title-actions.ts #: apps/native/src/lib/title-actions.ts #: apps/web/src/components/titles/use-title-actions.ts @@ -1236,7 +1235,6 @@ msgstr "Export" msgid "Failed" msgstr "" -#: apps/native/src/hooks/use-title-actions.ts #: apps/native/src/lib/title-actions.ts msgid "Failed to add to watchlist" msgstr "" @@ -1293,6 +1291,12 @@ msgstr "" msgid "Failed to mark all episodes as watched" msgstr "" +#: apps/native/src/hooks/use-title-actions.ts +#: apps/native/src/lib/title-actions.ts +#: apps/web/src/components/titles/use-title-actions.ts +msgid "Failed to mark as unwatched" +msgstr "Failed to mark as unwatched" + #: apps/native/src/hooks/use-title-actions.ts #: apps/native/src/lib/title-actions.ts #: apps/web/src/components/titles/use-title-actions.ts @@ -1386,7 +1390,6 @@ msgstr "" msgid "Failed to update rating" msgstr "" -#: apps/native/src/app/(tabs)/(settings)/index.tsx #: apps/web/src/components/settings/registration-section.tsx msgid "Failed to update registration setting" msgstr "" @@ -1860,6 +1863,13 @@ msgstr "Mark episode {epNum} watched" msgid "Mark Episode Watched" msgstr "Mark Episode Watched" +#: apps/native/src/app/title/[id].tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/native/src/components/ui/poster-card.tsx +#: apps/web/src/components/titles/title-actions.tsx +msgid "Mark Unwatched" +msgstr "Mark Unwatched" + #: apps/web/src/components/command-palette.tsx msgid "Mark watched" msgstr "Mark watched" @@ -1871,6 +1881,10 @@ msgstr "Mark watched" msgid "Mark Watched" msgstr "" +#: apps/native/src/app/title/[id].tsx +msgid "Marked \"{name}\" as unwatched" +msgstr "Marked \"{name}\" as unwatched" + #: apps/native/src/app/title/[id].tsx msgid "Marked \"{name}\" as watched" msgstr "Marked \"{name}\" as watched" @@ -1879,6 +1893,11 @@ msgstr "Marked \"{name}\" as watched" #~ msgid "Marked \"{titleName}\" as completed" #~ msgstr "" +#: apps/native/src/lib/title-actions.ts +#: apps/web/src/components/titles/use-title-actions.ts +msgid "Marked \"{titleName}\" as unwatched" +msgstr "Marked \"{titleName}\" as unwatched" + #: apps/native/src/lib/title-actions.ts #: apps/web/src/components/titles/use-title-actions.ts msgid "Marked \"{titleName}\" as watched" @@ -1898,6 +1917,12 @@ msgstr "Marked all episodes of \"{titleName}\" as watched" #~ msgid "Marked as completed" #~ msgstr "" +#: apps/native/src/app/title/[id].tsx +#: apps/native/src/hooks/use-title-actions.ts +#: apps/native/src/lib/title-actions.ts +msgid "Marked as unwatched" +msgstr "Marked as unwatched" + #: apps/native/src/app/title/[id].tsx #: apps/native/src/hooks/use-title-actions.ts #: apps/native/src/lib/title-actions.ts