feat(filters): pick one year from a decade grid, in the feed and in Plex

A shared YearPicker: the recent years as chips with their counts, and a
More dialog that lays every year out by decade (sortable by count), so a
ninety-year library fits on one screen. The feed's scrolling popover gives
way to it; the Plex rail gets it above its range fields, a pick setting
both ends. The Plex facets now count titles per year, the year filter left
out.
This commit is contained in:
2026-11-14 15:08:50 +01:00
parent 94f9ffdb1c
commit 1dff809268
10 changed files with 338 additions and 161 deletions
+160
View File
@@ -0,0 +1,160 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Chip } from "./Chip";
import CountBadge from "./CountBadge";
import Modal from "./Modal";
import SegmentedToggle from "./SegmentedToggle";
export interface YearCount {
year: number;
count: number;
}
// A filter for one calendar year, shared by the feed and Plex rails: chips for the most recent years
// and a "More" dialog with every year and its count. The dialog lays the years out by decade — a row
// per decade, a cell per year — so a library spanning ninety years fits on one screen without paging,
// and a decade reads at a glance; sorted by count it is one grid, the busiest year first. A year with
// no titles is a blank cell, not a choice. Picking the selected year again clears it. What a pick
// means (a whole-year date range, a min = max range) is the caller's: this only names the year.
const QUICK = 6; // recent-year chips before "More"
type Sort = "year" | "count";
export default function YearPicker({
years,
selected,
onPick,
}: {
/** Every year that has titles, with its count; any order. */
years: readonly YearCount[];
selected?: number;
onPick: (year?: number) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [sort, setSort] = useState<Sort>("year");
const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]);
// The selected year stays visible among the chips, even when it is older than the recent ones.
const quick = useMemo(() => {
const top = newestFirst.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [newestFirst, selected]);
const toggle = (year: number) => onPick(selected === year ? undefined : year);
const pickAndClose = (year: number) => {
toggle(year);
setOpen(false);
};
const cell = (y: YearCount) => {
const on = selected === y.year;
return (
<Chip
key={y.year}
pressed={on}
className="w-full"
onClick={() => pickAndClose(y.year)}
data-year={y.year}
>
<span className="tabular-nums">{y.year}</span>
<CountBadge count={y.count} active={on} />
</Chip>
);
};
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<Chip key={y.year} pressed={on} onClick={() => toggle(y.year)}>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</Chip>
);
})}
{years.length > quick.length && (
<Chip aria-haspopup="dialog" onClick={() => setOpen(true)}>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</Chip>
)}
{open && (
<Modal
title={t("sidebar.year.moreLabel")}
initialFocus="close"
onClose={() => setOpen(false)}
maxWidth="max-w-4xl"
>
<div className="mb-3 flex items-center">
<SegmentedToggle
className="ml-auto"
ariaLabel={t("sidebar.year.sortBy")}
label={t("sidebar.year.sortBy")}
value={sort}
onChange={setSort}
options={[
{ value: "year", label: t("sidebar.year.sortYear") },
{ value: "count", label: t("sidebar.year.sortCount") },
]}
/>
</div>
{sort === "year" ? (
<DecadeGrid years={newestFirst} cell={cell} />
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
{[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)}
</div>
)}
</Modal>
)}
</div>
);
}
/** One row per decade, newest first, with a cell per year of it; a year without titles is blank. */
function DecadeGrid({
years,
cell,
}: {
years: readonly YearCount[];
cell: (y: YearCount) => React.ReactNode;
}) {
const { t } = useTranslation();
const byYear = new Map(years.map((y) => [y.year, y]));
const decades = [...new Set(years.map((y) => y.year - (y.year % 10)))].sort((a, b) => b - a);
return (
<div className="flex flex-col gap-1.5">
{decades.map((from) => (
<div
key={from}
role="group"
aria-label={t("sidebar.year.decade", { from, to: from + 9 })}
className="grid grid-cols-[3rem_repeat(10,minmax(0,1fr))] items-center gap-1.5"
>
<span className="text-muted text-xs tabular-nums">{from}</span>
{Array.from({ length: 10 }, (_, i) => {
const y = byYear.get(from + i);
return y ? (
cell(y)
) : (
<span
key={from + i}
aria-hidden="true"
className="text-muted/50 text-center text-xs tabular-nums"
>
{from + i}
</span>
);
})}
</div>
))}
</div>
);
}