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:
+14
-11
@@ -242,6 +242,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
|||||||
"content_ratings": [],
|
"content_ratings": [],
|
||||||
"studios": [],
|
"studios": [],
|
||||||
"playable": [],
|
"playable": [],
|
||||||
|
"years": [],
|
||||||
"year_min": None,
|
"year_min": None,
|
||||||
"year_max": None,
|
"year_max": None,
|
||||||
"rating_max": None,
|
"rating_max": None,
|
||||||
@@ -302,7 +303,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
|||||||
cr_counts: dict[str, int] = {}
|
cr_counts: dict[str, int] = {}
|
||||||
studio_counts: dict[str, int] = {}
|
studio_counts: dict[str, int] = {}
|
||||||
playable_counts: dict[str, int] = {}
|
playable_counts: dict[str, int] = {}
|
||||||
years: list[int] = []
|
year_counts: dict[int, int] = {}
|
||||||
ratings: list[float] = []
|
ratings: list[float] = []
|
||||||
durs: list[int] = []
|
durs: list[int] = []
|
||||||
|
|
||||||
@@ -383,21 +384,22 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
|||||||
)
|
)
|
||||||
for cls, c in db.execute(pcq).all():
|
for cls, c in db.execute(pcq).all():
|
||||||
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||||
# Year bounds — exclude the year filter.
|
# Titles per year — exclude the year filter, so a picked year never empties the list it was
|
||||||
by = db.execute(
|
# picked from. The year bounds are read from the same counts.
|
||||||
|
yq = (
|
||||||
wsq(
|
wsq(
|
||||||
select(func.min(model.year), func.max(model.year)),
|
select(model.year, func.count()),
|
||||||
model,
|
model,
|
||||||
ids,
|
ids,
|
||||||
{**p, "year_min": None, "year_max": None},
|
{**p, "year_min": None, "year_max": None},
|
||||||
kind_movie,
|
kind_movie,
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
).one()
|
.where(model.year.isnot(None))
|
||||||
if by[0] is not None:
|
.group_by(model.year)
|
||||||
years.append(by[0])
|
)
|
||||||
if by[1] is not None:
|
for y, c in db.execute(yq).all():
|
||||||
years.append(by[1])
|
year_counts[y] = year_counts.get(y, 0) + c
|
||||||
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
|
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
|
||||||
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
||||||
br = db.scalar(
|
br = db.scalar(
|
||||||
@@ -465,8 +467,9 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
|||||||
for c in PLAYBACK_CLASSES
|
for c in PLAYBACK_CLASSES
|
||||||
if c in playable_counts
|
if c in playable_counts
|
||||||
],
|
],
|
||||||
"year_min": min(years) if years else None,
|
"years": [{"year": y, "count": c} for y, c in sorted(year_counts.items(), reverse=True)],
|
||||||
"year_max": max(years) if years else None,
|
"year_min": min(year_counts) if year_counts else None,
|
||||||
|
"year_max": max(year_counts) if year_counts else None,
|
||||||
"rating_max": max(ratings) if ratings else None,
|
"rating_max": max(ratings) if ratings else None,
|
||||||
"duration_min": min(durs) if durs else None,
|
"duration_min": min(durs) if durs else None,
|
||||||
"duration_max": max(durs) if durs else None,
|
"duration_max": max(durs) if durs else None,
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ def test_the_facets_count_what_the_caller_would_see(db, make_user):
|
|||||||
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
|
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_facets_count_titles_per_year_without_the_year_filter(db, make_user):
|
||||||
|
# The year picker lists every year with its count. Like each facet it drops its own filter (a
|
||||||
|
# picked year must not empty the list it was picked from) and keeps the others; movies and shows
|
||||||
|
# of one year add up, newest first.
|
||||||
|
me = make_user()
|
||||||
|
movies, shows = _lib(db), _lib(db, "2", kind="show")
|
||||||
|
_movie(db, movies, "a", genres=["Drama"], year=2001)
|
||||||
|
_movie(db, movies, "b", genres=["Drama"], year=2001)
|
||||||
|
_movie(db, movies, "c", genres=["Drama"], year=1990)
|
||||||
|
_movie(db, movies, "d", genres=["Horror"], year=1985)
|
||||||
|
_movie(db, movies, "e", genres=["Drama"]) # no year: not a year to pick
|
||||||
|
db.add(PlexShow(rating_key="s1", library_id=shows.id, title="S", genres=["Drama"], year=1990))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
f = LibraryFilters(genres="Drama", year_min=2001, year_max=2001)
|
||||||
|
got = library.facets(db, me, scope="both", show="all", lang="en", f=f)
|
||||||
|
assert got["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 2}]
|
||||||
|
assert (got["year_min"], got["year_max"]) == (1990, 2001)
|
||||||
|
movie_only = library.facets(db, me, scope="movie", show="all", lang="en", f=f)
|
||||||
|
assert movie_only["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 1}]
|
||||||
|
|
||||||
|
|
||||||
def test_an_episode_search_joins_the_callers_state(db, make_user):
|
def test_an_episode_search_joins_the_callers_state(db, make_user):
|
||||||
me, other = make_user(), make_user()
|
me, other = make_user(), make_user()
|
||||||
lib = _lib(db, "2", kind="show")
|
lib = _lib(db, "2", kind="show")
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
## What lives here
|
## What lives here
|
||||||
|
|
||||||
| area | components |
|
| area | components |
|
||||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `YearPicker` (one calendar year from a list of years with counts: the recent ones as chips, every one in a decade-grid dialog; the feed and Plex rails), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, o?: Record<string, unknown>) =>
|
||||||
|
o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||||
|
|
||||||
|
import YearPicker from "./YearPicker";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
// Eight years over three decades, 1987 and 2014 without titles.
|
||||||
|
const YEARS = [
|
||||||
|
{ year: 2026, count: 3 },
|
||||||
|
{ year: 2024, count: 9 },
|
||||||
|
{ year: 2023, count: 1 },
|
||||||
|
{ year: 2021, count: 4 },
|
||||||
|
{ year: 2020, count: 2 },
|
||||||
|
{ year: 2015, count: 5 },
|
||||||
|
{ year: 2013, count: 7 },
|
||||||
|
{ year: 1985, count: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const open = () => fireEvent.click(screen.getByRole("button", { name: /sidebar\.year\.more/ }));
|
||||||
|
|
||||||
|
describe("YearPicker", () => {
|
||||||
|
it("shows the six newest years as chips, the selected one kept beside them", () => {
|
||||||
|
render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
|
||||||
|
const chips = screen.getAllByRole("button", { pressed: false }).map((b) => b.textContent);
|
||||||
|
expect(chips.slice(0, 6)).toEqual(["20263", "20249", "20231", "20214", "20202", "20155"]);
|
||||||
|
expect(screen.getByRole("button", { pressed: true }).textContent).toBe("19856");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lays every year out by decade in the dialog; a year without titles is not a choice", () => {
|
||||||
|
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||||
|
open();
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
const rows = within(dialog).getAllByRole("group", { name: /sidebar\.year\.decade/ });
|
||||||
|
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual([
|
||||||
|
"sidebar.year.decade:2020-2029",
|
||||||
|
"sidebar.year.decade:2010-2019",
|
||||||
|
"sidebar.year.decade:1980-1989",
|
||||||
|
]);
|
||||||
|
const in1980s = within(rows[2]!)
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((b) => b.dataset.year);
|
||||||
|
expect(in1980s).toEqual(["1985"]);
|
||||||
|
expect(rows[1]!.querySelector('[data-year="2014"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorted by count, the busiest year comes first", () => {
|
||||||
|
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||||
|
open();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "sidebar.year.sortCount" }));
|
||||||
|
const order = within(screen.getByRole("dialog"))
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((b) => b.dataset.year)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(order.slice(0, 3)).toEqual(["2024", "2013", "1985"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pick in the dialog names the year and closes it; the selected year again clears it", () => {
|
||||||
|
const onPick = vi.fn();
|
||||||
|
const { rerender } = render(<YearPicker years={YEARS} onPick={onPick} />);
|
||||||
|
open();
|
||||||
|
fireEvent.click(screen.getByRole("dialog").querySelector('[data-year="1985"]')!);
|
||||||
|
expect(onPick).toHaveBeenLastCalledWith(1985);
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
|
rerender(<YearPicker years={YEARS} selected={2024} onPick={onPick} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { pressed: true }));
|
||||||
|
expect(onPick).toHaveBeenLastCalledWith(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"year": {
|
"year": {
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"moreLabel": "All years",
|
"moreLabel": "All years",
|
||||||
|
"decade": "{{from}}–{{to}}",
|
||||||
"none": "No years yet",
|
"none": "No years yet",
|
||||||
"sortBy": "Sort",
|
"sortBy": "Sort",
|
||||||
"sortYear": "Year",
|
"sortYear": "Year",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"year": {
|
"year": {
|
||||||
"more": "Több",
|
"more": "Több",
|
||||||
"moreLabel": "Összes év",
|
"moreLabel": "Összes év",
|
||||||
|
"decade": "{{from}}–{{to}}",
|
||||||
"none": "Még nincs év",
|
"none": "Még nincs év",
|
||||||
"sortBy": "Rendezés",
|
"sortBy": "Rendezés",
|
||||||
"sortYear": "Év",
|
"sortYear": "Év",
|
||||||
|
|||||||
@@ -410,6 +410,8 @@ export interface PlexFacets {
|
|||||||
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker
|
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker
|
||||||
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
|
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
|
||||||
playable: { value: string; count: number }[];
|
playable: { value: string; count: number }[];
|
||||||
|
// Titles per year, newest first, the year filter itself left out (the sidebar's year picker).
|
||||||
|
years: { year: number; count: number }[];
|
||||||
year_min: number | null;
|
year_min: number | null;
|
||||||
year_max: number | null;
|
year_max: number | null;
|
||||||
rating_max: number | null;
|
rating_max: number | null;
|
||||||
|
|||||||
@@ -1,31 +1,21 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { api, type FeedFilters, type YearCount } from "../../lib/api";
|
import { api, type FeedFilters, type YearCount } from "../../lib/api";
|
||||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
import YearPicker from "../../components/ui/YearPicker";
|
||||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
|
||||||
import { Chip } from "../../components/ui/Chip";
|
|
||||||
import { Button } from "../../components/ui/Button";
|
|
||||||
import CountBadge from "../../components/ui/CountBadge";
|
|
||||||
import { useDebounced } from "../../lib/useDebounced";
|
import { useDebounced } from "../../lib/useDebounced";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
|
||||||
|
|
||||||
// The feed rail's "Year" control: quick chips for the most recent years plus a
|
// The feed rail's "Year" control (ui/YearPicker: recent-year chips and a "More" dialog of every
|
||||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
// year). Selecting a year filters the feed to that calendar year (a whole-year
|
||||||
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
|
// published_after/before range, applied in the api layer); it is mutually exclusive with the
|
||||||
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
|
// Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
||||||
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
|
||||||
//
|
//
|
||||||
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
|
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
|
||||||
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
|
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
|
||||||
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
|
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
|
||||||
// own category.
|
// own category.
|
||||||
|
|
||||||
const QUICK = 6; // recent-year chips shown before the "More…" overflow
|
// One shared empty list while the counts load: a fresh `[]` per render would change the picker's input.
|
||||||
// One shared empty list while the counts load: a fresh `[]` per render made the memos below recompute.
|
|
||||||
const NO_YEARS: YearCount[] = [];
|
const NO_YEARS: YearCount[] = [];
|
||||||
|
|
||||||
export default function YearFilter({
|
export default function YearFilter({
|
||||||
@@ -36,8 +26,6 @@ export default function YearFilter({
|
|||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [sortByCount, setSortByCount] = useState(false);
|
|
||||||
|
|
||||||
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
||||||
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
||||||
@@ -58,39 +46,6 @@ export default function YearFilter({
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const years = yearsQuery.data?.years ?? NO_YEARS;
|
const years = yearsQuery.data?.years ?? NO_YEARS;
|
||||||
const selected = filters.year;
|
|
||||||
|
|
||||||
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
|
||||||
const pick = (year?: number) =>
|
|
||||||
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
|
|
||||||
|
|
||||||
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
|
|
||||||
// active pick is always visible), re-sorted newest-first.
|
|
||||||
const quick = useMemo(() => {
|
|
||||||
const top = years.slice(0, QUICK);
|
|
||||||
if (selected != null && !top.some((y) => y.year === selected)) {
|
|
||||||
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
|
|
||||||
return [...top, sel].sort((a, b) => b.year - a.year);
|
|
||||||
}
|
|
||||||
return top;
|
|
||||||
}, [years, selected]);
|
|
||||||
|
|
||||||
const sorted = useMemo(
|
|
||||||
() =>
|
|
||||||
[...years].sort((a, b) =>
|
|
||||||
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
|
|
||||||
),
|
|
||||||
[years, sortByCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const pop = useAnchoredPopover({
|
|
||||||
open,
|
|
||||||
onOpenChange: setOpen,
|
|
||||||
mode: "menu",
|
|
||||||
placement: "bottom-start",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { setReference } = pop.refs;
|
|
||||||
|
|
||||||
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
||||||
if (years.length === 0) {
|
if (years.length === 0) {
|
||||||
@@ -112,85 +67,19 @@ export default function YearFilter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<YearPicker
|
||||||
{quick.map((y) => {
|
years={years}
|
||||||
const on = selected === y.year;
|
selected={filters.year}
|
||||||
return (
|
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
||||||
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
|
onPick={(year) =>
|
||||||
<span className="tabular-nums">{y.year}</span>
|
setFilters({
|
||||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
...filters,
|
||||||
</Chip>
|
year,
|
||||||
);
|
maxAgeDays: undefined,
|
||||||
})}
|
dateFrom: undefined,
|
||||||
|
dateTo: undefined,
|
||||||
{years.length > quick.length && (
|
})
|
||||||
<>
|
}
|
||||||
<Chip
|
/>
|
||||||
ref={setReference}
|
|
||||||
{...pop.getReferenceProps()}
|
|
||||||
aria-label={t("sidebar.year.moreLabel")}
|
|
||||||
>
|
|
||||||
<span>{t("sidebar.year.more")}</span>
|
|
||||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<PopoverSurface
|
|
||||||
popover={pop}
|
|
||||||
ariaLabel={t("sidebar.year.moreLabel")}
|
|
||||||
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
|
||||||
>
|
|
||||||
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
|
|
||||||
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
|
|
||||||
<div className="ml-auto flex gap-1">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["sortYear", false],
|
|
||||||
["sortCount", true],
|
|
||||||
] as const
|
|
||||||
).map(([key, v]) => (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
pressed={sortByCount === v}
|
|
||||||
onClick={() => setSortByCount(v)}
|
|
||||||
>
|
|
||||||
{t("sidebar.year." + key)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="max-h-64 overflow-y-auto no-scrollbar"
|
|
||||||
role="group"
|
|
||||||
aria-label={t("sidebar.year.moreLabel")}
|
|
||||||
>
|
|
||||||
{sorted.map((y) => {
|
|
||||||
const on = selected === y.year;
|
|
||||||
return (
|
|
||||||
<PopoverItem
|
|
||||||
key={y.year}
|
|
||||||
role="menuitemradio"
|
|
||||||
checked={on}
|
|
||||||
onSelect={() => {
|
|
||||||
pick(on ? undefined : y.year);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
label={String(y.year)}
|
|
||||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
|
||||||
on ? "text-fg" : "text-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MenuCheck kind="one" on={on} />
|
|
||||||
<span className="flex-1 tabular-nums">{y.year}</span>
|
|
||||||
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
|
|
||||||
</PopoverItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PopoverSurface>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { usePlexSavedViews } from "./plexSavedViews";
|
|||||||
import { usePlex, usePlexActions } from "./plexContext";
|
import { usePlex, usePlexActions } from "./plexContext";
|
||||||
import { DismissibleChip } from "../../components/ui/Chip";
|
import { DismissibleChip } from "../../components/ui/Chip";
|
||||||
import { Input } from "../../components/ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
|
import YearPicker from "../../components/ui/YearPicker";
|
||||||
import { useRail } from "../../lib/useRail";
|
import { useRail } from "../../lib/useRail";
|
||||||
import { msg } from "../../lib/notifications";
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
@@ -173,6 +174,20 @@ export default function PlexSidebar() {
|
|||||||
id: "year",
|
id: "year",
|
||||||
title: t("plex.filter.year"),
|
title: t("plex.filter.year"),
|
||||||
body: (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={filters.yearMin ?? null}
|
value={filters.yearMin ?? null}
|
||||||
@@ -186,6 +201,7 @@ export default function PlexSidebar() {
|
|||||||
onChange={(v) => patch({ yearMax: v })}
|
onChange={(v) => patch({ yearMax: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(scope === "movie"
|
...(scope === "movie"
|
||||||
|
|||||||
Reference in New Issue
Block a user