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,93 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type LibrarySort } from "@/lib/queries";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { LibraryCard } from "@/components/library/LibraryCard";
|
||||||
|
import { LibraryFilters } from "@/components/library/LibraryFilters";
|
||||||
|
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Bibliothek" };
|
||||||
|
|
||||||
|
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"];
|
||||||
|
const SORTS = ["aktualisiert", "hinzugefuegt", "titel", "bewertung", "jahr"];
|
||||||
|
|
||||||
|
export default async function LibraryPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | undefined>>;
|
||||||
|
}) {
|
||||||
|
const user = await requireUser("/bibliothek");
|
||||||
|
const settings = await getSettings();
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
const filters: Filters = {
|
||||||
|
query: params.q?.trim() || undefined,
|
||||||
|
status: STATUSES.includes(params.status ?? "") ? (params.status as EntryStatus) : undefined,
|
||||||
|
type: params.typ === "SERIES" || params.typ === "MOVIE" ? (params.typ as TitleType) : undefined,
|
||||||
|
genre: params.genre || undefined,
|
||||||
|
tag: params.tag || undefined,
|
||||||
|
provider: params.dienst || undefined,
|
||||||
|
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, facets] = await Promise.all([
|
||||||
|
getLibrary(user.id, filters),
|
||||||
|
getLibraryFacets(user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasFilters = Object.values(filters).some(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell user={user} instanceName={settings.instanceName}>
|
||||||
|
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Bibliothek</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
{facets.counts.ALLE === 0
|
||||||
|
? "Noch nichts aufgenommen."
|
||||||
|
: `${facets.counts.ALLE} ${facets.counts.ALLE === 1 ? "Titel" : "Titel"} — davon ${facets.counts.WATCHING} gerade am Laufen.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||||
|
Titel hinzufügen
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="fl-card p-8 text-center">
|
||||||
|
<p className="text-[var(--fl-muted)]">
|
||||||
|
{facets.counts.ALLE === 0
|
||||||
|
? "Deine Bibliothek ist noch leer."
|
||||||
|
: hasFilters
|
||||||
|
? "Zu dieser Auswahl passt nichts."
|
||||||
|
: "Hier ist nichts."}
|
||||||
|
</p>
|
||||||
|
{facets.counts.ALLE === 0 && (
|
||||||
|
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
|
||||||
|
Ersten Titel suchen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm text-[var(--fl-faint)]">
|
||||||
|
{items.length} {items.length === 1 ? "Titel" : "Titel"}
|
||||||
|
</p>
|
||||||
|
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.entryId}>
|
||||||
|
<LibraryCard item={item} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { getTrash } from "@/lib/queries";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { Poster } from "@/components/Poster";
|
||||||
|
import { TrashActions } from "@/components/library/TrashActions";
|
||||||
|
import { formatRelative, TYPE_LABEL } from "@/lib/labels";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Papierkorb" };
|
||||||
|
|
||||||
|
/** Nach dieser Frist räumt der Hintergrundlauf endgültig auf. */
|
||||||
|
const RETENTION_DAYS = 30;
|
||||||
|
|
||||||
|
export default async function TrashPage() {
|
||||||
|
const user = await requireUser("/papierkorb");
|
||||||
|
const settings = await getSettings();
|
||||||
|
const entries = await getTrash(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell user={user} instanceName={settings.instanceName}>
|
||||||
|
<header className="mb-5">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Papierkorb</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Entfernte Titel bleiben {RETENTION_DAYS} Tage liegen. Danach werden sie samt ihrer
|
||||||
|
Sichtungen endgültig gelöscht.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="fl-card p-8 text-center">
|
||||||
|
<p className="text-[var(--fl-muted)]">Der Papierkorb ist leer.</p>
|
||||||
|
<Link href="/bibliothek" className="fl-btn fl-btn-ghost mt-4">
|
||||||
|
Zur Bibliothek
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const deletedAt = entry.deletedAt!;
|
||||||
|
const purgeAt = new Date(deletedAt.getTime() + RETENTION_DAYS * 86_400_000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={entry.id} className="fl-card flex flex-wrap items-center gap-4 p-3 sm:p-4">
|
||||||
|
<div className="w-16 shrink-0">
|
||||||
|
<Poster path={entry.title.posterPath} alt={entry.title.title} size="w154" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{entry.title.title}</p>
|
||||||
|
<p className="text-sm text-[var(--fl-faint)]">
|
||||||
|
{TYPE_LABEL[entry.title.type]}
|
||||||
|
{entry.title.releaseDate ? ` · ${entry.title.releaseDate.getFullYear()}` : ""}
|
||||||
|
{" · entfernt "}
|
||||||
|
{formatRelative(deletedAt)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--fl-warning)]">
|
||||||
|
Wird {formatRelative(purgeAt)} endgültig gelöscht
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrashActions entryId={entry.id} title={entry.title.title} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ export default async function SearchPage({
|
|||||||
|
|
||||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
||||||
|
Nichts dabei?{" "}
|
||||||
|
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
|
||||||
|
Titel selbst anlegen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{!env.tmdb.isConfigured ? (
|
{!env.tmdb.isConfigured ? (
|
||||||
<Notice tone="warning">
|
<Notice tone="warning">
|
||||||
@@ -107,7 +114,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={`${item.type}-${item.tmdbId}`}>
|
<li key={`${item.type}-${item.tmdbId}`}>
|
||||||
<Link
|
<Link
|
||||||
href={`/titel/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
|
href={`/tmdb/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
|
||||||
className="fl-poster-card group block focus:outline-none"
|
className="fl-poster-card group block focus:outline-none"
|
||||||
>
|
>
|
||||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
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 { TmdbError, TmdbNotConfiguredError } from "@/lib/tmdb";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
|
||||||
import { Backdrop, Poster } from "@/components/Poster";
|
|
||||||
import { ProviderList, type ProviderBadgeData, type ProviderKindKey } from "@/components/ProviderBadges";
|
|
||||||
import { SeasonList, type SeasonData } from "@/components/SeasonList";
|
|
||||||
import type { TitleType } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
/** URL-Segment auf den Titeltyp abbilden — /titel/serie/1396 bzw. /titel/film/550. */
|
|
||||||
const ART_TO_TYPE: Record<string, TitleType> = { serie: "SERIES", film: "MOVIE" };
|
|
||||||
|
|
||||||
type Params = Promise<{ art: string; tmdbId: string }>;
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
|
||||||
const { art, tmdbId } = await params;
|
|
||||||
const type = ART_TO_TYPE[art];
|
|
||||||
if (!type) return { title: "Nicht gefunden" };
|
|
||||||
|
|
||||||
const title = await prisma.title.findUnique({
|
|
||||||
where: { type_tmdbId: { type, tmdbId: Number(tmdbId) } },
|
|
||||||
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 { art, tmdbId: rawId } = await params;
|
|
||||||
const type = ART_TO_TYPE[art];
|
|
||||||
const tmdbId = Number.parseInt(rawId, 10);
|
|
||||||
if (!type || !Number.isFinite(tmdbId)) notFound();
|
|
||||||
|
|
||||||
let importError: string | null = null;
|
|
||||||
try {
|
|
||||||
await ensureTitle(type, tmdbId, { region: user.region });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof TmdbError && error.status === 404) notFound();
|
|
||||||
importError =
|
|
||||||
error instanceof TmdbNotConfiguredError
|
|
||||||
? error.message
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Der Titel konnte nicht von TMDB geladen werden.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = await prisma.title.findUnique({
|
|
||||||
where: { type_tmdbId: { type, tmdbId } },
|
|
||||||
include: {
|
|
||||||
seasons: {
|
|
||||||
orderBy: { seasonNumber: "asc" },
|
|
||||||
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
where: { region: user.region },
|
|
||||||
include: { provider: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return (
|
|
||||||
<AppShell user={user} instanceName={settings.instanceName}>
|
|
||||||
<div className="fl-card p-6">
|
|
||||||
<h1 className="text-xl font-semibold">Titel nicht verfügbar</h1>
|
|
||||||
<p className="mt-2 text-sm text-[var(--fl-muted)]">
|
|
||||||
{importError ?? "Zu dieser Kennung liegt nichts vor."}
|
|
||||||
</p>
|
|
||||||
<Link href="/suche" className="fl-btn fl-btn-ghost mt-4">
|
|
||||||
Zurück zur Suche
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = title.releaseDate?.getFullYear() ?? null;
|
|
||||||
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
|
|
||||||
for (const row of title.providers) {
|
|
||||||
const kind = row.kind as ProviderKindKey;
|
|
||||||
(providerGroups[kind] ??= []).push({
|
|
||||||
id: row.provider.id,
|
|
||||||
name: row.provider.name,
|
|
||||||
logoPath: row.provider.logoPath,
|
|
||||||
color: row.provider.color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell user={user} instanceName={settings.instanceName}>
|
|
||||||
<article className="relative">
|
|
||||||
<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">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
|
||||||
{title.type === "SERIES" ? "Serie" : "Film"}
|
|
||||||
{year ? ` · ${year}` : ""}
|
|
||||||
{title.status ? ` · ${title.status}` : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{title.type === "SERIES" ? (
|
|
||||||
<Fact label="Umfang">
|
|
||||||
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
|
|
||||||
{title.episodeCount} Episoden
|
|
||||||
</Fact>
|
|
||||||
) : null}
|
|
||||||
{title.runtime ? (
|
|
||||||
<Fact label={title.type === "SERIES" ? "Pro Episode" : "Laufzeit"}>
|
|
||||||
{title.runtime} min
|
|
||||||
</Fact>
|
|
||||||
) : null}
|
|
||||||
{title.voteAverage ? (
|
|
||||||
<Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-5 rounded-xl border border-dashed border-[var(--fl-border)] px-3 py-2 text-xs text-[var(--fl-faint)]">
|
|
||||||
Die Bibliothek kommt in der nächsten Etappe — dann lässt sich der Titel von hier
|
|
||||||
aus aufnehmen und der Fortschritt abhaken.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{title.type === "SERIES" && (
|
|
||||||
<section className="mt-6">
|
|
||||||
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
|
|
||||||
<SeasonList seasons={seasons} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importError && (
|
|
||||||
<p className="mt-6 text-xs text-[var(--fl-warning)]">
|
|
||||||
Hinweis: Die Daten stammen aus dem lokalen Katalog — die Auffrischung bei TMDB ist
|
|
||||||
fehlgeschlagen ({importError}).
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { ManualTitleForm } from "@/components/library/ManualTitleForm";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Titel anlegen" };
|
||||||
|
|
||||||
|
export default async function NewTitlePage() {
|
||||||
|
const user = await requireUser("/titel/neu");
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell user={user} instanceName={settings.instanceName}>
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Titel selbst anlegen</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Für alles, was TMDB nicht kennt. Solche Titel werden vom Abgleich nie angefasst — die
|
||||||
|
Angaben bleiben so, wie du sie einträgst.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="fl-card p-5 sm:p-6">
|
||||||
|
<ManualTitleForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-sm text-[var(--fl-faint)]">
|
||||||
|
Lieber doch bei TMDB nachsehen?{" "}
|
||||||
|
<Link href="/suche" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
|
||||||
|
Zur Suche
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { ensureTitle } from "@/lib/catalog";
|
||||||
|
import { TmdbError } from "@/lib/tmdb";
|
||||||
|
import type { TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einsprung von der Suche: holt den Titel bei TMDB und leitet auf die
|
||||||
|
* kanonische Detailseite weiter.
|
||||||
|
*
|
||||||
|
* Warum der Umweg: Die Detailseite hängt an der internen Titel-ID, damit auch
|
||||||
|
* manuell angelegte Titel ohne TMDB-Treffer dieselbe Seite bekommen. Vor dem
|
||||||
|
* ersten Import gibt es diese ID aber noch nicht.
|
||||||
|
*/
|
||||||
|
const ART_TO_TYPE: Record<string, TitleType> = { serie: "SERIES", film: "MOVIE" };
|
||||||
|
|
||||||
|
export default async function TmdbRedirectPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ art: string; tmdbId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const { art, tmdbId: raw } = await params;
|
||||||
|
|
||||||
|
const type = ART_TO_TYPE[art];
|
||||||
|
const tmdbId = Number.parseInt(raw, 10);
|
||||||
|
if (!type || !Number.isFinite(tmdbId)) notFound();
|
||||||
|
|
||||||
|
let title;
|
||||||
|
try {
|
||||||
|
title = await ensureTitle(type, tmdbId, { region: user.region });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TmdbError && error.status === 404) notFound();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/titel/${title.id}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user