feat(nav): group rail modules + move chrome controls into the sidebar

- Split the rail into a content group (Feed/Channels/Playlists) and an admin
  group (Stats/Scheduler) separated by a divider (system group hidden for non-admins).
- Move the language switcher, About and notification bell out of the top header
  into an icon cluster above Settings (horizontal expanded, vertical collapsed).
  Their popovers portal to <body> and anchor right + above the button, escaping the
  nav's backdrop-filter. About is removed from the account popover (now in the cluster).
- LanguageSwitcher/NotificationCenter gain a 'rail' variant for the above.
- Also relocate the Toaster mount into the (now relative) content column.
This commit is contained in:
npeter83 2026-06-17 14:28:29 +02:00
parent 970ef352ec
commit 7a5f52a89b
5 changed files with 194 additions and 54 deletions

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
@ -31,26 +32,44 @@ function relTime(ts: number, t: TFunction): string {
export default function NotificationCenter({
filters,
setFilters,
variant = "header",
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
variant?: "header" | "rail";
}) {
const { t } = useTranslation();
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const [open, setOpen] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Rail variant anchors the panel right + above the button (fixed, viewport coords) and
// portals it to <body> so it escapes the nav's backdrop-filter.
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const qc = useQueryClient();
// Close when clicking anywhere outside the panel.
// Close when clicking anywhere outside the button + panel.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
if (variant === "header" && wrap.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
}, [open, variant]);
function toggle() {
if (!open && variant === "rail") {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Opening the center marks everything read.
useEffect(() => {
@ -81,10 +100,21 @@ export default function NotificationCenter({
.catch(() => {});
}
function renderPanel(el: JSX.Element) {
if (variant !== "rail") return el;
return createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
{el}
</div>,
document.body
);
}
return (
<div className="relative" ref={wrap}>
<button
onClick={() => setOpen((o) => !o)}
ref={btnRef}
onClick={toggle}
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
@ -96,8 +126,14 @@ export default function NotificationCenter({
)}
</button>
{open && (
<div className="glass-menu absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
{open &&
renderPanel(
<div
ref={panelRef}
className={`glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease] ${
variant === "rail" ? "" : "absolute right-0 mt-2 z-30"
}`}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (