feat(plex): active-filter chip row above the grid (R13 S2)
Plex now shows the same at-a-glance "what's narrowing this" row the feed has, instead of the narrowing being visible only inside the collapsed sidebar. New usePlexActiveFilters is the Plex analogue of useActiveFilters — it turns the Plex state (expanded filters + watch-state + sort) into the source-agnostic ActiveFilter[] the SHARED ActiveFilterChips renders. One removable chip per dimension (genres/ratings/people emit one each), plus Clear-all resetting filters + watch-state + sort. Labels reuse the existing plex.* i18n keys; no new strings. Lives in components (not lib) since it reads the Plex provider — lib stays provider-free like useActiveFilters. Gate green: tsc/eslint-0/prettier/knip. Self-E2E'd: a sort=year chip renders, removing it resets sort to title and hides the row.
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { healPlexSort } from "../lib/plexSort";
|
||||
import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -59,6 +59,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import { usePlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
@@ -89,8 +91,16 @@ export default function PlexBrowse() {
|
||||
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
||||
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
||||
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
||||
const { setScope, setShow, setSort, setFilters, setPlaylistOpen } = usePlexActions();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||
// narrowing, plus a Clear-all that resets filters + watch-state + sort to their defaults.
|
||||
const plexChips = usePlexActiveFilters();
|
||||
const clearAllFilters = () => {
|
||||
setFilters(EMPTY_PLEX_FILTERS);
|
||||
setShow("all");
|
||||
setSort(PLEX_SORT_FALLBACK);
|
||||
};
|
||||
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||
const sortState = healPlexSort(sort, scope);
|
||||
@@ -333,6 +343,8 @@ export default function PlexBrowse() {
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
|
||||
|
||||
{personCards.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
{personCards.map((p) => (
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { type PlexFilters } from "../lib/api";
|
||||
import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state +
|
||||
// sort) into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid.
|
||||
// Plex thereby gets the same at-a-glance "what's narrowing this" row — with per-chip removal — that
|
||||
// the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse
|
||||
// the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so
|
||||
// removing one keeps the rest (same shape as the feed's tag/channel chips). Lives here, not in lib,
|
||||
// because it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
||||
export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
const { scope, show, sort, filters } = usePlex();
|
||||
const { setShow, setSort, setFilters } = usePlexActions();
|
||||
const { t } = useTranslation();
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
const set = (x: number | null | undefined) => x != null;
|
||||
|
||||
for (const g of filters.genres) {
|
||||
out.push({
|
||||
key: `genre:${g}`,
|
||||
label: g,
|
||||
remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }),
|
||||
});
|
||||
}
|
||||
for (const r of filters.contentRatings) {
|
||||
out.push({
|
||||
key: `contentRating:${r}`,
|
||||
label: r,
|
||||
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
|
||||
});
|
||||
}
|
||||
if (set(filters.yearMin) || set(filters.yearMax)) {
|
||||
const range =
|
||||
set(filters.yearMin) && set(filters.yearMax)
|
||||
? `${filters.yearMin}–${filters.yearMax}`
|
||||
: set(filters.yearMin)
|
||||
? `≥${filters.yearMin}`
|
||||
: `≤${filters.yearMax}`;
|
||||
out.push({
|
||||
key: "year",
|
||||
label: `${t("plex.filter.year")}: ${range}`,
|
||||
remove: () => patch({ yearMin: null, yearMax: null }),
|
||||
});
|
||||
}
|
||||
if (set(filters.ratingMin)) {
|
||||
out.push({
|
||||
key: "ratingMin",
|
||||
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
|
||||
remove: () => patch({ ratingMin: null }),
|
||||
});
|
||||
}
|
||||
if (set(filters.durationMin) || set(filters.durationMax)) {
|
||||
const lo = set(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
|
||||
const hi = set(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
|
||||
const range = lo != null && hi != null ? `${lo}–${hi}m` : lo != null ? `≥${lo}m` : `≤${hi}m`;
|
||||
out.push({
|
||||
key: "duration",
|
||||
label: `${t("plex.filter.duration")}: ${range}`,
|
||||
remove: () => patch({ durationMin: null, durationMax: null }),
|
||||
});
|
||||
}
|
||||
if (filters.addedWithin) {
|
||||
out.push({
|
||||
key: "added",
|
||||
label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`,
|
||||
remove: () => patch({ addedWithin: "" }),
|
||||
});
|
||||
}
|
||||
for (const d of filters.directors) {
|
||||
out.push({
|
||||
key: `director:${d}`,
|
||||
label: d,
|
||||
remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }),
|
||||
});
|
||||
}
|
||||
for (const a of filters.actors) {
|
||||
out.push({
|
||||
key: `actor:${a}`,
|
||||
label: a,
|
||||
remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }),
|
||||
});
|
||||
}
|
||||
for (const s of filters.studios) {
|
||||
out.push({
|
||||
key: `studio:${s}`,
|
||||
label: s,
|
||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||
});
|
||||
}
|
||||
if (filters.collection) {
|
||||
out.push({
|
||||
key: "collection",
|
||||
label: filters.collectionTitle ?? filters.collection,
|
||||
remove: () => patch({ collection: null, collectionTitle: null }),
|
||||
});
|
||||
}
|
||||
if (show !== "all") {
|
||||
out.push({
|
||||
key: "show",
|
||||
label: t("plex.filter.showOpt." + show),
|
||||
remove: () => setShow("all"),
|
||||
});
|
||||
}
|
||||
const healed = healPlexSort(sort, scope);
|
||||
if (healed.key !== "title") {
|
||||
out.push({
|
||||
key: "sort",
|
||||
label: t("plex.sort." + healed.key),
|
||||
remove: () => setSort(PLEX_SORT_FALLBACK),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user