Explorar o código

♻️ refactor(layout): rename workspace switcher to system brand

Rename the layout branding component to reflect that it displays the system identity rather than switching workspaces. Update header usage and layout exports, and remove the now-unused workspace data dependency.
t0ng7u hai 2 días
pai
achega
415d21d071

+ 2 - 7
web/default/src/components/layout/components/app-header.tsx

@@ -1,5 +1,4 @@
 import { useNotifications } from '@/hooks/use-notifications'
-import { useSidebarData } from '@/hooks/use-sidebar-data'
 import { useTopNavLinks } from '@/hooks/use-top-nav-links'
 import { ConfigDrawer } from '@/components/config-drawer'
 import { LanguageSwitcher } from '@/components/language-switcher'
@@ -10,8 +9,8 @@ import { Search } from '@/components/search'
 import { defaultTopNavLinks } from '../config/top-nav.config'
 import { type TopNavLink } from '../types'
 import { Header } from './header'
+import { SystemBrand } from './system-brand'
 import { TopNav } from './top-nav'
-import { WorkspaceSwitcher } from './workspace-switcher'
 
 /**
  * General application Header component
@@ -89,7 +88,6 @@ export function AppHeader({
   // Prioritize dynamically generated links from backend
   const dynamicLinks = useTopNavLinks()
   const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
-  const sidebarData = useSidebarData()
 
   // Notifications hook
   const notifications = useNotifications()
@@ -97,10 +95,7 @@ export function AppHeader({
   return (
     <>
       <Header>
-        <WorkspaceSwitcher
-          variant='inline'
-          workspaces={sidebarData.workspaces}
-        />
+        <SystemBrand variant='inline' />
 
         {leftContent ? (
           <div className='ms-2 flex items-center'>{leftContent}</div>

+ 84 - 0
web/default/src/components/layout/components/system-brand.tsx

@@ -0,0 +1,84 @@
+import { Link } from '@tanstack/react-router'
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
+import { useStatus } from '@/hooks/use-status'
+import { useSystemConfig } from '@/hooks/use-system-config'
+import {
+  SidebarMenu,
+  SidebarMenuButton,
+  SidebarMenuItem,
+} from '@/components/ui/sidebar'
+
+type SystemBrandProps = {
+  defaultName?: string
+  defaultVersion?: string
+  /**
+   * Visual layout:
+   * - 'sidebar': stacked card style (used inside the sidebar header).
+   * - 'inline': compact horizontal pill (used inside the top app bar).
+   */
+  variant?: 'sidebar' | 'inline'
+}
+
+/**
+ * System brand component
+ * Displays current system logo + name.
+ * - inline: compact pill in the top app bar; clicking navigates to home (/)
+ * - sidebar: stacked card in the sidebar header (display only)
+ */
+export function SystemBrand(props: SystemBrandProps) {
+  const { t } = useTranslation()
+  const { status } = useStatus()
+  const { logo } = useSystemConfig()
+
+  const variant = props.variant ?? 'sidebar'
+  const name = status?.system_name || props.defaultName || 'New API'
+  const version =
+    status?.version || props.defaultVersion || t('Unknown version')
+
+  if (variant === 'inline') {
+    return (
+      <Link
+        to='/'
+        aria-label={t('Go to home')}
+        className={cn(
+          'text-foreground inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
+          'hover:bg-accent focus-visible:ring-ring/40 focus-visible:ring-2'
+        )}
+      >
+        <div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
+          <img
+            src={logo}
+            alt={t('Logo')}
+            className='size-full rounded-md object-cover'
+          />
+        </div>
+        <span className='max-w-[12rem] truncate'>{name}</span>
+      </Link>
+    )
+  }
+
+  return (
+    <SidebarMenu>
+      <SidebarMenuItem>
+        <SidebarMenuButton
+          size='lg'
+          className='hover:text-sidebar-foreground active:text-sidebar-foreground cursor-default hover:bg-transparent active:bg-transparent'
+          render={<div />}
+        >
+          <div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
+            <img
+              src={logo}
+              alt={t('Logo')}
+              className='size-full rounded-lg object-cover'
+            />
+          </div>
+          <div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
+            <span className='truncate font-semibold'>{name}</span>
+            <span className='truncate text-xs'>{version}</span>
+          </div>
+        </SidebarMenuButton>
+      </SidebarMenuItem>
+    </SidebarMenu>
+  )
+}

