- Facets now respect the watch-state filter too (In progress / Watched / Unwatched), so e.g. the Year bounds match the visible set (was showing the library min, not the filtered min). - Genre chips sorted alphabetically (was by count). - Sort chips ordered alphabetically by their localized label. - Ascending/Descending chips swapped (Ascending first) and the default sort direction is now Ascending. - Default sort is now Title (ascending) instead of Recently added / Descending.
461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
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";
|
||
|
||
// 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.
|
||
const facetsQ = useQuery({
|
||
queryKey: ["plex-facets", scope, show, filters],
|
||
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;
|
||
|
||
// (Collection filtering: the active-collection chip is set from an item info page's "Browse
|
||
// collection"; there's no per-library picker in the unified cross-library scope.)
|
||
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 (
|
||
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
|
||
<button
|
||
onClick={onToggleCollapse}
|
||
title={t("sidebar.expandPanel")}
|
||
aria-label={t("sidebar.expandPanel")}
|
||
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={onToggleCollapse}
|
||
title={t("sidebar.filters")}
|
||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||
>
|
||
<SlidersHorizontal className="w-5 h-5" />
|
||
{activeCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
|
||
{activeCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
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">
|
||
{/* 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 — active-collection chip only (set from an item's info page "Browse
|
||
collection"). The searchable picker is per-library, so it's omitted in the unified scope. */}
|
||
{filters.collection && (
|
||
<Section label={t("plex.filter.collection")}>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<RemovableChip
|
||
label={filters.collectionTitle || filters.collection}
|
||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||
/>
|
||
</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"
|
||
/>
|
||
);
|
||
}
|