Skip to content

Commit 0708b7b

Browse files
committed
feat: add global sidebar and new routes for templates and examples; refactor layout components for better organization
1 parent 17ed89e commit 0708b7b

12 files changed

Lines changed: 628 additions & 130 deletions
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* GlobalSidebar
5+
*
6+
* Top-level navigation shell rendered on routes that are NOT scoped to a
7+
* specific package (i.e. outside `/$package/*` and
8+
* `/environments/:envId/:package/*`). Provides stable entry points to
9+
* organizations, environments, packages, and the library of templates &
10+
* examples — mirroring the v0.app-style left rail.
11+
*
12+
* When the user drills into a package, the package-scoped `AppSidebar`
13+
* takes over instead. The two sidebars are mutually exclusive and share
14+
* the same `SidebarProvider` in `routes/__root.tsx`.
15+
*/
16+
17+
import { useMemo } from 'react';
18+
import { Link, useLocation } from '@tanstack/react-router';
19+
import {
20+
Building2,
21+
Check,
22+
ChevronsUpDown,
23+
Plus,
24+
Home,
25+
Boxes,
26+
Package as PackageIcon,
27+
LayoutTemplate,
28+
Sparkles,
29+
Settings,
30+
Terminal,
31+
MapPin,
32+
} from 'lucide-react';
33+
34+
import {
35+
Sidebar,
36+
SidebarContent,
37+
SidebarGroup,
38+
SidebarGroupContent,
39+
SidebarGroupLabel,
40+
SidebarHeader,
41+
SidebarMenu,
42+
SidebarMenuButton,
43+
SidebarMenuItem,
44+
SidebarMenuSkeleton,
45+
SidebarMenuSub,
46+
SidebarMenuSubButton,
47+
SidebarMenuSubItem,
48+
SidebarSeparator,
49+
} from '@/components/ui/sidebar';
50+
import {
51+
DropdownMenu,
52+
DropdownMenuContent,
53+
DropdownMenuItem,
54+
DropdownMenuLabel,
55+
DropdownMenuSeparator,
56+
DropdownMenuTrigger,
57+
} from '@/components/ui/dropdown-menu';
58+
import { useOrganizations, useSession } from '@/hooks/useSession';
59+
import { useEnvironments } from '@/hooks/useEnvironments';
60+
import { toast } from '@/hooks/use-toast';
61+
62+
const MAX_ENV_ITEMS = 6;
63+
64+
/** Header: active organization + switcher. */
65+
function OrgHeader() {
66+
const { organizations, loading, reload } = useOrganizations();
67+
const { session, setActiveOrganization } = useSession();
68+
const activeId = session?.activeOrganizationId ?? undefined;
69+
const active = useMemo(
70+
() => organizations.find((o) => o.id === activeId) ?? null,
71+
[organizations, activeId],
72+
);
73+
74+
const handleSelect = async (id: string) => {
75+
if (id === activeId) return;
76+
try {
77+
await setActiveOrganization(id);
78+
await reload();
79+
toast({ title: 'Organization switched' });
80+
} catch (err) {
81+
toast({
82+
title: 'Failed to switch organization',
83+
description: (err as Error).message,
84+
variant: 'destructive',
85+
});
86+
}
87+
};
88+
89+
return (
90+
<SidebarHeader className="border-b">
91+
<SidebarMenu>
92+
<SidebarMenuItem>
93+
<DropdownMenu>
94+
<DropdownMenuTrigger asChild>
95+
<SidebarMenuButton
96+
size="lg"
97+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
98+
>
99+
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
100+
<Building2 className="size-4" />
101+
</div>
102+
<div className="grid flex-1 text-left text-sm leading-tight">
103+
<span className="truncate font-semibold">
104+
{active?.name ?? (loading ? 'Loading…' : 'Select organization')}
105+
</span>
106+
{active?.slug && (
107+
<span className="truncate text-xs text-muted-foreground">
108+
{active.slug}
109+
</span>
110+
)}
111+
</div>
112+
<ChevronsUpDown className="ml-auto size-4 opacity-60" />
113+
</SidebarMenuButton>
114+
</DropdownMenuTrigger>
115+
<DropdownMenuContent
116+
className="w-[--radix-dropdown-menu-trigger-width] min-w-56"
117+
align="start"
118+
side="bottom"
119+
sideOffset={4}
120+
>
121+
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">
122+
Organizations
123+
</DropdownMenuLabel>
124+
{organizations.map((org) => (
125+
<DropdownMenuItem
126+
key={org.id}
127+
onSelect={(e) => {
128+
e.preventDefault();
129+
handleSelect(org.id);
130+
}}
131+
className="gap-2"
132+
>
133+
<div className="min-w-0 flex-1">
134+
<div className="truncate font-medium">{org.name}</div>
135+
{org.slug && (
136+
<code className="font-mono text-[11px] text-muted-foreground">
137+
{org.slug}
138+
</code>
139+
)}
140+
</div>
141+
{org.id === activeId && (
142+
<Check className="size-3.5 text-primary" />
143+
)}
144+
</DropdownMenuItem>
145+
))}
146+
<DropdownMenuSeparator />
147+
<DropdownMenuItem asChild>
148+
<Link to="/orgs/new" className="gap-2">
149+
<Plus className="size-3.5" />
150+
New organization…
151+
</Link>
152+
</DropdownMenuItem>
153+
<DropdownMenuItem asChild>
154+
<Link to="/orgs" className="gap-2 text-muted-foreground">
155+
Manage organizations
156+
</Link>
157+
</DropdownMenuItem>
158+
</DropdownMenuContent>
159+
</DropdownMenu>
160+
</SidebarMenuItem>
161+
</SidebarMenu>
162+
</SidebarHeader>
163+
);
164+
}
165+
166+
interface NavItem {
167+
label: string;
168+
to: string;
169+
icon: React.ComponentType<{ className?: string }>;
170+
matchPrefix?: string;
171+
}
172+
173+
const WORKSPACE_ITEMS: NavItem[] = [
174+
{ label: 'Home', to: '/', icon: Home, matchPrefix: '/' },
175+
{ label: 'Organizations', to: '/orgs', icon: Building2, matchPrefix: '/orgs' },
176+
];
177+
178+
const PACKAGE_ITEMS: NavItem[] = [
179+
{ label: 'Packages', to: '/packages', icon: PackageIcon, matchPrefix: '/packages' },
180+
{
181+
label: 'API Console',
182+
to: '/api-console',
183+
icon: Terminal,
184+
matchPrefix: '/api-console',
185+
},
186+
];
187+
188+
const LIBRARY_ITEMS: NavItem[] = [
189+
{ label: 'Templates', to: '/templates', icon: LayoutTemplate, matchPrefix: '/templates' },
190+
{ label: 'Examples', to: '/examples', icon: Sparkles, matchPrefix: '/examples' },
191+
];
192+
193+
function isActive(pathname: string, item: NavItem): boolean {
194+
if (item.matchPrefix === '/') return pathname === '/';
195+
if (!item.matchPrefix) return pathname === item.to;
196+
return pathname === item.matchPrefix || pathname.startsWith(item.matchPrefix + '/');
197+
}
198+
199+
function NavSection({
200+
label,
201+
items,
202+
pathname,
203+
}: {
204+
label: string;
205+
items: NavItem[];
206+
pathname: string;
207+
}) {
208+
return (
209+
<SidebarGroup>
210+
<SidebarGroupLabel>{label}</SidebarGroupLabel>
211+
<SidebarGroupContent>
212+
<SidebarMenu>
213+
{items.map((item) => {
214+
const Icon = item.icon;
215+
return (
216+
<SidebarMenuItem key={item.to}>
217+
<SidebarMenuButton asChild isActive={isActive(pathname, item)}>
218+
<Link to={item.to}>
219+
<Icon className="size-4" />
220+
<span>{item.label}</span>
221+
</Link>
222+
</SidebarMenuButton>
223+
</SidebarMenuItem>
224+
);
225+
})}
226+
</SidebarMenu>
227+
</SidebarGroupContent>
228+
</SidebarGroup>
229+
);
230+
}
231+
232+
function EnvironmentsSection({ pathname }: { pathname: string }) {
233+
const { environments, loading } = useEnvironments();
234+
const envsActive =
235+
pathname === '/environments' || pathname.startsWith('/environments/');
236+
237+
const shown = environments.slice(0, MAX_ENV_ITEMS);
238+
const hasMore = environments.length > MAX_ENV_ITEMS;
239+
240+
return (
241+
<SidebarGroup>
242+
<SidebarGroupLabel>Environments</SidebarGroupLabel>
243+
<SidebarGroupContent>
244+
<SidebarMenu>
245+
<SidebarMenuItem>
246+
<SidebarMenuButton asChild isActive={envsActive && shown.length === 0}>
247+
<Link to="/environments">
248+
<Boxes className="size-4" />
249+
<span>All environments</span>
250+
</Link>
251+
</SidebarMenuButton>
252+
</SidebarMenuItem>
253+
{loading && environments.length === 0 && (
254+
<>
255+
<SidebarMenuSkeleton showIcon />
256+
<SidebarMenuSkeleton showIcon />
257+
</>
258+
)}
259+
{shown.length > 0 && (
260+
<SidebarMenuSub>
261+
{shown.map((env) => {
262+
const href = `/environments/${env.id}`;
263+
const active = pathname === href || pathname.startsWith(href + '/');
264+
return (
265+
<SidebarMenuSubItem key={env.id}>
266+
<SidebarMenuSubButton asChild isActive={active}>
267+
<Link
268+
to="/environments/$environmentId"
269+
params={{ environmentId: env.id }}
270+
>
271+
<MapPin className="size-3.5 opacity-60" />
272+
<span className="truncate">
273+
{env.displayName || env.name || env.id}
274+
</span>
275+
</Link>
276+
</SidebarMenuSubButton>
277+
</SidebarMenuSubItem>
278+
);
279+
})}
280+
{hasMore && (
281+
<SidebarMenuSubItem>
282+
<SidebarMenuSubButton asChild>
283+
<Link to="/environments">
284+
<span className="text-muted-foreground">
285+
View all ({environments.length})…
286+
</span>
287+
</Link>
288+
</SidebarMenuSubButton>
289+
</SidebarMenuSubItem>
290+
)}
291+
</SidebarMenuSub>
292+
)}
293+
</SidebarMenu>
294+
</SidebarGroupContent>
295+
</SidebarGroup>
296+
);
297+
}
298+
299+
export function GlobalSidebar() {
300+
const location = useLocation();
301+
const pathname = location.pathname;
302+
const { session } = useSession();
303+
const activeOrgId = session?.activeOrganizationId ?? undefined;
304+
305+
return (
306+
<Sidebar collapsible="icon">
307+
<OrgHeader />
308+
<SidebarContent>
309+
<NavSection label="Workspace" items={WORKSPACE_ITEMS} pathname={pathname} />
310+
<EnvironmentsSection pathname={pathname} />
311+
<NavSection label="Packages" items={PACKAGE_ITEMS} pathname={pathname} />
312+
<NavSection label="Library" items={LIBRARY_ITEMS} pathname={pathname} />
313+
<SidebarSeparator />
314+
<SidebarGroup>
315+
<SidebarGroupContent>
316+
<SidebarMenu>
317+
<SidebarMenuItem>
318+
<SidebarMenuButton asChild isActive={pathname === `/orgs/${activeOrgId}`}>
319+
{activeOrgId ? (
320+
<Link to="/orgs/$orgId" params={{ orgId: activeOrgId }}>
321+
<Settings className="size-4" />
322+
<span>Settings</span>
323+
</Link>
324+
) : (
325+
<Link to="/orgs">
326+
<Settings className="size-4" />
327+
<span>Settings</span>
328+
</Link>
329+
)}
330+
</SidebarMenuButton>
331+
</SidebarMenuItem>
332+
</SidebarMenu>
333+
</SidebarGroupContent>
334+
</SidebarGroup>
335+
</SidebarContent>
336+
</Sidebar>
337+
);
338+
}

apps/studio/src/components/site-header.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { UserMenu } from '@/components/user-menu'
2121
interface SiteHeaderProps {
2222
selectedObject?: string | null;
2323
selectedMeta?: { type: string; name: string } | null;
24-
selectedView: 'overview' | 'packages' | 'object' | 'metadata' | 'api-console' | 'environments';
24+
selectedView: 'overview' | 'packages' | 'object' | 'metadata' | 'api-console' | 'environments' | 'templates' | 'examples';
2525
packageLabel?: string;
2626
}
2727

@@ -44,6 +44,8 @@ export function SiteHeader({ selectedObject = null, selectedMeta, selectedView,
4444
packages: 'Package Manager',
4545
'api-console': 'API Console',
4646
environments: 'Environments',
47+
templates: 'Templates',
48+
examples: 'Examples',
4749
object: selectedObject || 'Object',
4850
metadata: selectedMeta ? (META_TYPE_LABELS[selectedMeta.type] || selectedMeta.type) : 'Metadata',
4951
};

0 commit comments

Comments
 (0)