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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user