feat: Bibliotheksseite, Papierkorb und manuelles Anlegen

Die Detailseite hängt jetzt an der internen Titel-ID statt an der TMDB-ID —
nur so bekommen manuell angelegte Titel dieselbe Seite. Treffer aus der Suche
laufen über /tmdb/<art>/<id>, das den Titel holt und weiterleitet.

Der Fortschritt einer ganzen Liste wird in einer Abfrage gezählt statt je
Eintrag; bei 300 Titeln wären das sonst 300 Rundreisen zur Datenbank.

Entfernte Titel wandern für 30 Tage in den Papierkorb und behalten ihren
Fortschritt.
This commit is contained in:
Florian Wilde
2026-09-01 19:24:51 +02:00
parent f613136e8a
commit f9ce2f7021
7 changed files with 557 additions and 220 deletions
+309
View File
@@ -0,0 +1,309 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { ensureTitle } from "@/lib/catalog";
import { getNextEpisode, getProgress, getWatchedEpisodeIds } from "@/lib/library";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { Backdrop, Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import {
ProviderList,
type ProviderBadgeData,
type ProviderKindKey,
} from "@/components/ProviderBadges";
import { SeasonList, type SeasonData } from "@/components/library/SeasonList";
import {
AddToLibrary,
EntryActions,
RatingPicker,
StatusPicker,
} from "@/components/library/EntryControls";
import { EntryNotes } from "@/components/library/EntryNotes";
import { NextEpisodeCard, WatchMovieButton } from "@/components/library/NextEpisode";
type Params = Promise<{ id: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const title = await prisma.title.findUnique({ where: { id }, select: { title: true } });
return { title: title?.title ?? "Titel" };
}
export default async function TitlePage({ params }: { params: Params }) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const title = await prisma.title.findUnique({
where: { id },
include: {
seasons: {
orderBy: { seasonNumber: "asc" },
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
},
providers: { where: { region: user.region }, include: { provider: true } },
entries: {
where: { userId: user.id },
include: { tags: { include: { tag: true } } },
},
},
});
if (!title) notFound();
// Im Hintergrund auffrischen, falls der Eintrag alt ist. Schlägt das fehl,
// bleibt der lokale Stand stehen — die Seite funktioniert trotzdem.
if (title.tmdbId) {
await ensureTitle(title.type, title.tmdbId, { region: user.region }).catch(() => undefined);
}
const entry = title.entries[0] ?? null;
const isSeries = title.type === "SERIES";
const [progress, next, watchedIds] = entry
? await Promise.all([
getProgress(user.id, entry.id),
isSeries ? getNextEpisode(user.id, entry.id) : Promise.resolve(null),
isSeries ? getWatchedEpisodeIds(user.id, entry.id) : Promise.resolve(new Set<string>()),
])
: [null, null, new Set<string>()];
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
for (const row of title.providers) {
(providerGroups[row.kind as ProviderKindKey] ??= []).push({
id: row.provider.id,
name: row.provider.name,
logoPath: row.provider.logoPath,
color: row.provider.color,
});
}
const suggestion = providerGroups.FLATRATE?.[0]?.name ?? providerGroups.FREE?.[0]?.name ?? null;
const seasons: SeasonData[] = title.seasons.map((season) => ({
id: season.id,
seasonNumber: season.seasonNumber,
name: season.name,
overview: season.overview,
airDate: season.airDate?.toISOString() ?? null,
episodes: season.episodes.map((episode) => ({
id: episode.id,
episodeNumber: episode.episodeNumber,
name: episode.name,
overview: episode.overview,
airDate: episode.airDate?.toISOString() ?? null,
runtime: episode.runtime,
})),
}));
const year = title.releaseDate?.getFullYear() ?? null;
const movieWatched = !isSeries && (progress?.watched ?? 0) > 0;
return (
<AppShell user={user} instanceName={settings.instanceName}>
<article>
<div className="fl-card relative overflow-hidden">
<Backdrop path={title.backdropPath} alt="" />
<div className="relative flex flex-col gap-6 p-5 sm:flex-row sm:p-7">
<div className="w-36 shrink-0 sm:w-48">
<Poster path={title.posterPath} alt={title.title} size="w500" priority />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{isSeries ? "Serie" : "Film"}
{year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""}
</p>
{entry && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{
color: STATUS_COLOR[entry.status as StatusKey],
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[entry.status as StatusKey]} 16%, transparent)`,
}}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
{title.title}
</h1>
{title.originalTitle && title.originalTitle !== title.title && (
<p className="mt-0.5 text-sm text-[var(--fl-faint)]">{title.originalTitle}</p>
)}
{title.tagline && (
<p className="mt-2 text-sm italic text-[var(--fl-muted)]">„{title.tagline}"</p>
)}
<dl className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
{isSeries && (
<Fact label="Umfang">
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
{title.episodeCount} Episoden
</Fact>
)}
{title.runtime ? (
<Fact label={isSeries ? "Pro Episode" : "Laufzeit"}>{title.runtime} min</Fact>
) : null}
{title.voteAverage ? <Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</Fact> : null}
{entry && progress ? (
<Fact label="Deine Zeit">{formatMinutes(progress.minutesWatched)}</Fact>
) : null}
</dl>
{title.genres.length > 0 && (
<ul className="mt-4 flex flex-wrap gap-1.5">
{title.genres.map((genre) => (
<li
key={genre}
className="rounded-full border border-[var(--fl-border)] px-2.5 py-0.5 text-xs text-[var(--fl-muted)]"
>
{genre}
</li>
))}
</ul>
)}
{title.overview && (
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--fl-muted)]">
{title.overview}
</p>
)}
<div className="mt-6">
{entry ? (
<div className="space-y-4">
{progress && (
<div className="max-w-md">
<ProgressBar
percent={progress.percent}
label={
isSeries
? `${progress.watched} von ${progress.total} Episoden gesehen — noch ${formatMinutes(progress.minutesLeft)}`
: movieWatched
? "Gesehen"
: `Noch ${formatMinutes(progress.minutesLeft)}`
}
showValue
/>
</div>
)}
{isSeries && next && <NextEpisodeCard entryId={entry.id} next={{ ...next, airDate: next.airDate?.toISOString() ?? null }} />}
{isSeries && !next && progress?.total ? (
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
Durchgeschaut — alle {progress.total} Episoden abgehakt.
</p>
) : null}
{!isSeries && <WatchMovieButton entryId={entry.id} watched={movieWatched} />}
<StatusPicker entryId={entry.id} status={entry.status as StatusKey} />
</div>
) : (
<AddToLibrary titleId={title.id} isSeries={isSeries} />
)}
</div>
</div>
</div>
</div>
{entry && (
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<section className="order-2 lg:order-1">
<h2 className="mb-3 text-lg font-semibold">
{isSeries ? "Staffeln" : "Wo läuft das?"}
</h2>
{isSeries ? (
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
) : (
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
</section>
<aside className="order-1 space-y-6 lg:order-2">
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Deine Bewertung
</h2>
<RatingPicker entryId={entry.id} rating={entry.rating} />
</div>
<div className="fl-card p-4">
<EntryNotes
entryId={entry.id}
notes={entry.notes}
whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion}
/>
</div>
{isSeries && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Wo läuft das?
</h2>
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
<div className="fl-card p-4">
<EntryActions
entryId={entry.id}
currentRun={entry.currentRun}
canRewatch={entry.status === "COMPLETED"}
/>
</div>
</aside>
</div>
)}
{!entry && (
<>
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Wo läuft das?</h2>
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
<p className="mt-3 text-xs text-[var(--fl-faint)]">
Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}.
</p>
</div>
</section>
{isSeries && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
<SeasonList seasons={seasons} />
</section>
)}
</>
)}
<p className="mt-8 text-sm">
<Link href="/bibliothek" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zur Bibliothek
</Link>
</p>
</article>
</AppShell>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 font-medium">{children}</dd>
</div>
);
}