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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 21:44:23 -05:00
co-authored by Claude Opus 4.6
parent 7a64ced5e0
commit bcf9a3c751
11 changed files with 129 additions and 22 deletions
+1
View File
@@ -157,6 +157,7 @@ export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) {
releaseDate={r.releaseDate}
voteAverage={r.voteAverage}
href={`/titles/tmdb-${r.tmdbId}-${r.type}`}
showQuickAdd
/>
</motion.div>
))}
+1
View File
@@ -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
/>
</motion.div>
</CarouselItem>
-214
View File
@@ -1,214 +0,0 @@
"use server";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { type BackupFrequency, rescheduleBackup } from "@/lib/cron";
import { db } from "@/lib/db/client";
import { webhookConnections } from "@/lib/db/schema";
import {
type BackupInfo,
createBackup,
deleteBackup,
listBackups,
} from "@/lib/services/backup";
import { getSetting, setSetting } from "@/lib/services/settings";
async function getSession() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
return session;
}
async function getAdminSession() {
const session = await getSession();
if (session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
// --- Webhook actions ---
export async function saveWebhookConnection(
provider: "plex" | "jellyfin",
mediaServerUsername: string,
enabled?: boolean,
) {
const session = await getSession();
if (!["plex", "jellyfin"].includes(provider)) {
throw new Error("Invalid provider");
}
if (!mediaServerUsername?.trim()) {
throw new Error("Media server username is required");
}
const existing = db
.select()
.from(webhookConnections)
.where(
and(
eq(webhookConnections.userId, session.user.id),
eq(webhookConnections.provider, provider),
),
)
.get();
if (existing) {
const connection = db
.update(webhookConnections)
.set({
mediaServerUsername: mediaServerUsername.trim(),
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
})
.where(eq(webhookConnections.id, existing.id))
.returning()
.get();
return {
...connection,
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
createdAt: connection.createdAt.toISOString(),
};
}
const token = Buffer.from(
crypto.getRandomValues(new Uint8Array(32)),
).toString("hex");
const now = new Date();
const connection = db
.insert(webhookConnections)
.values({
userId: session.user.id,
provider,
token,
mediaServerUsername: mediaServerUsername.trim(),
enabled: true,
createdAt: now,
})
.returning()
.get();
return {
...connection,
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
createdAt: connection.createdAt.toISOString(),
};
}
export async function deleteWebhookConnection(provider: "plex" | "jellyfin") {
const session = await getSession();
if (!["plex", "jellyfin"].includes(provider)) {
throw new Error("Invalid provider");
}
db.delete(webhookConnections)
.where(
and(
eq(webhookConnections.userId, session.user.id),
eq(webhookConnections.provider, provider),
),
)
.run();
}
export async function regenerateWebhookToken(provider: "plex" | "jellyfin") {
const session = await getSession();
if (!["plex", "jellyfin"].includes(provider)) {
throw new Error("Invalid provider");
}
const newToken = Buffer.from(
crypto.getRandomValues(new Uint8Array(32)),
).toString("hex");
const connection = db
.update(webhookConnections)
.set({ token: newToken })
.where(
and(
eq(webhookConnections.userId, session.user.id),
eq(webhookConnections.provider, provider),
),
)
.returning()
.get();
if (!connection) {
throw new Error("Connection not found");
}
return {
...connection,
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
createdAt: connection.createdAt.toISOString(),
};
}
export async function toggleRegistration(open: boolean) {
await getAdminSession();
setSetting("registrationOpen", String(open));
}
// --- Backup actions ---
export async function createBackupAction(): Promise<BackupInfo> {
await getAdminSession();
return await createBackup();
}
export async function listBackupsAction(): Promise<BackupInfo[]> {
await getAdminSession();
return await listBackups();
}
export async function deleteBackupAction(filename: string): Promise<void> {
await getAdminSession();
await deleteBackup(filename);
}
export async function setScheduledBackupAction(
enabled: boolean,
): Promise<void> {
await getAdminSession();
setSetting("scheduledBackups", String(enabled));
}
export async function getScheduledBackupSettings(): Promise<{
enabled: boolean;
maxRetention: number;
}> {
await getAdminSession();
return {
enabled: getSetting("scheduledBackups") === "true",
maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10),
};
}
export async function setMaxBackupsAction(max: number): Promise<void> {
await getAdminSession();
if (max < 0 || (max > 30 && max !== 0))
throw new Error("Max backups must be between 1 and 30, or 0 for unlimited");
setSetting("maxBackupRetention", String(max));
}
const VALID_FREQUENCIES: BackupFrequency[] = ["6h", "12h", "1d", "7d"];
export async function setBackupScheduleAction(
frequency: BackupFrequency,
time: string,
dayOfWeek = 0,
): Promise<void> {
await getAdminSession();
if (!VALID_FREQUENCIES.includes(frequency))
throw new Error("Invalid frequency");
if (!/^\d{2}:\d{2}$/.test(time)) throw new Error("Invalid time format");
const [h, m] = time.split(":").map(Number);
if (h < 0 || h > 23 || m < 0 || m > 59) throw new Error("Invalid time value");
if (dayOfWeek < 0 || dayOfWeek > 6) throw new Error("Invalid day of week");
setSetting("backupScheduleFrequency", frequency);
setSetting("backupScheduleTime", time);
setSetting("backupScheduleDow", String(dayOfWeek));
rescheduleBackup();
}
@@ -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" },
@@ -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`;
@@ -7,7 +7,7 @@ import {
deleteWebhookConnection,
regenerateWebhookToken,
saveWebhookConnection,
} from "./actions";
} from "@/lib/actions/settings";
import { WebhookCard, type WebhookConnection } from "./webhook-card";
export function IntegrationsSection({
@@ -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,
@@ -1,85 +0,0 @@
"use server";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db } from "@/lib/db/client";
import { episodes } from "@/lib/db/schema";
import {
logEpisodeWatch,
logMovieWatch,
markAllEpisodesWatched,
rateTitleStars,
removeTitleStatus,
setTitleStatus,
unwatchEpisode,
unwatchSeason,
} from "@/lib/services/tracking";
async function getSessionUserId() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
return session.user.id;
}
export async function updateTitleStatus(
titleId: string,
status: "in_progress" | null,
) {
const userId = await getSessionUserId();
if (status === null) {
removeTitleStatus(userId, titleId);
} else {
setTitleStatus(userId, titleId, status);
}
}
export async function markAllWatchedAction(titleId: string) {
const userId = await getSessionUserId();
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");
rateTitleStars(userId, titleId, ratingStars);
}
export async function watchMovie(titleId: string) {
const userId = await getSessionUserId();
logMovieWatch(userId, titleId);
}
export async function watchEpisode(episodeId: string) {
const userId = await getSessionUserId();
logEpisodeWatch(userId, episodeId);
}
export async function unwatchEpisodeAction(episodeId: string) {
const userId = await getSessionUserId();
unwatchEpisode(userId, episodeId);
}
export async function watchSeason(seasonId: string) {
const userId = await getSessionUserId();
const seasonEps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
.all();
for (const ep of seasonEps) {
logEpisodeWatch(userId, ep.id);
}
}
export async function unwatchSeasonAction(seasonId: string) {
const userId = await getSessionUserId();
unwatchSeason(userId, seasonId);
}
export async function batchWatchEpisodes(episodeIds: string[]) {
const userId = await getSessionUserId();
for (const id of episodeIds) {
logEpisodeWatch(userId, id);
}
}
@@ -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();