Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/series-picker.tsx
Gabriel Kaszewski bf07a65dcd feat(library): add media library browsing functionality
- Introduced new `library` module in the API routes to handle media library requests.
- Enhanced `AppState` to include a media provider for library interactions.
- Defined new `IMediaProvider` trait methods for listing collections, series, and genres.
- Implemented Jellyfin media provider methods for fetching collections and series.
- Added frontend components for selecting series and displaying filter previews.
- Created hooks for fetching collections, series, and genres from the library.
- Updated media filter to support series name and search term.
- Enhanced API client to handle new library-related endpoints.
2026-03-12 02:54:30 +01:00

97 lines
3.2 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { X } from "lucide-react";
import type { SeriesResponse } from "@/lib/types";
interface SeriesPickerProps {
value: string | null;
onChange: (v: string | null) => void;
series: SeriesResponse[];
isLoading?: boolean;
}
export function SeriesPicker({ value, onChange, series, isLoading }: SeriesPickerProps) {
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = search.trim()
? series.filter((s) => s.name.toLowerCase().includes(search.toLowerCase())).slice(0, 40)
: series.slice(0, 40);
const handleSelect = (name: string) => {
onChange(name);
setSearch("");
setOpen(false);
};
const handleClear = () => {
onChange(null);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
};
// Delay blur so clicks inside the dropdown register before closing
const handleBlur = () => setTimeout(() => setOpen(false), 150);
if (value) {
return (
<div className="flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2">
<span className="flex-1 truncate text-sm text-zinc-100">{value}</span>
<button
type="button"
onClick={handleClear}
className="shrink-0 text-zinc-500 hover:text-zinc-300"
aria-label="Clear series"
>
<X className="size-3.5" />
</button>
</div>
);
}
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={search}
placeholder={isLoading ? "Loading series…" : "Search 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>
);
}