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.
This commit is contained in:
parent
7f358f63e3
commit
d92e487cf4
18 changed files with 1111 additions and 872 deletions
127
frontend/src/components/SidePanel.tsx
Normal file
127
frontend/src/components/SidePanel.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read
|
||||
// as one system: a rounded glass card that floats beside the nav (aligned with the header top,
|
||||
// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a
|
||||
// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses
|
||||
// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav).
|
||||
export default function SidePanel({
|
||||
title,
|
||||
icon,
|
||||
count = 0,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
editing = false,
|
||||
onToggleEditing,
|
||||
onReset,
|
||||
editHint,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
count?: number;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
// Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely.
|
||||
editing?: boolean;
|
||||
onToggleEditing?: () => void;
|
||||
onReset?: () => void;
|
||||
editHint?: string;
|
||||
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fade = useScrollFade();
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.expand")}
|
||||
aria-label={t("sidebar.expand")}
|
||||
className="hidden md:flex my-3 mr-3 w-11 shrink-0 rounded-2xl glass glass-hover flex-col items-center justify-center gap-3 text-fg"
|
||||
>
|
||||
<span className="text-accent">{icon}</span>
|
||||
{count > 0 && (
|
||||
<span className="bg-accent text-accent-fg text-[10px] font-bold px-1.5 py-0.5 rounded-full tabular-nums">
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted"
|
||||
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex my-3 mr-3 w-64 shrink-0 rounded-2xl glass flex-col overflow-hidden">
|
||||
{/* Row 1 — identity only: collapse, icon, title (flexes + truncates last), count, customize.
|
||||
Module actions live on their own row below so the title always has room. */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.collapsePanel")}
|
||||
aria-label={t("sidebar.collapsePanel")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="shrink-0 flex text-accent">{icon}</span>
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count })}
|
||||
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
title={t("sidebar.resetDefaults")}
|
||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onToggleEditing && (
|
||||
<button
|
||||
onClick={onToggleEditing}
|
||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
||||
{!editing && actions && (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={fade.ref} style={fade.style} className="flex-1 min-h-0 overflow-y-auto no-scrollbar">
|
||||
<div className="p-3 space-y-3">
|
||||
{editing && editHint && <div className="text-[11px] text-muted">{editHint}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue