Skip to content

Commit ed2b820

Browse files
committed
feat(cloud): projects
1 parent 746212f commit ed2b820

File tree

21 files changed

+1881
-55
lines changed

21 files changed

+1881
-55
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ tf/.terraform.lock.hcl
4343
frontend/.env.*
4444
frontend/dist/
4545
frontend/node_modules/
46+
frontend/.tanstack
4647

4748
# Site
4849
site/.next/

frontend/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ VITE_APP_SENTRY_PROJECT_ID="4506435887366144"
55
# This is a public-facing token, safe to commit to repo
66
VITE_APP_POSTHOG_API_KEY=phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo
77
VITE_APP_POSTHOG_HOST=https://ph.rivet.gg
8+
VITE_APP_CLOUD_API_URL=https://cloud.rivet.gg/api
89

910
# Overridden in CI
1011
SENTRY_AUTH_TOKEN=

frontend/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
"dev": "pnpm run '/^dev:.*/'",
88
"dev:inspector": "vite --config vite.inspector.config.ts",
99
"dev:engine": "vite --config vite.engine.config.ts",
10+
"dev:cloud": "vite --config vite.cloud.config.ts",
1011
"ts-check": "tsc --noEmit",
1112
"build:inspector": "vite build --mode=production --config vite.inspector.config.ts",
1213
"build:engine": "vite build --mode=production --config vite.engine.config.ts",
14+
"build:cloud": "vite build --mode=production --config vite.cloud.config.ts",
1315
"preview:inspector": "vite preview --config vite.inspector.config.ts",
14-
"preview:engine": "vite preview --config vite.engine.config.ts"
16+
"preview:engine": "vite preview --config vite.engine.config.ts",
17+
"preview:cloud": "vite preview --config vite.cloud.config.ts"
1518
},
1619
"dependencies": {
20+
"@clerk/clerk-js": "^5.91.2",
21+
"@clerk/clerk-react": "^5.46.1",
22+
"@clerk/themes": "^2.4.17",
1723
"@codemirror/commands": "^6.8.1",
1824
"@codemirror/lang-javascript": "^6.2.2",
1925
"@codemirror/lang-json": "^6.0.1",
@@ -48,6 +54,7 @@
4854
"@radix-ui/react-toggle-group": "^1.1.1",
4955
"@radix-ui/react-tooltip": "^1.1.1",
5056
"@radix-ui/react-visually-hidden": "^1.0.3",
57+
"@rivet-gg/cloud": "file:./vendor/rivet-cloud.tgz",
5158
"@rivet-gg/icons": "file:./vendor/rivet-icons.tgz",
5259
"@rivetkit/actor": "file:./vendor/rivetkit-actor.tgz",
5360
"@rivetkit/core": "file:./vendor/rivetkit-core.tgz",
@@ -109,6 +116,7 @@
109116
"tailwind-merge": "^2.2.2",
110117
"tailwindcss": "^3.4.1",
111118
"tailwindcss-animate": "^1.0.7",
119+
"ts-pattern": "^5.8.0",
112120
"typescript": "^5.5.4",
113121
"usehooks-ts": "^3.1.0",
114122
"vite": "^5.2.0",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { useNavigate } from "@tanstack/react-router";
3+
import * as CreateProjectForm from "@/app/forms/create-project-form";
4+
import { DialogFooter, DialogHeader, DialogTitle, Flex } from "@/components";
5+
import { convertStringToId } from "@/lib/utils";
6+
import {
7+
createProjectMutationOptions,
8+
projectsQueryOptions,
9+
} from "@/queries/manager-cloud";
10+
import {
11+
managerClient,
12+
namespacesQueryOptions,
13+
} from "@/queries/manager-engine";
14+
15+
export default function CreateProjectDialogContent() {
16+
const queryClient = useQueryClient();
17+
const navigate = useNavigate();
18+
19+
const { mutateAsync } = useMutation(
20+
createProjectMutationOptions({
21+
onSuccess: async (values) => {
22+
await queryClient.invalidateQueries({
23+
...projectsQueryOptions(),
24+
});
25+
navigate({
26+
to: "/orgs/$organization/projects/$project",
27+
params: {
28+
organization: values.organizationId,
29+
project: values.name,
30+
},
31+
});
32+
},
33+
}),
34+
);
35+
36+
return (
37+
<CreateProjectForm.Form
38+
onSubmit={async (values) => {
39+
await mutateAsync({
40+
displayName: values.name,
41+
nameId: values.slug || convertStringToId(values.name),
42+
});
43+
}}
44+
defaultValues={{ name: "", slug: "" }}
45+
>
46+
<DialogHeader>
47+
<DialogTitle>Create New Project</DialogTitle>
48+
</DialogHeader>
49+
<Flex gap="4" direction="col">
50+
<CreateProjectForm.Name />
51+
<CreateProjectForm.Slug />
52+
</Flex>
53+
<DialogFooter>
54+
<CreateProjectForm.Submit type="submit">
55+
Create
56+
</CreateProjectForm.Submit>
57+
</DialogFooter>
58+
</CreateProjectForm.Form>
59+
);
60+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { type UseFormReturn, useFormContext } from "react-hook-form";
2+
import z from "zod";
3+
import {
4+
createSchemaForm,
5+
FormControl,
6+
FormField,
7+
FormItem,
8+
FormLabel,
9+
FormMessage,
10+
Input,
11+
} from "@/components";
12+
import { convertStringToId } from "@/lib/utils";
13+
14+
export const formSchema = z.object({
15+
name: z
16+
.string()
17+
.max(25)
18+
.refine((value) => value.trim() !== "" && value.trim() === value, {
19+
message: "Name cannot be empty or contain whitespaces",
20+
}),
21+
slug: z.string().max(25).optional(),
22+
});
23+
24+
export type FormValues = z.infer<typeof formSchema>;
25+
export type SubmitHandler = (
26+
values: FormValues,
27+
form: UseFormReturn<FormValues>,
28+
) => Promise<void>;
29+
30+
const { Form, Submit, SetValue } = createSchemaForm(formSchema);
31+
export { Form, Submit, SetValue };
32+
33+
export const Name = ({ className }: { className?: string }) => {
34+
const { control } = useFormContext<FormValues>();
35+
return (
36+
<FormField
37+
control={control}
38+
name="name"
39+
render={({ field }) => (
40+
<FormItem className={className}>
41+
<FormLabel className="col-span-1">Name</FormLabel>
42+
<FormControl className="row-start-2">
43+
<Input
44+
placeholder="Enter a project name..."
45+
maxLength={25}
46+
{...field}
47+
/>
48+
</FormControl>
49+
<FormMessage className="col-span-1" />
50+
</FormItem>
51+
)}
52+
/>
53+
);
54+
};
55+
56+
export const Slug = ({ className }: { className?: string }) => {
57+
const { control, watch } = useFormContext<FormValues>();
58+
59+
const name = watch("name");
60+
61+
return (
62+
<FormField
63+
control={control}
64+
name="slug"
65+
render={({ field }) => (
66+
<FormItem className={className}>
67+
<FormLabel className="col-span-2">Slug</FormLabel>
68+
<FormControl className="row-start-2">
69+
<Input
70+
placeholder={
71+
name
72+
? convertStringToId(name)
73+
: "Enter a slug..."
74+
}
75+
maxLength={25}
76+
{...field}
77+
onChange={(event) => {
78+
const value = convertStringToId(
79+
event.target.value,
80+
);
81+
field.onChange({ target: { value } });
82+
}}
83+
/>
84+
</FormControl>
85+
<FormMessage className="col-span-2" />
86+
</FormItem>
87+
)}
88+
/>
89+
);
90+
};

frontend/src/app/layout.tsx

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import { OrganizationSwitcher, useClerk } from "@clerk/clerk-react";
12
import {
23
faArrowUpRight,
3-
faCheck,
44
faLink,
55
faServer,
66
faSpinnerThird,
7-
faTriangleExclamation,
87
Icon,
98
} from "@rivet-gg/icons";
109
import { useQuery } from "@tanstack/react-query";
11-
import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
10+
import {
11+
Link,
12+
useMatch,
13+
useMatchRoute,
14+
useNavigate,
15+
} from "@tanstack/react-router";
1216
import {
1317
type ComponentProps,
1418
createContext,
@@ -22,8 +26,10 @@ import {
2226
useState,
2327
} from "react";
2428
import type { ImperativePanelGroupHandle } from "react-resizable-panels";
29+
import { match } from "ts-pattern";
2530
import {
2631
Button,
32+
type ButtonProps,
2733
cn,
2834
DocsSheet,
2935
type ImperativePanelHandle,
@@ -148,13 +154,25 @@ const Sidebar = ({
148154
/>
149155
</Link>
150156
<div className="flex flex-1 flex-col gap-4 px-2 min-h-0">
151-
{__APP_TYPE__ === "inspector" ? (
152-
<ConnectionStatus />
153-
) : null}
154-
{__APP_TYPE__ === "engine" ? <Breadcrumbs /> : null}
155-
<ScrollArea>
156-
<Subnav />
157-
</ScrollArea>
157+
{match(__APP_TYPE__)
158+
.with("engine", () => (
159+
<>
160+
<ConnectionStatus />
161+
<ScrollArea>
162+
<Subnav />
163+
</ScrollArea>
164+
</>
165+
))
166+
.with("inspector", () => (
167+
<>
168+
<Breadcrumbs />
169+
<ScrollArea>
170+
<Subnav />
171+
</ScrollArea>
172+
</>
173+
))
174+
.with("cloud", () => <CloudSidebar />)
175+
.exhaustive()}
158176
</div>
159177
<div>
160178
<div className="border-t p-2 flex flex-col gap-[1px] text-sm">
@@ -233,7 +251,7 @@ const Footer = () => {
233251

234252
export { Root, Main, Header, Footer, VisibleInFull, Sidebar };
235253

236-
const Breadcrumbs = () => {
254+
const Breadcrumbs = (): ReactNode => {
237255
const matchRoute = useMatchRoute();
238256
const nsMatch = matchRoute({
239257
to: "/ns/$namespace",
@@ -333,26 +351,37 @@ const Subnav = () => {
333351

334352
function HeaderLink({ icon, children, className, ...props }: HeaderLinkProps) {
335353
return (
336-
<Button
354+
<HeaderButton
337355
asChild
338356
variant="ghost"
339357
{...props}
340-
className={cn(
341-
"text-muted-foreground px-2 aria-current-page:text-foreground relative h-auto py-1 justify-start",
342-
className,
343-
)}
344358
startIcon={
345359
icon ? (
346360
<Icon className={cn("size-5 opacity-80")} icon={icon} />
347361
) : undefined
348362
}
349363
>
350364
<Link to={props.to}>{children}</Link>
365+
</HeaderButton>
366+
);
367+
}
368+
369+
function HeaderButton({ children, className, ...props }: ButtonProps) {
370+
return (
371+
<Button
372+
variant="ghost"
373+
{...props}
374+
className={cn(
375+
"text-muted-foreground px-2 aria-current-page:text-foreground relative h-auto py-1 justify-start",
376+
className,
377+
)}
378+
>
379+
{children}
351380
</Button>
352381
);
353382
}
354383

355-
function ConnectionStatus() {
384+
function ConnectionStatus(): ReactNode {
356385
const { endpoint, ...queries } = useManager();
357386
const { setCredentials } = useInspectorCredentials();
358387
const { isLoading, isError, isSuccess } = useQuery(
@@ -406,4 +435,48 @@ function ConnectionStatus() {
406435
</div>
407436
);
408437
}
438+
439+
return null;
440+
}
441+
442+
function CloudSidebar(): ReactNode {
443+
const match = useMatch({
444+
from: "/_layout/orgs/$organization/",
445+
shouldThrow: false,
446+
});
447+
448+
const clerk = useClerk();
449+
return (
450+
<>
451+
<OrganizationSwitcher />
452+
453+
<ScrollArea>
454+
<div className="flex gap-1.5 flex-col">
455+
<HeaderLink
456+
to="/orgs/$organization"
457+
className="font-normal"
458+
params={match?.params}
459+
>
460+
Projects
461+
</HeaderLink>
462+
<HeaderButton
463+
onClick={() => {
464+
clerk.openUserProfile({
465+
__experimental_startPath: "/billing",
466+
});
467+
}}
468+
>
469+
Billing
470+
</HeaderButton>
471+
<HeaderButton
472+
onClick={() => {
473+
clerk.openUserProfile();
474+
}}
475+
>
476+
Settings
477+
</HeaderButton>
478+
</div>
479+
</ScrollArea>
480+
</>
481+
);
409482
}

frontend/src/app/use-dialog.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@ d.CreateNamespace = createDialogHook(
66
import("@/app/dialogs/create-namespace-dialog"),
77
);
88

9+
d.CreateProject = createDialogHook(
10+
import("@/app/dialogs/create-project-dialog"),
11+
);
12+
913
export { d as useDialog };

frontend/src/components/header/header-link.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,8 @@ export function HeaderLink({
2929
icon ? <Icon className={cn("size-5")} icon={icon} /> : undefined
3030
}
3131
>
32-
<Link to={props.to}>
33-
{children}
34-
<div className="absolute inset-x-0 -bottom-2 z-[1] h-[2px] rounded-sm bg-primary group-data-active:block hidden" />
35-
</Link>
32+
{children}
33+
<div className="absolute inset-x-0 -bottom-2 z-[1] h-[2px] rounded-sm bg-primary group-data-active:block hidden" />
3634
</Button>
3735
);
3836
}

frontend/src/lib/auth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Clerk } from "@clerk/clerk-js";
2+
import { cloudEnv } from "./env";
3+
4+
export const clerk = new Clerk(cloudEnv().VITE_CLERK_PUBLISHABLE_KEY);

0 commit comments

Comments
 (0)