diff --git a/app/(pages)/titles/[id]/_components/actions.ts b/app/(pages)/titles/[id]/_components/actions.ts
index c2e9685..3af8949 100644
--- a/app/(pages)/titles/[id]/_components/actions.ts
+++ b/app/(pages)/titles/[id]/_components/actions.ts
@@ -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");
diff --git a/app/(pages)/titles/[id]/_components/title-actions.tsx b/app/(pages)/titles/[id]/_components/title-actions.tsx
index b7c6f68..af61635 100644
--- a/app/(pages)/titles/[id]/_components/title-actions.tsx
+++ b/app/(pages)/titles/[id]/_components/title-actions.tsx
@@ -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"
>
-
+
Mark Watched
)}
-
- Rate:
-
-
+ {titleType === "tv" && userStatus && userStatus !== "completed" && (
+
+ )}
+
+
);
}
+
+function MarkAllWatchedButton({ onConfirm }: { onConfirm: () => void }) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ Mark All Watched
+
+ }
+ />
+
+
+ Mark all episodes as watched?
+
+ This will mark every episode of this show as watched. You can undo
+ this later by unmarking individual seasons.
+
+
+
+ Cancel
+ {
+ onConfirm();
+ setOpen(false);
+ }}
+ >
+ Mark All Watched
+
+
+
+
+ );
+}
diff --git a/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx b/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx
index ad6967f..63149a7 100644
--- a/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx
+++ b/app/(pages)/titles/[id]/_components/title-interaction-provider.tsx
@@ -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,
],
);
diff --git a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx
index 1a8ea75..e87116a 100644
--- a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx
+++ b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx
@@ -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",
() => {
diff --git a/components/status-button.tsx b/components/status-button.tsx
index ad4536f..6bc85d9 100644
--- a/components/status-button.tsx
+++ b/components/status-button.tsx
@@ -1,38 +1,37 @@
"use client";
import {
- IconBookmark,
+ IconBookmarkFilled,
IconCheck,
- IconPlayerPlay,
+ IconPlayerPlayFilled,
IconPlus,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
-import { useEffect, useRef, useState } from "react";
-const statuses = [
- {
- value: "watchlist",
+const statusConfig = {
+ watchlist: {
label: "Watchlist",
- icon: IconBookmark,
- colorClass:
- "border-status-watchlist/30 bg-status-watchlist/10 text-status-watchlist hover:bg-status-watchlist/15",
+ icon: IconBookmarkFilled,
+ class: "text-status-watchlist",
+ bgClass: "bg-status-watchlist/10 hover:bg-status-watchlist/15",
+ borderClass: "ring-status-watchlist/20",
},
- {
- value: "in_progress",
+ in_progress: {
label: "Watching",
- icon: IconPlayerPlay,
- colorClass:
- "border-status-watching/30 bg-status-watching/10 text-status-watching hover:bg-status-watching/15",
+ icon: IconPlayerPlayFilled,
+ class: "text-status-watching",
+ bgClass: "bg-status-watching/10 hover:bg-status-watching/15",
+ borderClass: "ring-status-watching/20",
},
- {
- value: "completed",
+ completed: {
label: "Completed",
icon: IconCheck,
- colorClass:
- "border-status-completed/30 bg-status-completed/10 text-status-completed hover:bg-status-completed/15",
+ class: "text-status-completed",
+ bgClass: "bg-status-completed/10 hover:bg-status-completed/15",
+ borderClass: "ring-status-completed/20",
},
-] as const;
+} as const;
interface StatusButtonProps {
currentStatus: string | null;
@@ -40,93 +39,57 @@ interface StatusButtonProps {
}
export function StatusButton({ currentStatus, onChange }: StatusButtonProps) {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
-
- useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- setOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, []);
-
- const current = statuses.find((s) => s.value === currentStatus);
- const CurrentIcon = current?.icon ?? IconPlus;
+ const config =
+ statusConfig[currentStatus as keyof typeof statusConfig] ?? null;
return (
-
-
-
-
- {open && (
-
- {statuses.map((s) => {
- const Icon = s.icon;
- return (
-
- );
- })}
- {currentStatus && (
- <>
-
-
- >
- )}
-
- )}
-
-
+
+ {!config ? (
+ onChange("watchlist")}
+ initial={{ opacity: 0, y: 4 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: -4 }}
+ transition={{ duration: 0.15 }}
+ className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary/10 px-4 text-sm font-medium text-primary ring-1 ring-primary/20 transition-all hover:bg-primary/15 hover:ring-primary/30 active:scale-[0.97]"
+ >
+
+ Watchlist
+
+ ) : (
+ onChange(null)}
+ initial={{ opacity: 0, y: 4 }}
+ animate={{ opacity: 1, y: 0 }}
+ exit={{ opacity: 0, y: -4 }}
+ transition={{ duration: 0.15 }}
+ title="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:ring-destructive/30 hover:bg-destructive/10 hover:text-destructive`}
+ >
+
+
+
+
+
+
+ {config.label}
+
+
+ Remove
+
+
+
+ )}
+
);
}
diff --git a/lib/services/tracking.ts b/lib/services/tracking.ts
index 93a3e03..ba9dd97 100644
--- a/lib/services/tracking.ts
+++ b/lib/services/tracking.ts
@@ -14,6 +14,7 @@ export async function setTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
@@ -25,10 +26,6 @@ export async function setTitleStatus(
set: { status, updatedAt: now },
})
.run();
-
- if (status === "completed") {
- await markAllEpisodesWatched(userId, titleId, source);
- }
}
export async function removeTitleStatus(userId: string, titleId: string) {
@@ -119,7 +116,7 @@ export async function logEpisodeWatch(
await checkAllEpisodesWatched(userId, titleId);
}
-async function markAllEpisodesWatched(
+export async function markAllEpisodesWatched(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
@@ -169,6 +166,8 @@ async function markAllEpisodesWatched(
}
}
}
+
+ await setTitleStatus(userId, titleId, "completed", source);
}
async function checkAllEpisodesWatched(userId: string, titleId: string) {