Skip to content

Commit feb9bc4

Browse files
committed
feat(cloud): namespaces
1 parent ed2b820 commit feb9bc4

23 files changed

+1466
-463
lines changed

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"tailwindcss-animate": "^1.0.7",
119119
"ts-pattern": "^5.8.0",
120120
"typescript": "^5.5.4",
121+
"typescript-plugin-css-modules": "^5.2.0",
121122
"usehooks-ts": "^3.1.0",
122123
"vite": "^5.2.0",
123124
"vite-plugin-favicons-inject": "^2.2.0",
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
import { useClerk, useOrganizationList } from "@clerk/clerk-react";
2+
import { AvatarImage } from "@radix-ui/react-avatar";
3+
import { faPlusCircle, Icon } from "@rivet-gg/icons";
4+
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
5+
import { useMatchRoute, useNavigate, useParams } from "@tanstack/react-router";
6+
import { useState } from "react";
7+
import {
8+
Avatar,
9+
Button,
10+
Command,
11+
CommandEmpty,
12+
CommandGroup,
13+
CommandInput,
14+
CommandItem,
15+
CommandList,
16+
cn,
17+
Popover,
18+
PopoverContent,
19+
PopoverTrigger,
20+
Skeleton,
21+
} from "@/components";
22+
import { SafeHover } from "@/components/safe-hover";
23+
import { VisibilitySensor } from "@/components/visibility-sensor";
24+
import {
25+
organizationQueryOptions,
26+
projectQueryOptions,
27+
projectsQueryOptions,
28+
} from "@/queries/manager-cloud";
29+
30+
export function ContextSwitcher() {
31+
const [isOpen, setIsOpen] = useState(false);
32+
33+
return (
34+
<Popover open={isOpen} onOpenChange={setIsOpen}>
35+
<PopoverTrigger asChild>
36+
<Button
37+
variant="outline"
38+
className="flex flex-col h-auto justify-start items-start"
39+
>
40+
<Breadcrumbs />
41+
</Button>
42+
</PopoverTrigger>
43+
<PopoverContent className="p-0 max-w-96 w-full" align="start">
44+
<Content onClose={() => setIsOpen(false)} />
45+
</PopoverContent>
46+
</Popover>
47+
);
48+
}
49+
50+
function Breadcrumbs() {
51+
const match = useMatchRoute();
52+
53+
const matchOrg = match({
54+
to: "/orgs/$organization",
55+
});
56+
57+
if (matchOrg) {
58+
return <OrganizationBreadcrumb org={matchOrg.organization} />;
59+
}
60+
61+
const matchProject = match({
62+
to: "/orgs/$organization/projects/$project",
63+
});
64+
65+
if (matchProject) {
66+
return (
67+
<>
68+
<OrganizationBreadcrumb
69+
org={matchProject.organization}
70+
className="text-xs [&>span]:size-4 mb-1"
71+
/>
72+
<ProjectBreadcrumb project={matchProject.project} />
73+
</>
74+
);
75+
}
76+
}
77+
78+
function OrganizationBreadcrumb({
79+
org,
80+
className,
81+
}: {
82+
org: string;
83+
className?: string;
84+
}) {
85+
const { isLoading, data } = useQuery(organizationQueryOptions({ org }));
86+
if (isLoading) {
87+
return <Skeleton className="h-5 w-32" />;
88+
}
89+
90+
return (
91+
<div className={cn("flex justify-start", className)}>
92+
<Avatar className="size-5 mr-1">
93+
<AvatarImage
94+
src={data?.imageUrl}
95+
alt={data?.name || "Organization Avatar"}
96+
/>
97+
</Avatar>
98+
<span>{data?.name}</span>
99+
</div>
100+
);
101+
}
102+
103+
function ProjectBreadcrumb({ project }: { project: string }) {
104+
const { isLoading, data } = useQuery(projectQueryOptions({ project }));
105+
if (isLoading) {
106+
return <Skeleton className="h-5 w-32" />;
107+
}
108+
109+
return <span>{data?.name}</span>;
110+
}
111+
112+
function Content({ onClose }: { onClose?: () => void }) {
113+
const params = useParams({ strict: false });
114+
const {
115+
userMemberships: {
116+
data: userMemberships = [],
117+
isLoading,
118+
hasNextPage,
119+
fetchNext,
120+
},
121+
} = useOrganizationList({
122+
userMemberships: {
123+
infinite: true,
124+
},
125+
});
126+
const clerk = useClerk();
127+
128+
const [currentOrgHover, setCurrentOrgHover] = useState<string | null>(
129+
params.organization || null,
130+
);
131+
132+
const navigate = useNavigate();
133+
134+
return (
135+
<div className="flex w-full">
136+
<div className="w-48">
137+
<Command loop defaultValue={clerk.organization?.id}>
138+
<CommandInput
139+
className="text-sm"
140+
placeholder="Find Organization..."
141+
/>
142+
<CommandList className="relative p-1">
143+
<CommandGroup heading="Organizations">
144+
{!isLoading ? (
145+
<CommandEmpty>
146+
No organizations found.
147+
<Button
148+
className="mt-1"
149+
variant="outline"
150+
size="sm"
151+
startIcon={<Icon icon={faPlusCircle} />}
152+
onClick={() => {
153+
onClose?.();
154+
clerk.openCreateOrganization();
155+
}}
156+
>
157+
Create Organization
158+
</Button>
159+
</CommandEmpty>
160+
) : null}
161+
{userMemberships.map((membership) => (
162+
<SafeHover key={membership.id} offset={40}>
163+
<CommandItem
164+
onSelect={() => {
165+
clerk.setActive({
166+
organization:
167+
membership.organization.id,
168+
});
169+
navigate({
170+
to: "/orgs/$organization",
171+
params: {
172+
organization:
173+
membership.organization
174+
.id,
175+
},
176+
});
177+
onClose?.();
178+
}}
179+
value={membership.organization.id}
180+
onMouseEnter={() => {
181+
setCurrentOrgHover(
182+
membership.organization.id,
183+
);
184+
}}
185+
keywords={[
186+
membership.organization.name,
187+
]}
188+
className="static cursor-pointer"
189+
>
190+
{membership.organization.name}
191+
</CommandItem>
192+
</SafeHover>
193+
))}
194+
{isLoading ? (
195+
<>
196+
<ListItemSkeleton />
197+
<ListItemSkeleton />
198+
<ListItemSkeleton />
199+
<ListItemSkeleton />
200+
<ListItemSkeleton />
201+
</>
202+
) : null}
203+
<CommandItem
204+
keywords={[
205+
"create",
206+
"new",
207+
"organization",
208+
"team",
209+
]}
210+
onMouseEnter={() => {
211+
setCurrentOrgHover(null);
212+
}}
213+
onFocus={() => {
214+
setCurrentOrgHover(null);
215+
}}
216+
onSelect={() => {
217+
clerk.openCreateOrganization();
218+
}}
219+
>
220+
<Icon icon={faPlusCircle} className="mr-2" />
221+
Create Organization
222+
</CommandItem>
223+
224+
{hasNextPage ? (
225+
<VisibilitySensor onChange={fetchNext} />
226+
) : null}
227+
</CommandGroup>
228+
</CommandList>
229+
</Command>
230+
</div>
231+
{currentOrgHover ? (
232+
<ProjectList organization={currentOrgHover} onClose={onClose} />
233+
) : null}
234+
</div>
235+
);
236+
}
237+
238+
function ProjectList({
239+
organization,
240+
onClose,
241+
}: {
242+
organization: string;
243+
onClose?: () => void;
244+
}) {
245+
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
246+
useInfiniteQuery(projectsQueryOptions({ organization: organization }));
247+
const navigate = useNavigate();
248+
const clerk = useClerk();
249+
const project = useParams({
250+
strict: false,
251+
select(params) {
252+
return params.project;
253+
},
254+
});
255+
256+
return (
257+
<div className="border-l w-48">
258+
<Command loop>
259+
<CommandInput placeholder="Find project..." />
260+
<CommandList
261+
className="relative p-1 w-full"
262+
defaultValue={project}
263+
>
264+
<CommandGroup heading="Projects" className="w-full">
265+
{!isLoading ? (
266+
<CommandEmpty>
267+
No projects found.
268+
<Button
269+
className="mt-1"
270+
variant="outline"
271+
size="sm"
272+
startIcon={<Icon icon={faPlusCircle} />}
273+
onClick={() => {
274+
navigate({
275+
to: ".",
276+
search: (old) => ({
277+
...old,
278+
modal: "create-project",
279+
}),
280+
});
281+
}}
282+
>
283+
Create Project
284+
</Button>
285+
</CommandEmpty>
286+
) : null}
287+
288+
{data?.map((project) => (
289+
<CommandItem
290+
key={project.id}
291+
value={project.name}
292+
keywords={[project.displayName, project.name]}
293+
className="static w-full"
294+
onSelect={() => {
295+
clerk.setActive({
296+
organization,
297+
});
298+
navigate({
299+
to: "/orgs/$organization/projects/$project",
300+
params: {
301+
organization: organization,
302+
project: project.name,
303+
},
304+
});
305+
onClose?.();
306+
}}
307+
>
308+
<span className="truncate w-full">
309+
{project.displayName}
310+
</span>
311+
</CommandItem>
312+
))}
313+
{isLoading || isFetchingNextPage ? (
314+
<>
315+
<ListItemSkeleton />
316+
<ListItemSkeleton />
317+
<ListItemSkeleton />
318+
<ListItemSkeleton />
319+
<ListItemSkeleton />
320+
</>
321+
) : null}
322+
323+
<CommandItem
324+
keywords={["create", "new", "project"]}
325+
onSelect={() => {
326+
navigate({
327+
to: ".",
328+
search: (old) => ({
329+
...old,
330+
modal: "create-project",
331+
}),
332+
});
333+
}}
334+
>
335+
<Icon icon={faPlusCircle} className="mr-2" />
336+
Create Project
337+
</CommandItem>
338+
339+
{hasNextPage ? (
340+
<VisibilitySensor onChange={fetchNextPage} />
341+
) : null}
342+
</CommandGroup>
343+
</CommandList>
344+
</Command>
345+
</div>
346+
);
347+
}
348+
349+
function ListItemSkeleton() {
350+
return (
351+
<div className="px-2 py-1.5">
352+
<Skeleton className="h-5 w-32" />
353+
</div>
354+
);
355+
}

0 commit comments

Comments
 (0)