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} />
|
||||
|
||||
<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">
|
||||
{!env.tmdb.isConfigured ? (
|
||||
<Notice tone="warning">
|
||||
@@ -107,7 +114,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
||||
{items.map((item) => (
|
||||
<li key={`${item.type}-${item.tmdbId}`}>
|
||||
<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"
|
||||
>
|
||||
<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