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:
@@ -10,8 +10,9 @@ import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||
import TagManager from "./TagManager";
|
||||
import SavedViewsWidget from "./SavedViewsWidget";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
@@ -108,7 +109,9 @@ export default function Sidebar({
|
||||
const userTags = tags.filter((t) => !t.system);
|
||||
const [customDates, setCustomDates] = 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) {
|
||||
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;
|
||||
// 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])
|
||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
||||
|
||||
@@ -314,46 +317,45 @@ export default function Sidebar({
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("feed"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
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 && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
</SidePanel>
|
||||
</FilterRail>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user