Files
siftlode/frontend/src/components/PanelGroups.tsx
T
peter facb3ac9cb feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.

- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
  its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
  floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
2026-07-13 05:13:40 +02:00

81 lines
3.0 KiB
TypeScript

import { type ReactNode } from "react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import type { PanelLayout } from "../lib/panelLayout";
import PanelGroup from "./PanelGroup";
export type PanelItem = { id: string; title: string; body: ReactNode };
// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
// groups (a caller drops facet-gated / empty ones); the layout order is filtered to them so a
// stored order that references a now-absent group is tolerated. Reordering/hiding is active only
// in `editing` mode. Shared by all three side panels.
export default function PanelGroups({
items,
layout,
setLayout,
editing,
}: {
items: PanelItem[];
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
editing: boolean;
}) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const byId = new Map(items.map((it) => [it.id, it]));
const orderedAvailable = layout.order.filter((id) => byId.has(id));
// Append any available id missing from the stored order (e.g. a group that became available
// after the layout was saved) so it never silently disappears.
for (const it of items) if (!orderedAvailable.includes(it.id)) orderedAvailable.push(it.id);
const renderedIds = editing
? orderedAvailable
: orderedAvailable.filter((id) => !layout.hidden[id]);
function toggleCollapse(id: string) {
setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } });
}
function toggleHidden(id: string) {
setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } });
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const from = layout.order.indexOf(active.id as string);
const to = layout.order.indexOf(over.id as string);
if (from < 0 || to < 0) return;
setLayout({ ...layout, order: arrayMove(layout.order, from, to) });
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id) => (
<PanelGroup
key={id}
id={id}
title={byId.get(id)!.title}
editing={editing}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
>
{byId.get(id)!.body}
</PanelGroup>
))}
</div>
</SortableContext>
</DndContext>
);
}