feat(plex): P1.5 — Plex as a nav module + integrated search + filter sidebar

Per user feedback: promote Plex from a hidden feed-Source option to a first-class
left-nav module, with its own filter sidebar and the shared top search box.

- Plex is now a nav-rail module (page='plex', gated on me.plex_enabled) — its own
  page → correct browser Back/Forward; removed the Feed Source-dropdown 'plex' hack.
- PlexSidebar.tsx: left filter column (library scope, watch-state for movies:
  all/unwatched/in_progress/watched, sort) — per-account persisted (App state).
- Header search box now also serves the Plex page (integrated search); the live
  YouTube-search escalation stays feed-only.
- PlexBrowse.tsx reworked: infinite scroll (IntersectionObserver, no 'Load more'),
  content-visibility for smoother long-list scroll, show drill-down rides history
  (useHistorySubview) so Back returns to the grid; dropped the stray 'YouTube feed'
  button on the show page.
- backend /browse gains a per-user watch-state filter (show=, movies only).
- plex i18n (navLabel + filter.*) en/hu/de.

Note: episode-title 'Episode N' on some shows (e.g. Westworld) is Plex-side
(unmatched show) — we mirror what Plex has; Match/Refresh in Plex + re-sync fixes it.
This commit is contained in:
npeter83 2026-07-05 03:29:20 +02:00
parent 62636319b1
commit 219a935121
14 changed files with 290 additions and 133 deletions

View file

@ -0,0 +1,135 @@
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>
);
}