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
+4
View File
@@ -232,6 +232,8 @@ Also in force:
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces `FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2] it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch). - **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
One exception, the `cell` size: a cell of a grid of choices (the year dialog's decades) is a 36-high
rounded box with its count under the label, so ten fit across a dialog at a tablet's width.
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter `filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
chips too, and while their popover is open they show it as an open trigger does, the accent edge and chips too, and while their popover is open they show it as an open trigger does, the accent edge and
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
@@ -894,3 +896,5 @@ of a full-width panel and nothing on the right.
| 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB | | 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB |
| 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit | | 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit |
| 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere | | 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere |
| 2026-11-14 | One year filter for the feed and Plex (owner ★) | `ui/YearPicker`: the six newest years as chips (the selected one kept beside them), "More…" opens every year laid out by decade, no paging, sortable by count; Plex keeps its range fields under it, a year is a range of one | The feed's scrolling popover; Plex's range fields alone | A library spanning ninety years fits on one screen by decade, and both rails pick a year the same way |
| 2026-11-14 | The year dialog's cell (owner ★A) | The count under the year in a 36-high rounded box, compact ("17.2K"), one row per decade at every width | The year-and-count pill, five columns and two rows per decade on a narrow screen | The pill spilled out of its cell at a tablet's width (measured 74 px in 62); a decade on one row still reads at a glance |
+1 -1
View File
@@ -52,7 +52,7 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes - **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
and player rungs). Pick a rung; do not hand-roll padding on a primitive. and player rungs; the `cell` chip, 36, a grid of choices). Pick a rung; do not hand-roll padding on a primitive.
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale` - **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -9,6 +9,7 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => t: (k: string, o?: Record<string, unknown>) =>
o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k, o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k,
i18n: { language: "en" },
}), }),
}; };
}); });
@@ -80,4 +81,60 @@ describe("YearPicker", () => {
fireEvent.click(screen.getByRole("button", { pressed: true })); fireEvent.click(screen.getByRole("button", { pressed: true }));
expect(onPick).toHaveBeenLastCalledWith(undefined); expect(onPick).toHaveBeenLastCalledWith(undefined);
}); });
it("offers More… while a year with titles is not among the chips, the kept selection aside", () => {
// Seven years, and a selected year with no titles kept beside the six newest: 2013 is hidden.
render(<YearPicker years={YEARS.slice(0, 7)} selected={1999} onPick={() => {}} />);
expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy();
});
it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
expect(
screen.getByRole("button", { name: "sidebar.year.more" }).querySelector("svg"),
).toBeNull();
});
it("clearing the kept older year hands the focus on, not to <body>", () => {
const { rerender } = render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
const kept = screen.getByRole("button", { pressed: true });
kept.focus();
fireEvent.click(kept);
rerender(<YearPicker years={YEARS} onPick={() => {}} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "sidebar.year.more" }));
});
it("a decade row is named once: its caption is not read again", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const row = screen.getAllByRole("group", { name: /sidebar\.year\.decade/ })[0]!;
expect(row.firstElementChild?.getAttribute("aria-hidden")).toBe("true");
});
it("a cell carries its count under the year, compact", () => {
render(<YearPicker years={[...YEARS, { year: 2025, count: 17243 }]} onPick={() => {}} />);
open();
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[data-year="2025"]')!;
expect(cell.textContent).toBe("202517.2K");
});
it("the arrows walk the cells: along a decade, then down to the next one", () => {
render(<YearPicker years={YEARS} onPick={() => {}} />);
open();
const dialog = screen.getByRole("dialog");
dialog.querySelector<HTMLElement>('[data-year="2020"]')!.focus();
press("ArrowRight");
expect((document.activeElement as HTMLElement).dataset.year).toBe("2021");
press("ArrowDown");
expect((document.activeElement as HTMLElement).dataset.year).toMatch(/^201/);
});
}); });
/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */
function press(key: string): void {
act(() => {
(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
});
}
+78 -49
View File
@@ -1,10 +1,13 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Chip } from "./Chip"; import { Chip } from "./Chip";
import CountBadge from "./CountBadge"; import CountBadge from "./CountBadge";
import Modal from "./Modal"; import Modal from "./Modal";
import NavGrid from "./NavGrid";
import SegmentedToggle from "./SegmentedToggle"; import SegmentedToggle from "./SegmentedToggle";
import { formatCompact } from "../../lib/format";
import { navItemAttrs } from "../../lib/listNav";
import { handOffFocus } from "../../lib/useFocusKeeper";
export interface YearCount { export interface YearCount {
year: number; year: number;
@@ -12,13 +15,15 @@ export interface YearCount {
} }
// A filter for one calendar year, shared by the feed and Plex rails: chips for the most recent years // 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 // 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, // per decade, a cell per year, the count under the year so ten fit across at a tablet's width — and a
// and a decade reads at a glance; sorted by count it is one grid, the busiest year first. A year with // library spanning ninety years fits on one screen without paging; sorted by count it is one grid,
// no titles is a blank cell, not a choice. Picking the selected year again clears it. What a pick // the busiest year first. A year with no titles is a blank cell, not a choice. The arrows walk the
// means (a whole-year date range, a min = max range) is the caller's: this only names the year. // cells (lib/listNav): ←→ along a decade, ↑↓ to the nearest year of the next one. 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" const QUICK = 6; // recent-year chips before "More…"
type Sort = "year" | "count"; type Sort = "year" | "count";
export default function YearPicker({ export default function YearPicker({
@@ -31,20 +36,21 @@ export default function YearPicker({
selected?: number; selected?: number;
onPick: (year?: number) => void; onPick: (year?: number) => void;
}) { }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [sort, setSort] = useState<Sort>("year"); const [sort, setSort] = useState<Sort>("year");
const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]); const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]);
const recent = useMemo(() => newestFirst.slice(0, QUICK), [newestFirst]);
// The selected year stays visible among the chips, even when it is older than the recent ones. // The selected year stays visible among the chips, even when it is older than the recent ones.
const quick = useMemo(() => { const quick = useMemo(() => {
const top = newestFirst.slice(0, QUICK); if (selected == null || recent.some((y) => y.year === selected)) return recent;
if (selected != null && !top.some((y) => y.year === selected)) { const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 };
const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 }; return [...recent, sel].sort((a, b) => b.year - a.year);
return [...top, sel].sort((a, b) => b.year - a.year); }, [newestFirst, recent, selected]);
} // "More…" while a year with titles is not among the chips — the one kept for the selection counts
return top; // only if it is one of them.
}, [newestFirst, selected]); const more = years.some((y) => !quick.some((q) => q.year === y.year));
const toggle = (year: number) => onPick(selected === year ? undefined : year); const toggle = (year: number) => onPick(selected === year ? undefined : year);
const pickAndClose = (year: number) => { const pickAndClose = (year: number) => {
@@ -52,18 +58,22 @@ export default function YearPicker({
setOpen(false); setOpen(false);
}; };
const cell = (y: YearCount) => { const cell = (y: YearCount, index: number) => {
const on = selected === y.year; const on = selected === y.year;
return ( return (
<Chip <Chip
key={y.year} key={y.year}
size="cell"
pressed={on} pressed={on}
className="w-full" className="w-full"
onClick={() => pickAndClose(y.year)} onClick={() => pickAndClose(y.year)}
data-year={y.year} data-year={y.year}
{...navItemAttrs(index)}
> >
<span className="tabular-nums">{y.year}</span> <span className="tabular-nums">{y.year}</span>
<CountBadge count={y.count} active={on} /> <span className={on ? "text-2xs tabular-nums" : "text-muted text-2xs tabular-nums"}>
{formatCompact(y.count, i18n.language)}
</span>
</Chip> </Chip>
); );
}; };
@@ -72,17 +82,27 @@ export default function YearPicker({
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{quick.map((y) => { {quick.map((y) => {
const on = selected === y.year; const on = selected === y.year;
// The chip kept only for the selection leaves with it: from the keyboard, the focus goes to
// its neighbour first, not to <body> (which also folded an unpinned rail).
const leaves = on && !recent.some((r) => r.year === y.year);
return ( return (
<Chip key={y.year} pressed={on} onClick={() => toggle(y.year)}> <Chip
key={y.year}
pressed={on}
onClick={(e) => {
if (leaves && e.currentTarget === document.activeElement)
handOffFocus(e.currentTarget, () => toggle(y.year));
else toggle(y.year);
}}
>
<span className="tabular-nums">{y.year}</span> <span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />} {y.count > 0 && <CountBadge count={y.count} active={on} />}
</Chip> </Chip>
); );
})} })}
{years.length > quick.length && ( {more && (
<Chip aria-haspopup="dialog" onClick={() => setOpen(true)}> <Chip aria-haspopup="dialog" onClick={() => setOpen(true)}>
<span>{t("sidebar.year.more")}</span> {t("sidebar.year.more")}
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</Chip> </Chip>
)} )}
{open && ( {open && (
@@ -109,9 +129,9 @@ export default function YearPicker({
{sort === "year" ? ( {sort === "year" ? (
<DecadeGrid years={newestFirst} cell={cell} /> <DecadeGrid years={newestFirst} cell={cell} />
) : ( ) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5"> <NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-1.5">
{[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)} {[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)}
</div> </NavGrid>
)} )}
</Modal> </Modal>
)} )}
@@ -119,43 +139,52 @@ export default function YearPicker({
); );
} }
/** One row per decade, newest first, with a cell per year of it; a year without titles is blank. */ /** One row per decade, newest first, with a cell per year of it; a year without titles is blank.
* Each decade is a list of its own, so ↑↓ land on the nearest year of the next decade. */
function DecadeGrid({ function DecadeGrid({
years, years,
cell, cell,
}: { }: {
years: readonly YearCount[]; years: readonly YearCount[];
cell: (y: YearCount) => React.ReactNode; cell: (y: YearCount, index: number) => React.ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const byYear = new Map(years.map((y) => [y.year, y])); 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); const decades = [...new Set(years.map((y) => y.year - (y.year % 10)))].sort((a, b) => b - a);
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{decades.map((from) => ( {decades.map((from) => {
<div const row = Array.from({ length: 10 }, (_, i) => byYear.get(from + i));
key={from} return (
role="group" <div
aria-label={t("sidebar.year.decade", { from, to: from + 9 })} key={from}
className="grid grid-cols-[3rem_repeat(10,minmax(0,1fr))] items-center gap-1.5" role="group"
> aria-label={t("sidebar.year.decade", { from, to: from + 9 })}
<span className="text-muted text-xs tabular-nums">{from}</span> className="grid grid-cols-[3rem_minmax(0,1fr)] items-center gap-1.5"
{Array.from({ length: 10 }, (_, i) => { >
const y = byYear.get(from + i); {/* The group's name says the decade; read twice, it was noise. */}
return y ? ( <span className="text-muted text-xs tabular-nums" aria-hidden="true">
cell(y) {from}
) : ( </span>
<span <NavGrid className="grid grid-cols-10 items-center gap-1.5">
key={from + i} {row.map((y, i) =>
aria-hidden="true" y ? (
className="text-muted/50 text-center text-xs tabular-nums" // Its index among the decade's years: a blank cell is not an item.
> cell(y, row.slice(0, i).filter(Boolean).length)
{from + i} ) : (
</span> <span
); key={from + i}
})} aria-hidden="true"
</div> className="text-muted/50 text-center text-xs tabular-nums"
))} >
{from + i}
</span>
),
)}
</NavGrid>
</div>
);
})}
</div> </div>
); );
} }
+8 -5
View File
@@ -7,7 +7,8 @@ export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
export type ChipSize = export type ChipSize =
| "md" // 28, text-sm — the applied-filter row above the feed | "md" // 28, text-sm — the applied-filter row above the feed
| "sm" // 24, text-xs — a filter row | "sm" // 24, text-xs — a filter row
| "xs"; // 24, text-2xs, tight — a tag in a table row | "xs" // 24, text-2xs, tight — a tag in a table row
| "cell"; // 36, two lines (a label over its count), a rounded box — a cell of a grid of choices
const PAINT: Record<ChipVariant, string> = { const PAINT: Record<ChipVariant, string> = {
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed // `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
@@ -23,13 +24,15 @@ const PAINT: Record<ChipVariant, string> = {
}; };
const SIZES: Record<ChipSize, string> = { const SIZES: Record<ChipSize, string> = {
md: "h-7 px-2.5 text-sm gap-1", md: "h-7 px-2.5 text-sm gap-1 rounded-full",
sm: "h-6 px-2.5 text-xs gap-1.5", sm: "h-6 px-2.5 text-xs gap-1.5 rounded-full",
xs: "h-6 px-1.5 text-2xs gap-1", xs: "h-6 px-1.5 text-2xs gap-1 rounded-full",
// Narrow enough for ten across a dialog at a tablet's width: the count goes under the label.
cell: "h-9 px-1 text-xs leading-tight flex-col rounded-lg",
}; };
const BASE = const BASE =
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0"; "inline-flex min-w-6 shrink-0 items-center justify-center whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing /** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */ * chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
@@ -35,7 +35,7 @@ const IV = keys<IconButtonVariant>({
}); // prettier-ignore }); // prettier-ignore
const IS = keys<IconButtonSize>({ sm: true, md: true, lg: true, player: true }); const IS = keys<IconButtonSize>({ sm: true, md: true, lg: true, player: true });
const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true }); const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true });
const CS = keys<ChipSize>({ md: true, sm: true, xs: true }); const CS = keys<ChipSize>({ md: true, sm: true, xs: true, cell: true });
const missing = (emitted: string[], file: string) => { const missing = (emitted: string[], file: string) => {
const found = tokens(file); const found = tokens(file);
+2
View File
@@ -76,6 +76,8 @@
"rating": "IMDb rating", "rating": "IMDb rating",
"genre": "Genre", "genre": "Genre",
"year": "Year", "year": "Year",
"yearFrom": "From year",
"yearTo": "To year",
"duration": "Length", "duration": "Length",
"durationUnit": "min", "durationUnit": "min",
"durationOpt": { "durationOpt": {
+1 -1
View File
@@ -34,7 +34,7 @@
"year": "Year" "year": "Year"
}, },
"year": { "year": {
"more": "More", "more": "More…",
"moreLabel": "All years", "moreLabel": "All years",
"decade": "{{from}}–{{to}}", "decade": "{{from}}–{{to}}",
"none": "No years yet", "none": "No years yet",
+2
View File
@@ -76,6 +76,8 @@
"rating": "IMDb pont", "rating": "IMDb pont",
"genre": "Műfaj", "genre": "Műfaj",
"year": "Év", "year": "Év",
"yearFrom": "Ettől az évtől",
"yearTo": "Eddig az évig",
"duration": "Hossz", "duration": "Hossz",
"durationUnit": "perc", "durationUnit": "perc",
"durationOpt": { "durationOpt": {
+1 -1
View File
@@ -34,7 +34,7 @@
"year": "Év" "year": "Év"
}, },
"year": { "year": {
"more": "Több", "more": "Több…",
"moreLabel": "Összes év", "moreLabel": "Összes év",
"decade": "{{from}}–{{to}}", "decade": "{{from}}–{{to}}",
"none": "Még nincs év", "none": "Még nincs év",
+2
View File
@@ -52,6 +52,8 @@ import { plexApi } from "./api/plex";
export type * from "./api/plex"; export type * from "./api/plex";
export { export {
EMPTY_PLEX_FILTERS, EMPTY_PLEX_FILTERS,
oneYear,
singleYear,
plexFilterCount, plexFilterCount,
coercePlexFilters, coercePlexFilters,
plexFiltersSignature, plexFiltersSignature,
+11
View File
@@ -261,6 +261,17 @@ export interface PlexPersonDetail {
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
shows: PlexCard[]; // enabled-library shows, same shows: PlexCard[]; // enabled-library shows, same
} }
/** One year as a Plex filter: a range of one, both ends set (the year picker, a year on an info page).
* `null` clears it. */
export const oneYear = (year?: number | null): Pick<PlexFilters, "yearMin" | "yearMax"> => ({
yearMin: year ?? null,
yearMax: year ?? null,
});
/** The one year a filter narrows to, when its range is a single year. */
export const singleYear = (f: Pick<PlexFilters, "yearMin" | "yearMax">): number | undefined =>
f.yearMin != null && f.yearMin === f.yearMax ? f.yearMin : undefined;
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON. // The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters { export interface PlexFilters {
genres: string[]; genres: string[];
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { buildPlexActiveFilters } from "./plexActiveFilters";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api";
const t = (key: string) => key;
const yearLabel = (f: Partial<PlexFilters>) =>
buildPlexActiveFilters(
{ ...EMPTY_PLEX_FILTERS, ...f },
"",
() => {},
() => {},
t,
).find((c) => c.key === "year")?.label;
describe("the Plex year chip", () => {
it("names one year as the year, not a range of one", () => {
expect(yearLabel({ yearMin: 1999, yearMax: 1999 })).toBe("plex.filter.year: 1999");
});
it("names a range and an open end as before", () => {
expect(yearLabel({ yearMin: 1990, yearMax: 1999 })).toBe("plex.filter.year: 1990–1999");
expect(yearLabel({ yearMin: 1990 })).toBe("plex.filter.year: ≥1990");
expect(yearLabel({ yearMax: 1999 })).toBe("plex.filter.year: ≤1999");
});
});
+9 -6
View File
@@ -1,6 +1,6 @@
import { vocabI18nKey } from "./vocab"; import { vocabI18nKey } from "./vocab";
import { type ActiveFilter } from "./useActiveFilters"; import { type ActiveFilter } from "./useActiveFilters";
import { type PlexFilters } from "./api"; import { singleYear, type PlexFilters } from "./api";
type TFn = (key: string, options?: Record<string, unknown>) => string; type TFn = (key: string, options?: Record<string, unknown>) => string;
@@ -42,12 +42,15 @@ export function buildPlexActiveFilters(
}); });
} }
if (has(filters.yearMin) || has(filters.yearMax)) { if (has(filters.yearMin) || has(filters.yearMax)) {
const one = singleYear(filters);
const range = const range =
has(filters.yearMin) && has(filters.yearMax) one != null
? `${filters.yearMin}–${filters.yearMax}` ? String(one)
: has(filters.yearMin) : has(filters.yearMin) && has(filters.yearMax)
? `≥${filters.yearMin}` ? `${filters.yearMin}–${filters.yearMax}`
: `≤${filters.yearMax}`; : has(filters.yearMin)
? `≥${filters.yearMin}`
: `≤${filters.yearMax}`;
out.push({ out.push({
key: "year", key: "year",
label: `${t("plex.filter.year")}: ${range}`, label: `${t("plex.filter.year")}: ${range}`,
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
//
// The feed rail's Year: a year and the Upload-date control are the same narrowing, so picking a year
// clears an upload-date window or range.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import YearFilter from "./YearFilter";
import { api, type FeedFilters } from "../../lib/api";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const BASE: FeedFilters = {
tags: [],
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
channelIds: [],
};
describe("the feed rail's Year", () => {
it("picking a year clears the Upload-date window and range", async () => {
vi.spyOn(api, "feedYears").mockResolvedValue({ years: [{ year: 2010, count: 3 }] });
const setFilters = vi.fn();
const filters = { ...BASE, maxAgeDays: 7, dateFrom: "2020-01-01", dateTo: "2020-02-01" };
render(
<QueryClientProvider client={new QueryClient()}>
<YearFilter filters={filters} setFilters={setFilters} />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: /2010/ }));
expect(setFilters).toHaveBeenCalledWith({
...filters,
year: 2010,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
});
});
});
+2 -6
View File
@@ -3,7 +3,7 @@ import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api"; import { api, oneYear, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format"; import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
@@ -427,11 +427,7 @@ export default function PlexInfo({
title={ title={
onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined
} }
onClick={ onClick={onFilter ? () => onFilter(oneYear(detail.year)) : undefined}
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
> >
{detail.year} {detail.year}
</Filterable> </Filterable>
+54 -31
View File
@@ -6,7 +6,14 @@ import { vocab } from "../../lib/vocab";
import { plexLang } from "../../i18n"; import { plexLang } from "../../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; 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 { notifyActionError } from "../../lib/actionErrors";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import FilterRail from "../../components/ui/FilterRail"; import FilterRail from "../../components/ui/FilterRail";
@@ -173,36 +180,7 @@ export default function PlexSidebar() {
{ {
id: "year", id: "year",
title: t("plex.filter.year"), title: t("plex.filter.year"),
body: ( body: <PlexYearFilter filters={filters} facets={facets} patch={patch} />,
<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>
),
}, },
...(scope === "movie" ...(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({ function NumberInput({
label,
value, value,
placeholder, placeholder,
onChange, onChange,
}: { }: {
/** Its accessible name: the placeholder (a year) is not one. */
label: string;
value: number | null; value: number | null;
placeholder?: number; placeholder?: number;
onChange: (v: number | null) => void; onChange: (v: number | null) => void;
}) { }) {
return ( return (
<Input <Input
aria-label={label}
className="w-20 tabular-nums" className="w-20 tabular-nums"
size="sm" size="sm"
type="number" type="number"
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// The rail's Year section: a year from the picker is a range of one (both ends), the picker shows a
// range of one as its selected year, and the two range fields carry a name of their own.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("./plexContext", () => ({
usePlex: () => ({}),
usePlexActions: () => ({ setPlaylistOpen: vi.fn() }),
}));
import { PlexYearFilter } from "./PlexSidebar";
import { EMPTY_PLEX_FILTERS, type PlexFacets } from "../../lib/api";
afterEach(cleanup);
const FACETS = {
years: [
{ year: 2001, count: 4 },
{ year: 1999, count: 2 },
],
year_min: 1999,
year_max: 2001,
} as unknown as PlexFacets;
describe("the Plex rail's Year section", () => {
it("picks a year as a range of one, and clears both ends again", () => {
const patch = vi.fn();
const { rerender } = render(
<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={patch} />,
);
fireEvent.click(screen.getByRole("button", { name: /1999/ }));
expect(patch).toHaveBeenLastCalledWith({ yearMin: 1999, yearMax: 1999 });
rerender(
<PlexYearFilter
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 1999 }}
facets={FACETS}
patch={patch}
/>,
);
const on = screen.getByRole("button", { pressed: true });
expect(on.textContent).toContain("1999");
fireEvent.click(on);
expect(patch).toHaveBeenLastCalledWith({ yearMin: null, yearMax: null });
});
it("a wider range selects no year", () => {
render(
<PlexYearFilter
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 2001 }}
facets={FACETS}
patch={() => {}}
/>,
);
expect(screen.queryByRole("button", { pressed: true })).toBeNull();
});
it("names the range fields", () => {
render(<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={() => {}} />);
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy();
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearTo" })).toBeTruthy();
});
});
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { PlexFilters } from "../../lib/api"; import { oneYear, type PlexFilters } from "../../lib/api";
import { formatMoney, formatRuntime } from "../../lib/format"; import { formatMoney, formatRuntime } from "../../lib/format";
import type { AsideSection, DetailAsideState, DetailFacts } from "../../lib/plexDetail"; import type { AsideSection, DetailAsideState, DetailFacts } from "../../lib/plexDetail";
import { regionName, vocab } from "../../lib/vocab"; import { regionName, vocab } from "../../lib/vocab";
@@ -75,7 +75,7 @@ function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFi
<Filterable <Filterable
tag tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined} title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined} onClick={onFilter ? () => onFilter(oneYear(year)) : undefined}
> >
{year} {year}
</Filterable> </Filterable>
+2 -5
View File
@@ -16,6 +16,7 @@ import {
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { import {
api, api,
oneYear,
type PlexCard, type PlexCard,
type PlexFilters, type PlexFilters,
type PlexPersonDetail, type PlexPersonDetail,
@@ -613,11 +614,7 @@ export function PlexShowView({
<Filterable <Filterable
tag tag
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined} title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
onClick={ onClick={onFilter ? () => onFilter(oneYear(show.year)) : undefined}
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
> >
{show.year} {show.year}
</Filterable> </Filterable>