From 4fe6f38f1b127de1d461ee2b5c7e4d0171ce1f49 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 00:47:09 +0200 Subject: [PATCH] refactor(rail): extract shared FilterRail shell + name the MediaSource seam (E4 S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three filter rails — feed Sidebar, PlexSidebar, PlaylistsRail — each rendered the same SidePanel chrome (title bar, customize `editing` state, edit-toggle, reset-to-layout-default, edit hint) around a PanelGroups body, differing only in identity + filter content. Extracted that shell into components/FilterRail.tsx: each rail now supplies only what differs (title/icon, active-filter count + Clear-all action, its `items`, and optional content above/below the groups — `top` for the feed's scope toggle, `children` for its tag manager). FilterRail owns the `editing` state, so all three drop their duplicated useState + SidePanel wiring. MediaSource facade (lib/mediaSource.ts): a thin `MediaSourceKind = "feed" | "plex"` naming the two browsable, filterable sources (a PanelId minus "playlists", which is a rail but not a source). Already unified in practice — one SortControl, the source-agnostic ActiveFilter[] chips, PanelId layouts, now the shared shell — so this is formalization, not new capability: a single named seam for a future third source or a cross-source feature. The two media-source rails annotate their `kind` with it; PlaylistsRail passes the plain PanelId. Per the recon, the playlist-dialog merge was deliberately NOT forced (it proved forced + low value). UX-3 control-vocab sweep deliberately deferred — the rails already use the canonical primitives. Behaviour-preserving. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): all three rails render via FilterRail — feed (scope toggle + panel groups + Customize→editing with reset + 56 reorder affordances, exits clean), Plex (filter groups + customize), Playlists (sections + customize); 0 JS console errors. --- frontend/src/components/FilterRail.tsx | 62 +++++++++++++++++++ frontend/src/components/PlaylistsRail.tsx | 21 +++---- frontend/src/components/PlexSidebar.tsx | 25 ++++---- frontend/src/components/Sidebar.tsx | 72 ++++++++++++----------- frontend/src/lib/mediaSource.ts | 10 ++++ 5 files changed, 131 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/FilterRail.tsx create mode 100644 frontend/src/lib/mediaSource.ts 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 ? (