Correctness / behaviour:
- Move the Personal Views modal from inside the collapsible filter rail to the
feed page (moduleRegistry FeedPage), so opening it from the always-visible
toolbar view chip works while the rail is unpinned (its default) instead of
no-op'ing and latching modalOpen into a later stale pop-open.
- Migrate a persisted legacy global `tagMode` into per-category `tagModes` on the
localStorage seed paths too (loadAccountFilters), matching the saved-view and
URL paths — an upgrading user's topic any/all no longer silently reverts to 'or'.
- DateFilterSelect: reset the custom reveal on an external clear while the popover
is closed (guard on !open, not maxAgeDays), so the trigger reverts to "Any time"
instead of lingering on "Custom range" with empty fields.
- PersonalViewsModal delete: only advance the detail selection when the delete
actually happened (remove() now resolves a boolean) — cancelling the confirm no
longer switches the selected view.
- SavedViewsProvider: prefer the explicitly-applied (sticky) view among several
that capture identical filters, so apply(B) isn't re-attributed to an earlier
duplicate A (wrong "modified" chip name).
A11y:
- Give the clickable panel title a descriptive accessible name (titleActionLabel,
wired to "Personal views") instead of announcing only "Filters".
- Give the "+N" overflow pill an aria-label ("N more filters").
- Use t("views.reorder") for the saved-view drag handle instead of a hardcoded
English "reorder" literal.
Also extract the chip-fit decision into a pure, testable lib/chipOverflow.ts and
measure the inter-chip gap live (rem-safe) instead of a frozen 6px constant.
179 lines
7.9 KiB
TypeScript
179 lines
7.9 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,
|
|
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>
|
|
);
|
|
}
|