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.
162 lines
5.4 KiB
TypeScript
162 lines
5.4 KiB
TypeScript
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 (
|
|
<AppShell user={user} instanceName={settings.instanceName}>
|
|
<header className="mb-6">
|
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Suchen</h1>
|
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
|
Serien und Filme aus der TMDB-Datenbank — mit Postern, Staffeln und Episoden.
|
|
</p>
|
|
</header>
|
|
|
|
<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">
|
|
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
|
|
<code>.env</code> ein und starte die Instanz neu.
|
|
</Notice>
|
|
) : query.length < 2 ? (
|
|
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
|
) : (
|
|
<Results query={query} filter={filter} />
|
|
)}
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
async function Results({ query, filter }: { query: string; filter: Filter }) {
|
|
let items: ReturnType<typeof normalize>[];
|
|
|
|
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 (
|
|
<Notice tone="danger">
|
|
{error instanceof Error ? error.message : "Die Suche bei TMDB ist fehlgeschlagen."}
|
|
</Notice>
|
|
);
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return <Notice>Zu „{query}" hat TMDB nichts gefunden.</Notice>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<p className="mb-4 text-sm text-[var(--fl-faint)]">
|
|
{items.length} {items.length === 1 ? "Treffer" : "Treffer"} zu „{query}"
|
|
</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.type}-${item.tmdbId}`}>
|
|
<Link
|
|
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" />
|
|
<div className="mt-2 space-y-0.5">
|
|
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
|
{item.title}
|
|
</p>
|
|
<p className="text-xs text-[var(--fl-faint)]">
|
|
{item.type === "tv" ? "Serie" : "Film"}
|
|
{item.year ? ` · ${item.year}` : ""}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<p
|
|
className="fl-card px-4 py-3 text-sm"
|
|
style={{
|
|
color,
|
|
borderColor:
|
|
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 45%, transparent)`,
|
|
backgroundColor:
|
|
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 10%, transparent)`,
|
|
}}
|
|
>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|