From bcf9a3c7516091877d0e0b3e977a1f8f17ce228d Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 4 Mar 2026 21:44:23 -0500 Subject: [PATCH] Add quick-add watchlist button and consolidate server actions into lib/actions Move all server actions from scattered page-level files into shared lib/actions/ directory (settings.ts, titles.ts, watchlist.ts) so they can be reused across the app. Add a hover-triggered plus button on explore page title cards that lets users add titles to their watchlist without navigating away. Co-Authored-By: Claude Opus 4.6 --- app/(pages)/explore/genre-browser.tsx | 1 + app/(pages)/explore/title-row.tsx | 1 + .../_components/backup-schedule-section.tsx | 4 +- .../settings/_components/backup-section.tsx | 2 +- .../_components/integrations-section.tsx | 2 +- .../settings/_components/server-section.tsx | 2 +- .../[id]/_components/use-title-actions.ts | 22 +++--- components/title-card.tsx | 78 +++++++++++++++++-- .../actions.ts => lib/actions/settings.ts | 0 .../actions.ts => lib/actions/titles.ts | 0 lib/actions/watchlist.ts | 39 ++++++++++ 11 files changed, 129 insertions(+), 22 deletions(-) rename app/(pages)/settings/_components/actions.ts => lib/actions/settings.ts (100%) rename app/(pages)/titles/[id]/_components/actions.ts => lib/actions/titles.ts (100%) create mode 100644 lib/actions/watchlist.ts diff --git a/app/(pages)/explore/genre-browser.tsx b/app/(pages)/explore/genre-browser.tsx index 31dec2b..b8da92c 100644 --- a/app/(pages)/explore/genre-browser.tsx +++ b/app/(pages)/explore/genre-browser.tsx @@ -157,6 +157,7 @@ export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) { releaseDate={r.releaseDate} voteAverage={r.voteAverage} href={`/titles/tmdb-${r.tmdbId}-${r.type}`} + showQuickAdd /> ))} diff --git a/app/(pages)/explore/title-row.tsx b/app/(pages)/explore/title-row.tsx index 4d3b164..f58d1f9 100644 --- a/app/(pages)/explore/title-row.tsx +++ b/app/(pages)/explore/title-row.tsx @@ -73,6 +73,7 @@ export function TitleRow({ heading, icon, items }: TitleRowProps) { releaseDate={item.releaseDate} voteAverage={item.voteAverage} href={`/titles/tmdb-${item.tmdbId}-${item.type}`} + showQuickAdd /> diff --git a/app/(pages)/settings/_components/backup-schedule-section.tsx b/app/(pages)/settings/_components/backup-schedule-section.tsx index 80d48a4..96b7cd7 100644 --- a/app/(pages)/settings/_components/backup-schedule-section.tsx +++ b/app/(pages)/settings/_components/backup-schedule-section.tsx @@ -15,12 +15,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Switch } from "@/components/ui/switch"; -import type { BackupFrequency } from "@/lib/cron"; import { setBackupScheduleAction, setMaxBackupsAction, setScheduledBackupAction, -} from "./actions"; +} from "@/lib/actions/settings"; +import type { BackupFrequency } from "@/lib/cron"; const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [ { value: "6h", label: "6h" }, diff --git a/app/(pages)/settings/_components/backup-section.tsx b/app/(pages)/settings/_components/backup-section.tsx index d3f3e42..94075a5 100644 --- a/app/(pages)/settings/_components/backup-section.tsx +++ b/app/(pages)/settings/_components/backup-section.tsx @@ -32,8 +32,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { createBackupAction, deleteBackupAction } from "@/lib/actions/settings"; import type { BackupInfo } from "@/lib/services/backup"; -import { createBackupAction, deleteBackupAction } from "./actions"; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; diff --git a/app/(pages)/settings/_components/integrations-section.tsx b/app/(pages)/settings/_components/integrations-section.tsx index 4e06678..b85671a 100644 --- a/app/(pages)/settings/_components/integrations-section.tsx +++ b/app/(pages)/settings/_components/integrations-section.tsx @@ -7,7 +7,7 @@ import { deleteWebhookConnection, regenerateWebhookToken, saveWebhookConnection, -} from "./actions"; +} from "@/lib/actions/settings"; import { WebhookCard, type WebhookConnection } from "./webhook-card"; export function IntegrationsSection({ diff --git a/app/(pages)/settings/_components/server-section.tsx b/app/(pages)/settings/_components/server-section.tsx index a38e14c..038719c 100644 --- a/app/(pages)/settings/_components/server-section.tsx +++ b/app/(pages)/settings/_components/server-section.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; -import { toggleRegistration } from "./actions"; +import { toggleRegistration } from "@/lib/actions/settings"; export function ServerSection({ initialRegistrationOpen, diff --git a/app/(pages)/titles/[id]/_components/use-title-actions.ts b/app/(pages)/titles/[id]/_components/use-title-actions.ts index 8d007c8..35b9883 100644 --- a/app/(pages)/titles/[id]/_components/use-title-actions.ts +++ b/app/(pages)/titles/[id]/_components/use-title-actions.ts @@ -3,16 +3,6 @@ import { useStore } from "jotai"; import { useCallback } from "react"; import { toast } from "sonner"; -import { - episodeWatchesAtom, - seasonsAtom, - titleIdAtom, - titleNameAtom, - userRatingAtom, - userStatusAtom, - watchingEpAtom, -} from "@/lib/atoms/title"; -import type { Season } from "@/lib/types/title"; import { batchWatchEpisodes, markAllWatchedAction, @@ -23,7 +13,17 @@ import { watchEpisode, watchMovie, watchSeason, -} from "./actions"; +} from "@/lib/actions/titles"; +import { + episodeWatchesAtom, + seasonsAtom, + titleIdAtom, + titleNameAtom, + userRatingAtom, + userStatusAtom, + watchingEpAtom, +} from "@/lib/atoms/title"; +import type { Season } from "@/lib/types/title"; export function useTitleActions() { const store = useStore(); diff --git a/components/title-card.tsx b/components/title-card.tsx index bd88dd1..b98da4d 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -1,9 +1,23 @@ "use client"; -import { IconDeviceTv, IconMovie, IconStarFilled } from "@tabler/icons-react"; +import { + IconCheck, + IconDeviceTv, + IconLoader, + IconMovie, + IconPlus, + IconStarFilled, +} from "@tabler/icons-react"; import { motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; +import { useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { quickAddToWatchlist } from "@/lib/actions/watchlist"; interface TitleCardProps { id?: string; @@ -15,10 +29,54 @@ interface TitleCardProps { voteAverage?: number | null; href?: string; onImport?: () => void; + showQuickAdd?: boolean; +} + +type QuickAddState = "idle" | "loading" | "added"; + +function QuickAddButton({ + tmdbId, + type, +}: { + tmdbId: number; + type: "movie" | "tv"; +}) { + const [state, setState] = useState("idle"); + + async function handleClick(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (state === "loading" || state === "added") return; + setState("loading"); + try { + await quickAddToWatchlist(tmdbId, type); + setState("added"); + } catch { + setState("idle"); + } + } + + return ( + + } + > + {state === "idle" && } + {state === "loading" && } + {state === "added" && } + + + {state === "added" ? "Added to Watchlist" : "Add to Watchlist"} + + + ); } export function TitleCard({ id, + tmdbId, type, title, posterPath, @@ -26,13 +84,14 @@ export function TitleCard({ voteAverage, href, onImport, + showQuickAdd, }: TitleCardProps) { const year = releaseDate?.slice(0, 4); const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv; - const content = ( + const cardInner = ( @@ -83,16 +142,23 @@ export function TitleCard({ ); if (href || id) { - return {content}; + return ( +
+ {showQuickAdd && ( + + )} + {cardInner} +
+ ); } if (onImport) { return ( ); } - return content; + return cardInner; } diff --git a/app/(pages)/settings/_components/actions.ts b/lib/actions/settings.ts similarity index 100% rename from app/(pages)/settings/_components/actions.ts rename to lib/actions/settings.ts diff --git a/app/(pages)/titles/[id]/_components/actions.ts b/lib/actions/titles.ts similarity index 100% rename from app/(pages)/titles/[id]/_components/actions.ts rename to lib/actions/titles.ts diff --git a/lib/actions/watchlist.ts b/lib/actions/watchlist.ts new file mode 100644 index 0000000..b147e74 --- /dev/null +++ b/lib/actions/watchlist.ts @@ -0,0 +1,39 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db } from "@/lib/db/client"; +import { userTitleStatus } from "@/lib/db/schema"; +import { importTitle } from "@/lib/services/metadata"; +import { setTitleStatus } from "@/lib/services/tracking"; + +export async function quickAddToWatchlist( + tmdbId: number, + type: "movie" | "tv", +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error("Unauthorized"); + const userId = session.user.id; + + const title = await importTitle(tmdbId, type); + if (!title) throw new Error("Failed to import title"); + + const existing = db + .select() + .from(userTitleStatus) + .where( + and( + eq(userTitleStatus.userId, userId), + eq(userTitleStatus.titleId, title.id), + ), + ) + .get(); + + if (existing) { + return { success: true, titleId: title.id, alreadyAdded: true }; + } + + setTitleStatus(userId, title.id, "watchlist"); + return { success: true, titleId: title.id, alreadyAdded: false }; +}