Files
agent-desk-ng/web/components/workspace-switcher.tsx

234 lines
7.3 KiB
TypeScript

"use client"
import { Building2Icon, CheckIcon, ChevronsUpDownIcon, LayoutDashboardIcon, WrenchIcon } from "lucide-react"
import Link from "next/link"
import { useEffect, useState, type ReactElement } from "react"
import { toast } from "sonner"
import { useI18n } from "@/i18n/provider"
import { fetchPublicConfig } from "@/lib/api/config"
import { listMyOrganizations, switchOrganization, type OrganizationItem } from "@/lib/api/organization"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type WorkspaceKey = "dashboard" | "workbench"
export type WorkspaceOption = {
key: WorkspaceKey
href: string
labelKey: string
icon: typeof LayoutDashboardIcon
}
export const workspaceOptions: WorkspaceOption[] = [
{
key: "dashboard",
href: "/dashboard",
labelKey: "workspace.dashboard",
icon: LayoutDashboardIcon,
},
{
key: "workbench",
href: "/workbench",
labelKey: "workspace.workbench",
icon: WrenchIcon,
},
]
type WorkspaceSwitcherProps = {
currentWorkspace: WorkspaceKey
variant?: "sidebar" | "header" | "rail"
className?: string
trigger?: ReactElement
}
export function WorkspaceSwitcher({
currentWorkspace,
variant = "header",
className,
trigger,
}: WorkspaceSwitcherProps) {
const t = useI18n()
const [orgs, setOrgs] = useState<OrganizationItem[]>([])
const [activeOrgId, setActiveOrgId] = useState<number | null>(null)
const [switching, setSwitching] = useState(false)
const [logoUrl, setLogoUrl] = useState("")
useEffect(() => {
let mounted = true
void fetchPublicConfig()
.then((config) => {
if (mounted) {
setLogoUrl(config.companyLogoUrl?.trim() || "")
}
})
.catch(() => {
// Fall back to the bundled logo.
})
return () => {
mounted = false
}
}, [])
useEffect(() => {
let mounted = true
listMyOrganizations()
.then((res) => {
if (mounted && res?.organizations) {
setOrgs(res.organizations)
setActiveOrgId(res.currentOrganizationId || res.organizations[0]?.id || null)
}
})
.catch(() => {})
return () => {
mounted = false
}
}, [])
const currentOption =
workspaceOptions.find((item) => item.key === currentWorkspace) ?? workspaceOptions[0]
const currentOrg = orgs.find((o) => o.id === activeOrgId) || orgs[0]
const handleSwitchOrg = async (orgId: number) => {
if (orgId === activeOrgId || switching) return
setSwitching(true)
try {
await switchOrganization(orgId)
setActiveOrgId(orgId)
toast.success("Switched organization successfully")
window.location.reload()
} catch {
toast.error("Failed to switch organization")
} finally {
setSwitching(false)
}
}
const switchIndicatorClassName =
"absolute bottom-0.5 right-0.5 size-2.5 rounded-full bg-sidebar text-sidebar-foreground/70"
const triggerClassName = cn(
"gap-2 text-left",
variant === "header" &&
"h-9 rounded-md border border-border/70 bg-background px-2.5 shadow-xs hover:bg-muted",
variant === "sidebar" &&
"relative data-[slot=sidebar-menu-button]:p-1.5! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:data-[slot=sidebar-menu-button]:p-0!",
variant === "rail" &&
"relative size-8 rounded-md border-0 bg-transparent p-0 shadow-none hover:bg-sidebar-accent",
className
)
const triggerContent =
variant === "rail" ? (
<>
<img
src={logoUrl || "/images/logo.svg"}
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<span className="sr-only">
{currentOrg?.name || t("app.brand")} - {t(currentOption.labelKey)}
</span>
<ChevronsUpDownIcon className={switchIndicatorClassName} />
</>
) : (
<>
<img
src={logoUrl || "/images/logo.svg"}
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{currentOrg?.name || t("app.brand")}</span>
<span className="truncate text-xs text-muted-foreground">
{t(currentOption.labelKey)} {currentOrg?.role ? `• ${currentOrg.role}` : ""}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4 shrink-0 text-muted-foreground group-data-[collapsible=icon]:hidden" />
{variant === "sidebar" ? (
<ChevronsUpDownIcon
className={cn(switchIndicatorClassName, "hidden group-data-[collapsible=icon]:block")}
/>
) : null}
</>
)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
trigger ?? <Button variant="ghost" className={triggerClassName} />
}
>
{triggerContent}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side={variant === "sidebar" || variant === "rail" ? "right" : "bottom"}
sideOffset={8}
className="w-64 min-w-64"
>
{orgs.length > 0 ? (
<>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1.5 text-xs text-muted-foreground font-medium">
<Building2Icon className="size-3.5" />
Organizations / Workspaces
</DropdownMenuLabel>
{orgs.map((org) => {
const isActive = org.id === activeOrgId
return (
<DropdownMenuItem
key={org.id}
className="cursor-pointer gap-2"
onClick={() => handleSwitchOrg(org.id)}
>
<div className="flex flex-1 flex-col min-w-0">
<span className="truncate font-medium text-sm">{org.name}</span>
<span className="truncate text-xs text-muted-foreground">
{org.role || "Member"} {org.plan ? `• ${org.plan}` : ""}
</span>
</div>
{isActive ? <CheckIcon className="size-4 text-primary shrink-0" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuGroup>
<DropdownMenuLabel>{t("workspace.switchWorkspace")}</DropdownMenuLabel>
{workspaceOptions.map((item) => (
<DropdownMenuItem
key={item.key}
render={<Link href={item.href} />}
className="cursor-pointer gap-2"
>
<item.icon className="size-4 text-muted-foreground" />
<span className="flex-1 truncate">{t(item.labelKey)}</span>
{item.key === currentWorkspace ? (
<CheckIcon className="size-4 text-primary" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}