136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
|
|
import { useEffect } from "react";
|
||
|
|
import { useTranslation } from "react-i18next";
|
||
|
|
import { useQuery } from "@tanstack/react-query";
|
||
|
|
import { ChevronRight, Film, SlidersHorizontal, Tv2 } from "lucide-react";
|
||
|
|
import { api } from "../lib/api";
|
||
|
|
|
||
|
|
// The Plex module's left filter column (mirrors the feed Sidebar's shell): pick a library, filter
|
||
|
|
// by watch state (movie libraries only), and sort. State is owned by App (per-account persisted).
|
||
|
|
|
||
|
|
type Props = {
|
||
|
|
library: string;
|
||
|
|
setLibrary: (v: string) => void;
|
||
|
|
show: string;
|
||
|
|
setShow: (v: string) => void;
|
||
|
|
sort: string;
|
||
|
|
setSort: (v: string) => void;
|
||
|
|
collapsed: boolean;
|
||
|
|
onToggleCollapse: () => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||
|
|
const SORT_OPTS = ["added", "title"] as const;
|
||
|
|
|
||
|
|
export default function PlexSidebar({
|
||
|
|
library,
|
||
|
|
setLibrary,
|
||
|
|
show,
|
||
|
|
setShow,
|
||
|
|
sort,
|
||
|
|
setSort,
|
||
|
|
collapsed,
|
||
|
|
onToggleCollapse,
|
||
|
|
}: Props) {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||
|
|
const libs = libsQ.data?.libraries ?? [];
|
||
|
|
const activeLib = libs.find((l) => l.key === library);
|
||
|
|
|
||
|
|
// Default to the first library once loaded (or if the stored one vanished).
|
||
|
|
useEffect(() => {
|
||
|
|
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||
|
|
}, [libs, library, setLibrary]);
|
||
|
|
|
||
|
|
const isMovieLib = activeLib?.kind === "movie";
|
||
|
|
const activeCount = (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||
|
|
|
||
|
|
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">
|
||
|
|
{/* Library scope */}
|
||
|
|
<div>
|
||
|
|
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.library")}</div>
|
||
|
|
<div className="flex flex-col gap-1">
|
||
|
|
{libs.map((l) => (
|
||
|
|
<button
|
||
|
|
key={l.key}
|
||
|
|
onClick={() => setLibrary(l.key)}
|
||
|
|
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
||
|
|
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
|
||
|
|
<span className="flex-1 text-left truncate">{l.title}</span>
|
||
|
|
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Watch state (movie libraries only — an episode's state lives in the show drill-down) */}
|
||
|
|
{isMovieLib && (
|
||
|
|
<div>
|
||
|
|
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.show")}</div>
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{SHOW_OPTS.map((s) => (
|
||
|
|
<button
|
||
|
|
key={s}
|
||
|
|
onClick={() => setShow(s)}
|
||
|
|
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||
|
|
show === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{t(`plex.filter.showOpt.${s}`)}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Sort */}
|
||
|
|
<div>
|
||
|
|
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.sort")}</div>
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{SORT_OPTS.map((s) => (
|
||
|
|
<button
|
||
|
|
key={s}
|
||
|
|
onClick={() => setSort(s)}
|
||
|
|
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||
|
|
sort === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{t(`plex.sort.${s}`)}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
);
|
||
|
|
}
|