This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 92574ae  feat: move the account menu to the top-right header
92574ae is described below

commit 92574ae06525a55ee749d3b9dfb02c7f1bea8e6d
Author: Yang Luo <[email protected]>
AuthorDate: Wed Aug 19 01:49:38 2026 +0800

    feat: move the account menu to the top-right header
---
 web/src/App.tsx                       |  33 ++++++-
 web/src/components/layout/Header.tsx  | 158 ++++++++++++++++++++++++++++++++++
 web/src/components/layout/Sidebar.tsx | 145 +++----------------------------
 3 files changed, 198 insertions(+), 138 deletions(-)

diff --git a/web/src/App.tsx b/web/src/App.tsx
index 2ffa1e1..32bacca 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -19,6 +19,7 @@ import * as AccountBackend from "@/backend/AccountBackend";
 import * as Conf from "@/Conf";
 import * as Setting from "@/Setting";
 import {Footer} from "@/components/layout/Footer";
+import {Header} from "@/components/layout/Header";
 import {Sidebar} from "@/components/layout/Sidebar";
 import {PageSpinner} from "@/components/ui/spinner";
 import {TooltipProvider} from "@/components/ui/tooltip";
@@ -51,11 +52,24 @@ const DashboardPage = React.lazy(() => 
import("@/pages/DashboardPage"));
 
 Setting.initCasdoorSdk(Conf.AuthConfig);
 
