From d04248bec9b9d3946717b89c34ca744d5665547a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Wed, 2 Sep 2026 00:23:04 +0200 Subject: [PATCH] Rueckmeldung beim Tippen in beiden Suchen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/bibliothek/page.tsx | 65 ++++++++++++---------- src/app/suche/page.tsx | 43 +++++++------- src/components/SearchField.tsx | 17 +++--- src/components/SearchPending.tsx | 68 +++++++++++++++++++++++ src/components/library/LibraryFilters.tsx | 32 +++++++---- 5 files changed, 157 insertions(+), 68 deletions(-) create mode 100644 src/components/SearchPending.tsx diff --git a/src/app/bibliothek/page.tsx b/src/app/bibliothek/page.tsx index 1b70e0f..5438072 100644 --- a/src/app/bibliothek/page.tsx +++ b/src/app/bibliothek/page.tsx @@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr import { AppShell } from "@/components/AppShell"; import { LibraryCard } from "@/components/library/LibraryCard"; import { LibraryFilters } from "@/components/library/LibraryFilters"; +import { SearchPendingProvider, SearchResults } from "@/components/SearchPending"; import type { EntryStatus, TitleType } from "@/generated/prisma/client"; export const metadata: Metadata = { title: "Bibliothek" }; @@ -56,39 +57,43 @@ export default async function LibraryPage({ - {facets.counts.ALLE > 0 && } + + {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 === 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) => ( +
  • + +
  • + ))} +
+ )}
- ) : ( - <> -

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

-
    - {items.map((item) => ( -
  • - -
  • - ))} -
- - )} -
+ + ); } diff --git a/src/app/suche/page.tsx b/src/app/suche/page.tsx index a1cef24..fa09fb9 100644 --- a/src/app/suche/page.tsx +++ b/src/app/suche/page.tsx @@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from " 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" }; @@ -54,27 +55,31 @@ export default async function SearchPage({

- + + -

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

+

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

-
- {!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. - ) : ( - - )} -
+ +
+ {!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. + ) : ( + + )} +
+
+
); } diff --git a/src/components/SearchField.tsx b/src/components/SearchField.tsx index fa01707..27e91b8 100644 --- a/src/components/SearchField.tsx +++ b/src/components/SearchField.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useTransition } from "react"; import { usePathname, useRouter } from "next/navigation"; import { clsx } from "clsx"; +import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending"; /** * Suchfeld mit Typfilter. @@ -26,6 +27,11 @@ export function SearchField({ const [pending, startTransition] = useTransition(); const first = useRef(true); + const reportPending = useReportSearchPending(); + useEffect(() => { + reportPending(pending); + }, [pending, reportPending]); + useEffect(() => { // Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die // gerade geladene URL durch eine identische. @@ -41,7 +47,7 @@ export function SearchField({ startTransition(() => { router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }); - }, 350); + }, 250); return () => clearTimeout(timer); }, [value, filter, pathname, router]); @@ -68,14 +74,7 @@ export function SearchField({ autoFocus className="fl-input pl-10 text-base" /> - {pending && ( - - sucht … - - )} +
diff --git a/src/components/SearchPending.tsx b/src/components/SearchPending.tsx new file mode 100644 index 0000000..5e88839 --- /dev/null +++ b/src/components/SearchPending.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; +import { clsx } from "clsx"; + +/** + * Rückmeldung beim Tippen — geteilt von der Suche und der Bibliothek. + * + * Beide Filterleisten wissen über `useTransition()` längst, dass eine neue + * Antwort unterwegs ist. Die Trefferliste weiß es nicht: sie ist eine + * Server-Komponente und steht als `children` daneben. Diese schmale Klammer + * reicht das eine Bit vom Filter zur Liste durch. + * + * Bewusst zwei Kontexte: der Setter behält so über alle Renderdurchgänge + * dieselbe Identität und taugt als Abhängigkeit eines Effekts. + */ + +const PendingContext = createContext(false); +const SetPendingContext = createContext<(value: boolean) => void>(() => {}); + +export function SearchPendingProvider({ children }: { children: ReactNode }) { + const [pending, setPending] = useState(false); + return ( + + {children} + + ); +} + +/** Für die Filterleiste: meldet, ob gerade gesucht wird. */ +export function useReportSearchPending() { + return useContext(SetPendingContext); +} + +/** + * Dämpft die Treffer, solange die neue Antwort unterwegs ist. + * + * Die alten Treffer bleiben stehen — sie verschwinden zu lassen wäre ein + * Rückschritt, weil die Liste dann bei jedem Tastendruck springt. Gedämpft + * und nicht anklickbar ist aber sichtbar „nicht mehr aktuell". + */ +export function SearchResults({ children }: { children: ReactNode }) { + const pending = useContext(PendingContext); + return ( +
+ {children} +
+ ); +} + +/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */ +export function SearchSpinner({ show }: { show: boolean }) { + if (!show) return null; + return ( + + sucht … + + ); +} diff --git a/src/components/library/LibraryFilters.tsx b/src/components/library/LibraryFilters.tsx index 6d60ff7..48bb27a 100644 --- a/src/components/library/LibraryFilters.tsx +++ b/src/components/library/LibraryFilters.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { clsx } from "clsx"; import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels"; import type { LibraryFacets } from "@/lib/queries"; +import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending"; /** * Filterleiste der Bibliothek. @@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) { const router = useRouter(); const pathname = usePathname(); const params = useSearchParams(); - const [, startTransition] = useTransition(); + const [pending, startTransition] = useTransition(); const [query, setQuery] = useState(params.get("q") ?? ""); const first = useRef(true); + // Der Trefferliste mitteilen, dass eine neue Antwort unterwegs ist — ohne + // das Signal blieben die alten Treffer stumm stehen und es sah aus, als + // passiere nichts. + const reportPending = useReportSearchPending(); + useEffect(() => { + reportPending(pending); + }, [pending, reportPending]); + function apply(changes: Record) { const next = new URLSearchParams(params.toString()); for (const [key, value] of Object.entries(changes)) { @@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) { first.current = false; return; } - const timer = setTimeout(() => apply({ q: query.trim() || null }), 300); + const timer = setTimeout(() => apply({ q: query.trim() || null }), 250); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]); @@ -62,14 +71,17 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) { return (
- setQuery(event.target.value)} - placeholder="In der Bibliothek suchen …" - aria-label="Bibliothek durchsuchen" - className="fl-input" - /> +
+ setQuery(event.target.value)} + placeholder="In der Bibliothek suchen …" + aria-label="Bibliothek durchsuchen" + className="fl-input" + /> + +
apply({ status: null })}>