refactor(ui): unify Plex filter chips; retire local Chip/ChipRow (R13 S2c step 3)
PlexSidebar's bespoke chip system is gone. The media scope (both/movie/show) now uses the shared SegmentedToggle (fullWidth bar, matching the feed Mine/Library scope), and every filter chip renders the canonical rounded-full look: - show / rating / duration / added (single-select) -> FilterChipGroup, with the null/"" "Any" sentinels mapped to string option values - genres / content-ratings (multi-select) -> plain buttons on filterChipClass in their own labelled group (same look, independent semantics) The local Chip (glass-card resting) and ChipRow helpers are deleted; the unused ReactNode import dropped. Self-E2E'd on Plex: scope bar, show + rating chips toggle, zero leftover glass-resting chips. gate green (tsc/eslint-0/ vitest 136/knip/prettier). Branch not merged.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
|
|||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import SortControl from "./SortControl";
|
import SortControl from "./SortControl";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
@@ -131,20 +132,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "scope",
|
id: "scope",
|
||||||
title: t("plex.filter.scope"),
|
title: t("plex.filter.scope"),
|
||||||
body: (
|
body: (
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<SegmentedToggle
|
||||||
{(["both", "movie", "show"] as const).map((s) => (
|
ariaLabel={t("plex.filter.scope")}
|
||||||
<button
|
fullWidth
|
||||||
key={s}
|
size="md"
|
||||||
onClick={() => setScope(s)}
|
options={(["both", "movie", "show"] as const).map((s) => ({
|
||||||
aria-pressed={s === scope}
|
value: s,
|
||||||
className={`pill min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate ${
|
label: t(`plex.filter.scopeOpt.${s}`),
|
||||||
s === scope ? "" : "glass-card glass-hover"
|
}))}
|
||||||
}`}
|
value={scope}
|
||||||
>
|
onChange={setScope}
|
||||||
{t(`plex.filter.scopeOpt.${s}`)}
|
/>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -193,13 +191,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "watch",
|
id: "watch",
|
||||||
title: t("plex.filter.show"),
|
title: t("plex.filter.show"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
{SHOW_OPTS.map((s) => (
|
ariaLabel={t("plex.filter.show")}
|
||||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||||||
{t(`plex.filter.showOpt.${s}`)}
|
value={show}
|
||||||
</Chip>
|
onChange={setShow}
|
||||||
))}
|
/>
|
||||||
</ChipRow>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -304,16 +301,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "rating",
|
id: "rating",
|
||||||
title: t("plex.filter.rating"),
|
title: t("plex.filter.rating"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
ariaLabel={t("plex.filter.rating")}
|
||||||
{t("plex.filter.any")}
|
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
|
||||||
</Chip>
|
options={[
|
||||||
{RATING_STEPS.map((r) => (
|
{ value: "", label: t("plex.filter.any") },
|
||||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
|
||||||
{r}+
|
]}
|
||||||
</Chip>
|
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
|
||||||
))}
|
/>
|
||||||
</ChipRow>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(facets && facets.genres.length > 0
|
...(facets && facets.genres.length > 0
|
||||||
@@ -329,17 +325,22 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
onChange={(m) => patch({ genreMode: m })}
|
onChange={(m) => patch({ genreMode: m })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ChipRow>
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("plex.filter.genre")}
|
||||||
|
>
|
||||||
{facets.genres.map((g) => (
|
{facets.genres.map((g) => (
|
||||||
<Chip
|
<button
|
||||||
key={g.value}
|
key={g.value}
|
||||||
active={filters.genres.includes(g.value)}
|
|
||||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||||
|
aria-pressed={filters.genres.includes(g.value)}
|
||||||
|
className={filterChipClass}
|
||||||
>
|
>
|
||||||
{g.value}
|
{g.value}
|
||||||
</Chip>
|
</button>
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -370,25 +371,21 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "duration",
|
id: "duration",
|
||||||
title: t("plex.filter.duration"),
|
title: t("plex.filter.duration"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
<Chip
|
ariaLabel={t("plex.filter.duration")}
|
||||||
active={
|
value={durBucketKey ?? ""}
|
||||||
!durBucketKey && filters.durationMin == null && filters.durationMax == null
|
options={[
|
||||||
}
|
{ value: "", label: t("plex.filter.any") },
|
||||||
onClick={() => patch({ durationMin: null, durationMax: null })}
|
...DURATION_BUCKETS.map((b) => ({
|
||||||
>
|
value: b.key,
|
||||||
{t("plex.filter.any")}
|
label: t(`plex.filter.durationOpt.${b.key}`),
|
||||||
</Chip>
|
})),
|
||||||
{DURATION_BUCKETS.map((b) => (
|
]}
|
||||||
<Chip
|
onChange={(key) => {
|
||||||
key={b.key}
|
const b = DURATION_BUCKETS.find((x) => x.key === key);
|
||||||
active={durBucketKey === b.key}
|
patch({ durationMin: b?.min ?? null, durationMax: b?.max ?? null });
|
||||||
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
|
}}
|
||||||
>
|
/>
|
||||||
{t(`plex.filter.durationOpt.${b.key}`)}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipRow>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -397,20 +394,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "added",
|
id: "added",
|
||||||
title: t("plex.filter.added"),
|
title: t("plex.filter.added"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<FilterChipGroup
|
||||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
ariaLabel={t("plex.filter.added")}
|
||||||
{t("plex.filter.any")}
|
value={filters.addedWithin || ""}
|
||||||
</Chip>
|
options={[
|
||||||
{ADDED_OPTS.map((a) => (
|
{ value: "", label: t("plex.filter.any") },
|
||||||
<Chip
|
...ADDED_OPTS.map((a) => ({ value: a, label: t(`plex.filter.addedOpt.${a}`) })),
|
||||||
key={a}
|
]}
|
||||||
active={filters.addedWithin === a}
|
onChange={(v) => patch({ addedWithin: v })}
|
||||||
onClick={() => patch({ addedWithin: a })}
|
/>
|
||||||
>
|
|
||||||
{t(`plex.filter.addedOpt.${a}`)}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipRow>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(facets && facets.content_ratings.length > 0
|
...(facets && facets.content_ratings.length > 0
|
||||||
@@ -419,19 +411,24 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
id: "contentrating",
|
id: "contentrating",
|
||||||
title: t("plex.filter.contentRating"),
|
title: t("plex.filter.contentRating"),
|
||||||
body: (
|
body: (
|
||||||
<ChipRow>
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("plex.filter.contentRating")}
|
||||||
|
>
|
||||||
{facets.content_ratings.map((c) => (
|
{facets.content_ratings.map((c) => (
|
||||||
<Chip
|
<button
|
||||||
key={c.value}
|
key={c.value}
|
||||||
active={filters.contentRatings.includes(c.value)}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
|
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
|
||||||
}
|
}
|
||||||
|
aria-pressed={filters.contentRatings.includes(c.value)}
|
||||||
|
className={filterChipClass}
|
||||||
>
|
>
|
||||||
{c.value}
|
{c.value}
|
||||||
</Chip>
|
</button>
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -466,30 +463,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChipRow({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap gap-1">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={`pill px-2.5 py-1 rounded-full text-xs ${active ? "" : "glass-card glass-hover"}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||||
return (
|
return (
|
||||||
<span className="chip px-2 py-1 text-xs">
|
<span className="chip px-2 py-1 text-xs">
|
||||||
|
|||||||
Reference in New Issue
Block a user