siftlode/frontend/src/components/PlexSidebar.tsx
npeter83 b481de0e48 feat(plex): collapse button in the expanded Plex filter sidebar
The collapse feature was only half-present on the Plex page: App already passed the
shared filterCollapsed state + toggle to PlexSidebar, and PlexSidebar rendered the
CollapsedFilterRail when collapsed — but the EXPANDED PlexSidebar had no trigger, so
you could only collapse it by first collapsing on the feed page. Add the same
ChevronLeft 'Filters' collapse header the feed Sidebar has (reusing the existing
sidebar.* i18n keys). E2E-verified: collapse→rail→expand round-trips on the Plex page.
2026-07-12 01:55:10 +02:00

504 lines
20 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 { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react";
import CollapsedFilterRail from "./CollapsedFilterRail";
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", <plex_key>] 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<PlexFilters>) => 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 <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
}
return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
{/* Collapse control + active-filter count — mirrors the feed Sidebar so the Plex filter panel
can be collapsed from the Plex page too (the collapsed rail + shared state already existed). */}
<div className="flex items-center gap-1.5 min-w-0">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
{activeCount > 0 && (
<span
title={t("sidebar.activeCount", { count: activeCount })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{activeCount}
</span>
)}
</div>
{/* Scope — one unified library, filtered to movies, shows, or both. */}
<Section label={t("plex.filter.scope")}>
<div className="flex gap-1">
{(["both", "movie", "show"] as const).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
</button>
))}
</div>
</Section>
{/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
a filter. Available in any library. */}
<Section label={t("plex.playlist.section")}>
<div className="mb-1.5 flex gap-1.5">
<input
value={newPlaylist}
onChange={(e) => 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"
/>
<button
onClick={createPlaylist}
disabled={!newPlaylist.trim()}
title={t("plex.playlist.create")}
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
) : (
<div className="space-y-0.5">
{playlistsQ.data!.playlists.map((p) => (
<button
key={p.id}
onClick={() => onOpenPlaylist(p.id)}
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate">{p.title}</span>
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
</button>
))}
</div>
)}
</Section>
{anyActive && (
<button
onClick={clearAll}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition"
>
<X className="w-3.5 h-3.5" />
{t("plex.filter.clearAll")} ({activeCount})
</button>
)}
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
<Section label={t("plex.filter.show")}>
<ChipRow>
{SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
{t(`plex.filter.showOpt.${s}`)}
</Chip>
))}
</ChipRow>
</Section>
{/* Sort + direction */}
<Section label={t("plex.filter.sort")}>
<ChipRow>
{sorts.map((s) => (
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
{t(`plex.sort.${s}`)}
</Chip>
))}
</ChipRow>
<div className="mt-1.5 flex gap-1">
{(["asc", "desc"] as const).map((d) => (
<Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
{t(`plex.filter.dir.${d}`)}
</Chip>
))}
</div>
</Section>
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
{(
<>
{/* Active people / studios — set by clicking the info page (stackable). */}
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
<Section label={t("plex.filter.active")}>
<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) })}
/>
))}
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
label={a}
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
/>
))}
{filters.studios.map((s) => (
<RemovableChip
key={`s:${s}`}
label={s}
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
/>
))}
</div>
</Section>
)}
{/* Collection — the active one shows as a chip; otherwise a searchable picker over the union
of all enabled libraries' collections (also settable from an item info page's "Browse
collection"). */}
<Section label={t("plex.filter.collection")}>
{filters.collection ? (
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => 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"
/>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
)}
</div>
</div>
)}
</Section>
{/* IMDb rating min */}
<Section label={t("plex.filter.rating")}>
<ChipRow>
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
{t("plex.filter.any")}
</Chip>
{RATING_STEPS.map((r) => (
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
{r}+
</Chip>
))}
</ChipRow>
</Section>
{/* Genres (from facets) + Any/All when more than one is picked */}
{facets && facets.genres.length > 0 && (
<Section label={t("plex.filter.genre")}>
{filters.genres.length > 1 && (
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => patch({ genreMode: m })}
className={`px-2 py-0.5 transition ${
(filters.genreMode ?? "any") === m
? "bg-accent text-accent-fg"
: "text-muted hover:bg-surface"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
)}
<ChipRow>
{facets.genres.map((g) => (
<Chip
key={g.value}
active={filters.genres.includes(g.value)}
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
>
{g.value}
</Chip>
))}
</ChipRow>
</Section>
)}
{/* Year range */}
<Section label={t("plex.filter.year")}>
<div className="flex items-center gap-2">
<NumberInput
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs"></span>
<NumberInput
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
</Section>
{/* Duration buckets — movie-only (a show has no single runtime) */}
{scope === "movie" && (
<Section label={t("plex.filter.duration")}>
<ChipRow>
<Chip
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
onClick={() => patch({ durationMin: null, durationMax: null })}
>
{t("plex.filter.any")}
</Chip>
{DURATION_BUCKETS.map((b) => (
<Chip
key={b.key}
active={durBucketKey === b.key}
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
>
{t(`plex.filter.durationOpt.${b.key}`)}
</Chip>
))}
</ChipRow>
</Section>
)}
{/* Added to Plex */}
<Section label={t("plex.filter.added")}>
<ChipRow>
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
{t("plex.filter.any")}
</Chip>
{ADDED_OPTS.map((a) => (
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
{t(`plex.filter.addedOpt.${a}`)}
</Chip>
))}
</ChipRow>
</Section>
{/* Content rating (from facets) */}
{facets && facets.content_ratings.length > 0 && (
<Section label={t("plex.filter.contentRating")}>
<ChipRow>
{facets.content_ratings.map((c) => (
<Chip
key={c.value}
active={filters.contentRatings.includes(c.value)}
onClick={() => patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })}
>
{c.value}
</Chip>
))}
</ChipRow>
</Section>
)}
</>
)}
</aside>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div>
{children}
</div>
);
}
function ChipRow({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap gap-1">{children}</div>;
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
className={`px-2.5 py-1 rounded-full text-xs transition ${
active ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{children}
</button>
);
}
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
<span className="truncate max-w-[9rem]">{label}</span>
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
<X className="w-3 h-3" />
</button>
</span>
);
}
function NumberInput({
value,
placeholder,
onChange,
}: {
value: number | null;
placeholder?: number;
onChange: (v: number | null) => void;
}) {
return (
<input
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);
}}
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none"
/>
);
}