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 { 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">