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: (
<>
{(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 })}
/>
) : (
),
},
{
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"
/>
);
}