feat: update media filter to support multiple series names and enhance library item fetching

This commit is contained in:
2026-03-12 03:12:59 +01:00
parent bf07a65dcd
commit f028b1be98
10 changed files with 173 additions and 93 deletions

View File

@@ -38,7 +38,7 @@ const mediaFilterSchema = z.object({
min_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
max_duration_secs: z.number().min(0, "Must be ≥ 0").nullable().optional(),
collections: z.array(z.string()),
series_name: z.string().nullable().optional(),
series_names: z.array(z.string()),
search_term: z.string().nullable().optional(),
});
@@ -199,7 +199,7 @@ function defaultFilter(): MediaFilter {
min_duration_secs: null,
max_duration_secs: null,
collections: [],
series_name: null,
series_names: [],
search_term: null,
};
}
@@ -252,8 +252,8 @@ function AlgorithmicFilterEditor({
onChange={(v) =>
setFilter({
content_type: v === "" ? null : (v as ContentType),
// clear series name if switching away from episode
series_name: v !== "episode" ? null : content.filter.series_name,
// clear series names if switching away from episode
series_names: v !== "episode" ? [] : content.filter.series_names,
})
}
>
@@ -282,12 +282,12 @@ function AlgorithmicFilterEditor({
hint={
content.strategy === "sequential"
? "Episodes will play in chronological order"
: "Filter to one show, or leave empty for all"
: "Filter to specific shows, or leave empty for all"
}
>
<SeriesPicker
value={content.filter.series_name ?? null}
onChange={(v) => setFilter({ series_name: v })}
values={content.filter.series_names ?? []}
onChange={(v) => setFilter({ series_names: v })}
series={series ?? []}
isLoading={loadingSeries}
/>

View File

@@ -5,92 +5,100 @@ import { X } from "lucide-react";
import type { SeriesResponse } from "@/lib/types";
interface SeriesPickerProps {
value: string | null;
onChange: (v: string | null) => void;
values: string[];
onChange: (v: string[]) => void;
series: SeriesResponse[];
isLoading?: boolean;
}
export function SeriesPicker({ value, onChange, series, isLoading }: SeriesPickerProps) {
export function SeriesPicker({ values, 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 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(name);
onChange([...values, name]);
setSearch("");
setOpen(false);
inputRef.current?.focus();
};
const handleClear = () => {
onChange(null);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
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);
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}>
<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"
onMouseDown={() => handleSelect(s.name)}
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-zinc-800"
onClick={() => handleRemove(name)}
className="ml-0.5 text-zinc-500 hover:text-zinc-300"
aria-label={`Remove ${name}`}
>
<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>
<X className="size-3" />
</button>
</li>
</span>
))}
</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>
)}
{/* 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>
);
}