mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 17:05:56 -04:00
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"
This commit is contained in:
@@ -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<PosterRowItem[]>(
|
||||
() =>
|
||||
@@ -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" && (
|
||||
<Pressable
|
||||
onPress={() => 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 ? (
|
||||
<Spinner size="sm" />
|
||||
) : isMovieWatched ? (
|
||||
<>
|
||||
<ScaledIcon icon={IconX} size={16} color={destructiveColor} />
|
||||
<Text className="text-destructive font-sans text-sm font-medium">
|
||||
<Trans>Mark Unwatched</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScaledIcon icon={IconCheck} size={16} color={titleAccentForeground} />
|
||||
|
||||
@@ -126,7 +126,14 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) {
|
||||
<Link.Trigger withAppleZoom>{rowContent}</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
{item.titleType === "movie" && (
|
||||
{item.titleType === "movie" && item.userStatus === "completed" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark Unwatched`}
|
||||
icon="xmark.circle"
|
||||
onPress={() => titleActions.unwatchMovie(item.titleId, item.titleName)}
|
||||
/>
|
||||
)}
|
||||
{item.titleType === "movie" && item.userStatus !== "completed" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark Watched`}
|
||||
icon="checkmark.circle"
|
||||
|
||||
@@ -217,7 +217,14 @@ export const PosterCard = memo(function PosterCard({
|
||||
onPress={handleQuickAddPress}
|
||||
/>
|
||||
)}
|
||||
{type === "movie" && (
|
||||
{type === "movie" && userStatus === "completed" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark Unwatched`}
|
||||
icon="xmark.circle"
|
||||
onPress={() => titleActions.unwatchMovie(id, title)}
|
||||
/>
|
||||
)}
|
||||
{type === "movie" && userStatus !== "completed" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark Watched`}
|
||||
icon="checkmark.circle"
|
||||
|
||||
@@ -26,6 +26,7 @@ interface UseTitleActionsOptions {
|
||||
toasts?: {
|
||||
updateStatus?: ToastOverride<{ id: string; status: string | null }>;
|
||||
watchMovie?: ToastOverride<WatchInput>;
|
||||
unwatchMovie?: ToastOverride<WatchInput>;
|
||||
updateRating?: ToastOverride<{ id: string; stars: number }>;
|
||||
watchEpisode?: ToastOverride<WatchInput>;
|
||||
unwatchEpisode?: ToastOverride<WatchInput>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<StatusButton currentStatus={userStatus ?? null} onChange={handleStatusChange} />
|
||||
{titleType === "movie" && (
|
||||
<Button
|
||||
onClick={handleWatchMovie}
|
||||
onClick={isMovieWatched ? handleUnwatchMovie : handleWatchMovie}
|
||||
variant={isMovieWatched ? "destructive" : "default"}
|
||||
size="lg"
|
||||
className="hover:shadow-primary/20 h-9 rounded-lg px-4 text-sm hover:shadow-md active:scale-[0.97]"
|
||||
className={`h-9 rounded-lg px-4 text-sm hover:shadow-md active:scale-[0.97] ${isMovieWatched ? "hover:shadow-destructive/20" : "hover:shadow-primary/20"}`}
|
||||
>
|
||||
<IconCheck aria-hidden={true} className="size-3.5" />
|
||||
<Trans>Mark Watched</Trans>
|
||||
{isMovieWatched ? (
|
||||
<>
|
||||
<IconX aria-hidden={true} className="size-3.5" />
|
||||
<Trans>Mark Unwatched</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck aria-hidden={true} className="size-3.5" />
|
||||
<Trans>Mark Watched</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Separator orientation="vertical" className="bg-border/50 mx-0.5 my-auto h-6" />
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user