♻️ 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.
This commit is contained in:
t0ng7u 2026-05-07 03:54:32 +08:00
parent abc255dd6d
commit 415d21d071
10 changed files with 93 additions and 284 deletions

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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'

View File

@ -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",

View File

@ -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",

View File

@ -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": "次のページへ移動",

View File

@ -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": "Перейти на следующую страницу",

View File

@ -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",

View File

@ -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": "前往下一页",