mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat: add org switcher to sidebar header for workspace switching (#296)
- OrgSwitcher component in sidebar header with dropdown - Personal space and team orgs separated with slug-based filter - Switch calls setActive and invalidates objects query - i18n: org.mySpace, org.switchWorkspace (en + zh) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
||||
import { useSiteOptions } from '@/hooks/use-site-options'
|
||||
import { getUserQuota } from '@/lib/api'
|
||||
import { signOut, useSession } from '@/lib/auth-client'
|
||||
import { OrgSwitcher } from '../team/org-switcher'
|
||||
import { FolderTree } from './folder-tree'
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -82,6 +83,7 @@ export function AppSidebar() {
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span className="text-lg font-semibold">{siteName || 'ZPan'}</span>
|
||||
</div>
|
||||
<OrgSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { setActive, useActiveOrganization, useListOrganizations, useSession } from '@/lib/auth-client'
|
||||
|
||||
type Organization = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo?: string | null
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
export function OrgSwitcher() {
|
||||
const { t } = useTranslation()
|
||||
const { data: session } = useSession()
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const { data: orgs } = useListOrganizations()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const allOrgs = (orgs ?? []) as Organization[]
|
||||
const personalOrg = allOrgs.find((o) => o.slug.startsWith('personal-'))
|
||||
const teams = allOrgs.filter((o) => !o.slug.startsWith('personal-'))
|
||||
|
||||
const isPersonal = !activeOrg || activeOrg.id === personalOrg?.id
|
||||
const displayName = isPersonal ? session?.user?.name : activeOrg?.name
|
||||
|
||||
async function handleSwitch(orgId: string) {
|
||||
const { error } = await setActive({ organizationId: orgId })
|
||||
if (error) {
|
||||
toast.error(error.message)
|
||||
return
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['objects'] })
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
|
||||
<Avatar size="sm">
|
||||
{!isPersonal && activeOrg?.logo ? <AvatarImage src={activeOrg.logo} alt={activeOrg.name} /> : null}
|
||||
<AvatarFallback>
|
||||
{isPersonal ? getInitials(session?.user?.name || '?') : getInitials(activeOrg?.name || '?')}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate text-left font-medium">{displayName || t('org.mySpace')}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
{t('org.switchWorkspace')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{personalOrg && (
|
||||
<DropdownMenuItem onClick={() => handleSwitch(personalOrg.id)} className="gap-2">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{getInitials(session?.user?.name || '?')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">{session?.user?.name || t('org.mySpace')}</span>
|
||||
{isPersonal && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{teams.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{teams.map((org) => (
|
||||
<DropdownMenuItem key={org.id} onClick={() => handleSwitch(org.id)} className="gap-2">
|
||||
<Avatar size="sm">
|
||||
{org.logo ? <AvatarImage src={org.logo} alt={org.name} /> : null}
|
||||
<AvatarFallback>{getInitials(org.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">{org.name}</span>
|
||||
{activeOrg?.id === org.id && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -310,5 +310,7 @@
|
||||
"teams.members.left": "You have left the team",
|
||||
"teams.role.owner": "Owner",
|
||||
"teams.role.admin": "Admin",
|
||||
"teams.role.member": "Member"
|
||||
"teams.role.member": "Member",
|
||||
"org.mySpace": "My Space",
|
||||
"org.switchWorkspace": "Switch Workspace"
|
||||
}
|
||||
|
||||
@@ -310,5 +310,7 @@
|
||||
"teams.members.left": "你已离开团队",
|
||||
"teams.role.owner": "所有者",
|
||||
"teams.role.admin": "管理员",
|
||||
"teams.role.member": "成员"
|
||||
"teams.role.member": "成员",
|
||||
"org.mySpace": "我的空间",
|
||||
"org.switchWorkspace": "切换工作区"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user