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 { 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">
|
||||
|
||||
Reference in New Issue
Block a user