import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; // The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the // full metadata filter set (genre / rating / year / duration / added / content rating + the // director/actor/studio chips set by clicking the info page); shows keep library + sort only. State // is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the // backend so the sidebar only offers what the library actually contains. type Props = { scope: string; // movie | show | both (unified cross-library scope) setScope: (v: string) => void; show: string; setShow: (v: string) => void; sort: string; setSort: (v: string) => void; filters: PlexFilters; setFilters: (f: PlexFilters) => void; onOpenPlaylist: (id: number) => 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; // Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime). 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; // Duration buckets → [minSeconds|null, maxSeconds|null]. 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({ scope, setScope, show, setShow, sort, setSort, filters, setFilters, onOpenPlaylist, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); 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); } // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change. // Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets, // so keying on the whole `filters` object would re-run the heavy facet computation on every // sort-direction toggle (or chip-title change) for an identical request. 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, // keep the old chips visible during the refetch (no flicker) }); const facets = facetsQ.data; // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the // unified scope has no single library). Only fetched while no collection is active (else the chip // shows instead). Applying one sets the collection filter; the actual filtering already flows through // /library + /facets by rating_key. const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ // "union" disambiguates from the editor's per-library ["plex-collections", ] key (a search // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working. 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"); }; // Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime). // Ordered alphabetically by their localized label. 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; if (collapsed) { return ( ); } return ( ); } function Section({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); } 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" /> ); }