Fix #3: Suche fühlt sich träge an — Rückmeldung beim Tippen #11

Merged
florian.wilde merged 1 commits from fix/3-suche-rueckmeldung into main 2026-09-02 04:33:28 +00:00
5 changed files with 157 additions and 68 deletions
+35 -30
View File
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { LibraryCard } from "@/components/library/LibraryCard"; import { LibraryCard } from "@/components/library/LibraryCard";
import { LibraryFilters } from "@/components/library/LibraryFilters"; import { LibraryFilters } from "@/components/library/LibraryFilters";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
import type { EntryStatus, TitleType } from "@/generated/prisma/client"; import type { EntryStatus, TitleType } from "@/generated/prisma/client";
export const metadata: Metadata = { title: "Bibliothek" }; export const metadata: Metadata = { title: "Bibliothek" };
@@ -56,39 +57,43 @@ export default async function LibraryPage({
</Link> </Link>
</header> </header>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />} <SearchPendingProvider>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<div className="mt-6"> <SearchResults>
{items.length === 0 ? ( <div className="mt-6">
<div className="fl-card p-8 text-center"> {items.length === 0 ? (
<p className="text-[var(--fl-muted)]"> <div className="fl-card p-8 text-center">
{facets.counts.ALLE === 0 <p className="text-[var(--fl-muted)]">
? "Deine Bibliothek ist noch leer." {facets.counts.ALLE === 0
: hasFilters ? "Deine Bibliothek ist noch leer."
? "Zu dieser Auswahl passt nichts." : hasFilters
: "Hier ist nichts."} ? "Zu dieser Auswahl passt nichts."
</p> : "Hier ist nichts."}
{facets.counts.ALLE === 0 && ( </p>
<Link href="/suche" className="fl-btn fl-btn-primary mt-4"> {facets.counts.ALLE === 0 && (
Ersten Titel suchen <Link href="/suche" className="fl-btn fl-btn-primary mt-4">
</Link> Ersten Titel suchen
</Link>
)}
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</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.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)} )}
</div> </div>
) : ( </SearchResults>
<> </SearchPendingProvider>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</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.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)}
</div>
</AppShell> </AppShell>
); );
} }
+24 -19
View File
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster"; import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField"; import { SearchField } from "@/components/SearchField";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
export const metadata: Metadata = { title: "Suchen" }; export const metadata: Metadata = { title: "Suchen" };
@@ -54,27 +55,31 @@ export default async function SearchPage({
</p> </p>
</header> </header>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} /> <SearchPendingProvider>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<p className="mt-3 text-sm text-[var(--fl-faint)]"> <p className="mt-3 text-sm text-[var(--fl-faint)]">
Nichts dabei?{" "} Nichts dabei?{" "}
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline"> <Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Titel selbst anlegen Titel selbst anlegen
</Link> </Link>
</p> </p>
<div className="mt-8"> <SearchResults>
{!env.tmdb.isConfigured ? ( <div className="mt-8">
<Notice tone="warning"> {!env.tmdb.isConfigured ? (
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "} <Notice tone="warning">
<code>.env</code> ein und starte die Instanz neu. Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
</Notice> der <code>.env</code> ein und starte die Instanz neu.
) : query.length < 2 ? ( </Notice>
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice> ) : query.length < 2 ? (
) : ( <Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
<Results query={query} filter={filter} /> ) : (
)} <Results query={query} filter={filter} />
</div> )}
</div>
</SearchResults>
</SearchPendingProvider>
</AppShell> </AppShell>
); );
} }
+8 -9
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/** /**
* Suchfeld mit Typfilter. * Suchfeld mit Typfilter.
@@ -26,6 +27,11 @@ export function SearchField({
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const first = useRef(true); const first = useRef(true);
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
useEffect(() => { useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die // Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische. // gerade geladene URL durch eine identische.
@@ -41,7 +47,7 @@ export function SearchField({
startTransition(() => { startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false }); router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}); });
}, 350); }, 250);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [value, filter, pathname, router]); }, [value, filter, pathname, router]);
@@ -68,14 +74,7 @@ export function SearchField({
autoFocus autoFocus
className="fl-input pl-10 text-base" className="fl-input pl-10 text-base"
/> />
{pending && ( <SearchSpinner show={pending} />
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
</div> </div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern"> <div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
+68
View File
@@ -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 (
<SetPendingContext.Provider value={setPending}>
<PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
</SetPendingContext.Provider>
);
}
/** 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 (
<div
aria-busy={pending}
className={clsx(
"transition-opacity duration-200 motion-reduce:transition-none",
pending && "pointer-events-none opacity-45",
)}
>
{children}
</div>
);
}
/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */
export function SearchSpinner({ show }: { show: boolean }) {
if (!show) return null;
return (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
);
}
+22 -10
View File
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels"; import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries"; import type { LibraryFacets } from "@/lib/queries";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/** /**
* Filterleiste der Bibliothek. * Filterleiste der Bibliothek.
@@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const params = useSearchParams(); const params = useSearchParams();
const [, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? ""); const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true); 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<string, string | null>) { function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString()); const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) { for (const [key, value] of Object.entries(changes)) {
@@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
first.current = false; first.current = false;
return; return;
} }
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300); const timer = setTimeout(() => apply({ q: query.trim() || null }), 250);
return () => clearTimeout(timer); return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]); }, [query]);
@@ -62,14 +71,17 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<input <div className="relative">
type="search" <input
value={query} type="search"
onChange={(event) => setQuery(event.target.value)} value={query}
placeholder="In der Bibliothek suchen …" onChange={(event) => setQuery(event.target.value)}
aria-label="Bibliothek durchsuchen" placeholder="In der Bibliothek suchen …"
className="fl-input" aria-label="Bibliothek durchsuchen"
/> className="fl-input"
/>
<SearchSpinner show={pending} />
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern"> <div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}> <Chip active={!status} onClick={() => apply({ status: null })}>