Fix #3: Suche fühlt sich träge an — Rückmeldung beim Tippen #11
+35
-30
@@ -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({
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||
<SearchPendingProvider>
|
||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||
|
||||
<div className="mt-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
{facets.counts.ALLE === 0
|
||||
? "Deine Bibliothek ist noch leer."
|
||||
: hasFilters
|
||||
? "Zu dieser Auswahl passt nichts."
|
||||
: "Hier ist nichts."}
|
||||
</p>
|
||||
{facets.counts.ALLE === 0 && (
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
|
||||
Ersten Titel suchen
|
||||
</Link>
|
||||
<SearchResults>
|
||||
<div className="mt-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
{facets.counts.ALLE === 0
|
||||
? "Deine Bibliothek ist noch leer."
|
||||
: hasFilters
|
||||
? "Zu dieser Auswahl passt nichts."
|
||||
: "Hier ist nichts."}
|
||||
</p>
|
||||
{facets.counts.ALLE === 0 && (
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</SearchResults>
|
||||
</SearchPendingProvider>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-19
@@ -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({
|
||||
</p>
|
||||
</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)]">
|
||||
Nichts dabei?{" "}
|
||||
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</p>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<span
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
|
||||
role="status"
|
||||
>
|
||||
sucht …
|
||||
</span>
|
||||
)}
|
||||
<SearchSpinner show={pending} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string | null>) {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="In der Bibliothek suchen …"
|
||||
aria-label="Bibliothek durchsuchen"
|
||||
className="fl-input"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="In der Bibliothek suchen …"
|
||||
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">
|
||||
<Chip active={!status} onClick={() => apply({ status: null })}>
|
||||
|
||||
Reference in New Issue
Block a user