+ 0 - 276
web/default/src/components/layout/components/workspace-switcher.tsx

@@ -1,276 +0,0 @@
-import * as React from 'react'
-import { useNavigate, useLocation } from '@tanstack/react-router'
-import { ChevronsUpDown } from 'lucide-react'
-import { useTranslation } from 'react-i18next'
-import { useAuthStore } from '@/stores/auth-store'
-import { ROLE } from '@/lib/roles'
-import { cn } from '@/lib/utils'
-import { useStatus } from '@/hooks/use-status'
-import { useSystemConfig } from '@/hooks/use-system-config'
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuGroup,
-  DropdownMenuItem,
-  DropdownMenuLabel,
-  DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import {
-  SidebarMenu,
-  SidebarMenuButton,
-  SidebarMenuItem,
-  useSidebar,
-} from '@/components/ui/sidebar'
-import { useWorkspace } from '../context/workspace-context'
-import { getWorkspaceByPath, WORKSPACE_IDS } from '../lib/workspace-registry'
-import { type Workspace } from '../types'
-
-type WorkspaceSwitcherProps = {
-  workspaces: Workspace[]
-  defaultName?: string
-  defaultVersion?: string
-  /**
-   * Visual layout:
-   * - 'sidebar': stacked card style (used inside the sidebar header).
-   * - 'inline': compact horizontal pill (used inside the top app bar).
-   */
-  variant?: 'sidebar' | 'inline'
-}
-
-/**
- * Workspace switcher component
- * Allows users to switch between different workspaces
- * - Regular users can only see the default workspace
- * - Super administrators can see the system settings workspace
- */
-export function WorkspaceSwitcher({
-  workspaces,
-  defaultName = 'New API',
-  defaultVersion,
-  variant = 'sidebar',
-}: WorkspaceSwitcherProps) {
-  const { t } = useTranslation()
-  const navigate = useNavigate()
-  const { pathname } = useLocation()
-  const { isMobile } = useSidebar()
-  const { status } = useStatus()
-  const { logo } = useSystemConfig()
-  const isSuperAdmin = useAuthStore(
-    (state) => state.auth.user?.role === ROLE.SUPER_ADMIN
-  )
-  const { activeWorkspace, setActiveWorkspace } = useWorkspace()
-
-  // Handle workspace list:
-  // 1. Populate first workspace with system info
-  // 2. Filter based on user permissions (non-super admins cannot see system settings)
-  const availableWorkspaces = React.useMemo(
-    () =>
-      workspaces
-        .map((workspace, index) =>
-          index === 0
-            ? {
-                ...workspace,
-                name: status?.system_name || defaultName,
-                plan: status?.version || defaultVersion || t('Unknown version'),
-              }
-            : workspace
-        )
-        .filter(
-          (workspace) =>
-            isSuperAdmin || workspace.id !== WORKSPACE_IDS.SYSTEM_SETTINGS
-        ),
-    [
-      workspaces,
-      status?.system_name,
-      status?.version,
-      defaultName,
-      defaultVersion,
-      isSuperAdmin,
-      t,
-    ]
-  )
-
-  // Initialize and synchronize active workspace
-  // Detect from URL first, then sync from activeWorkspace
-  React.useEffect(() => {
-    // Detect which workspace should be active from workspace registry
-    const detectedWorkspace = getWorkspaceByPath(pathname)
-
-    if (detectedWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS) {
-      // Currently in system settings route, should activate System Settings workspace
-      const systemSettingsWorkspace = availableWorkspaces.find(
-        (w) => w.id === WORKSPACE_IDS.SYSTEM_SETTINGS
-      )
-      if (systemSettingsWorkspace) {
-        setActiveWorkspace(systemSettingsWorkspace)
-      }
-    } else {
-      // Currently in main workspace route, should activate main workspace
-      const mainWorkspace =
-        availableWorkspaces.find((w) => w.id === WORKSPACE_IDS.DEFAULT) ||
-        availableWorkspaces[0]
-      if (mainWorkspace) {
-        setActiveWorkspace(mainWorkspace)
-      }
-    }
-  }, [pathname, availableWorkspaces, setActiveWorkspace])
-
-  const handleWorkspaceChange = (workspace: Workspace) => {
-    // Only navigate, let useEffect synchronize workspace state based on new pathname
-    // This avoids race conditions and context loss issues
-    if (workspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS) {
-      navigate({ to: '/system-settings/site' })
-    } else {
-      navigate({ to: '/dashboard' })
-    }
-  }
-
-  if (!activeWorkspace) {
-    return null
-  }
-
-  const canSwitchWorkspace = availableWorkspaces.length > 1
-
-  const renderWorkspaceList = () => (
-    <DropdownMenuGroup>
-      <DropdownMenuLabel className='text-muted-foreground text-xs'>
-        {t('Workspaces')}
-      </DropdownMenuLabel>
-      {availableWorkspaces.map((workspace, index) => (
-        <DropdownMenuItem
-          key={workspace.id}
-          onClick={() => handleWorkspaceChange(workspace)}
-          className='gap-2 p-2'
-        >
-          {index === 0 ? (
-            <div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
-              <img src={logo} alt='Logo' className='size-full object-cover' />
-            </div>
-          ) : (
-            <div className='flex size-6 items-center justify-center rounded-sm border'>
-              <workspace.logo className='size-4 shrink-0' />
-            </div>
-          )}
-          {workspace.name}
-        </DropdownMenuItem>
-      ))}
-    </DropdownMenuGroup>
-  )
-
-  if (variant === 'inline') {
-    const inlineLogo =
-      activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
-        <div className='bg-primary text-primary-foreground flex size-5 items-center justify-center rounded-md'>
-          <activeWorkspace.logo className='size-3' />
-        </div>
-      ) : (
-        <div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
-          <img
-            src={logo}
-            alt={t('Logo')}
-            className='size-full rounded-md object-cover'
-          />
-        </div>
-      )
-
-    const inlineButtonClass = cn(
-      'inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium text-foreground outline-none select-none transition-colors',
-      'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/40',
-      'data-popup-open:bg-accent'
-    )
-
-    if (!canSwitchWorkspace) {
-      return (
-        <div
-          className={cn(
-            inlineButtonClass,
-            'cursor-default hover:bg-transparent'
-          )}
-        >
-          {inlineLogo}
-          <span className='max-w-[12rem] truncate'>{activeWorkspace.name}</span>
-        </div>
-      )
-    }
-
-    return (
-      <DropdownMenu>
-        <DropdownMenuTrigger className={inlineButtonClass}>
-          {inlineLogo}
-          <span className='max-w-[12rem] truncate'>{activeWorkspace.name}</span>
-          <ChevronsUpDown className='text-muted-foreground size-3.5' />
-        </DropdownMenuTrigger>
-        <DropdownMenuContent
-          className='min-w-56 rounded-lg'
-          align='start'
-          side='bottom'
-          sideOffset={6}
-        >
-          {renderWorkspaceList()}
-        </DropdownMenuContent>
-      </DropdownMenu>
-    )
-  }
-
-  const workspaceButtonContent = (
-    <>
-      {activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
-        <div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
-          <activeWorkspace.logo className='size-4' />
-        </div>
-      ) : (
-        <div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
-          <img
-            src={logo}
-            alt={t('Logo')}
-            className='size-full rounded-lg object-cover'
-          />
-        </div>
-      )}
-      <div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
-        <span className='truncate font-semibold'>{activeWorkspace.name}</span>
-        <span className='truncate text-xs'>{activeWorkspace.plan}</span>
-      </div>
-      {canSwitchWorkspace && (
-        <ChevronsUpDown className='ms-auto group-data-[collapsible=icon]:hidden' />
-      )}
-    </>
-  )
-
-  return (
-    <SidebarMenu>
-      <SidebarMenuItem>
-        {canSwitchWorkspace ? (
-          <DropdownMenu>
-            <DropdownMenuTrigger
-              render={
-                <SidebarMenuButton
-                  size='lg'
-                  className='data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground'
-                />
-              }
-            >
-              {workspaceButtonContent}
-            </DropdownMenuTrigger>
-            <DropdownMenuContent
-              className='w-(--anchor-width) min-w-56 rounded-lg'
-              align='start'
-              side={isMobile ? 'bottom' : 'right'}
-              sideOffset={4}
-            >
-              {renderWorkspaceList()}
-            </DropdownMenuContent>
-          </DropdownMenu>
-        ) : (
-          <SidebarMenuButton
-            size='lg'
-            className='hover:text-sidebar-foreground active:text-sidebar-foreground cursor-default hover:bg-transparent active:bg-transparent'
-            render={<div />}
-          >
-            {workspaceButtonContent}
-          </SidebarMenuButton>
-        )}
-      </SidebarMenuItem>
-    </SidebarMenu>
-  )
-}

