Skip to content

Commit a193453

Browse files
committed
feat: add TeamSwitcher component and supporting UI elements
- Implemented TeamSwitcher component for organization switching in the dashboard. - Created DropdownMenu component for enhanced dropdown functionality. - Added Sheet component for mobile-friendly sidebar navigation. - Developed Sidebar component with context for managing sidebar state and responsiveness. - Introduced Tooltip component for better user guidance on sidebar actions. - Added useIsMobile hook to determine mobile view for responsive design.
1 parent 0c2ec25 commit a193453

File tree

22 files changed

+1678
-116
lines changed

22 files changed

+1678
-116
lines changed

apps/web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
"test:watch": "vitest"
1313
},
1414
"dependencies": {
15+
"@radix-ui/react-dialog": "^1.1.15",
16+
"@radix-ui/react-dropdown-menu": "^2.1.16",
1517
"@radix-ui/react-slot": "^1.2.3",
18+
"@radix-ui/react-tooltip": "^1.2.8",
1619
"better-auth": "^1.4.18",
1720
"class-variance-authority": "^0.7.1",
1821
"clsx": "^2.1.1",

apps/web/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ const CreateOrganizationPage = lazy(() => import('./pages/organization/create'))
1313

1414
export function App() {
1515
const fallback = (
16-
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
17-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
16+
<div className="flex min-h-svh items-center justify-center">
17+
<div className="animate-spin rounded-full size-8 border-2 border-muted border-t-primary" />
1818
</div>
1919
);
2020

apps/web/src/__tests__/lib/auth-client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('auth-client', () => {
3737

3838
expect(mockCreateAuthClient).toHaveBeenCalledTimes(1);
3939
const config = mockCreateAuthClient.mock.calls[0]?.[0] as Record<string, unknown>;
40-
expect(config.baseURL).toBe('http://localhost:3001/api/v1/auth');
40+
expect(config.baseURL).toBe('http://localhost:5321/api/v1/auth');
4141
});
4242

4343
it('should include organizationClient plugin', async () => {
Lines changed: 67 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,84 @@
11
import { Link, useLocation, Outlet } from 'react-router-dom';
2-
import { OrganizationSwitcher } from './OrganizationSwitcher';
3-
import { UserDropdown } from './UserDropdown';
42
import { Blocks, LayoutDashboard, Users, Settings } from 'lucide-react';
3+
import {
4+
Sidebar,
5+
SidebarContent,
6+
SidebarFooter,
7+
SidebarGroup,
8+
SidebarGroupContent,
9+
SidebarGroupLabel,
10+
SidebarHeader,
11+
SidebarInset,
12+
SidebarMenu,
13+
SidebarMenuButton,
14+
SidebarMenuItem,
15+
SidebarProvider,
16+
SidebarRail,
17+
SidebarTrigger,
18+
} from '@/components/ui/sidebar';
519
import { Separator } from '@/components/ui/separator';
6-
import { cn } from '@/lib/utils';
20+
import { NavUser } from '@/components/dashboard/NavUser';
21+
import { TeamSwitcher } from '@/components/dashboard/TeamSwitcher';
722

8-
const navigation = [
9-
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
10-
{ name: 'Teams', href: '/dashboard/teams', icon: Users },
11-
{ name: 'Settings', href: '/dashboard/settings', icon: Settings },
23+
const navMain = [
24+
{ title: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
25+
{ title: 'Teams', href: '/dashboard/teams', icon: Users },
26+
{ title: 'Settings', href: '/dashboard/settings', icon: Settings },
1227
];
1328

1429
export function DashboardLayout() {
1530
const { pathname } = useLocation();
1631

1732
return (
18-
<div className="min-h-screen bg-muted/40">
19-
{/* Top Navigation */}
20-
<nav className="fixed top-0 z-50 w-full bg-background border-b h-14">
21-
<div className="flex h-14 items-center justify-between px-4">
22-
<Link to="/dashboard" className="flex items-center gap-2">
23-
<div className="size-8 bg-primary rounded-lg flex items-center justify-center">
24-
<Blocks className="size-5 text-primary-foreground" />
25-
</div>
26-
<span className="text-lg font-bold tracking-tight">ObjectOS</span>
27-
</Link>
33+
<SidebarProvider>
34+
<Sidebar>
35+
<SidebarHeader>
36+
<TeamSwitcher />
37+
</SidebarHeader>
2838

29-
<div className="flex items-center gap-3">
30-
<OrganizationSwitcher />
31-
<Separator orientation="vertical" className="h-6" />
32-
<UserDropdown />
33-
</div>
34-
</div>
35-
</nav>
39+
<SidebarContent>
40+
<SidebarGroup>
41+
<SidebarGroupLabel>Platform</SidebarGroupLabel>
42+
<SidebarGroupContent>
43+
<SidebarMenu>
44+
{navMain.map((item) => (
45+
<SidebarMenuItem key={item.title}>
46+
<SidebarMenuButton
47+
asChild
48+
isActive={pathname === item.href}
49+
tooltip={item.title}
50+
>
51+
<Link to={item.href}>
52+
<item.icon />
53+
<span>{item.title}</span>
54+
</Link>
55+
</SidebarMenuButton>
56+
</SidebarMenuItem>
57+
))}
58+
</SidebarMenu>
59+
</SidebarGroupContent>
60+
</SidebarGroup>
61+
</SidebarContent>
3662

37-
{/* Sidebar */}
38-
<aside className="fixed top-14 left-0 z-40 hidden w-56 h-[calc(100vh-3.5rem)] border-r bg-background sm:block">
39-
<div className="flex flex-col gap-1 p-3">
40-
{navigation.map((item) => {
41-
const isActive = pathname === item.href;
42-
return (
43-
<Link
44-
key={item.name}
45-
to={item.href}
46-
className={cn(
47-
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
48-
isActive
49-
? 'bg-accent text-accent-foreground'
50-
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
51-
)}
52-
>
53-
<item.icon className="size-4" />
54-
{item.name}
55-
</Link>
56-
);
57-
})}
58-
</div>
59-
</aside>
63+
<SidebarFooter>
64+
<NavUser />
65+
</SidebarFooter>
66+
<SidebarRail />
67+
</Sidebar>
6068

61-
{/* Main Content */}
62-
<main className="pt-14 sm:pl-56">
63-
<div className="p-6">
69+
<SidebarInset>
70+
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
71+
<SidebarTrigger className="-ml-1" />
72+
<Separator orientation="vertical" className="mr-2 h-4" />
73+
<div className="flex items-center gap-2">
74+
<Blocks className="size-5 text-primary" />
75+
<span className="font-semibold">ObjectOS</span>
76+
</div>
77+
</header>
78+
<div className="flex flex-1 flex-col gap-4 p-4">
6479
<Outlet />
6580
</div>
66-
</main>
67-
</div>
81+
</SidebarInset>
82+
</SidebarProvider>
6883
);
6984
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useNavigate } from 'react-router-dom';
2+
import { useSession, signOut } from '@/lib/auth-client';
3+
import {
4+
BadgeCheck,
5+
ChevronsUpDown,
6+
LogOut,
7+
} from 'lucide-react';
8+
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
9+
import {
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuGroup,
13+
DropdownMenuItem,
14+
DropdownMenuLabel,
15+
DropdownMenuSeparator,
16+
DropdownMenuTrigger,
17+
} from '@/components/ui/dropdown-menu';
18+
import {
19+
SidebarMenu,
20+
SidebarMenuButton,
21+
SidebarMenuItem,
22+
useSidebar,
23+
} from '@/components/ui/sidebar';
24+
25+
export function NavUser() {
26+
const { data: session } = useSession();
27+
const { isMobile } = useSidebar();
28+
const navigate = useNavigate();
29+
30+
if (!session?.user) return null;
31+
32+
const user = session.user;
33+
const initials = user.name?.charAt(0) || user.email?.charAt(0) || '?';
34+
35+
const handleSignOut = async () => {
36+
await signOut();
37+
navigate('/sign-in');
38+
};
39+
40+
return (
41+
<SidebarMenu>
42+
<SidebarMenuItem>
43+
<DropdownMenu>
44+
<DropdownMenuTrigger asChild>
45+
<SidebarMenuButton
46+
size="lg"
47+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
48+
>
49+
<Avatar className="size-8 rounded-lg">
50+
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
51+
</Avatar>
52+
<div className="grid flex-1 text-left text-sm leading-tight">
53+
<span className="truncate font-medium">{user.name || 'User'}</span>
54+
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
55+
</div>
56+
<ChevronsUpDown className="ml-auto size-4" />
57+
</SidebarMenuButton>
58+
</DropdownMenuTrigger>
59+
<DropdownMenuContent
60+
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
61+
side={isMobile ? 'bottom' : 'right'}
62+
align="end"
63+
sideOffset={4}
64+
>
65+
<DropdownMenuLabel className="p-0 font-normal">
66+
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
67+
<Avatar className="size-8 rounded-lg">
68+
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
69+
</Avatar>
70+
<div className="grid flex-1 text-left text-sm leading-tight">
71+
<span className="truncate font-medium">{user.name || 'User'}</span>
72+
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
73+
</div>
74+
</div>
75+
</DropdownMenuLabel>
76+
<DropdownMenuSeparator />
77+
<DropdownMenuGroup>
78+
<DropdownMenuItem onClick={() => navigate('/dashboard/settings')}>
79+
<BadgeCheck />
80+
Account
81+
</DropdownMenuItem>
82+
</DropdownMenuGroup>
83+
<DropdownMenuSeparator />
84+
<DropdownMenuItem variant="destructive" onClick={handleSignOut}>
85+
<LogOut />
86+
Sign Out
87+
</DropdownMenuItem>
88+
</DropdownMenuContent>
89+
</DropdownMenu>
90+
</SidebarMenuItem>
91+
</SidebarMenu>
92+
);
93+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useNavigate } from 'react-router-dom';
2+
import {
3+
useListOrganizations,
4+
organization,
5+
useActiveOrganization,
6+
} from '@/lib/auth-client';
7+
import { ChevronsUpDown, Plus, Blocks } from 'lucide-react';
8+
import {
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuLabel,
13+
DropdownMenuSeparator,
14+
DropdownMenuTrigger,
15+
} from '@/components/ui/dropdown-menu';
16+
import {
17+
SidebarMenu,
18+
SidebarMenuButton,
19+
SidebarMenuItem,
20+
useSidebar,
21+
} from '@/components/ui/sidebar';
22+
23+
export function TeamSwitcher() {
24+
const { data: organizations, isPending } = useListOrganizations();
25+
const { data: activeOrg } = useActiveOrganization();
26+
const { isMobile } = useSidebar();
27+
const navigate = useNavigate();
28+
29+
const currentOrg =
30+
organizations?.find((o) => o.id === activeOrg?.id) || organizations?.[0];
31+
const displayName = currentOrg?.name || 'Personal';
32+
const initial = displayName.charAt(0).toUpperCase();
33+
34+
const handleSwitchOrg = async (orgId: string) => {
35+
await organization.setActive({ organizationId: orgId });
36+
};
37+
38+
if (isPending) {
39+
return (
40+
<SidebarMenu>
41+
<SidebarMenuItem>
42+
<SidebarMenuButton size="lg" disabled>
43+
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
44+
<Blocks className="size-4" />
45+
</div>
46+
<div className="grid flex-1 text-left text-sm leading-tight">
47+
<span className="truncate font-medium">Loading…</span>
48+
</div>
49+
</SidebarMenuButton>
50+
</SidebarMenuItem>
51+
</SidebarMenu>
52+
);
53+
}
54+
55+
return (
56+
<SidebarMenu>
57+
<SidebarMenuItem>
58+
<DropdownMenu>
59+
<DropdownMenuTrigger asChild>
60+
<SidebarMenuButton
61+
size="lg"
62+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
63+
>
64+
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
65+
<span className="text-xs font-bold">{initial}</span>
66+
</div>
67+
<div className="grid flex-1 text-left text-sm leading-tight">
68+
<span className="truncate font-medium">{displayName}</span>
69+
<span className="truncate text-xs text-muted-foreground">Organization</span>
70+
</div>
71+
<ChevronsUpDown className="ml-auto" />
72+
</SidebarMenuButton>
73+
</DropdownMenuTrigger>
74+
<DropdownMenuContent
75+
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
76+
align="start"
77+
side={isMobile ? 'bottom' : 'right'}
78+
sideOffset={4}
79+
>
80+
<DropdownMenuLabel className="text-xs text-muted-foreground">
81+
Organizations
82+
</DropdownMenuLabel>
83+
{organizations?.map((org) => (
84+
<DropdownMenuItem
85+
key={org.id}
86+
onClick={() => handleSwitchOrg(org.id)}
87+
className="gap-2 p-2"
88+
>
89+
<div className="flex size-6 items-center justify-center rounded-sm border">
90+
<span className="text-xs font-medium">
91+
{org.name.charAt(0).toUpperCase()}
92+
</span>
93+
</div>
94+
{org.name}
95+
</DropdownMenuItem>
96+
))}
97+
<DropdownMenuSeparator />
98+
<DropdownMenuItem
99+
className="gap-2 p-2"
100+
onClick={() => navigate('/organization/create')}
101+
>
102+
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
103+
<Plus className="size-4" />
104+
</div>
105+
<span className="text-muted-foreground font-medium">
106+
Add organization
107+
</span>
108+
</DropdownMenuItem>
109+
</DropdownMenuContent>
110+
</DropdownMenu>
111+
</SidebarMenuItem>
112+
</SidebarMenu>
113+
);
114+
}

0 commit comments

Comments
 (0)