105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { X } from "lucide-react";
|
|
import type { SeriesResponse } from "@/lib/types";
|
|
|
|
interface SeriesPickerProps {
|
|
values: string[];
|
|
onChange: (v: string[]) => void;
|
|
series: SeriesResponse[];
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
export function SeriesPicker({ values, onChange, series, isLoading }: SeriesPickerProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const filtered = series
|
|
.filter((s) => !values.includes(s.name))
|
|
.filter((s) => !search.trim() || s.name.toLowerCase().includes(search.toLowerCase()))
|
|
.slice(0, 40);
|
|
|
|
const handleSelect = (name: string) => {
|
|
onChange([...values, name]);
|
|
setSearch("");
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const handleRemove = (name: string) => {
|
|
onChange(values.filter((v) => v !== name));
|
|
};
|
|
|
|
// Delay blur so clicks inside the dropdown register before closing
|
|
const handleBlur = () => setTimeout(() => setOpen(false), 150);
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
{/* Selected chips */}
|
|
{values.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{values.map((name) => (
|
|
<span
|
|
key={name}
|
|
className="flex items-center gap-1 rounded-full border border-zinc-600 bg-zinc-700/60 px-2.5 py-0.5 text-xs text-zinc-200"
|
|
>
|
|
{name}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemove(name)}
|
|
className="ml-0.5 text-zinc-500 hover:text-zinc-300"
|
|
aria-label={`Remove ${name}`}
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search input */}
|
|
<div className="relative">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={search}
|
|
placeholder={isLoading ? "Loading series…" : values.length === 0 ? "Search series…" : "Add another series…"}
|
|
disabled={isLoading}
|
|
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={handleBlur}
|
|
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none disabled:opacity-50"
|
|
/>
|
|
|
|
{open && filtered.length > 0 && (
|
|
<ul className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-md border border-zinc-700 bg-zinc-900 py-1 shadow-xl">
|
|
{filtered.map((s) => (
|
|
<li key={s.id}>
|
|
<button
|
|
type="button"
|
|
onMouseDown={() => handleSelect(s.name)}
|
|
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-zinc-800"
|
|
>
|
|
<span className="truncate text-sm text-zinc-100">{s.name}</span>
|
|
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
|
|
{s.episode_count} ep{s.episode_count !== 1 ? "s" : ""}
|
|
{s.year ? ` · ${s.year}` : ""}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{open && !isLoading && series.length === 0 && (
|
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-500 shadow-xl">
|
|
No series found in library.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|