+ 1 - 1
web/default/src/components/layout/index.ts

@@ -16,7 +16,7 @@ export { Main } from './components/main'
 export { PageFooterPortal } from './components/page-footer'
 export { NavGroup } from './components/nav-group'
 export { SectionPageLayout } from './components/section-page-layout'
-export { WorkspaceSwitcher } from './components/workspace-switcher'
+export { SystemBrand } from './components/system-brand'
 export { TopNav } from './components/top-nav'
 export { MobileDrawer } from './components/mobile-drawer'
 

+ 1 - 0
web/default/src/i18n/locales/en.json

@@ -1826,6 +1826,7 @@
     "Go back and edit": "Go back and edit",
     "Go to Dashboard": "Go to Dashboard",
     "Go to first page": "Go to first page",
+    "Go to home": "Go to home",
     "Go to io.net API Keys": "Go to io.net API Keys",
     "Go to last page": "Go to last page",
     "Go to next page": "Go to next page",

+ 1 - 0
web/default/src/i18n/locales/fr.json

@@ -1826,6 +1826,7 @@
     "Go back and edit": "Retour et modifier",
     "Go to Dashboard": "Aller au tableau de bord",
     "Go to first page": "Aller à la première page",
+    "Go to home": "Retour à l'accueil",
     "Go to io.net API Keys": "Accéder aux clés API io.net",
     "Go to last page": "Aller à la dernière page",
     "Go to next page": "Aller à la page suivante",

