refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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,
|
||||
iconExpanded,
|
||||
onTitleClick,
|
||||
titleActionLabel,
|
||||
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;
|
||||
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
|
||||
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
|
||||
iconExpanded?: ReactNode;
|
||||
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
|
||||
onTitleClick?: () => void;
|
||||
// Accessible name / tooltip describing what onTitleClick does (the plain title text alone doesn't).
|
||||
titleActionLabel?: string;
|
||||
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 --rail-pinned-w: 16.75rem (in-flow, pushes) on a
|
||||
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
|
||||
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
|
||||
<div
|
||||
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
|
||||
// on this full-height slot — that made the whole column next to the nav rail a live target, so
|
||||
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
|
||||
// handle + the expanded card below, so only pointing AT the tab opens it.
|
||||
{...peek.focusHandlers}
|
||||
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "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
|
||||
{...peek.hoverHandlers}
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
className={`glass glass-hover absolute top-3 left-0 z-paneltab 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-3xs font-bold px-1.5 py-0.5 rounded-full tabular-nums">
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-[0.18em] text-muted"
|
||||
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<aside
|
||||
{...peek.hoverHandlers}
|
||||
className="glass absolute top-3 bottom-3 left-0 z-panel 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>
|
||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
aria-label={titleActionLabel}
|
||||
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{count > 0 && (
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count })}
|
||||
className="shrink-0 min-w-4.5 h-4.5 px-1.5 rounded-full bg-accent/15 text-accent text-2xs 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-2xs text-muted">{editHint}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user