mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Derive TV show status from episode watch data instead of manual selection
Status (watchlist/watching/completed) for TV shows is now automatically determined by episode progress rather than requiring manual selection from a dropdown. The only manual action is adding/removing from watchlist — watching and completed states flow from episode data. - Decouple markAllEpisodesWatched from setTitleStatus and export it - Replace status dropdown with toggle button + read-only status badge - Add "Mark All Watched" button with confirmation dialog for TV shows - Fix optimistic transitions (watchlist → watching on first episode) - Simplify W keyboard shortcut to toggle watchlist on/off - Add optimistic completion check when marking entire seasons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { episodes } from "@/lib/db/schema";
|
||||
import {
|
||||
logEpisodeWatch,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
@@ -23,20 +24,21 @@ async function getSessionUserId() {
|
||||
|
||||
export async function updateTitleStatus(
|
||||
titleId: string,
|
||||
status: string | null,
|
||||
status: "watchlist" | null,
|
||||
) {
|
||||
const userId = await getSessionUserId();
|
||||
if (status === null || status === undefined) {
|
||||
if (status === null) {
|
||||
await removeTitleStatus(userId, titleId);
|
||||
} else {
|
||||
await setTitleStatus(
|
||||
userId,
|
||||
titleId,
|
||||
status as "watchlist" | "in_progress" | "completed",
|
||||
);
|
||||
await setTitleStatus(userId, titleId, status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAllWatchedAction(titleId: string) {
|
||||
const userId = await getSessionUserId();
|
||||
await markAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
|
||||
export async function updateTitleRating(titleId: string, ratingStars: number) {
|
||||
const userId = await getSessionUserId();
|
||||
if (ratingStars < 0 || ratingStars > 5) throw new Error("Invalid rating");
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { IconChecks, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { StatusButton } from "@/components/status-button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTitleInteraction } from "./title-interaction-provider";
|
||||
|
||||
export function TitleActions() {
|
||||
@@ -13,6 +25,7 @@ export function TitleActions() {
|
||||
handleStatusChange,
|
||||
handleRating,
|
||||
handleWatchMovie,
|
||||
handleMarkAllWatched,
|
||||
} = useTitleInteraction();
|
||||
|
||||
return (
|
||||
@@ -27,14 +40,55 @@ export function TitleActions() {
|
||||
onClick={handleWatchMovie}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-all active:scale-[0.97] hover:shadow-md hover:shadow-primary/20"
|
||||
>
|
||||
<IconPlayerPlay size={15} />
|
||||
<IconPlayerPlay size={14} />
|
||||
Mark Watched
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Rate:</span>
|
||||
<StarRating value={userRating ?? 0} onChange={handleRating} />
|
||||
</div>
|
||||
{titleType === "tv" && userStatus && userStatus !== "completed" && (
|
||||
<MarkAllWatchedButton onConfirm={handleMarkAllWatched} />
|
||||
)}
|
||||
<span className="mx-0.5 h-4 w-px bg-border/50" />
|
||||
<StarRating value={userRating ?? 0} onChange={handleRating} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkAllWatchedButton({ onConfirm }: { onConfirm: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:scale-[0.97]"
|
||||
>
|
||||
<IconChecks size={14} />
|
||||
Mark All Watched
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mark all episodes as watched?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will mark every episode of this show as watched. You can undo
|
||||
this later by unmarking individual seasons.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Mark All Watched
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import type { Season } from "@/lib/types/title";
|
||||
import {
|
||||
markAllWatchedAction,
|
||||
unwatchEpisodeAction,
|
||||
unwatchSeasonAction,
|
||||
updateTitleRating,
|
||||
@@ -38,6 +39,7 @@ interface TitleInteractionState {
|
||||
) => void;
|
||||
handleMarkSeason: (season: Season) => void;
|
||||
handleUnmarkSeason: (season: Season) => void;
|
||||
handleMarkAllWatched: () => void;
|
||||
watchingEp: string | null;
|
||||
}
|
||||
|
||||
@@ -81,30 +83,23 @@ export function TitleInteractionProvider({
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: string | null) => {
|
||||
const prev = userStatus;
|
||||
const prevWatches = episodeWatches;
|
||||
setUserStatus(status);
|
||||
if (status === "completed" && titleType === "tv") {
|
||||
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
|
||||
setEpisodeWatches(allEpIds);
|
||||
}
|
||||
try {
|
||||
await updateTitleStatus(titleId, status);
|
||||
const label =
|
||||
await updateTitleStatus(
|
||||
titleId,
|
||||
status === "watchlist" ? "watchlist" : null,
|
||||
);
|
||||
toast.success(
|
||||
status === "watchlist"
|
||||
? "Added to watchlist"
|
||||
: status === "in_progress"
|
||||
? "Marked as watching"
|
||||
: status === "completed"
|
||||
? "Marked as completed"
|
||||
: "Removed from list";
|
||||
toast.success(label);
|
||||
: "Removed from library",
|
||||
);
|
||||
} catch {
|
||||
setUserStatus(prev);
|
||||
setEpisodeWatches(prevWatches);
|
||||
toast.error("Failed to update status");
|
||||
}
|
||||
},
|
||||
[titleId, titleType, userStatus, episodeWatches, seasons],
|
||||
[titleId, userStatus],
|
||||
);
|
||||
|
||||
const handleRating = useCallback(
|
||||
@@ -162,7 +157,9 @@ export function TitleInteractionProvider({
|
||||
setEpisodeWatches((w) =>
|
||||
w.includes(episodeId) ? w : [...w, episodeId],
|
||||
);
|
||||
setUserStatus((s) => s ?? "in_progress");
|
||||
setUserStatus((s) =>
|
||||
s === null || s === "watchlist" ? "in_progress" : s,
|
||||
);
|
||||
try {
|
||||
await watchEpisode(episodeId);
|
||||
toast.success(`Watched S${seasonNum} E${epNum}`);
|
||||
@@ -183,11 +180,20 @@ export function TitleInteractionProvider({
|
||||
);
|
||||
if (unwatched.length === 0) return;
|
||||
|
||||
setEpisodeWatches((w) => {
|
||||
const set = new Set(w);
|
||||
for (const ep of unwatched) set.add(ep.id);
|
||||
return [...set];
|
||||
});
|
||||
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);
|
||||
@@ -198,7 +204,7 @@ export function TitleInteractionProvider({
|
||||
toast.error("Failed to mark some episodes");
|
||||
}
|
||||
},
|
||||
[episodeWatches],
|
||||
[episodeWatches, seasons],
|
||||
);
|
||||
|
||||
const handleUnmarkSeason = useCallback(async (season: Season) => {
|
||||
@@ -217,6 +223,22 @@ export function TitleInteractionProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
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,
|
||||
@@ -232,6 +254,7 @@ export function TitleInteractionProvider({
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
watchingEp,
|
||||
}),
|
||||
[
|
||||
@@ -248,6 +271,7 @@ export function TitleInteractionProvider({
|
||||
handleWatchEpisode,
|
||||
handleMarkSeason,
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
watchingEp,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,12 +4,9 @@ import type { Hotkey } from "@tanstack/react-hotkeys";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||
import { useTitleInteraction } from "./title-interaction-provider";
|
||||
|
||||
const statusCycle = ["watchlist", "in_progress", "completed"] as const;
|
||||
|
||||
export function TitleKeyboardShortcuts() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
@@ -23,16 +20,10 @@ export function TitleKeyboardShortcuts() {
|
||||
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
|
||||
const enabled = !commandPaletteOpen;
|
||||
|
||||
const nextStatus = useMemo(() => {
|
||||
const currentIdx = statusCycle.indexOf(
|
||||
userStatus as (typeof statusCycle)[number],
|
||||
);
|
||||
return currentIdx === statusCycle.length - 1
|
||||
? null
|
||||
: statusCycle[currentIdx + 1];
|
||||
}, [userStatus]);
|
||||
|
||||
useHotkey("W", () => handleStatusChange(nextStatus), { enabled });
|
||||
// W: toggle watchlist (add if not in library, remove if in library)
|
||||
useHotkey("W", () => handleStatusChange(userStatus ? null : "watchlist"), {
|
||||
enabled,
|
||||
});
|
||||
useHotkey(
|
||||
"M",
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user