|
| 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