Fix #3: Suche fühlt sich träge an — Rückmeldung beim Tippen #11
@@ -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,8 +57,10 @@ export default async function LibraryPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchPendingProvider>
|
||||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||||
|
|
||||||
|
<SearchResults>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="fl-card p-8 text-center">
|
<div className="fl-card p-8 text-center">
|
||||||
@@ -89,6 +92,8 @@ export default async function LibraryPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SearchResults>
|
||||||
|
</SearchPendingProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +55,7 @@ export default async function SearchPage({
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchPendingProvider>
|
||||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
<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)]">
|
||||||
@@ -63,11 +65,12 @@ export default async function SearchPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SearchResults>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{!env.tmdb.isConfigured ? (
|
{!env.tmdb.isConfigured ? (
|
||||||
<Notice tone="warning">
|
<Notice tone="warning">
|
||||||
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
|
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
|
||||||
<code>.env</code> ein und starte die Instanz neu.
|
der <code>.env</code> ein und starte die Instanz neu.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : query.length < 2 ? (
|
) : query.length < 2 ? (
|
||||||
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
||||||
@@ -75,6 +78,8 @@ export default async function SearchPage({
|
|||||||
<Results query={query} filter={filter} />
|
<Results query={query} filter={filter} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SearchResults>
|
||||||
|
</SearchPendingProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 { 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,6 +71,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
@@ -70,6 +80,8 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
aria-label="Bibliothek durchsuchen"
|
aria-label="Bibliothek durchsuchen"
|
||||||
className="fl-input"
|
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 })}>
|
||||||
|
|||||||
Reference in New Issue
Block a user