feat: Suchseite und Titel-Detailseite
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.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user