feat: Bedienelemente für Bibliothek und Episodenfortschritt
Server-Aktionen prüfen die Sitzung jeweils selbst — auf Angaben aus dem Formular ist beim Zugriffsschutz kein Verlass. Häkchen reagieren sofort und werden zurückgedreht, wenn der Server widerspricht: Abhaken soll sich anfühlen wie ein Schalter, nicht wie ein Formular. Jede Sichtungsaktion meldet sich unten kurz zurück und lässt sich von dort zurücknehmen — die Wirkung passiert weit weg vom Klick.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import {
|
||||
addToLibrary,
|
||||
LibraryError,
|
||||
markEpisodesWatched,
|
||||
markMovieWatched,
|
||||
markSeasonWatched,
|
||||
markUpTo,
|
||||
moveToTrash,
|
||||
purgeEntry,
|
||||
restoreFromTrash,
|
||||
setStatus,
|
||||
startRewatch,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkSeason,
|
||||
updateEntryDetails,
|
||||
watchNextEpisode,
|
||||
type WatchResult,
|
||||
} from "@/lib/library";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Server-Aktionen rund um die Bibliothek.
|
||||
*
|
||||
* Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist
|
||||
* beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit
|
||||
* das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann.
|
||||
*/
|
||||
|
||||
export type ActionResult = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
||||
watchIds?: string[];
|
||||
};
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
||||
revalidate: string[] = [],
|
||||
): Promise<ActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
for (const path of revalidate) revalidatePath(path, "layout");
|
||||
return { ok: true, message: result.message, watchIds: result.watchIds };
|
||||
} catch (error) {
|
||||
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
||||
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
|
||||
const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"];
|
||||
|
||||
const cuid = z.string().min(1).max(64);
|
||||
const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aufnehmen und entfernen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function addTitleAction(titleId: string, status: string = "WATCHLIST") {
|
||||
return run(async (userId) => {
|
||||
const parsedStatus = statusSchema.parse(status);
|
||||
await addToLibrary(userId, cuid.parse(titleId), parsedStatus);
|
||||
return {
|
||||
message:
|
||||
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
||||
};
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function trashEntryAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await moveToTrash(userId, cuid.parse(entryId));
|
||||
return { message: "In den Papierkorb gelegt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function restoreEntryAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await restoreFromTrash(userId, cuid.parse(entryId));
|
||||
return { message: "Wiederhergestellt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function purgeEntryAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await purgeEntry(userId, cuid.parse(entryId));
|
||||
return { message: "Endgültig gelöscht" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status und Angaben
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setStatusAction(entryId: string, status: string) {
|
||||
return run(async (userId) => {
|
||||
await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status));
|
||||
return { message: "Status geändert" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
const detailsSchema = z.object({
|
||||
rating: z.number().int().min(1).max(10).nullable().optional(),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function updateEntryAction(entryId: string, data: unknown) {
|
||||
return run(async (userId) => {
|
||||
await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data));
|
||||
return { message: "Gespeichert" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function startRewatchAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
const entry = await startRewatch(userId, cuid.parse(entryId));
|
||||
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sichtungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toResult(result: WatchResult) {
|
||||
return { message: result.label, watchIds: result.watchIds };
|
||||
}
|
||||
|
||||
export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) {
|
||||
return run(async (userId) => {
|
||||
if (watched) {
|
||||
return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)]));
|
||||
}
|
||||
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
|
||||
return run(async (userId) => {
|
||||
if (watched) {
|
||||
return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId)));
|
||||
}
|
||||
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
|
||||
return { message: "Staffel zurückgesetzt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function markUpToAction(entryId: string, episodeId: string) {
|
||||
return run(
|
||||
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
||||
PATHS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function watchNextAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
|
||||
}
|
||||
|
||||
export async function markMovieAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
||||
}
|
||||
|
||||
export async function undoAction(entryId: string, watchIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
||||
return { message: "Rückgängig gemacht" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setTagsAction(entryId: string, tagNames: string[]) {
|
||||
return run(async (userId) => {
|
||||
const id = cuid.parse(entryId);
|
||||
const names = z
|
||||
.array(z.string().trim().min(1).max(40))
|
||||
.max(20)
|
||||
.parse(tagNames)
|
||||
.map((name) => name.toLocaleLowerCase("de-DE"));
|
||||
|
||||
const entry = await prisma.libraryEntry.findFirst({
|
||||
where: { id, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
|
||||
|
||||
const tags = await Promise.all(
|
||||
names.map((name) =>
|
||||
prisma.tag.upsert({
|
||||
where: { userId_name: { userId, name } },
|
||||
create: { userId, name },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.tagOnEntry.deleteMany({
|
||||
where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } },
|
||||
}),
|
||||
...tags.map((tag) =>
|
||||
prisma.tagOnEntry.upsert({
|
||||
where: { tagId_entryId: { tagId: tag.id, entryId: id } },
|
||||
create: { tagId: tag.id, entryId: id },
|
||||
update: {},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return { message: "Tags gespeichert" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manuell angelegte Titel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const manualSchema = z.object({
|
||||
type: z.enum(["SERIES", "MOVIE"]),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
year: z.number().int().min(1880).max(2200).nullable().optional(),
|
||||
runtime: z.number().int().min(1).max(2000).nullable().optional(),
|
||||
overview: z.string().trim().max(4000).optional(),
|
||||
genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(),
|
||||
status: statusSchema.default("WATCHLIST"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Legt einen Titel ohne TMDB-Treffer an.
|
||||
*
|
||||
* Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale
|
||||
* Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf
|
||||
* daher nie angefasst.
|
||||
*/
|
||||
export async function createManualTitleAction(data: unknown): Promise<ActionResult & { titleId?: string }> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
const parsed = manualSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: "Bitte gib mindestens einen Titel an." };
|
||||
}
|
||||
|
||||
const { type, title, year, runtime, overview, genres, status } = parsed.data;
|
||||
|
||||
try {
|
||||
const created = await prisma.title.create({
|
||||
data: {
|
||||
type,
|
||||
title,
|
||||
overview: overview || null,
|
||||
runtime: runtime ?? null,
|
||||
releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null,
|
||||
genres: genres ?? [],
|
||||
status: "Manuell angelegt",
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await addToLibrary(user.id, created.id, status);
|
||||
for (const path of PATHS) revalidatePath(path, "layout");
|
||||
|
||||
return { ok: true, message: `„${title}" angelegt`, titleId: created.id };
|
||||
} catch (error) {
|
||||
console.error("Manuellen Titel anlegen fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user