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:
Florian Wilde
2026-09-01 19:24:51 +02:00
parent 4c3c878980
commit f613136e8a
13 changed files with 1866 additions and 1 deletions
+285
View File
@@ -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." };
}
}
+12
View File
@@ -332,6 +332,18 @@
animation: fl-glimmer 900ms var(--ease-glow);
}
/* Rückmeldungen fahren von unten herein — kurz und ohne Sprung. */
@keyframes fl-toast-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google";
import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts";
import { env } from "@/lib/env";
import "./globals.css";
@@ -73,7 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
>
Zum Inhalt springen
</a>
{children}
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);