+ 1 - 0
web/default/src/i18n/locales/ja.json

@@ -1826,6 +1826,7 @@
     "Go back and edit": "戻って編集",
     "Go to Dashboard": "ダッシュボードへ移動",
     "Go to first page": "最初のページへ移動",
+    "Go to home": "ホームへ戻る",
     "Go to io.net API Keys": "io.net API キーへ移動",
     "Go to last page": "最後のページへ移動",
     "Go to next page": "次のページへ移動",

+ 1 - 0
web/default/src/i18n/locales/ru.json

@@ -1826,6 +1826,7 @@
     "Go back and edit": "Вернуться и изменить",
     "Go to Dashboard": "Перейти в панель управления",
     "Go to first page": "Перейти на первую страницу",
+    "Go to home": "На главную",
     "Go to io.net API Keys": "Перейти к ключам API io.net",
     "Go to last page": "Перейти на последнюю страницу",
     "Go to next page": "Перейти на следующую страницу",

+ 1 - 0
web/default/src/i18n/locales/vi.json

@@ -1826,6 +1826,7 @@
     "Go back and edit": "Quay lại và chỉnh sửa",
     "Go to Dashboard": "Truy cập Dashboard",
     "Go to first page": "Go to the first page",
+    "Go to home": "Về trang chủ",
     "Go to io.net API Keys": "Đi đến Khóa API io.net",
     "Go to last page": "Go to the last page",
     "Go to next page": "Đi đến trang tiếp theo",

+ 1 - 0
web/default/src/i18n/locales/zh.json

@@ -1826,6 +1826,7 @@
     "Go back and edit": "返回修改",
     "Go to Dashboard": "前往仪表板",
     "Go to first page": "前往首页",
+    "Go to home": "返回主页",
     "Go to io.net API Keys": "前往 io.net API 密钥",
     "Go to last page": "前往末页",
     "Go to next page": "前往下一页",