Die Suchanfrage steht in der URL: Treffer sind teilbar, der Zurück-Knopf funktioniert, und die Suche selbst bleibt eine Server-Komponente. Posterkacheln zeigen bei fehlendem Bild die Bildmarke über dem Signaturverlauf. Die Rückfalldarstellung liegt dauerhaft unter dem Bild — ein onError-Handler hilft nicht, wenn das Bild schon vor der Hydration scheitert, und der Browser zeigt dann sein kaputtes Bildsymbol.
119 lines
3.5 KiB
TypeScript
119 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, useTransition } from "react";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { clsx } from "clsx";
|
|
|
|
/**
|
|
* Suchfeld mit Typfilter.
|
|
*
|
|
* Die Anfrage steht in der URL — damit sind Treffer teilbar, der Zurück-Knopf
|
|
* funktioniert und die Suche selbst bleibt eine Server-Komponente. Getippte
|
|
* Eingaben werden kurz gesammelt, bevor die Adresse fortgeschrieben wird.
|
|
*/
|
|
export function SearchField({
|
|
initialQuery,
|
|
filter,
|
|
filters,
|
|
}: {
|
|
initialQuery: string;
|
|
filter: string;
|
|
filters: { key: string; label: string }[];
|
|
}) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const [value, setValue] = useState(initialQuery);
|
|
const [pending, startTransition] = useTransition();
|
|
const first = useRef(true);
|
|
|
|
useEffect(() => {
|
|
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
|
|
// gerade geladene URL durch eine identische.
|
|
if (first.current) {
|
|
first.current = false;
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
const params = new URLSearchParams();
|
|
if (value.trim()) params.set("q", value.trim());
|
|
if (filter !== "alle") params.set("typ", filter);
|
|
startTransition(() => {
|
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
});
|
|
}, 350);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [value, filter, pathname, router]);
|
|
|
|
function selectFilter(key: string) {
|
|
const params = new URLSearchParams();
|
|
if (value.trim()) params.set("q", value.trim());
|
|
if (key !== "alle") params.set("typ", key);
|
|
startTransition(() => {
|
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="relative">
|
|
<SearchIcon />
|
|
<input
|
|
type="search"
|
|
value={value}
|
|
onChange={(event) => setValue(event.target.value)}
|
|
placeholder="Serie oder Film suchen …"
|
|
aria-label="Suchbegriff"
|
|
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>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
|
|
{filters.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => selectFilter(item.key)}
|
|
aria-pressed={filter === item.key}
|
|
className={clsx(
|
|
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
|
|
filter === item.key
|
|
? "border-transparent text-[var(--fl-on-accent)]"
|
|
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
|
|
)}
|
|
style={filter === item.key ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SearchIcon() {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--fl-faint)]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
aria-hidden
|
|
>
|
|
<circle cx="11" cy="11" r="6.5" />
|
|
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|