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:
parent
970ef352ec
commit
7a5f52a89b
5 changed files with 194 additions and 54 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue