Files
siftlode/frontend/src/modules/plex/PlexSidebar.tsx
T
peter 7dd50db9b7 fix(plex): the rail says once that its facets failed, with Retry
A failed facets load put the same muted line in up to five sections with nothing to press (the
query never retries by itself), and only the Year section announced it, so a reader who hid that
section heard nothing. The rail now shows one StateMessage with Retry, in the Year section or in
its foot when Year is hidden; the facet sections wait for their data as before.
2026-11-14 20:07:13 +01:00

585 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type ReactNode, useState } from "react";
import { qk } from "../../lib/queryKeys";
import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles";
import { useTranslation } from "react-i18next";
import { vocab } from "../../lib/vocab";
import { plexLang } from "../../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
import {
api,
oneYear,
plexFilterCount,
singleYear,
type PlexFacets,
type PlexFilters,
} from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { useDebounced } from "../../lib/useDebounced";
import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups";
import MatchToggle from "../../components/ui/MatchToggle";
import FilterChipGroup from "../../components/ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
import { usePlexSavedViews } from "./plexSavedViews";
import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
import YearPicker from "../../components/ui/YearPicker";
import { StateMessage } from "../../components/ui/QueryState";
import { useRail } from "../../lib/useRail";
import { msg } from "../../lib/notifications";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
// set; shows keep library + sort. The filter state lives in PlexProvider; facets come from
// the backend so the panel only offers what the library actually contains.
const RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
{ key: "short", min: null, max: 90 * 60 },
{ key: "mid", min: 90 * 60, max: 120 * 60 },
{ key: "long", min: 120 * 60, max: null },
];
export default function PlexSidebar() {
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("plex");
const { t, i18n } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setFilters } = usePlexActions();
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
// title opens the full manager modal (same shared components as the feed).
// They reach the shared views UI in this rail through the scope below.
const savedViews = usePlexSavedViews();
const { openModal, modalOpen } = savedViews;
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
// never refetches. collectionMode stays IN the key: any/all changes the result set.
const { collectionTitles: _cts, ...facetKey } = filters;
const lang = plexLang(i18n.language); // HU/EN genre vocabulary — refetch facets on a language switch
const facetsQ = useQuery({
queryKey: qk.plexFacets(scope, show, facetKey, lang),
queryFn: () => api.plexFacets(scope, filters, show, lang),
enabled: !!scope,
placeholderData: (prev) => prev,
});
const facets = facetsQ.data;
const fCount = plexFilterCount(filters);
// Scope and sort moved to the header / above-the-grid toolbar; the panel badge now counts only
// the filters that still live in this panel (expanded filters + watch-state).
const activeCount = fCount + (show !== "all" ? 1 : 0);
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
// Directors + actors have no picker (they're added from the detail/cast surfaces) — this section
// just lists them as removable chips. Studios now have their own picker section (below).
const peopleActive = filters.directors.length + filters.actors.length > 0;
// A section the facets fill, shown when its facet has values.
const facetSection = (
id: string,
title: string,
has: (f: PlexFacets) => boolean,
body: (f: PlexFacets) => ReactNode,
): PanelItem[] => (facets && has(facets) ? [{ id, title, body: body(facets) }] : []);
// While the facets are not here, the rail says so ONCE — a first load or a failed one must not
// read as a library with no genres (honest states) — with a way to try again. In the Year section,
// which every library has; in the rail's foot if the reader hid it.
const facetsStatus =
facets || (!facetsQ.isError && !facetsQ.isLoading) ? null : (
<FacetsStatus failed={facetsQ.isError} onRetry={() => void facetsQ.refetch()} />
);
const yearHidden = !!layout.hidden.year;
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
// layout and lets the user reorder / hide / collapse them.
const items: PanelItem[] = [
{
id: "playlists",
title: t("plex.playlist.section"),
body: <PlaylistPicker />,
},
...(peopleActive
? [
{
id: "people",
title: t("plex.filter.active"),
body: (
<div className="flex flex-wrap gap-1.5">
{filters.directors.map((d) => (
<RemovableChip
key={`d:${d}`}
label={d}
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
/>
))}
<MatchToggle
picked={filters.actors.length}
className="w-full"
value={filters.actorMode ?? "any"}
onChange={(m) => patch({ actorMode: m })}
/>
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
label={a}
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
/>
))}
</div>
),
},
]
: []),
{
id: "collection",
title: t("plex.filter.collection"),
body: (
<CollectionPicker
values={filters.collections}
titles={filters.collectionTitles}
mode={filters.collectionMode ?? "any"}
onChange={(collections, collectionTitles) => patch({ collections, collectionTitles })}
onModeChange={(m) => patch({ collectionMode: m })}
/>
),
},
{
id: "rating",
title: t("plex.filter.rating"),
body: (
<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) })}
/>
),
},
...facetSection(
"genre",
t("plex.filter.genre"),
(f) => f.genres.length > 0,
(f) => (
<>
<MatchToggle
picked={filters.genres.length}
value={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
<Combobox
label={t("plex.filter.genre")}
options={f.genres}
selected={filters.genres}
onChange={(next) => patch({ genres: next })}
/>
</>
),
),
{
id: "year",
title: t("plex.filter.year"),
body: (
<PlexYearFilter
filters={filters}
facets={facets}
status={yearHidden ? null : facetsStatus}
patch={patch}
/>
),
},
...(scope === "movie"
? [
{
id: "duration",
title: t("plex.filter.duration"),
body: (
<FilterChipGroup
ariaLabel={t("plex.filter.duration")}
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
value={
durBucketKey ??
(filters.durationMin == null && filters.durationMax == null ? "" : " ")
}
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 });
}}
/>
),
},
]
: []),
{
id: "added",
title: t("plex.filter.added"),
body: (
<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 })}
/>
),
},
...facetSection(
"contentrating",
t("plex.filter.contentRating"),
(f) => f.content_ratings.length > 0,
(f) => (
<Combobox
label={t("plex.filter.contentRating")}
options={f.content_ratings.map((o) => ({
...o,
label: vocab(t, "certificate", o.value),
}))}
selected={filters.contentRatings}
onChange={(next) => patch({ contentRatings: next })}
/>
),
),
...facetSection(
"studio",
t("plex.filter.studio"),
(f) => f.studios.length > 0,
(f) => (
<Combobox
label={t("plex.filter.studio")}
options={f.studios}
selected={filters.studios}
onChange={(next) => patch({ studios: next })}
/>
),
),
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
...(scope === "movie"
? facetSection(
"playable",
t("plex.filter.playable"),
(f) => f.playable.length > 0,
(f) => (
<Combobox
label={t("plex.filter.playable")}
options={f.playable.map((p) => ({
value: p.value,
label: t(`plex.filter.playableOpt.${p.value}`),
count: p.count,
}))}
selected={filters.playable}
onChange={(next) => patch({ playable: next })}
/>
),
)
: []),
];
return (
<SavedViewsScope value={savedViews}>
<FilterRail
onResetLayout={onResetLayout}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
collapsible={false}
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
// The same shared components as the feed rail, over Plex's own views.
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
onTitleClick={openModal}
titleActionLabel={t("views.manageTitle")}
>
{yearHidden && facetsStatus}
</FilterRail>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
</SavedViewsScope>
);
}
// The playlists section: a combobox that stays compact (just a field + chevron) until opened, instead
// of an always-expanded list that grew the rail with every playlist. Picking a playlist opens it (the
// PlexBrowse `playlistOpen` hand-off); typing a name that doesn't exist yet offers a "Create …" row so
// you can still add one — the same reuse-the-Combobox idea as the feed's "Your tags" widget. `selected`
// stays empty: this picks an action (open / create), it doesn't hold a persistent selection.
const CREATE_PLAYLIST = "__create__";
export function PlaylistPicker() {
const { t } = useTranslation();
const { setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const qc = useQueryClient();
const [query, setQuery] = useState("");
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const playlists = playlistsQ.data?.playlists ?? [];
const q = query.trim();
const ql = q.toLowerCase();
const matches = ql ? playlists.filter((p) => p.title.toLowerCase().includes(ql)) : playlists;
const exact = playlists.some((p) => p.title.toLowerCase() === ql);
const options: ComboboxOption[] = [
...matches.map((p) => ({ value: `pl:${p.id}`, label: p.title, count: p.item_count })),
// Offer to create only when the typed name isn't already a playlist — which is unknown until the
// lists load: over a failed load the row pushed out the error and its Retry.
...(q && !exact && playlistsQ.data ? [{ value: CREATE_PLAYLIST, label: q }] : []),
];
async function createPlaylist(title: string) {
try {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
} catch (e) {
// Called without an await from the picker, so a refusal was an unhandled rejection.
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
}
}
return (
<Combobox
label={t("plex.playlist.section")}
multiple={false}
clientFilter={false} // we build `options` (incl. the create row) against the query ourselves
options={options}
selected={[]}
query={query}
onQueryChange={setQuery}
placeholder={t("plex.playlist.pickerPlaceholder")}
loading={playlistsQ.isLoading}
error={playlistsQ.isError && !playlistsQ.data ? t("common.loadError") : undefined}
onRetry={() => void playlistsQ.refetch()}
emptyText={t("plex.playlist.none")}
renderOption={(o) =>
o.value === CREATE_PLAYLIST ? (
<>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="flex-1 truncate">
{t("plex.playlist.createNamed", { name: o.label })}
</span>
</>
) : (
<>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{o.label}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
)}
</>
)
}
onChange={(next) => {
const v = next[0];
if (!v) return;
if (v === CREATE_PLAYLIST)
void createPlaylist(q); // reports its own failure
else onOpenPlaylist(Number(v.slice(3)));
}}
/>
);
}
// The collection filter (a multi-select combobox): a combi-input matching the genre/studio
// pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic <Combobox>
// owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on
// `open`) and maps collections to options. A title can belong to several collections, so multiple can
// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips.
function CollectionPicker({
values,
titles,
mode,
onChange,
onModeChange,
}: {
values: string[];
titles: Record<string, string> | undefined;
mode: "any" | "all";
onChange: (values: string[], titles: Record<string, string> | undefined) => void;
onModeChange: (mode: "any" | "all") => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const searchDeb = useDebounced(search.trim(), 300);
const q = useQuery({
queryKey: qk.plexCollections("union", searchDeb),
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
enabled: open, // only fetch while the picker is open
});
// …but a selection restored from a LINK has ids and no titles, and the list above is only fetched
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
// selected ids (see usePlexCollectionTitles).
const { titles: resolved, pending } = usePlexCollectionTitles(values, titles);
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
value: c.id,
label: c.title,
count: c.child_count ?? undefined,
}));
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
// selected collection's title (the fetched list is narrowed by the current search).
const labelMap: Record<string, string> = { ...resolved };
for (const o of options) if (o.label) labelMap[o.value] = o.label;
return (
<>
<MatchToggle picked={values.length} value={mode} onChange={onModeChange} />
<Combobox
label={t("plex.filter.collection")}
clientFilter={false} // the server already narrows to `search`
options={options}
selected={values}
selectedLabels={resolved}
labelsPending={pending}
query={search}
onQueryChange={setSearch}
onOpenChange={setOpen}
loading={q.isFetching}
error={q.isError ? t("common.loadError") : undefined}
onRetry={() => void q.refetch()}
placeholder={t("plex.filter.collectionSearch")}
emptyText={t("plex.filter.collectionEmpty")}
onChange={(next) => {
// Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an
// empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes
// differently from an absent key), mirroring the chip-row remove handler.
const nextTitles: Record<string, string> = {};
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
onChange(next, next.length ? nextTitles : undefined);
}}
/>
</>
);
}
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
const { t } = useTranslation();
return (
<DismissibleChip
onDismiss={onRemove}
dismissLabel={t("common.removeNamed", { name: label })}
className="max-w-40"
>
{label}
</DismissibleChip>
);
}
/** The rail's Year section: the year picker over the range fields. One year is a range of one:
* picking it sets both ends, as the year on an info page does, so the range fields show it and a
* decade stays typeable there. */
export function PlexYearFilter({
filters,
facets,
status,
patch,
}: {
filters: PlexFilters;
facets: PlexFacets | undefined;
/** The rail's word on facets still loading or failed, when it shows here. */
status?: ReactNode;
patch: (p: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
{status}
{facets && facets.years.length > 0 && (
<YearPicker
years={facets.years}
selected={singleYear(filters)}
onPick={(year) => patch(oneYear(year))}
/>
)}
<div className="flex items-center gap-2">
<NumberInput
label={t("plex.filter.yearFrom")}
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
label={t("plex.filter.yearTo")}
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</div>
);
}
/** The facets still loading, or failed with a way to try again — said once for the whole rail. */
export function FacetsStatus({ failed, onRetry }: { failed: boolean; onRetry: () => void }) {
const { t } = useTranslation();
if (failed)
return (
<StateMessage tone="error" compact onRetry={onRetry}>
{t("common.loadError")}
</StateMessage>
);
return (
<p className="text-muted px-1 py-1.5 text-xs" role="status">
{t("common.loading")}
</p>
);
}
function NumberInput({
label,
value,
placeholder,
onChange,
}: {
/** Its accessible name: the placeholder (a year) is not one. */
label: string;
value: number | null;
placeholder?: number;
onChange: (v: number | null) => void;
}) {
return (
<Input
aria-label={label}
className="w-20 tabular-nums"
size="sm"
type="number"
inputMode="numeric"
value={value ?? ""}
placeholder={placeholder != null ? String(placeholder) : ""}
onChange={(e) => {
const n = e.target.value === "" ? null : Number(e.target.value);
onChange(n != null && Number.isFinite(n) ? n : null);
}}
/>
);
}