feat(panels): peek-open side panels with a docked tab
The shared SidePanel now follows the nav rail's model, so Filters, Plex and Playlists all inherit it: unpinned it sits as a small tab docked flush against the rail (replacing the full-height vertical strip) and expands into the card on hover or keyboard focus without pushing content; pinned it stays expanded in-flow. The collapse toggle became pin/unpin, and panels now default to unpinned. The tab carries the active-filter count and an accent ring. The tab stays mounted under the open card on purpose: swapping it out from under the pointer drops the hover that opened the card, and unmounting it while focused strands keyboard focus. Measured: feed content starts at 123px instead of 336px when unpinned.
This commit is contained in:
@@ -373,12 +373,13 @@ export default function App() {
|
|||||||
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readAccount(LS.navCollapsed, true))
|
Boolean(readAccount(LS.navCollapsed, true))
|
||||||
);
|
);
|
||||||
|
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readAccount(LS.filterCollapsed, false))
|
Boolean(readAccount(LS.filterCollapsed, true))
|
||||||
);
|
);
|
||||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
||||||
Boolean(readAccount(LS.playlistsCollapsed, false))
|
Boolean(readAccount(LS.playlistsCollapsed, true))
|
||||||
);
|
);
|
||||||
function setNavCollapsed(next: boolean) {
|
function setNavCollapsed(next: boolean) {
|
||||||
setNavCollapsedState(next);
|
setNavCollapsedState(next);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react";
|
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
||||||
|
|
||||||
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read
|
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
||||||
// as one system: a rounded glass card that floats beside the nav (aligned with the header top,
|
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
||||||
// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a
|
// against the rail's right edge (this panel is the flex sibling right after it) and expands into a
|
||||||
// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses
|
// rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
|
||||||
// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav).
|
// `collapsed` (persisted per-account) therefore means "unpinned".
|
||||||
export default function SidePanel({
|
export default function SidePanel({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
@@ -37,14 +38,29 @@ export default function SidePanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fade = useScrollFade();
|
const fade = useScrollFade();
|
||||||
|
const peek = useHoverFocusWithin();
|
||||||
|
const pinned = !collapsed;
|
||||||
|
const expanded = pinned || peek.active;
|
||||||
|
|
||||||
if (collapsed) {
|
return (
|
||||||
return (
|
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
|
||||||
|
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
|
||||||
|
<div
|
||||||
|
{...peek.handlers}
|
||||||
|
className={`hidden md:block relative shrink-0 transition-[width] ${
|
||||||
|
pinned ? "w-[268px]" : "w-9"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
|
||||||
|
mounted while the card is open (the card shares its top-left corner and covers it) —
|
||||||
|
swapping it out from under the pointer would drop the very hover that opened the card, and
|
||||||
|
unmounting it while focused would throw keyboard focus to nowhere. */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
title={t("sidebar.expand")}
|
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||||
aria-label={t("sidebar.expand")}
|
className={`glass glass-hover absolute top-3 left-0 z-[34] flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl ${
|
||||||
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"
|
count > 0 ? "ring-1 ring-accent/40" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-accent">{icon}</span>
|
<span className="text-accent">{icon}</span>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
@@ -59,69 +75,73 @@ export default function SidePanel({
|
|||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{expanded && (
|
||||||
<aside className="hidden md:flex my-3 mr-3 w-64 shrink-0 rounded-2xl glass flex-col overflow-hidden">
|
<aside className="glass absolute top-3 bottom-3 left-0 z-[35] w-64 rounded-2xl flex flex-col overflow-hidden">
|
||||||
{/* Row 1 — identity only: collapse, icon, title (flexes + truncates last), count, customize.
|
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
|
||||||
Module actions live on their own row below so the title always has room. */}
|
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">
|
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
title={t("sidebar.collapsePanel")}
|
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||||
aria-label={t("sidebar.collapsePanel")}
|
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 flex text-accent">{icon}</span>
|
<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]">
|
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span
|
<span
|
||||||
title={t("sidebar.activeCount", { count })}
|
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"
|
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}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editing && onReset && (
|
{editing && onReset && (
|
||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
title={t("sidebar.resetDefaults")}
|
title={t("sidebar.resetDefaults")}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onToggleEditing && (
|
{onToggleEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleEditing}
|
onClick={onToggleEditing}
|
||||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||||
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
||||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
||||||
{!editing && actions && (
|
{!editing && actions && (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
"hideWidget": "Hide widget",
|
"hideWidget": "Hide widget",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"collapsePanel": "Collapse filters",
|
"pin": "Pin panel open",
|
||||||
"expandPanel": "Expand filters",
|
"unpin": "Unpin panel",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"tagModeTooltip": "Match any vs all selected tags",
|
"tagModeTooltip": "Match any vs all selected tags",
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
"hideWidget": "Modul elrejtése",
|
"hideWidget": "Modul elrejtése",
|
||||||
"expand": "Kibontás",
|
"expand": "Kibontás",
|
||||||
"collapse": "Összecsukás",
|
"collapse": "Összecsukás",
|
||||||
"collapsePanel": "Szűrők összecsukása",
|
"pin": "Panel rögzítése nyitva",
|
||||||
"expandPanel": "Szűrők kibontása",
|
"unpin": "Panel rögzítésének feloldása",
|
||||||
"any": "Bármelyik",
|
"any": "Bármelyik",
|
||||||
"all": "Összes",
|
"all": "Összes",
|
||||||
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||||
|
|||||||
Reference in New Issue
Block a user