From e11cb6abefd01d168612bb2cc44e31fb10a0ca8f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:00:13 +0200 Subject: [PATCH] feat: Suchseite und Titel-Detailseite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Suchanfrage steht in der URL: Treffer sind teilbar, der Zurück-Knopf funktioniert, und die Suche selbst bleibt eine Server-Komponente. Posterkacheln zeigen bei fehlendem Bild die Bildmarke über dem Signaturverlauf. Die Rückfalldarstellung liegt dauerhaft unter dem Bild — ein onError-Handler hilft nicht, wenn das Bild schon vor der Hydration scheitert, und der Browser zeigt dann sein kaputtes Bildsymbol. --- src/app/suche/page.tsx | 154 ++++++++++++++++++ src/app/titel/[art]/[tmdbId]/page.tsx | 219 ++++++++++++++++++++++++++ src/components/Poster.tsx | 97 ++++++++++++ src/components/ProviderBadges.tsx | 96 +++++++++++ src/components/SearchField.tsx | 118 ++++++++++++++ src/components/SeasonList.tsx | 137 ++++++++++++++++ 6 files changed, 821 insertions(+) create mode 100644 src/app/suche/page.tsx create mode 100644 src/app/titel/[art]/[tmdbId]/page.tsx create mode 100644 src/components/Poster.tsx create mode 100644 src/components/ProviderBadges.tsx create mode 100644 src/components/SearchField.tsx create mode 100644 src/components/SeasonList.tsx diff --git a/src/app/suche/page.tsx b/src/app/suche/page.tsx new file mode 100644 index 0000000..c20c094 --- /dev/null +++ b/src/app/suche/page.tsx @@ -0,0 +1,154 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { getSettings } from "@/lib/settings"; +import { env } from "@/lib/env"; +import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "@/lib/tmdb"; +import { AppShell } from "@/components/AppShell"; +import { Poster } from "@/components/Poster"; +import { SearchField } from "@/components/SearchField"; + +export const metadata: Metadata = { title: "Suchen" }; + +type Filter = "alle" | "serien" | "filme"; + +const FILTERS: { key: Filter; label: string }[] = [ + { key: "alle", label: "Alles" }, + { key: "serien", label: "Serien" }, + { key: "filme", label: "Filme" }, +]; + +/** Normalisiert die drei TMDB-Antwortformen auf eine gemeinsame Darstellung. */ +function normalize(item: TmdbSearchResult, fallbackType: "tv" | "movie") { + const type = item.media_type === "tv" || item.media_type === "movie" ? item.media_type : fallbackType; + const date = type === "tv" ? item.first_air_date : item.release_date; + return { + tmdbId: item.id, + type, + title: (type === "tv" ? item.name : item.title) ?? item.original_name ?? item.original_title ?? "Ohne Titel", + overview: item.overview ?? "", + posterPath: item.poster_path ?? null, + year: date ? date.slice(0, 4) : null, + rating: item.vote_average ?? null, + }; +} + +export default async function SearchPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; typ?: string }>; +}) { + const user = await requireUser("/suche"); + const settings = await getSettings(); + + const params = await searchParams; + const query = (params.q ?? "").trim(); + const filter: Filter = FILTERS.some((f) => f.key === params.typ) ? (params.typ as Filter) : "alle"; + + return ( + +
+

Suchen

+

+ Serien und Filme aus der TMDB-Datenbank — mit Postern, Staffeln und Episoden. +

+
+ + + +
+ {!env.tmdb.isConfigured ? ( + + Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage TMDB_API_KEY in der{" "} + .env ein und starte die Instanz neu. + + ) : query.length < 2 ? ( + Gib mindestens zwei Zeichen ein, um zu suchen. + ) : ( + + )} +
+
+ ); +} + +async function Results({ query, filter }: { query: string; filter: Filter }) { + let items: ReturnType[]; + + try { + if (filter === "serien") { + const data = await searchSeries(query); + items = data.results.map((item) => normalize(item, "tv")); + } else if (filter === "filme") { + const data = await searchMovies(query); + items = data.results.map((item) => normalize(item, "movie")); + } else { + const data = await searchMulti(query); + items = data.results.map((item) => normalize(item, "movie")); + } + } catch (error) { + return ( + + {error instanceof Error ? error.message : "Die Suche bei TMDB ist fehlgeschlagen."} + + ); + } + + if (items.length === 0) { + return Zu „{query}" hat TMDB nichts gefunden.; + } + + return ( + <> +

+ {items.length} {items.length === 1 ? "Treffer" : "Treffer"} zu „{query}" +

+ + + ); +} + +function Notice({ + children, + tone = "neutral", +}: { + children: React.ReactNode; + tone?: "neutral" | "warning" | "danger"; +}) { + const color = + tone === "warning" ? "var(--fl-warning)" : tone === "danger" ? "var(--fl-danger)" : "var(--fl-muted)"; + return ( +

+ {children} +

+ ); +} diff --git a/src/app/titel/[art]/[tmdbId]/page.tsx b/src/app/titel/[art]/[tmdbId]/page.tsx new file mode 100644 index 0000000..7c2dffa --- /dev/null +++ b/src/app/titel/[art]/[tmdbId]/page.tsx @@ -0,0 +1,219 @@ +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/components/Poster.tsx b/src/components/Poster.tsx new file mode 100644 index 0000000..d4abad9 --- /dev/null +++ b/src/components/Poster.tsx @@ -0,0 +1,97 @@ +import { clsx } from "clsx"; +import { posterUrl, type ImageSize } from "@/lib/images"; +import { FlimmerMark } from "@/components/brand/FlimmerLogo"; + +/** + * Posterbild mit Rückfallebene. + * + * Nicht jeder Titel hat bei TMDB ein Poster, manuell angelegte schon gar + * nicht, und einzelne Pfade verschwinden mit der Zeit. + * + * Die Rückfallkachel liegt deshalb **dauerhaft darunter**, das Bild darüber: + * lädt es, verdeckt es die Kachel; lädt es nicht, bleibt die Kachel stehen. + * Bewusst ohne onError-Handler — ein Bild kann schon fehlschlagen, bevor React + * den Handler überhaupt angehängt hat. Das leere alt-Attribut sorgt dafür, + * dass ein kaputtes Bild in sich zusammenfällt statt ein Symbol zu zeigen; + * den Titel trägt der Container als aria-label. + */ +export function Poster({ + path, + alt, + size = "w342", + className, + sizes = "(min-width: 1024px) 12rem, 40vw", + priority = false, +}: { + path: string | null | undefined; + alt: string; + size?: ImageSize; + className?: string; + sizes?: string; + priority?: boolean; +}) { + const url = posterUrl(path, size); + + return ( +
+
+ + {alt} +
+ + {url && ( + // Bewusst statt next/image: die Bilder laufen bereits über den + // eigenen Cache-Endpunkt, eine zweite Optimierungsschicht brächte dem + // selbst gehosteten Server nur zusätzliche Last. + // eslint-disable-next-line @next/next/no-img-element + + )} +
+ ); +} + +/** Breitbild-Variante für Kopfbereiche (Backdrop). */ +export function Backdrop({ + path, + alt, + className, +}: { + path: string | null | undefined; + alt: string; + className?: string; +}) { + const url = posterUrl(path, "w1280"); + if (!url) return null; + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ ); +} diff --git a/src/components/ProviderBadges.tsx b/src/components/ProviderBadges.tsx new file mode 100644 index 0000000..6b7a991 --- /dev/null +++ b/src/components/ProviderBadges.tsx @@ -0,0 +1,96 @@ +import { posterUrl } from "@/lib/images"; + +/** + * Streamingdienste als Badges. + * + * Farbe und Logo kommen aus dem Katalog; Dienste ohne hinterlegte Akzentfarbe + * bekommen einen neutralen Ton, damit die Reihe trotzdem ruhig aussieht. + */ + +export type ProviderBadgeData = { + id: string; + name: string; + logoPath: string | null; + color: string | null; +}; + +const KIND_LABEL = { + FLATRATE: "Im Abo", + FREE: "Kostenlos", + ADS: "Mit Werbung", + RENT: "Leihen", + BUY: "Kaufen", +} as const; + +export type ProviderKindKey = keyof typeof KIND_LABEL; + +export function ProviderBadge({ provider }: { provider: ProviderBadgeData }) { + const logo = posterUrl(provider.logoPath, "w92"); + const accent = provider.color ?? "var(--fl-border-strong)"; + + return ( + + {/* Farbpunkt liegt darunter; ein Logo, das nicht lädt, fällt in sich + zusammen und gibt ihn wieder frei. */} + + + {logo && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + + {provider.name} + + ); +} + +/** Alle Verfügbarkeiten eines Titels, nach Art gruppiert. */ +export function ProviderList({ + groups, + region, +}: { + groups: Partial>; + region: string; +}) { + const order: ProviderKindKey[] = ["FLATRATE", "FREE", "ADS", "RENT", "BUY"]; + const present = order.filter((kind) => (groups[kind]?.length ?? 0) > 0); + + if (present.length === 0) { + return ( +

+ Für die Region {region} meldet TMDB derzeit keinen Streamingdienst. +

+ ); + } + + return ( +
+ {present.map((kind) => ( +
+ + {KIND_LABEL[kind]} + + {groups[kind]?.map((provider) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/src/components/SearchField.tsx b/src/components/SearchField.tsx new file mode 100644 index 0000000..fa01707 --- /dev/null +++ b/src/components/SearchField.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { clsx } from "clsx"; + +/** + * Suchfeld mit Typfilter. + * + * Die Anfrage steht in der URL — damit sind Treffer teilbar, der Zurück-Knopf + * funktioniert und die Suche selbst bleibt eine Server-Komponente. Getippte + * Eingaben werden kurz gesammelt, bevor die Adresse fortgeschrieben wird. + */ +export function SearchField({ + initialQuery, + filter, + filters, +}: { + initialQuery: string; + filter: string; + filters: { key: string; label: string }[]; +}) { + const router = useRouter(); + const pathname = usePathname(); + const [value, setValue] = useState(initialQuery); + const [pending, startTransition] = useTransition(); + const first = useRef(true); + + useEffect(() => { + // Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die + // gerade geladene URL durch eine identische. + if (first.current) { + first.current = false; + return; + } + + const timer = setTimeout(() => { + const params = new URLSearchParams(); + if (value.trim()) params.set("q", value.trim()); + if (filter !== "alle") params.set("typ", filter); + startTransition(() => { + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }, 350); + + return () => clearTimeout(timer); + }, [value, filter, pathname, router]); + + function selectFilter(key: string) { + const params = new URLSearchParams(); + if (value.trim()) params.set("q", value.trim()); + if (key !== "alle") params.set("typ", key); + startTransition(() => { + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + }); + } + + return ( +
+
+ + setValue(event.target.value)} + placeholder="Serie oder Film suchen …" + aria-label="Suchbegriff" + autoFocus + className="fl-input pl-10 text-base" + /> + {pending && ( + + sucht … + + )} +
+ +
+ {filters.map((item) => ( + + ))} +
+
+ ); +} + +function SearchIcon() { + return ( + + + + + ); +} diff --git a/src/components/SeasonList.tsx b/src/components/SeasonList.tsx new file mode 100644 index 0000000..f543ddf --- /dev/null +++ b/src/components/SeasonList.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { clsx } from "clsx"; + +/** + * Staffel- und Episodenliste. + * + * Staffeln sind eingeklappt, damit eine Serie mit zwanzig Staffeln die Seite + * nicht sprengt; die erste reguläre Staffel steht offen. In Etappe 3 kommen + * hier die Häkchen für den Fortschritt dazu. + */ + +export type EpisodeData = { + id: string; + episodeNumber: number; + name: string; + overview: string | null; + airDate: string | null; + runtime: number | null; +}; + +export type SeasonData = { + id: string; + seasonNumber: number; + name: string; + overview: string | null; + airDate: string | null; + episodes: EpisodeData[]; +}; + +function formatDate(value: string | null): string | null { + if (!value) return null; + return new Date(value).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +export function SeasonList({ seasons }: { seasons: SeasonData[] }) { + const firstRegular = seasons.find((season) => season.seasonNumber > 0) ?? seasons[0]; + const [open, setOpen] = useState(firstRegular ? [firstRegular.id] : []); + + function toggle(id: string) { + setOpen((current) => + current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], + ); + } + + if (seasons.length === 0) { + return

Zu dieser Serie liegen keine Staffeln vor.

; + } + + return ( +
+ {seasons.map((season) => { + const isOpen = open.includes(season.id); + const air = formatDate(season.airDate); + + return ( +
+

+ +

+ + {isOpen && ( +
    + {season.episodes.map((episode) => ( +
  1. + + S{String(season.seasonNumber).padStart(2, "0")}E + {String(episode.episodeNumber).padStart(2, "0")} + + + {episode.name} + {episode.overview && ( + + {episode.overview} + + )} + + + {formatDate(episode.airDate) ?? "—"} + {episode.runtime ? {episode.runtime} min : null} + +
  2. + ))} + {season.episodes.length === 0 && ( +
  3. + Für diese Staffel liegen noch keine Episoden vor. +
  4. + )} +
+ )} +
+ ); + })} +
+ ); +} + +function Chevron({ open }: { open: boolean }) { + return ( + + + + ); +}