fix(filters): the year dialog fits a tablet, walks by arrow keys, names one year once

- a decade cell is a 36-high box with its count under the year (owner A)
- arrow keys walk the cells: along a decade, then to the nearest year below
- clearing the kept older year hands the focus on instead of dropping it
- More... shows while a year with titles is hidden, as a plain chip
- the decade caption is not read twice; the Plex range fields have names
- one Plex year is labelled 1999, not 1999-1999, via oneYear/singleYear
- tests for the Plex wiring and the feed year clearing the upload date
This commit is contained in:
2026-11-14 17:56:37 +01:00
parent 02714e4986
commit 0eca18bcb4
20 changed files with 393 additions and 109 deletions
+54 -31
View File
@@ -6,7 +6,14 @@ import { vocab } from "../../lib/vocab";
import { plexLang } from "../../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
import {
api,
oneYear,
plexFilterCount,
singleYear,
type PlexFacets,
type PlexFilters,
} from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { useDebounced } from "../../lib/useDebounced";
import FilterRail from "../../components/ui/FilterRail";
@@ -173,36 +180,7 @@ export default function PlexSidebar() {
{
id: "year",
title: t("plex.filter.year"),
body: (
<div className="flex flex-col gap-2">
{/* One year is a range of one: picking it sets both ends, as the year on an info page does,
so the range fields below show it and a decade stays typeable there. */}
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
selected={
filters.yearMin != null && filters.yearMin === filters.yearMax
? filters.yearMin
: undefined
}
onPick={(year) => patch({ yearMin: year ?? null, yearMax: year ?? null })}
/>
)}
<div className="flex items-center gap-2">
<NumberInput
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</div>
),
body: <PlexYearFilter filters={filters} facets={facets} patch={patch} />,
},
...(scope === "movie"
? [
@@ -499,17 +477,62 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi
);
}
/** The rail's Year section: the year picker over the range fields. One year is a range of one:
* picking it sets both ends, as the year on an info page does, so the range fields show it and a
* decade stays typeable there. */
export function PlexYearFilter({
filters,
facets,
patch,
}: {
filters: PlexFilters;
facets: PlexFacets | undefined;
patch: (p: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
selected={singleYear(filters)}
onPick={(year) => patch(oneYear(year))}
/>
)}
<div className="flex items-center gap-2">
<NumberInput
label={t("plex.filter.yearFrom")}
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
label={t("plex.filter.yearTo")}
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</div>
);
}
function NumberInput({
label,
value,
placeholder,
onChange,
}: {
/** Its accessible name: the placeholder (a year) is not one. */
label: string;
value: number | null;
placeholder?: number;
onChange: (v: number | null) => void;
}) {
return (
<Input
aria-label={label}
className="w-20 tabular-nums"
size="sm"
type="number"