import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; import { useScrollFade } from "../lib/useScrollFade"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import { usePlex, usePlexActions } from "./PlexProvider"; // 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. State is owned by App (per-account persisted); facets come from // the backend so the panel only offers what the library actually contains. type Props = { layout: PanelLayout; setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; 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 }, ]; // The any/all combine toggle for a multi-value filter (genres, actors). Same segmented-pill look as // the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row. function MatchToggle({ mode, onChange, }: { mode: "any" | "all"; onChange: (m: "any" | "all") => void; }) { const { t } = useTranslation(); return (
{t("plex.filter.matchLabel")}
{(["any", "all"] as const).map((m) => ( ))}
); } export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse, }: Props) { const { t } = 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, sort, filters } = usePlex(); const { setScope, setShow, setSort, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions(); const collFade = useScrollFade(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); const [editing, setEditing] = useState(false); const qc = useQueryClient(); async function createPlaylist() { const title = newPlaylist.trim(); if (!title) return; const pl = await api.plexCreatePlaylist(title); setNewPlaylist(""); qc.invalidateQueries({ queryKey: ["plex-playlists"] }); onOpenPlaylist(pl.id); } const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: ["plex-facets", scope, show, facetKey], queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, placeholderData: (prev) => prev, }); const facets = facetsQ.data; const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ queryKey: ["plex-collections", "union", collSearchDeb], queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), enabled: !filters.collection, }); const collections = collectionsQ.data?.collections ?? []; const fCount = plexFilterCount(filters); const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0); const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); const toggleIn = (arr: string[], v: string) => arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]; const clearAll = () => { setFilters(EMPTY_PLEX_FILTERS); setShow("all"); setSort("title"); }; const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), ); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0; // 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: "scope", title: t("plex.filter.scope"), body: (
{(["both", "movie", "show"] as const).map((s) => ( ))}
), }, { id: "playlists", title: t("plex.playlist.section"), body: ( <>
setNewPlaylist(e.target.value)} onKeyDown={(e) => e.key === "Enter" && createPlaylist()} placeholder={t("plex.playlist.newPlaceholder")} className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" />
{(playlistsQ.data?.playlists ?? []).length === 0 ? (

{t("plex.playlist.none")}

) : (
{playlistsQ.data!.playlists.map((p) => ( ))}
)} ), }, { id: "watch", title: t("plex.filter.show"), body: ( {SHOW_OPTS.map((s) => ( setShow(s)}> {t(`plex.filter.showOpt.${s}`)} ))} ), }, { id: "sort", title: t("plex.filter.sort"), body: ( <> {sorts.map((s) => ( setSort(s)}> {t(`plex.sort.${s}`)} ))}
{(["asc", "desc"] as const).map((d) => ( patch({ sortDir: d })}> {t(`plex.filter.dir.${d}`)} ))}
), }, ...(peopleActive ? [ { id: "people", title: t("plex.filter.active"), body: (
{filters.directors.map((d) => ( patch({ directors: filters.directors.filter((x) => x !== d) })} /> ))} {filters.actors.length > 1 && (
patch({ actorMode: m })} />
)} {filters.actors.map((a) => ( patch({ actors: filters.actors.filter((x) => x !== a) })} /> ))} {filters.studios.map((s) => ( patch({ studios: filters.studios.filter((x) => x !== s) })} /> ))}
), }, ] : []), { id: "collection", title: t("plex.filter.collection"), body: filters.collection ? (
patch({ collection: null, collectionTitle: null })} />
) : (
setCollSearch(e.target.value)} placeholder={t("plex.filter.collectionSearch")} className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none" />
{collections.map((c) => ( ))} {collections.length === 0 && ( {t("plex.filter.collectionEmpty")} )}
), }, { id: "rating", title: t("plex.filter.rating"), body: ( patch({ ratingMin: null })}> {t("plex.filter.any")} {RATING_STEPS.map((r) => ( patch({ ratingMin: r })}> {r}+ ))} ), }, ...(facets && facets.genres.length > 0 ? [ { id: "genre", title: t("plex.filter.genre"), body: ( <> {filters.genres.length > 1 && ( patch({ genreMode: m })} /> )} {facets.genres.map((g) => ( patch({ genres: toggleIn(filters.genres, g.value) })} > {g.value} ))} ), }, ] : []), { id: "year", title: t("plex.filter.year"), body: (
patch({ yearMin: v })} /> – patch({ yearMax: v })} />
), }, ...(scope === "movie" ? [ { id: "duration", title: t("plex.filter.duration"), body: ( patch({ durationMin: null, durationMax: null })} > {t("plex.filter.any")} {DURATION_BUCKETS.map((b) => ( patch({ durationMin: b.min, durationMax: b.max })} > {t(`plex.filter.durationOpt.${b.key}`)} ))} ), }, ] : []), { id: "added", title: t("plex.filter.added"), body: ( patch({ addedWithin: "" })}> {t("plex.filter.any")} {ADDED_OPTS.map((a) => ( patch({ addedWithin: a })}> {t(`plex.filter.addedOpt.${a}`)} ))} ), }, ...(facets && facets.content_ratings.length > 0 ? [ { id: "contentrating", title: t("plex.filter.contentRating"), body: ( {facets.content_ratings.map((c) => ( patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })} > {c.value} ))} ), }, ] : []), ]; return ( } count={activeCount} collapsed={collapsed} onToggleCollapse={onToggleCollapse} editing={editing} onToggleEditing={() => setEditing((e) => !e)} onReset={() => setLayout(defaultLayout("plex"))} editHint={t("sidebar.editHint")} actions={ anyActive ? ( ) : undefined } > ); } function ChipRow({ children }: { children: ReactNode }) { return
{children}
; } function Chip({ active, onClick, children, }: { active: boolean; onClick: () => void; children: ReactNode; }) { return ( ); } function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( {label} ); } function NumberInput({ value, placeholder, onChange, }: { 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); }} className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none" /> ); }