CI / pruefen (pull_request) Canceled after 0s
Die Bibliotheksfilterleiste erzeugte das Signal laengst und warf es weg: `const [, startTransition] = useTransition()`. Waehrend der Suche blieben die alten Treffer unveraendert stehen — ohne jeden Hinweis, dass gearbeitet wird. Beide Suchen teilen sich jetzt eine schmale Klammer (SearchPending): die Filterleiste meldet den Ladezustand, die Trefferliste daneben — eine Server-Komponente — wird gedaempft und auf aria-busy gesetzt. Die alten Treffer bleiben sichtbar; sie verschwinden zu lassen liesse die Liste bei jedem Tastendruck springen. Der Hinweis „sucht …" steht jetzt auch in der Bibliothek, nicht nur unter /suche. Sammelverzoegerung in beiden auf 250 ms angeglichen (vorher 350 und 300). Zwei Vermutungen aus dem Issue haben sich beim Nachmessen NICHT bestaetigt und bleiben deshalb unangetastet: Die Facetten kosten nichts. Gemessen an einer eigens angelegten Bibliothek mit 2000 Eintraegen (Median aus 9 Laeufen): getLibrary 50 ms, getLibraryFacets 17 ms, beide parallel 61 ms. Ein Wegfall spaerte rund 12 ms — eine Zwischenspeicherung braechte dafuer das Risiko veralteter Zaehler. Langsame Bilder blockieren die Trefferanzeige nicht. Mit kuenstlich um 1500 ms verzoegerten /api/image-Antworten stehen die Treffer nach 465 ms im DOM, 891 ms vor den Bildern; die Rueckfallkacheln zeigen derweil die Titel. Ein Umbau des Bild-Endpunkts waere also Aufwand ohne Wirkung. Refs #3
167 lines
5.7 KiB
TypeScript
167 lines
5.7 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";
|
|
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
|
|
|
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>
|
|
|
|
<SearchPendingProvider>
|
|
<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>
|
|
|
|
<SearchResults>
|
|
<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>
|
|
</SearchResults>
|
|
</SearchPendingProvider>
|
|
</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 min-h-[2lh] 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>
|
|
);
|
|
}
|