Skip to content

Commit 885d085

Browse files
committed
feat(cloud): projects
1 parent e1eb922 commit 885d085

File tree

21 files changed

+1892
-46
lines changed

21 files changed

+1892
-46
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,14 +7,20 @@
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": "echo 'Please use build:engine or build:inspector' && exit 1",
1213
"build:inspector": "vite build --mode=production --config vite.inspector.config.ts",
1314
"build:engine": "vite build --mode=production --config vite.engine.config.ts",
15+
"build:cloud": "vite build --mode=production --config vite.cloud.config.ts",
1416
"preview:inspector": "vite preview --config vite.inspector.config.ts",
15-
"preview:engine": "vite preview --config vite.engine.config.ts"
17+
"preview:engine": "vite preview --config vite.engine.config.ts",
18+
"preview:cloud": "vite preview --config vite.cloud.config.ts"
1619
},
1720
"dependencies": {
21+
"@clerk/clerk-js": "^5.91.2",
22+
"@clerk/clerk-react": "^5.46.1",
23+
"@clerk/themes": "^2.4.17",
1824
"@codemirror/commands": "^6.8.1",
1925
"@codemirror/lang-javascript": "^6.2.2",
2026
"@codemirror/lang-json": "^6.0.1",
@@ -49,6 +55,7 @@
4955
"@radix-ui/react-toggle-group": "^1.1.1",
5056
"@radix-ui/react-tooltip": "^1.1.1",
5157
"@radix-ui/react-visually-hidden": "^1.0.3",
58+
"@rivet-gg/cloud": "file:./vendor/rivet-cloud.tgz",
5259
"@rivet-gg/icons": "file:./vendor/rivet-icons.tgz",
5360
"rivetkit": "*",
5461
"@rivetkit/engine-api-full": "workspace:*",
@@ -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 & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { OrganizationSwitcher, useClerk } from "@clerk/clerk-react";
12
import {
23
faArrowUpRight,
34
faLink,
@@ -6,7 +7,12 @@ import {
67
Icon,
78
} from "@rivet-gg/icons";
89
import { useQuery } from "@tanstack/react-query";
9-
import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
10+
import {
11+
Link,
12+
useMatch,
13+
useMatchRoute,
14+
useNavigate,
15+
} from "@tanstack/react-router";
1016
import {
1117
type ComponentProps,
1218
createContext,
@@ -20,8 +26,10 @@ import {
2026
useState,
2127
} from "react";
2228
import type { ImperativePanelGroupHandle } from "react-resizable-panels";
29+
import { match } from "ts-pattern";
2330
import {
2431
Button,
32+
type ButtonProps,
2533
cn,
2634
DocsSheet,
2735
type ImperativePanelHandle,
@@ -146,13 +154,25 @@ const Sidebar = ({
146154
/>
147155
</Link>
148156
<div className="flex flex-1 flex-col gap-4 px-2 min-h-0">
149-
{__APP_TYPE__ === "inspector" ? (
150-
<ConnectionStatus />
151-
) : null}
152-
{__APP_TYPE__ === "engine" ? <Breadcrumbs /> : null}
153-
<ScrollArea>
154-
<Subnav />
155-
</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()}
156176
</div>
157177
<div>
158178
<div className="border-t p-2 flex flex-col gap-[1px] text-sm">
@@ -241,7 +261,7 @@ const Footer = () => {
241261

242262
export { Root, Main, Header, Footer, VisibleInFull, Sidebar };
243263

244-
const Breadcrumbs = () => {
264+
const Breadcrumbs = (): ReactNode => {
245265
const matchRoute = useMatchRoute();
246266
const nsMatch = matchRoute({
247267
to: "/ns/$namespace",
@@ -341,26 +361,37 @@ const Subnav = () => {
341361

342362
function HeaderLink({ icon, children, className, ...props }: HeaderLinkProps) {
343363
return (
344-
<Button
364+
<HeaderButton
345365
asChild
346366
variant="ghost"
347367
{...props}
348-
className={cn(
349-
"text-muted-foreground px-2 aria-current-page:text-foreground relative h-auto py-1 justify-start",
350-
className,
351-
)}
352368
startIcon={
353369
icon ? (
354370
<Icon className={cn("size-5 opacity-80")} icon={icon} />
355371
) : undefined
356372
}
357373
>
358374
<Link to={props.to}>{children}</Link>
375+
</HeaderButton>
376+
);
377+
}
378+
379+
function HeaderButton({ children, className, ...props }: ButtonProps) {
380+
return (
381+
<Button
382+
variant="ghost"
383+
{...props}
384+
className={cn(
385+
"text-muted-foreground px-2 aria-current-page:text-foreground relative h-auto py-1 justify-start",
386+
className,
387+
)}
388+
>
389+
{children}
359390
</Button>
360391
);
361392
}
362393

363-
function ConnectionStatus() {
394+
function ConnectionStatus(): ReactNode {
364395
const { endpoint, ...queries } = useManager();
365396
const { setCredentials } = useInspectorCredentials();
366397
const { isLoading, isError, isSuccess } = useQuery(
@@ -414,4 +445,48 @@ function ConnectionStatus() {
414445
</div>
415446
);
416447
}
448+
449+
return null;
450+
}
451+
452+
function CloudSidebar(): ReactNode {
453+
const match = useMatch({
454+
from: "/_layout/orgs/$organization/",
455+
shouldThrow: false,
456+
});
457+
458+
const clerk = useClerk();
459+
return (
460+
<>
461+
<OrganizationSwitcher />
462+
463+
<ScrollArea>
464+
<div className="flex gap-1.5 flex-col">
465+
<HeaderLink
466+
to="/orgs/$organization"
467+
className="font-normal"
468+
params={match?.params}
469+
>
470+
Projects
471+
</HeaderLink>
472+
<HeaderButton
473+
onClick={() => {
474+
clerk.openUserProfile({
475+
__experimental_startPath: "/billing",
476+
});
477+
}}
478+
>
479+
Billing
480+
</HeaderButton>
481+
<HeaderButton
482+
onClick={() => {
483+
clerk.openUserProfile();
484+
}}
485+
>
486+
Settings
487+
</HeaderButton>
488+
</div>
489+
</ScrollArea>
490+
</>
491+
);
417492
}

frontend/src/app/use-dialog.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const useDialog = {
88
CreateNamespace: createDialogHook(
99
import("@/app/dialogs/create-namespace-dialog"),
1010
),
11+
CreateProject: createDialogHook(
12+
import("@/app/dialogs/create-project-dialog"),
13+
),
1114
ProvideEngineCredentials: createDialogHook(
1215
import("@/app/dialogs/provide-engine-credentials-dialog"),
1316
),

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)