mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor: consolidate watchlist states (#18)
- Rename `watchlist` → `in_watchlist` and `in_progress` → `watching` across the full stack (DB migration, Drizzle schema, core services, API contract, web, native) - Add a new `caught_up` status for TV shows where all aired episodes are watched but the show is still airing - Add `titles.watchAll` procedure and `markAllWatched` action to mark every episode of a TV show as watched in one step; replace the old "Mark as Watching" / "Mark as Completed" context-menu actions with "Mark All Watched" (TV) and "Mark as Watched" (movie) - Extract `display-status.ts` in `@sofa/api` to share status → display label/color logic between web and native - Add `getDisplayStatusesByTitleIds` to `@sofa/core/tracking` and wire it into the dashboard library feed so clients receive resolved display statuses - Show a destructive Alert confirmation before removing a title from the library (native) - Remove "Mark as Completed" from the continue-watching card context menu - Update i18n catalogs for all 6 locales (de, en, es, fr, it, pt)
This commit is contained in:
@@ -48,7 +48,7 @@ export default function ExploreScreen() {
|
||||
() =>
|
||||
Object.assign({}, ...(trending.data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
|
||||
string,
|
||||
"watchlist" | "in_progress" | "completed"
|
||||
"in_watchlist" | "watching" | "caught_up" | "completed"
|
||||
>,
|
||||
[trending.data?.pages],
|
||||
);
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function PersonDetailScreen() {
|
||||
() =>
|
||||
Object.assign({}, ...(data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
|
||||
string,
|
||||
"watchlist" | "in_progress" | "completed"
|
||||
"in_watchlist" | "watching" | "caught_up" | "completed"
|
||||
>,
|
||||
[data?.pages],
|
||||
);
|
||||
|
||||
@@ -374,7 +374,7 @@ export default function TitleDetailScreen() {
|
||||
<StatusActionButton
|
||||
currentStatus={userInfo.data?.status ?? null}
|
||||
onStatusChange={(status) => {
|
||||
if (status === "watchlist") {
|
||||
if (status === "in_watchlist") {
|
||||
quickAddMutation.mutate({ id });
|
||||
} else {
|
||||
updateStatus.mutate({ id, status: null });
|
||||
|
||||
@@ -104,11 +104,6 @@ export const ContinueWatchingCard = memo(function ContinueWatchingCard({
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction
|
||||
title={t`Mark as Completed`}
|
||||
icon="checkmark.circle"
|
||||
onPress={() => titleActions.markCompleted(item.title.id, item.title.title)}
|
||||
/>
|
||||
<Link.MenuAction
|
||||
title={t`Remove from Library`}
|
||||
icon="trash"
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface PosterRowItem {
|
||||
releaseDate?: string | null;
|
||||
firstAirDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
userStatus?: "watchlist" | "in_progress" | "completed" | null;
|
||||
userStatus?: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
|
||||
episodeProgress?: { watched: number; total: number } | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
|
||||
const genreChipsContentStyle = { paddingHorizontal: 16 };
|
||||
|
||||
export function FilterableTitleRow({
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ContinueWatchingBanner({
|
||||
[seasons, watchedEpisodeIds],
|
||||
);
|
||||
|
||||
if (userStatus !== "in_progress" || !nextEpisode) return null;
|
||||
if (userStatus !== "watching" || !nextEpisode) return null;
|
||||
|
||||
const stillUrl = nextEpisode.stillPath ?? backdropPath ?? null;
|
||||
const progress = totalEpisodes > 0 ? (watchedEpisodes / totalEpisodes) * 100 : 0;
|
||||
|
||||
@@ -5,30 +5,35 @@ import {
|
||||
IconPlayerPlayFilled,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { Pressable } from "react-native";
|
||||
import { Alert, Pressable } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
|
||||
|
||||
const STATUS_ICONS = {
|
||||
watchlist: IconBookmarkFilled,
|
||||
in_progress: IconPlayerPlayFilled,
|
||||
in_watchlist: IconBookmarkFilled,
|
||||
watching: IconPlayerPlayFilled,
|
||||
caught_up: IconCheck,
|
||||
completed: IconCheck,
|
||||
} as const;
|
||||
|
||||
const STATUS_STYLES = {
|
||||
watchlist: {
|
||||
in_watchlist: {
|
||||
bgClass: "bg-title-accent/10 border-title-accent/20",
|
||||
textClass: "text-title-accent",
|
||||
},
|
||||
in_progress: {
|
||||
watching: {
|
||||
bgClass: "bg-title-accent/10 border-title-accent/20",
|
||||
textClass: "text-title-accent",
|
||||
},
|
||||
caught_up: {
|
||||
bgClass: "bg-status-completed/10 border-status-completed/20",
|
||||
textClass: "text-status-completed",
|
||||
},
|
||||
completed: {
|
||||
bgClass: "bg-status-completed/10 border-status-completed/20",
|
||||
textClass: "text-status-completed",
|
||||
@@ -37,10 +42,12 @@ const STATUS_STYLES = {
|
||||
|
||||
function StatusLabel({ status }: { status: TitleStatus }) {
|
||||
switch (status) {
|
||||
case "watchlist":
|
||||
return <Trans>Watchlisted</Trans>;
|
||||
case "in_progress":
|
||||
case "in_watchlist":
|
||||
return <Trans>In Watchlist</Trans>;
|
||||
case "watching":
|
||||
return <Trans>Watching</Trans>;
|
||||
case "caught_up":
|
||||
return <Trans>Caught Up</Trans>;
|
||||
case "completed":
|
||||
return <Trans>Completed</Trans>;
|
||||
}
|
||||
@@ -66,7 +73,7 @@ export function StatusActionButton({
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStatusChange("watchlist");
|
||||
onStatusChange("in_watchlist");
|
||||
}}
|
||||
disabled={isPending}
|
||||
accessibilityRole="button"
|
||||
@@ -83,13 +90,25 @@ export function StatusActionButton({
|
||||
|
||||
const StatusIcon = STATUS_ICONS[currentStatus];
|
||||
const styles = STATUS_STYLES[currentStatus];
|
||||
const iconColor = currentStatus === "completed" ? completedColor : titleAccent;
|
||||
const iconColor =
|
||||
currentStatus === "completed" || currentStatus === "caught_up" ? completedColor : titleAccent;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
onStatusChange(null);
|
||||
Alert.alert(
|
||||
t`Remove from library?`,
|
||||
t`This title will be removed from your library. Your watch history and ratings will be kept.`,
|
||||
[
|
||||
{ text: t`Cancel`, style: "cancel" },
|
||||
{
|
||||
text: t`Remove`,
|
||||
style: "destructive",
|
||||
onPress: () => onStatusChange(null),
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
disabled={isPending}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Text } from "@/components/ui/text";
|
||||
import { usePressAnimation } from "@/hooks/use-press-animation";
|
||||
import { titleActions } from "@/lib/title-actions";
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
|
||||
|
||||
interface PosterCardProps {
|
||||
id: string;
|
||||
@@ -61,8 +61,9 @@ export const PosterCard = memo(function PosterCard({
|
||||
const completedColor = useCSSVariable("--color-status-completed") as string;
|
||||
|
||||
const statusColors: Record<TitleStatus, string> = {
|
||||
watchlist: watchlistColor,
|
||||
in_progress: watchingColor,
|
||||
in_watchlist: watchlistColor,
|
||||
watching: watchingColor,
|
||||
caught_up: completedColor,
|
||||
completed: completedColor,
|
||||
};
|
||||
|
||||
@@ -79,11 +80,13 @@ export const PosterCard = memo(function PosterCard({
|
||||
const statusLabel =
|
||||
userStatus === "completed"
|
||||
? "completed"
|
||||
: userStatus === "in_progress"
|
||||
? "watching"
|
||||
: userStatus === "watchlist"
|
||||
? "on watchlist"
|
||||
: undefined;
|
||||
: userStatus === "caught_up"
|
||||
? "caught up"
|
||||
: userStatus === "watching"
|
||||
? "watching"
|
||||
: userStatus === "in_watchlist"
|
||||
? "on watchlist"
|
||||
: undefined;
|
||||
const cardAccessibilityLabel = [title, type === "movie" ? "movie" : "TV show", year, statusLabel]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
@@ -119,9 +122,9 @@ export const PosterCard = memo(function PosterCard({
|
||||
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
{userStatus === "completed" ? (
|
||||
{userStatus === "completed" || userStatus === "caught_up" ? (
|
||||
<IconCheckbox size={16} color="white" />
|
||||
) : userStatus === "in_progress" ? (
|
||||
) : userStatus === "watching" ? (
|
||||
<IconPlayerPlayFilled size={16} color="white" />
|
||||
) : (
|
||||
<IconBookmarkFilled size={16} color="white" />
|
||||
@@ -214,13 +217,6 @@ export const PosterCard = memo(function PosterCard({
|
||||
onPress={handleQuickAddPress}
|
||||
/>
|
||||
)}
|
||||
{userStatus !== "in_progress" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark as Watching`}
|
||||
icon="play.fill"
|
||||
onPress={() => titleActions.markWatching(id)}
|
||||
/>
|
||||
)}
|
||||
{type === "movie" && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark as Watched`}
|
||||
@@ -228,6 +224,13 @@ export const PosterCard = memo(function PosterCard({
|
||||
onPress={() => titleActions.markMovieWatched(id, title)}
|
||||
/>
|
||||
)}
|
||||
{type === "tv" && userStatus && (
|
||||
<Link.MenuAction
|
||||
title={t`Mark All Watched`}
|
||||
icon="checkmark.circle"
|
||||
onPress={() => titleActions.markAllWatched(id, title)}
|
||||
/>
|
||||
)}
|
||||
{userStatus && (
|
||||
<Link.MenuAction
|
||||
title={t`Remove from Library`}
|
||||
|
||||
@@ -57,8 +57,6 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
onSuccess: (_data, input) => {
|
||||
const statusMessages: Record<string, string> = {
|
||||
watchlist: t`Added to watchlist`,
|
||||
in_progress: t`Marked as watching`,
|
||||
completed: t`Marked as completed`,
|
||||
};
|
||||
const defaultMsg = input.status
|
||||
? (statusMessages[input.status] ?? t`Status updated`)
|
||||
|
||||
@@ -32,30 +32,6 @@ export const titleActions = {
|
||||
}
|
||||
},
|
||||
|
||||
async markWatching(id: string) {
|
||||
try {
|
||||
await client.titles.updateStatus({ id, status: "in_progress" });
|
||||
toast.success(i18n._(msg`Marked as watching`));
|
||||
invalidateTitleQueries();
|
||||
} catch {
|
||||
toast.error(i18n._(msg`Failed to update status`));
|
||||
}
|
||||
},
|
||||
|
||||
async markCompleted(id: string, titleName?: string) {
|
||||
try {
|
||||
await client.titles.updateStatus({ id, status: "completed" });
|
||||
toast.success(
|
||||
titleName
|
||||
? i18n._(msg`Marked "${titleName}" as completed`)
|
||||
: i18n._(msg`Marked as completed`),
|
||||
);
|
||||
invalidateTitleQueries();
|
||||
} catch {
|
||||
toast.error(i18n._(msg`Failed to update status`));
|
||||
}
|
||||
},
|
||||
|
||||
async markMovieWatched(id: string, titleName?: string) {
|
||||
try {
|
||||
await client.titles.watchMovie({ id });
|
||||
@@ -112,6 +88,20 @@ export const titleActions = {
|
||||
}
|
||||
},
|
||||
|
||||
async markAllWatched(id: string, titleName?: string) {
|
||||
try {
|
||||
await client.titles.watchAll({ id });
|
||||
toast.success(
|
||||
titleName
|
||||
? i18n._(msg`Marked all episodes of "${titleName}" as watched`)
|
||||
: i18n._(msg`Marked all episodes as watched`),
|
||||
);
|
||||
invalidateTitleQueries();
|
||||
} catch {
|
||||
toast.error(i18n._(msg`Failed to mark all episodes as watched`));
|
||||
}
|
||||
},
|
||||
|
||||
async watchSeason(id: string, seasonName?: string) {
|
||||
try {
|
||||
await client.seasons.watch({ id });
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getWatchCount,
|
||||
getWatchHistory,
|
||||
} from "@sofa/core/discovery";
|
||||
import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
@@ -48,6 +49,10 @@ export const library = os.dashboard.library.use(authed).handler(({ input, contex
|
||||
totalPages,
|
||||
totalResults,
|
||||
} = getLibraryFeed(context.user.id, input.page, input.limit);
|
||||
|
||||
const titleIds = feed.map((t) => t.titleId);
|
||||
const displayStatuses = getDisplayStatusesByTitleIds(context.user.id, titleIds);
|
||||
|
||||
const items = feed.map((t) => ({
|
||||
id: t.titleId,
|
||||
tmdbId: t.tmdbId,
|
||||
@@ -58,7 +63,7 @@ export const library = os.dashboard.library.use(authed).handler(({ input, contex
|
||||
releaseDate: t.releaseDate ?? null,
|
||||
firstAirDate: t.firstAirDate ?? null,
|
||||
voteAverage: t.voteAverage,
|
||||
userStatus: t.userStatus,
|
||||
userStatus: displayStatuses[t.titleId] ?? null,
|
||||
}));
|
||||
return { items, page, totalPages, totalResults };
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { getEpisodeProgressByTitleIds, getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
@@ -61,7 +61,7 @@ export const discover = os.discover.use(authed).handler(async ({ input, context
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getUserStatusesByTitleIds(context.user.id, titleIds),
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { getEpisodeProgressByTitleIds, getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
@@ -90,7 +90,7 @@ export const trending = os.explore.trending.use(authed).handler(async ({ input,
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getUserStatusesByTitleIds(context.user.id, titleIds),
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
@@ -136,7 +136,7 @@ export const popular = os.explore.popular.use(authed).handler(async ({ input, co
|
||||
const [userStatuses, episodeProgress] =
|
||||
titleIds.length > 0
|
||||
? [
|
||||
getUserStatusesByTitleIds(context.user.id, titleIds),
|
||||
getDisplayStatusesByTitleIds(context.user.id, titleIds),
|
||||
getEpisodeProgressByTitleIds(context.user.id, titleIds),
|
||||
]
|
||||
: [{}, {}];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { fetchFullFilmography, getOrFetchPerson } from "@sofa/core/person";
|
||||
import { getUserStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
|
||||
import { os } from "../context";
|
||||
import { authed } from "../middleware";
|
||||
@@ -20,7 +20,7 @@ export const detail = os.people.detail.use(authed).handler(async ({ input, conte
|
||||
const start = (input.page - 1) * input.limit;
|
||||
const pageCredits = allCredits.slice(start, start + input.limit);
|
||||
|
||||
const userStatuses = getUserStatusesByTitleIds(
|
||||
const userStatuses = getDisplayStatusesByTitleIds(
|
||||
context.user.id,
|
||||
pageCredits.map((c) => c.titleId),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { getRecommendationsForTitle } from "@sofa/core/discovery";
|
||||
import { getOrFetchTitle, getOrFetchTitleByTmdbId } from "@sofa/core/metadata";
|
||||
import {
|
||||
getUserStatusesByTitleIds,
|
||||
getDisplayStatusesByTitleIds,
|
||||
getUserTitleInfo,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
@@ -48,14 +48,19 @@ export const watchAll = os.titles.watchAll.use(authed).handler(({ input, context
|
||||
});
|
||||
|
||||
export const userInfo = os.titles.userInfo.use(authed).handler(({ input, context }) => {
|
||||
return getUserTitleInfo(context.user.id, input.id);
|
||||
const info = getUserTitleInfo(context.user.id, input.id);
|
||||
if (!info.status) return { ...info, status: null };
|
||||
|
||||
// Convert stored status to display status
|
||||
const displayStatuses = getDisplayStatusesByTitleIds(context.user.id, [input.id]);
|
||||
return { ...info, status: displayStatuses[input.id] ?? null };
|
||||
});
|
||||
|
||||
export const recommendations = os.titles.recommendations
|
||||
.use(authed)
|
||||
.handler(({ input, context }) => {
|
||||
const recs = getRecommendationsForTitle(input.id);
|
||||
const userStatuses = getUserStatusesByTitleIds(
|
||||
const userStatuses = getDisplayStatusesByTitleIds(
|
||||
context.user.id,
|
||||
recs.map((r) => r.id),
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface TitleGridItem {
|
||||
releaseDate?: string | null;
|
||||
firstAirDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
userStatus?: "watchlist" | "in_progress" | "completed" | null;
|
||||
userStatus?: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
|
||||
}
|
||||
|
||||
export function TitleGridSectionSkeleton() {
|
||||
|
||||
@@ -24,7 +24,7 @@ interface TitleRowItem {
|
||||
voteAverage: number | null;
|
||||
}
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
|
||||
|
||||
interface FilterableTitleRowProps {
|
||||
heading: string;
|
||||
|
||||
@@ -19,7 +19,7 @@ interface TitleRowProps {
|
||||
heading: string;
|
||||
icon: React.ReactNode;
|
||||
items: TitleRowItem[];
|
||||
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||
userStatuses?: Record<string, "in_watchlist" | "watching" | "caught_up" | "completed">;
|
||||
episodeProgress?: Record<string, { watched: number; total: number }>;
|
||||
onEndReached?: () => void;
|
||||
hasNextPage?: boolean;
|
||||
|
||||
@@ -18,7 +18,7 @@ type Sort = "newest" | "rating";
|
||||
|
||||
interface FilmographyGridProps {
|
||||
credits: PersonCredit[];
|
||||
userStatuses?: Record<string, "watchlist" | "in_progress" | "completed">;
|
||||
userStatuses?: Record<string, "in_watchlist" | "watching" | "caught_up" | "completed">;
|
||||
}
|
||||
|
||||
export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function PersonDetailClient({ id }: { id: string }) {
|
||||
() =>
|
||||
Object.assign({}, ...(data?.pages.map((p) => p.userStatuses) ?? [])) as Record<
|
||||
string,
|
||||
"watchlist" | "in_progress" | "completed"
|
||||
"in_watchlist" | "watching" | "caught_up" | "completed"
|
||||
>,
|
||||
[data?.pages],
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function TitleCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type TitleStatus = "watchlist" | "in_progress" | "completed";
|
||||
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
|
||||
|
||||
interface TiltStyles {
|
||||
imageStyle: MotionStyle;
|
||||
@@ -59,16 +59,21 @@ export interface TitleCardProps extends CardInnerProps {
|
||||
function useStatusConfig() {
|
||||
const { t } = useLingui();
|
||||
return {
|
||||
watchlist: {
|
||||
in_watchlist: {
|
||||
icon: IconBookmarkFilled,
|
||||
label: t`On Watchlist`,
|
||||
badgeClass: "bg-status-watching/90 text-white",
|
||||
},
|
||||
in_progress: {
|
||||
watching: {
|
||||
icon: IconPlayerPlayFilled,
|
||||
label: t`Watching`,
|
||||
badgeClass: "bg-status-watching/90 text-white",
|
||||
},
|
||||
caught_up: {
|
||||
icon: IconCircleCheckFilled,
|
||||
label: t`Caught Up`,
|
||||
badgeClass: "bg-status-watching/90 text-white",
|
||||
},
|
||||
completed: {
|
||||
icon: IconCircleCheckFilled,
|
||||
label: t`Completed`,
|
||||
@@ -91,7 +96,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
|
||||
|
||||
const quickAddMutation = useMutation(
|
||||
orpc.titles.quickAdd.mutationOptions({
|
||||
onSuccess: () => setAddedStatus("watchlist"),
|
||||
onSuccess: () => setAddedStatus("in_watchlist"),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { IconCheck, IconPlayerPlayFilled, IconPlus, IconX } from "@tabler/icons-react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
IconBookmarkFilled,
|
||||
IconCheck,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface StatusButtonProps {
|
||||
currentStatus: string | null;
|
||||
@@ -9,75 +27,120 @@ interface StatusButtonProps {
|
||||
|
||||
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
|
||||
const { t } = useLingui();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const watchingStyle = {
|
||||
label: t`Watching`,
|
||||
icon: IconPlayerPlayFilled,
|
||||
class: "text-status-watching",
|
||||
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
|
||||
borderClass: "ring-status-watching/20",
|
||||
const completedStyle = {
|
||||
class: "text-status-completed",
|
||||
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
|
||||
borderClass: "ring-status-completed/20",
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
watchlist: watchingStyle,
|
||||
in_progress: watchingStyle,
|
||||
in_watchlist: {
|
||||
label: t`In Watchlist`,
|
||||
icon: IconBookmarkFilled,
|
||||
class: "text-primary",
|
||||
bgClass: "bg-primary/10 hover:bg-primary/15",
|
||||
borderClass: "ring-primary/20",
|
||||
},
|
||||
watching: {
|
||||
label: t`Watching`,
|
||||
icon: IconPlayerPlayFilled,
|
||||
class: "text-status-watching",
|
||||
bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
|
||||
borderClass: "ring-status-watching/20",
|
||||
},
|
||||
caught_up: {
|
||||
label: t`Caught Up`,
|
||||
icon: IconCheck,
|
||||
...completedStyle,
|
||||
},
|
||||
completed: {
|
||||
label: t`Completed`,
|
||||
icon: IconCheck,
|
||||
class: "text-status-completed",
|
||||
bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
|
||||
borderClass: "ring-status-completed/20",
|
||||
...completedStyle,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const config = statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!config ? (
|
||||
<motion.button
|
||||
key="add"
|
||||
type="button"
|
||||
onClick={() => onChange("watchlist")}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="bg-primary/10 text-primary ring-primary/20 hover:bg-primary/15 hover:ring-primary/30 inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97]"
|
||||
>
|
||||
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
|
||||
{t`Watchlist`}
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
key="status"
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
title={t`Remove from library`}
|
||||
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
|
||||
>
|
||||
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
||||
<config.icon
|
||||
aria-hidden={true}
|
||||
className="size-3.5 transition-opacity group-hover:opacity-0"
|
||||
/>
|
||||
<IconX
|
||||
aria-hidden={true}
|
||||
className="text-destructive size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
||||
<span className="transition-opacity group-hover:opacity-0">{config.label}</span>
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{t`Remove`}
|
||||
<>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!config ? (
|
||||
<motion.button
|
||||
key="add"
|
||||
type="button"
|
||||
onClick={() => onChange("watchlist")}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="bg-primary/10 text-primary ring-primary/20 hover:bg-primary/15 hover:ring-primary/30 inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97]"
|
||||
>
|
||||
<IconPlus aria-hidden={true} className="size-3.5" strokeWidth={2.5} />
|
||||
{t`Watchlist`}
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
key="status"
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
title={t`Remove from library`}
|
||||
className={`group inline-flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium ring-1 transition-all active:scale-[0.97] ${config.class} ${config.bgClass} ${config.borderClass} hover:!bg-destructive/10 hover:!text-destructive hover:!ring-destructive/30`}
|
||||
>
|
||||
<span className="grid [&>svg]:col-start-1 [&>svg]:row-start-1">
|
||||
<config.icon
|
||||
aria-hidden={true}
|
||||
className="size-3.5 transition-opacity group-hover:opacity-0"
|
||||
/>
|
||||
<IconX
|
||||
aria-hidden={true}
|
||||
className="text-destructive size-3.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="grid [&>span]:col-start-1 [&>span]:row-start-1">
|
||||
<span className="transition-opacity group-hover:opacity-0">{config.label}</span>
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{t`Remove`}
|
||||
</span>
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans>Remove from library?</Trans>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Trans>
|
||||
This title will be removed from your library. Your watch history and ratings will be
|
||||
kept.
|
||||
</Trans>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans>Cancel</Trans>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Season } from "@sofa/api/schemas";
|
||||
import { useTitleContext } from "./title-context";
|
||||
|
||||
type UserInfo = {
|
||||
status: "watchlist" | "in_progress" | "completed" | null;
|
||||
status: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
|
||||
rating: number | null;
|
||||
episodeWatches: string[];
|
||||
};
|
||||
@@ -57,17 +57,14 @@ export function useTitleActions() {
|
||||
for (const id of episodeIds) newWatchSet.add(id);
|
||||
const newWatches = [...newWatchSet];
|
||||
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
|
||||
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: newWatches,
|
||||
status: allWatched ? "completed" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await batchWatchMutation.mutateAsync({ episodeIds });
|
||||
await queryClient.invalidateQueries({ queryKey: userInfoKey });
|
||||
toast.success(
|
||||
t`Caught up — marked ${episodeIds.length} ${plural(episodeIds.length, { one: "episode", other: "episodes" })} as watched`,
|
||||
);
|
||||
@@ -80,7 +77,7 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to catch up`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, seasons, batchWatchMutation, t],
|
||||
[getUserInfo, setUserInfo, batchWatchMutation, queryClient, userInfoKey, t],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
@@ -88,12 +85,12 @@ export function useTitleActions() {
|
||||
const prevStatus = getUserInfo().status;
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
status: status === "watchlist" ? "in_progress" : (status as UserInfo["status"]),
|
||||
status: status ? "in_watchlist" : null,
|
||||
}));
|
||||
try {
|
||||
await updateStatusMutation.mutateAsync({
|
||||
id: titleId,
|
||||
status: status ? "in_progress" : null,
|
||||
status: status ? "watchlist" : null,
|
||||
});
|
||||
toast.success(status ? t`Added to watchlist` : t`Removed from library`);
|
||||
} catch {
|
||||
@@ -148,7 +145,8 @@ export function useTitleActions() {
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: old.episodeWatches.filter((id) => id !== episodeId),
|
||||
status: old.status === "completed" ? "in_progress" : old.status,
|
||||
status:
|
||||
old.status === "completed" || old.status === "caught_up" ? "watching" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -173,7 +171,7 @@ export function useTitleActions() {
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: newWatches,
|
||||
status: old.status === null || old.status === "watchlist" ? "in_progress" : old.status,
|
||||
status: old.status === null || old.status === "in_watchlist" ? "watching" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -243,21 +241,15 @@ export function useTitleActions() {
|
||||
for (const ep of unwatched) newWatchSet.add(ep.id);
|
||||
const newWatches = [...newWatchSet];
|
||||
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
const allWatched = allEpIds.every((id) => newWatchSet.has(id));
|
||||
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: newWatches,
|
||||
status: allWatched
|
||||
? "completed"
|
||||
: old.status === null || old.status === "watchlist"
|
||||
? "in_progress"
|
||||
: old.status,
|
||||
status: old.status === null || old.status === "in_watchlist" ? "watching" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
await watchSeasonMutation.mutateAsync({ id: season.id });
|
||||
await queryClient.invalidateQueries({ queryKey: userInfoKey });
|
||||
|
||||
const currentWatchSet = new Set(getUserInfo().episodeWatches);
|
||||
const previousUnwatched: string[] = [];
|
||||
@@ -294,7 +286,7 @@ export function useTitleActions() {
|
||||
toast.error(t`Failed to mark some episodes`);
|
||||
}
|
||||
},
|
||||
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, t],
|
||||
[getUserInfo, setUserInfo, seasons, catchUp, watchSeasonMutation, queryClient, userInfoKey, t],
|
||||
);
|
||||
|
||||
const handleUnmarkSeason = useCallback(
|
||||
@@ -305,7 +297,7 @@ export function useTitleActions() {
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: old.episodeWatches.filter((id) => !seasonEpIds.has(id)),
|
||||
status: old.status === "completed" ? "in_progress" : old.status,
|
||||
status: old.status === "completed" || old.status === "caught_up" ? "watching" : old.status,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -330,10 +322,12 @@ export function useTitleActions() {
|
||||
setUserInfo((old) => ({
|
||||
...old,
|
||||
episodeWatches: allEpIds,
|
||||
status: "completed",
|
||||
status: old.status ?? "watching",
|
||||
}));
|
||||
try {
|
||||
await watchAllMutation.mutateAsync({ id: titleId });
|
||||
// Refresh to get server-derived display status (caught_up / completed)
|
||||
await queryClient.invalidateQueries({ queryKey: userInfoKey });
|
||||
toast.success(t`Marked all episodes as watched`);
|
||||
} catch {
|
||||
setUserInfo((old) => ({
|
||||
@@ -343,7 +337,7 @@ export function useTitleActions() {
|
||||
}));
|
||||
toast.error(t`Failed to mark all episodes as watched`);
|
||||
}
|
||||
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, t]);
|
||||
}, [getUserInfo, setUserInfo, seasons, titleId, watchAllMutation, queryClient, userInfoKey, t]);
|
||||
|
||||
return {
|
||||
handleStatusChange,
|
||||
|
||||
Reference in New Issue
Block a user