diff --git a/frontend/src/components/FilterRail.tsx b/frontend/src/components/FilterRail.tsx
new file mode 100644
index 00000000..2bc59b07
--- /dev/null
+++ b/frontend/src/components/FilterRail.tsx
@@ -0,0 +1,62 @@
+import { useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
+import SidePanel from "./SidePanel";
+import PanelGroups, { type PanelItem } from "./PanelGroups";
+
+// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
+// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
+// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
+// the layout default, edit hint); each rail supplies only what actually differs — its identity
+// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
+// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
+// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
+// that need neither.
+export default function FilterRail({
+ kind,
+ title,
+ icon,
+ count,
+ actions,
+ collapsed,
+ onToggleCollapse,
+ layout,
+ setLayout,
+ items,
+ top,
+ children,
+}: {
+ kind: PanelId;
+ title: string;
+ icon: ReactNode;
+ count?: number;
+ actions?: ReactNode;
+ collapsed: boolean;
+ onToggleCollapse: () => void;
+ layout: PanelLayout;
+ setLayout: (l: PanelLayout) => void;
+ items: PanelItem[];
+ top?: ReactNode;
+ children?: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const [editing, setEditing] = useState(false);
+ return (
+ setEditing((e) => !e)}
+ onReset={() => setLayout(defaultLayout(kind))}
+ editHint={t("sidebar.editHint")}
+ actions={actions}
+ >
+ {top}
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index 8fdfdd17..2c0d7ccb 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
-import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
-import SidePanel from "./SidePanel";
-import PanelGroups, { type PanelItem } from "./PanelGroups";
+import { type PanelLayout } from "../lib/panelLayout";
+import FilterRail from "./FilterRail";
+import { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
@@ -58,7 +58,6 @@ export default function PlaylistsRail({
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
- const [editing, setEditing] = useState(false);
const selectedRef = useRef(null);
const scrolledRef = useRef(false);
@@ -260,17 +259,15 @@ export default function PlaylistsRail({
];
return (
- }
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
- editing={editing}
- onToggleEditing={() => setEditing((e) => !e)}
- onReset={() => setLayout(defaultLayout("playlists"))}
- editHint={t("sidebar.editHint")}
- >
-
-
+ layout={layout}
+ setLayout={setLayout}
+ items={items}
+ />
);
}
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index a1a051b1..97dbf82b 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -5,11 +5,12 @@ import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
-import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
+import { type PanelLayout } from "../lib/panelLayout";
+import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
-import SidePanel from "./SidePanel";
-import PanelGroups, { type PanelItem } from "./PanelGroups";
+import FilterRail from "./FilterRail";
+import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import { usePlexClearFilters } from "./usePlexActiveFilters";
@@ -70,7 +71,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
- const [editing, setEditing] = useState(false);
+ // This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
+ // share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
+ const source: MediaSourceKind = "plex";
const qc = useQueryClient();
async function createPlaylist() {
const title = newPlaylist.trim();
@@ -402,16 +405,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
];
return (
- }
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
- editing={editing}
- onToggleEditing={() => setEditing((e) => !e)}
- onReset={() => setLayout(defaultLayout("plex"))}
- editHint={t("sidebar.editHint")}
+ layout={layout}
+ setLayout={setLayout}
+ items={items}
actions={
anyActive ? (