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
|
|
@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { Library, Search, User } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { type LangCode } from "../i18n";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
|
||||
// Contextual top bar. Primary navigation + account now live in the left NavSidebar; the
|
||||
// header carries the global sync status, the feed's scope toggle + search (or the current
|
||||
|
|
@ -15,17 +12,15 @@ export default function Header({
|
|||
filters,
|
||||
setFilters,
|
||||
page,
|
||||
onChangeLanguage,
|
||||
onGoToFullHistory,
|
||||
}: {
|
||||
me: Me;
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
page: Page;
|
||||
onChangeLanguage: (code: LangCode) => void;
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||
|
|
@ -83,11 +78,6 @@ export default function Header({
|
|||
: t("header.channelManager")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
|
||||
<NotificationCenter filters={filters} setFilters={setFilters} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
//
|
||||
// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left
|
||||
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and
|
||||
// anchored to the right + above the button, escaping the nav's backdrop-filter which would
|
||||
// otherwise trap an absolutely-positioned popover).
|
||||
export default function LanguageSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
align = "right",
|
||||
variant = "header",
|
||||
}: {
|
||||
value: LangCode;
|
||||
onChange: (code: LangCode) => void;
|
||||
align?: "left" | "right";
|
||||
variant?: "header" | "rail";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
||||
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
|
||||
|
||||
function openNow() {
|
||||
|
|
@ -24,6 +35,77 @@ export default function LanguageSwitcher({
|
|||
function closeSoon() {
|
||||
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
||||
}
|
||||
function toggleRail() {
|
||||
if (!open) {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
// Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check
|
||||
// spans both the button and the floating panel).
|
||||
useEffect(() => {
|
||||
if (variant !== "rail" || !open) return;
|
||||
function onDoc(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [variant, open]);
|
||||
|
||||
const menu = (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
onChange(l.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<span>{l.label}</span>
|
||||
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "rail") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={toggleRail}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Globe className="w-5 h-5" />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
|
||||
{menu}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@ import {
|
|||
Tv,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
|
||||
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
|
||||
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
|
||||
|
|
@ -28,11 +31,19 @@ export default function NavSidebar({
|
|||
page,
|
||||
setPage,
|
||||
onOpenAbout,
|
||||
onChangeLanguage,
|
||||
language,
|
||||
filters,
|
||||
setFilters,
|
||||
}: {
|
||||
me: Me;
|
||||
page: Page;
|
||||
setPage: (p: Page) => void;
|
||||
onOpenAbout: () => void;
|
||||
onChangeLanguage: (code: LangCode) => void;
|
||||
language: LangCode;
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
|
|
@ -105,20 +116,40 @@ export default function NavSidebar({
|
|||
}
|
||||
}
|
||||
|
||||
const items: { page: Page; icon: typeof Home; label: string }[] = [
|
||||
type NavItem = { page: Page; icon: typeof Home; label: string };
|
||||
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
||||
const userItems: NavItem[] = [
|
||||
{ page: "feed", icon: Home, label: t("header.account.feed") },
|
||||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||
];
|
||||
if (me.role === "admin") {
|
||||
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
|
||||
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
|
||||
}
|
||||
const systemItems: NavItem[] =
|
||||
me.role === "admin"
|
||||
? [
|
||||
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||
]
|
||||
: [];
|
||||
|
||||
const rowBase =
|
||||
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
||||
const name = me.display_name ?? me.email.split("@")[0];
|
||||
|
||||
const renderItem = ({ page: p, icon: Icon, label }: NavItem) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
title={collapsed ? label : undefined}
|
||||
aria-current={page === p ? "page" : undefined}
|
||||
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
|
||||
page === p ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] shrink-0" />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
||||
|
|
@ -151,25 +182,30 @@ export default function NavSidebar({
|
|||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
||||
{items.map(({ page: p, icon: Icon, label }) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
title={collapsed ? label : undefined}
|
||||
aria-current={page === p ? "page" : undefined}
|
||||
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
|
||||
page === p
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] shrink-0" />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</button>
|
||||
))}
|
||||
{userItems.map(renderItem)}
|
||||
{systemItems.length > 0 && (
|
||||
<div className="my-1.5 border-t border-border/70" />
|
||||
)}
|
||||
{systemItems.map(renderItem)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
|
||||
collapsed ? "flex-col" : "flex-row"
|
||||
}`}
|
||||
>
|
||||
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||
<button
|
||||
onClick={onOpenAbout}
|
||||
title={t("header.account.about")}
|
||||
aria-label={t("header.account.about")}
|
||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Info className="w-5 h-5" />
|
||||
</button>
|
||||
<NotificationCenter variant="rail" filters={filters} setFilters={setFilters} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPage("settings")}
|
||||
title={collapsed ? t("header.account.settings") : undefined}
|
||||
|
|
@ -257,16 +293,6 @@ export default function NavSidebar({
|
|||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenAbout();
|
||||
setAcctOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
{t("header.account.about")}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
|
|
|
|||
|
|
@ -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