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) => 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 : ( 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: , }, ...(peopleActive ? [ { id: "people", title: t("plex.filter.active"), body: (
{filters.directors.map((d) => ( patch({ directors: filters.directors.filter((x) => x !== d) })} /> ))} patch({ actorMode: m })} /> {filters.actors.map((a) => ( patch({ actors: filters.actors.filter((x) => x !== a) })} /> ))}
), }, ] : []), { id: "collection", title: t("plex.filter.collection"), body: ( patch({ collections, collectionTitles })} onModeChange={(m) => patch({ collectionMode: m })} /> ), }, { id: "rating", title: t("plex.filter.rating"), body: ( ({ 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) => ( <> patch({ genreMode: m })} /> patch({ genres: next })} /> ), ), { id: "year", title: t("plex.filter.year"), body: ( ), }, ...(scope === "movie" ? [ { id: "duration", title: t("plex.filter.duration"), body: ( ({ 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: ( ({ 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) => ( ({ ...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) => ( 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) => ( ({ value: p.value, label: t(`plex.filter.playableOpt.${p.value}`), count: p.count, }))} selected={filters.playable} onChange={(next) => patch({ playable: next })} /> ), ) : []), ]; return ( } 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={} />} onTitleClick={openModal} titleActionLabel={t("views.manageTitle")} > {yearHidden && facetsStatus} {/* The manager modal lives here (rail is always mounted on plex); Modal portals to . */} {modalOpen && } ); } // 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 ( void playlistsQ.refetch()} emptyText={t("plex.playlist.none")} renderOption={(o) => o.value === CREATE_PLAYLIST ? ( <> {t("plex.playlist.createNamed", { name: o.label })} ) : ( <> {o.label} {o.count != null && ( {o.count} )} ) } 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 // 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 | undefined; mode: "any" | "all"; onChange: (values: string[], titles: Record | 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 = { ...resolved }; for (const o of options) if (o.label) labelMap[o.value] = o.label; return ( <> 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 = {}; 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 ( {label} ); } /** 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) => void; }) { const { t } = useTranslation(); return (
{status} {facets && facets.years.length > 0 && ( patch(oneYear(year))} /> )}
patch({ yearMin: v })} /> – patch({ yearMax: v })} />
); } /** 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 ( {t("common.loadError")} ); return (

{t("common.loading")}

); } 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 ( { const n = e.target.value === "" ? null : Number(e.target.value); onChange(n != null && Number.isFinite(n) ? n : null); }} /> ); }