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:
2026-08-15 00:48:09 +02:00
parent 579af9cf89
commit f26e329ecf
+72 -99
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -12,6 +12,7 @@ import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import { usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
@@ -131,20 +132,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "scope",
title: t("plex.filter.scope"),
body: (
<div className="grid grid-cols-3 gap-1">
{(["both", "movie", "show"] as const).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
aria-pressed={s === scope}
className={`pill min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate ${
s === scope ? "" : "glass-card glass-hover"
}`}
>
{t(`plex.filter.scopeOpt.${s}`)}
</button>
))}
</div>
<SegmentedToggle
ariaLabel={t("plex.filter.scope")}
fullWidth
size="md"
options={(["both", "movie", "show"] as const).map((s) => ({
value: s,
label: t(`plex.filter.scopeOpt.${s}`),
}))}
value={scope}
onChange={setScope}
/>
),
},
{
@@ -193,13 +191,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "watch",
title: t("plex.filter.show"),
body: (
<ChipRow>
{SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
{t(`plex.filter.showOpt.${s}`)}
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.show")}
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
value={show}
onChange={setShow}
/>
),
},
{
@@ -304,16 +301,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "rating",
title: t("plex.filter.rating"),
body: (
<ChipRow>
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
{t("plex.filter.any")}
</Chip>
{RATING_STEPS.map((r) => (
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
{r}+
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.rating")}
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
options={[
{ value: "", label: t("plex.filter.any") },
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
]}
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
/>
),
},
...(facets && facets.genres.length > 0
@@ -329,17 +325,22 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
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) => (
<Chip
<button
key={g.value}
active={filters.genres.includes(g.value)}
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
aria-pressed={filters.genres.includes(g.value)}
className={filterChipClass}
>
{g.value}
</Chip>
</button>
))}
</ChipRow>
</div>
</>
),
},
@@ -370,25 +371,21 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "duration",
title: t("plex.filter.duration"),
body: (
<ChipRow>
<Chip
active={
!durBucketKey && filters.durationMin == null && filters.durationMax == null
}
onClick={() => patch({ durationMin: null, durationMax: null })}
>
{t("plex.filter.any")}
</Chip>
{DURATION_BUCKETS.map((b) => (
<Chip
key={b.key}
active={durBucketKey === b.key}
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
>
{t(`plex.filter.durationOpt.${b.key}`)}
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.duration")}
value={durBucketKey ?? ""}
options={[
{ value: "", label: t("plex.filter.any") },
...DURATION_BUCKETS.map((b) => ({
value: b.key,
label: t(`plex.filter.durationOpt.${b.key}`),
})),
]}
onChange={(key) => {
const b = DURATION_BUCKETS.find((x) => x.key === key);
patch({ durationMin: b?.min ?? null, durationMax: b?.max ?? null });
}}
/>
),
},
]
@@ -397,20 +394,15 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "added",
title: t("plex.filter.added"),
body: (
<ChipRow>
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
{t("plex.filter.any")}
</Chip>
{ADDED_OPTS.map((a) => (
<Chip
key={a}
active={filters.addedWithin === a}
onClick={() => patch({ addedWithin: a })}
>
{t(`plex.filter.addedOpt.${a}`)}
</Chip>
))}
</ChipRow>
<FilterChipGroup
ariaLabel={t("plex.filter.added")}
value={filters.addedWithin || ""}
options={[
{ value: "", label: t("plex.filter.any") },
...ADDED_OPTS.map((a) => ({ value: a, label: t(`plex.filter.addedOpt.${a}`) })),
]}
onChange={(v) => patch({ addedWithin: v })}
/>
),
},
...(facets && facets.content_ratings.length > 0
@@ -419,19 +411,24 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<ChipRow>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("plex.filter.contentRating")}
>
{facets.content_ratings.map((c) => (
<Chip
<button
key={c.value}
active={filters.contentRatings.includes(c.value)}
onClick={() =>
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
}
aria-pressed={filters.contentRatings.includes(c.value)}
className={filterChipClass}
>
{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 }) {
return (
<span className="chip px-2 py-1 text-xs">