+const collapsedKey = "sidebarCollapsed";
+
 export default function App() {
   // undefined while the account request is in flight, null when signed out.
   const [account, setAccount] = React.useState<Account | null | 
undefined>(undefined);
+  const [collapsed, setCollapsed] = React.useState(
+    () => localStorage.getItem(collapsedKey) === "true",
+  );
+  const [drawerOpen, setDrawerOpen] = React.useState(false);
   const location = useLocation();
 
+  const toggleCollapsed = () => {
+    setCollapsed(value => {
+      localStorage.setItem(collapsedKey, String(!value));
+      return !value;
+    });
+  };
+
   const getAccount = React.useCallback(() => {
     AccountBackend.getAccount().then(res => {
       const user = res.data;
@@ -120,10 +134,21 @@ export default function App() {
   return (
     <TooltipProvider delayDuration={200}>
       <div className="flex min-h-screen">
-        <Sidebar account={account} onSignout={signout} />
-        {/* pt-14 leaves room for the fixed mobile bar the sidebar renders;
-            min-w-0 keeps a wide table from stretching the whole layout. */}
-        <div className="flex min-w-0 flex-1 flex-col pt-14 md:pt-0">
+        <Sidebar
+          account={account}
+          collapsed={collapsed}
+          drawerOpen={drawerOpen}
+          onDrawerOpenChange={setDrawerOpen}
+        />
+        {/* min-w-0 keeps a wide table from stretching the whole layout. */}
+        <div className="flex min-w-0 flex-1 flex-col">
+          <Header
+            account={account}
+            collapsed={collapsed}
+            onToggleCollapsed={toggleCollapsed}
+            onOpenDrawer={() => setDrawerOpen(true)}
+            onSignout={signout}
+          />
           <main className="flex-1">
             <React.Suspense fallback={<PageSpinner />}>
               <Routes>
diff --git a/web/src/components/layout/Header.tsx 
b/web/src/components/layout/Header.tsx
new file mode 100644
index 0000000..fbf6867
--- /dev/null
+++ b/web/src/components/layout/Header.tsx
@@ -0,0 +1,158 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Link, useNavigate} from "react-router-dom";
+import {
+  LogOut,
+  Menu as MenuIcon,
+  PanelLeftClose,
+  PanelLeftOpen,
+  Settings,
+} from "lucide-react";
+import i18next from "i18next";
+import {useTranslation} from "react-i18next";
+
+import * as Setting from "@/Setting";
+import type {Account} from "@/types";
+import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
+import {Button} from "@/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {LanguageSelect} from "@/components/layout/LanguageSelect";
+
+function AccountMenu({
+  account,
+  onSignout,
+}: {
+  account: Account | null | undefined;
+  onSignout: () => void;
+}) {
+  const navigate = useNavigate();
+
+  if (account === undefined) {
+    return null;
+  }
+  if (account === null) {
+    return (
+      <Button asChild size="sm">
+        <a href={Setting.getSigninUrl() || "/signin"}>{i18next.t("account:Sign 
In")}</a>
+      </Button>
+    );
+  }
+
+  const openProfile = () => {
+    const profileUrl = Setting.getMyProfileUrl(account);
+    if (profileUrl === "") {
+      navigate("/account");
+    } else {
+      Setting.openLink(profileUrl);
+    }
+  };
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <button className="flex items-center gap-2 rounded-md px-1.5 py-1 
transition-colors hover:bg-accent">
+          <Avatar className="h-8 w-8 shrink-0">
+            {account.avatar ? <AvatarImage src={account.avatar} 
alt={account.name} /> : null}
+            <AvatarFallback style={{backgroundColor: 
Setting.getAvatarColor(account.name)}}>
+              <span className="text-white">
+                {Setting.getShortName(account.name).slice(0, 2).toUpperCase()}
+              </span>
+            </AvatarFallback>
+          </Avatar>
+          <span className="hidden max-w-[140px] truncate text-sm font-medium 
sm:inline">
+            {Setting.getShortName(account.displayName || account.name)}
+          </span>
+        </button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end" className="w-48">
+        <DropdownMenuItem onClick={openProfile}>
+          <Settings />
+          {i18next.t("account:My Account")}
+        </DropdownMenuItem>
+        <DropdownMenuSeparator />
+        <DropdownMenuItem onClick={onSignout}>
+          <LogOut />
+          {i18next.t("account:Sign Out")}
+        </DropdownMenuItem>
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}
+
+/**
+ * The top bar of the content column: navigation controls on the left, and
+ * everything about the viewer themselves on the right. It sticks so the 
account
+ * menu stays reachable on a long list page.
+ */
+export function Header({
+  account,
+  collapsed,
+  onToggleCollapsed,
+  onOpenDrawer,
+  onSignout,
+}: {
+  account: Account | null | undefined;
+  collapsed: boolean;
+  onToggleCollapsed: () => void;
+  onOpenDrawer: () => void;
+  onSignout: () => void;
+}) {
+  // The labels are read eagerly through i18next.t, so the header has to
+  // re-render when the language changes.
+  useTranslation();
+
+  return (
+    <header className="sticky top-0 z-30 flex h-14 shrink-0 items-center 
justify-between gap-2 border-b bg-background/95 px-3 backdrop-blur">
+      <div className="flex min-w-0 items-center gap-2">
+        <Button
+          variant="ghost"
+          size="icon"
+          onClick={onToggleCollapsed}
+          aria-label={i18next.t("general:Toggle sidebar")}
+          className="hidden md:inline-flex"
+        >
+          {collapsed ? (
+            <PanelLeftOpen className="h-5 w-5" />
+          ) : (
+            <PanelLeftClose className="h-5 w-5" />
+          )}
+        </Button>
+
+        <Button variant="ghost" size="icon" aria-label="Menu" 
onClick={onOpenDrawer} className="md:hidden">
+          <MenuIcon className="h-5 w-5" />
+        </Button>
+        {/* The sidebar, which carries the logo on desktop, is hidden here. */}
+        <Link to="/" className="flex items-center md:hidden">
+          <img
+            src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
+            alt="Casbin Gateway"
+            className="h-6 w-auto"
+          />
+        </Link>
+      </div>
+
+      <div className="flex items-center gap-1">
+        <LanguageSelect />
+        <AccountMenu account={account} onSignout={onSignout} />
+      </div>
+    </header>
+  );
+}
diff --git a/web/src/components/layout/Sidebar.tsx 
b/web/src/components/layout/Sidebar.tsx
index 305301a..3367797 100644
--- a/web/src/components/layout/Sidebar.tsx
+++ b/web/src/components/layout/Sidebar.tsx
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
-import {Link, useLocation, useNavigate} from "react-router-dom";
+import {Link, useLocation} from "react-router-dom";
 import {
   Bot,
   ChartColumn,
@@ -23,11 +23,7 @@ import {
   Globe,
   LayoutDashboard,
   ListFilter,
-  LogOut,
-  Menu as MenuIcon,
   MessageSquare,
-  PanelLeftClose,
-  PanelLeftOpen,
   Plug,
   ScrollText,
   Server,
@@ -41,19 +37,9 @@ import {useTranslation} from "react-i18next";
 import * as Setting from "@/Setting";
 import {cn} from "@/lib/utils";
 import type {Account} from "@/types";
-import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
 import {Button} from "@/components/ui/button";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuSeparator,
-  DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
 import {Tooltip} from "@/components/ui/tooltip";
-import {LanguageSelect} from "@/components/layout/LanguageSelect";
 
-const collapsedKey = "sidebarCollapsed";
 const advancedOpenKey = "sidebarAdvancedOpen";
 
 interface MenuEntry {
@@ -116,19 +102,20 @@ function readFlag(key: string, fallback: boolean) {
 
 export function Sidebar({
   account,
-  onSignout,
+  collapsed,
+  drawerOpen,
+  onDrawerOpenChange,
 }: {
   account: Account | null | undefined;
-  onSignout: () => void;
+  collapsed: boolean;
+  drawerOpen: boolean;
+  onDrawerOpenChange: (open: boolean) => void;
 }) {
   // Subscribing to the language keeps the menu labels, which are read eagerly
   // through i18next.t, re-rendering when the language changes.
   useTranslation();
   const location = useLocation();
-  const navigate = useNavigate();
-  const [collapsed, setCollapsed] = React.useState(() => 
readFlag(collapsedKey, false));
   const [advancedOpen, setAdvancedOpen] = React.useState(() => 
readFlag(advancedOpenKey, false));
-  const [drawerOpen, setDrawerOpen] = React.useState(false);
 
   const isAdmin = Setting.isAdminUser(account);
   const mainEntries = getMainEntries().filter(entry => !entry.adminOnly || 
isAdmin);
@@ -137,15 +124,8 @@ export function Sidebar({
   // The drawer covers the page, so leaving it open across a navigation would
   // hide whatever the viewer just asked for.
   React.useEffect(() => {
-    setDrawerOpen(false);
-  }, [location.pathname]);
-
-  const toggleCollapsed = () => {
-    setCollapsed(value => {
-      localStorage.setItem(collapsedKey, String(!value));
-      return !value;
-    });
-  };
+    onDrawerOpenChange(false);
+  }, [location.pathname, onDrawerOpenChange]);
 
   const toggleAdvanced = () => {
     setAdvancedOpen(value => {
@@ -154,15 +134,6 @@ export function Sidebar({
     });
   };
 
-  const openProfile = () => {
-    const profileUrl = Setting.getMyProfileUrl(account);
-    if (profileUrl === "") {
-      navigate("/account");
-    } else {
-      Setting.openLink(profileUrl);
-    }
-  };
-
   /** `iconOnly` is the collapsed desktop rail; the drawer is always full 
width. */
   const renderEntry = (entry: MenuEntry, iconOnly: boolean, indented = false) 
=> {
     const link = (
@@ -230,59 +201,6 @@ export function Sidebar({
     </nav>
   );
 
-  const renderAccount = (iconOnly: boolean) => {
-    if (account === undefined) {
-      return null;
-    }
-    if (account === null) {
-      return (
-        <Button asChild size="sm" className={cn("w-full", iconOnly && "px-0")}>
-          <a href={Setting.getSigninUrl() || "/signin"}>
-            {iconOnly ? <LogOut className="h-4 w-4" /> : 
i18next.t("account:Sign In")}
-          </a>
-        </Button>
-      );
-    }
-
-    return (
-      <DropdownMenu>
-        <DropdownMenuTrigger asChild>
-          <button
-            className={cn(
-              "flex w-full items-center gap-2 rounded-md px-2 py-1.5 
hover:bg-accent",
-              iconOnly && "justify-center px-0",
-            )}
-          >
-            <Avatar className="h-8 w-8 shrink-0">
-              {account.avatar ? <AvatarImage src={account.avatar} 
alt={account.name} /> : null}
-              <AvatarFallback style={{backgroundColor: 
Setting.getAvatarColor(account.name)}}>
-                <span className="text-white">
-                  {Setting.getShortName(account.name).slice(0, 
2).toUpperCase()}
-                </span>
-              </AvatarFallback>
-            </Avatar>
-            {iconOnly ? null : (
-              <span className="min-w-0 flex-1 truncate text-left text-sm 
font-medium">
-                {Setting.getShortName(account.displayName || account.name)}
-              </span>
-            )}
-          </button>
-        </DropdownMenuTrigger>
-        <DropdownMenuContent align="start" side="top" className="w-48">
-          <DropdownMenuItem onClick={openProfile}>
-            <Settings />
-            {i18next.t("account:My Account")}
-          </DropdownMenuItem>
-          <DropdownMenuSeparator />
-          <DropdownMenuItem onClick={onSignout}>
-            <LogOut />
-            {i18next.t("account:Sign Out")}
-          </DropdownMenuItem>
-        </DropdownMenuContent>
-      </DropdownMenu>
-    );
-  };
-
   const renderPanel = (iconOnly: boolean) => (
     <>
       <div
@@ -301,26 +219,6 @@ export function Sidebar({
       </div>
 
       {renderNav(iconOnly)}
-
-      <div className={cn("shrink-0 border-t p-2", iconOnly && "flex flex-col 
items-center gap-1")}>
-        {renderAccount(iconOnly)}
-        <div className={cn("mt-1 flex items-center", iconOnly ? "flex-col" : 
"justify-between")}>
-          <LanguageSelect />
-          <Button
-            variant="ghost"
-            size="icon"
-            onClick={toggleCollapsed}
-            aria-label={i18next.t("general:Toggle sidebar")}
-            className="hidden md:inline-flex"
-          >
-            {iconOnly ? (
-              <PanelLeftOpen className="h-5 w-5" />
-            ) : (
-              <PanelLeftClose className="h-5 w-5" />
-            )}
-          </Button>
-        </div>
-      </div>
     </>
   );
 
@@ -335,32 +233,11 @@ export function Sidebar({
         {renderPanel(collapsed)}
       </aside>
 
-      {/* Fixed rather than in flow, so that the flex row this sits in stays a
-          sidebar-plus-content layout at every width. The content column leaves
-          room for it with its own top padding. */}
-      <header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center 
gap-2 border-b bg-background/95 px-3 backdrop-blur md:hidden">
-        <Button
-          variant="ghost"
-          size="icon"
-          aria-label="Menu"
-          onClick={() => setDrawerOpen(true)}
-        >
-          <MenuIcon className="h-5 w-5" />
-        </Button>
-        <Link to="/" className="flex items-center">
-          <img
-            src={`${Setting.StaticBaseUrl}/img/logo_384x96.png`}
-            alt="Casbin Gateway"
-            className="h-6 w-auto"
-          />
-        </Link>
-      </header>
-
       {drawerOpen ? (
         <div className="fixed inset-0 z-50 md:hidden">
           <div
             className="absolute inset-0 bg-black/50"
-            onClick={() => setDrawerOpen(false)}
+            onClick={() => onDrawerOpenChange(false)}
             aria-hidden
           />
           <div className="absolute inset-y-0 left-0 flex w-64 flex-col 
border-r bg-background">
@@ -369,7 +246,7 @@ export function Sidebar({
               size="icon"
               aria-label="Close"
               className="absolute right-2 top-2 z-10"
-              onClick={() => setDrawerOpen(false)}
+              onClick={() => onDrawerOpenChange(false)}
             >
               <X className="h-5 w-5" />
             </Button>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to