From f9ce2f70217a069266315a6b1c113d933f45b5ed Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:24:51 +0200 Subject: [PATCH] feat: Bibliotheksseite, Papierkorb und manuelles Anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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. --- src/app/bibliothek/page.tsx | 93 ++++++++ src/app/papierkorb/page.tsx | 71 ++++++ src/app/suche/page.tsx | 9 +- src/app/titel/[art]/[tmdbId]/page.tsx | 219 ------------------ src/app/titel/[id]/page.tsx | 309 ++++++++++++++++++++++++++ src/app/titel/neu/page.tsx | 38 ++++ src/app/tmdb/[art]/[tmdbId]/page.tsx | 38 ++++ 7 files changed, 557 insertions(+), 220 deletions(-) create mode 100644 src/app/bibliothek/page.tsx create mode 100644 src/app/papierkorb/page.tsx delete mode 100644 src/app/titel/[art]/[tmdbId]/page.tsx create mode 100644 src/app/titel/[id]/page.tsx create mode 100644 src/app/titel/neu/page.tsx create mode 100644 src/app/tmdb/[art]/[tmdbId]/page.tsx diff --git a/src/app/bibliothek/page.tsx b/src/app/bibliothek/page.tsx new file mode 100644 index 0000000..2ef9acc --- /dev/null +++ b/src/app/bibliothek/page.tsx @@ -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>; +}) { + 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 ( + +
+
+

Bibliothek

+

+ {facets.counts.ALLE === 0 + ? "Noch nichts aufgenommen." + : `${facets.counts.ALLE} ${facets.counts.ALLE === 1 ? "Titel" : "Titel"} — davon ${facets.counts.WATCHING} gerade am Laufen.`} +

+
+ + Titel hinzufügen + +
+ + {facets.counts.ALLE > 0 && } + +
+ {items.length === 0 ? ( +
+

+ {facets.counts.ALLE === 0 + ? "Deine Bibliothek ist noch leer." + : hasFilters + ? "Zu dieser Auswahl passt nichts." + : "Hier ist nichts."} +

+ {facets.counts.ALLE === 0 && ( + + Ersten Titel suchen + + )} +
+ ) : ( + <> +

+ {items.length} {items.length === 1 ? "Titel" : "Titel"} +

+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ + )} +
+
+ ); +} diff --git a/src/app/papierkorb/page.tsx b/src/app/papierkorb/page.tsx new file mode 100644 index 0000000..d304569 --- /dev/null +++ b/src/app/papierkorb/page.tsx @@ -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 ( + +
+

Papierkorb

+

+ Entfernte Titel bleiben {RETENTION_DAYS} Tage liegen. Danach werden sie samt ihrer + Sichtungen endgültig gelöscht. +

+
+ + {entries.length === 0 ? ( +
+

Der Papierkorb ist leer.

+ + Zur Bibliothek + +
+ ) : ( +
    + {entries.map((entry) => { + const deletedAt = entry.deletedAt!; + const purgeAt = new Date(deletedAt.getTime() + RETENTION_DAYS * 86_400_000); + + return ( +
  • +
    + +
    + +
    +

    {entry.title.title}

    +

    + {TYPE_LABEL[entry.title.type]} + {entry.title.releaseDate ? ` · ${entry.title.releaseDate.getFullYear()}` : ""} + {" · entfernt "} + {formatRelative(deletedAt)} +

    +

    + Wird {formatRelative(purgeAt)} endgültig gelöscht +

    +
    + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/suche/page.tsx b/src/app/suche/page.tsx index c20c094..e434b37 100644 --- a/src/app/suche/page.tsx +++ b/src/app/suche/page.tsx @@ -56,6 +56,13 @@ export default async function SearchPage({ +

+ Nichts dabei?{" "} + + Titel selbst anlegen + +

+
{!env.tmdb.isConfigured ? ( @@ -107,7 +114,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) { {items.map((item) => (
  • diff --git a/src/app/titel/[art]/[tmdbId]/page.tsx b/src/app/titel/[art]/[tmdbId]/page.tsx deleted file mode 100644 index 7c2dffa..0000000 --- a/src/app/titel/[art]/[tmdbId]/page.tsx +++ /dev/null @@ -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 = { serie: "SERIES", film: "MOVIE" }; - -type Params = Promise<{ art: string; tmdbId: string }>; - -export async function generateMetadata({ params }: { params: Params }): Promise { - 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 ( - -
    -

    Titel nicht verfügbar

    -

    - {importError ?? "Zu dieser Kennung liegt nichts vor."} -

    - - Zurück zur Suche - -
    -
    - ); - } - - const year = title.releaseDate?.getFullYear() ?? null; - const providerGroups: Partial> = {}; - 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 ( - -
    -
    - - -
    -
    - -
    - -
    -

    - {title.type === "SERIES" ? "Serie" : "Film"} - {year ? ` · ${year}` : ""} - {title.status ? ` · ${title.status}` : ""} -

    - -

    - {title.title} -

    - {title.originalTitle && title.originalTitle !== title.title && ( -

    {title.originalTitle}

    - )} - {title.tagline && ( -

    „{title.tagline}"

    - )} - -
    - {title.type === "SERIES" ? ( - - {title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "} - {title.episodeCount} Episoden - - ) : null} - {title.runtime ? ( - - {title.runtime} min - - ) : null} - {title.voteAverage ? ( - {title.voteAverage.toFixed(1)} / 10 - ) : null} -
    - - {title.genres.length > 0 && ( -
      - {title.genres.map((genre) => ( -
    • - {genre} -
    • - ))} -
    - )} - - {title.overview && ( -

    - {title.overview} -

    - )} - -

    - Die Bibliothek kommt in der nächsten Etappe — dann lässt sich der Titel von hier - aus aufnehmen und der Fortschritt abhaken. -

    -
    -
    -
    - -
    -

    Wo läuft das?

    -
    - -

    - Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}. -

    -
    -
    - - {title.type === "SERIES" && ( -
    -

    Staffeln

    - -
    - )} - - {importError && ( -

    - Hinweis: Die Daten stammen aus dem lokalen Katalog — die Auffrischung bei TMDB ist - fehlgeschlagen ({importError}). -

    - )} -
    -
    - ); -} - -function Fact({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
    -
    {label}
    -
    {children}
    -
    - ); -} diff --git a/src/app/titel/[id]/page.tsx b/src/app/titel/[id]/page.tsx new file mode 100644 index 0000000..c9c6bb5 --- /dev/null +++ b/src/app/titel/[id]/page.tsx @@ -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 { + 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()), + ]) + : [null, null, new Set()]; + + const providerGroups: Partial> = {}; + 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 ( + +
    +
    + + +
    +
    + +
    + +
    +
    +

    + {isSeries ? "Serie" : "Film"} + {year ? ` · ${year}` : ""} + {title.status ? ` · ${title.status}` : ""} +

    + {entry && ( + + {STATUS_LABEL[entry.status as StatusKey]} + + )} +
    + +

    + {title.title} +

    + {title.originalTitle && title.originalTitle !== title.title && ( +

    {title.originalTitle}

    + )} + {title.tagline && ( +

    „{title.tagline}"

    + )} + +
    + {isSeries && ( + + {title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "} + {title.episodeCount} Episoden + + )} + {title.runtime ? ( + {title.runtime} min + ) : null} + {title.voteAverage ? {title.voteAverage.toFixed(1)} / 10 : null} + {entry && progress ? ( + {formatMinutes(progress.minutesWatched)} + ) : null} +
    + + {title.genres.length > 0 && ( +
      + {title.genres.map((genre) => ( +
    • + {genre} +
    • + ))} +
    + )} + + {title.overview && ( +

    + {title.overview} +

    + )} + +
    + {entry ? ( +
    + {progress && ( +
    + +
    + )} + + {isSeries && next && } + {isSeries && !next && progress?.total ? ( +

    + Durchgeschaut — alle {progress.total} Episoden abgehakt. +

    + ) : null} + {!isSeries && } + + +
    + ) : ( + + )} +
    +
    +
    +
    + + {entry && ( +
    +
    +

    + {isSeries ? "Staffeln" : "Wo läuft das?"} +

    + {isSeries ? ( + + ) : ( +
    + +
    + )} +
    + + +
    + )} + + {!entry && ( + <> +
    +

    Wo läuft das?

    +
    + +

    + Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}. +

    +
    +
    + + {isSeries && ( +
    +

    Staffeln

    + +
    + )} + + )} + +

    + + ← Zur Bibliothek + +

    +
    +
    + ); +} + +function Fact({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    +
    {label}
    +
    {children}
    +
    + ); +} diff --git a/src/app/titel/neu/page.tsx b/src/app/titel/neu/page.tsx new file mode 100644 index 0000000..3b0e4eb --- /dev/null +++ b/src/app/titel/neu/page.tsx @@ -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 ( + +
    +
    +

    Titel selbst anlegen

    +

    + Für alles, was TMDB nicht kennt. Solche Titel werden vom Abgleich nie angefasst — die + Angaben bleiben so, wie du sie einträgst. +

    +
    + +
    + +
    + +

    + Lieber doch bei TMDB nachsehen?{" "} + + Zur Suche + +

    +
    +
    + ); +} diff --git a/src/app/tmdb/[art]/[tmdbId]/page.tsx b/src/app/tmdb/[art]/[tmdbId]/page.tsx new file mode 100644 index 0000000..941c9f6 --- /dev/null +++ b/src/app/tmdb/[art]/[tmdbId]/page.tsx @@ -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 = { 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}`); +}