refactor(rail): extract shared FilterRail shell + name the MediaSource seam (E4 S7)
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.
This commit is contained in:
@@ -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 (
|
||||||
|
<SidePanel
|
||||||
|
title={title}
|
||||||
|
icon={icon}
|
||||||
|
count={count}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
editing={editing}
|
||||||
|
onToggleEditing={() => setEditing((e) => !e)}
|
||||||
|
onReset={() => setLayout(defaultLayout(kind))}
|
||||||
|
editHint={t("sidebar.editHint")}
|
||||||
|
actions={actions}
|
||||||
|
>
|
||||||
|
{top}
|
||||||
|
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||||
|
{children}
|
||||||
|
</SidePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import SortControl from "./SortControl";
|
import SortControl from "./SortControl";
|
||||||
import { type SortOption } from "../lib/columnSort";
|
import { type SortOption } from "../lib/columnSort";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
@@ -58,7 +58,6 @@ export default function PlaylistsRail({
|
|||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
|
|
||||||
@@ -260,17 +259,15 @@ export default function PlaylistsRail({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind="playlists"
|
||||||
title={t("playlists.title")}
|
title={t("playlists.title")}
|
||||||
icon={<ListVideo className="w-4 h-4" />}
|
icon={<ListVideo className="w-4 h-4" />}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("playlists"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
/>
|
||||||
>
|
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
</SidePanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { plexLang } from "../i18n";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
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 { useDebounced } from "../lib/useDebounced";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
@@ -70,7 +71,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const collFade = useScrollFade();
|
const collFade = useScrollFade();
|
||||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
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();
|
const qc = useQueryClient();
|
||||||
async function createPlaylist() {
|
async function createPlaylist() {
|
||||||
const title = newPlaylist.trim();
|
const title = newPlaylist.trim();
|
||||||
@@ -402,16 +405,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
count={activeCount}
|
count={activeCount}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("plex"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
|
||||||
actions={
|
actions={
|
||||||
anyActive ? (
|
anyActive ? (
|
||||||
<button
|
<button
|
||||||
@@ -423,9 +426,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
</button>
|
</button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
</SidePanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
|||||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsWidget from "./SavedViewsWidget";
|
import SavedViewsWidget from "./SavedViewsWidget";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
@@ -108,7 +109,9 @@ export default function Sidebar({
|
|||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
const [customDates, setCustomDates] = useState(false);
|
const [customDates, setCustomDates] = useState(false);
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
||||||
|
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
|
const source: MediaSourceKind = "feed";
|
||||||
|
|
||||||
function toggleTag(id: number) {
|
function toggleTag(id: number) {
|
||||||
const has = filters.tags.includes(id);
|
const has = filters.tags.includes(id);
|
||||||
@@ -297,7 +300,7 @@ export default function Sidebar({
|
|||||||
|
|
||||||
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
|
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
|
||||||
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
||||||
const items: PanelItem[] = defaultLayout("feed")
|
const items: PanelItem[] = defaultLayout(source)
|
||||||
.order.filter((id) => available[id])
|
.order.filter((id) => available[id])
|
||||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
||||||
|
|
||||||
@@ -314,46 +317,45 @@ export default function Sidebar({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
count={activeCount}
|
count={activeCount}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("feed"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
top={
|
||||||
|
/* Scope: your own subscriptions (Mine) vs the shared Library. */
|
||||||
|
<SegmentedToggle
|
||||||
|
ariaLabel={t("header.scope.label")}
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "my",
|
||||||
|
label: t("header.scope.my"),
|
||||||
|
icon: <User className="w-3.5 h-3.5" />,
|
||||||
|
title: t("header.scope.myTip"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: t("header.scope.all"),
|
||||||
|
icon: <Library className="w-3.5 h-3.5" />,
|
||||||
|
title: t("header.scope.allTip"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={filters.scope}
|
||||||
|
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
|
||||||
<SegmentedToggle
|
|
||||||
ariaLabel={t("header.scope.label")}
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "my",
|
|
||||||
label: t("header.scope.my"),
|
|
||||||
icon: <User className="w-3.5 h-3.5" />,
|
|
||||||
title: t("header.scope.myTip"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "all",
|
|
||||||
label: t("header.scope.all"),
|
|
||||||
icon: <Library className="w-3.5 h-3.5" />,
|
|
||||||
title: t("header.scope.allTip"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={filters.scope}
|
|
||||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
|
|
||||||
{tagManagerOpen && (
|
{tagManagerOpen && (
|
||||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||||
)}
|
)}
|
||||||
</SidePanel>
|
</FilterRail>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PanelId } from "./panelLayout";
|
||||||
|
|
||||||
|
// The two browsable media sources the app filters over: the YouTube feed and the Plex library.
|
||||||
|
// (A `PanelId` minus "playlists" — a rail, but not a filterable source.) The pair is already unified
|
||||||
|
// in practice — one shared SortControl, the source-agnostic `ActiveFilter[]` chip row, PanelId-keyed
|
||||||
|
// sidebar layouts, and the shared `FilterRail` shell. This names that abstraction as one narrow
|
||||||
|
// contract, so a future third source (or a feature built across sources) targets `MediaSourceKind`
|
||||||
|
// instead of re-deriving it from the YT/Plex twins. Deliberately thin — just the kind; widen it only
|
||||||
|
// when a concrete cross-source feature needs more than the name.
|
||||||
|
export type MediaSourceKind = Extract<PanelId, "feed" | "plex">;
|
||||||
Reference in New Issue
Block a user