The tab stays mounted under the open card on purpose, but the card's 17px corner radius cuts away its own pixels and the tab's square docked corner sits right under that arc — showing a hairline of tab glass. Fade the tab out while the card is open; opacity-0 rather than invisible/hidden, so it stays hoverable and focusable (that is the whole reason it stays mounted).
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
|
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 as
|
|
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
|
// against the rail's right edge (this panel is the flex sibling right after it) and expands into a
|
|
// rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
|
|
// `collapsed` (persisted per-account) therefore means "unpinned".
|
|
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();
|
|
const peek = useHoverFocusWithin();
|
|
const pinned = !collapsed;
|
|
const expanded = pinned || peek.active;
|
|
|
|
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 — 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. It only goes transparent: the card's rounded corner cuts away its own pixels, and
|
|
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
|
|
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
|
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 transition-opacity ${
|
|
expanded ? "opacity-0" : ""
|
|
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
|
|
>
|
|
<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>
|
|
|
|
{expanded && (
|
|
<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: pin, 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={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
|
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"
|
|
>
|
|
{pinned ? <PinOff className="w-4 h-4" /> : <Pin 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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|