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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user