Skip to content
195 linesCodeBlameRaw
1import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react";
2import { useState } from "react";
3import { Form, redirect, useNavigate } from "react-router";
4
5import type { Route } from "./+types/new";
6import { page } from "../lib/meta";
7import { ErrorText, SubmitButton } from "../components/ui";
8import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field";
9import { Input, InputAddon, InputGroup } from "../components/ui/input";
10import { RadioCard, RadioGroup } from "../components/ui/radio-group";
11import { repos } from "../lib/services.server";
12import { GithubMark } from "../components/github";
13import { githubApp } from "../lib/github.server";
14import { assertSameOrigin, requireUser } from "../lib/session.server";
15import { currentWorkspace } from "../lib/current-workspace.server";
16
17export function meta(args: Route.MetaArgs) {
18 return page(args, { title: "New project · g1t" });
19}
20
21export async function loader({ request, context }: Route.LoaderArgs) {
22 const user = requireUser(context, request);
23 // Projects live in a workspace, so there has to be one first; a new one
24 // goes in the workspace you're in.
25 const current = currentWorkspace(user, request);
26 if (!current) throw redirect("/workspaces/new");
27 const selected = current.slug;
28 // With g1t's GitHub App configured, repositories can come from GitHub.
29 const github = await githubApp.status(user, selected).catch(() => null);
30 return { selected, name: current.name?.trim() || current.slug, github: Boolean(github?.ok && github.value.configured) };
31}
32
33export async function action({ request, context }: Route.ActionArgs) {
34 assertSameOrigin(request);
35 const user = requireUser(context, request);
36 const form = await request.formData();
37 // A project hosted on g1t: its repository is made with it, and the
38 // project takes the repository's name.
39 const result = await repos.create(user, {
40 namespace: String(form.get("workspace") ?? ""),
41 name: String(form.get("name") ?? ""),
42 description: String(form.get("description") ?? ""),
43 isPrivate: form.get("visibility") === "private",
44 importUrl: form.get("source") === "import" ? String(form.get("importUrl") ?? "").trim() || undefined : undefined,
45 });
46 if (!result.ok) return { error: result.error.message };
47 throw redirect(`/${result.value.namespace}/${result.value.name}`);
48}
49
50type Source = "empty" | "import";
51
52const SOURCES: { id: Source | "mirror"; title: string; text: string; icon: React.ReactNode; soon?: boolean }[] = [
53 {
54 id: "empty",
55 title: "Start empty",
56 text: "A new repository on g1t. Push to it, or hand an agent the first issue.",
57 icon: <Sparkles />,
58 },
59 {
60 id: "import",
61 title: "Import code",
62 text: "Copy a public repository from GitHub or any git host into a new one on g1t.",
63 icon: <Download />,
64 },
65 {
66 id: "mirror",
67 title: "Mirror GitHub, GitLab or Bitbucket",
68 text: "Keep the code where it is and work on it with g1t. Deployments are yours to turn on.",
69 icon: <GitBranch />,
70 soon: true,
71 },
72];
73
74
75/** Replaces the mirror card when g1t's GitHub App is configured. */
76const GITHUB_SOURCE = {
77 id: "github" as const,
78 title: "Import from GitHub",
79 text: "Import, mirror or move repositories you can reach on GitHub, private ones too.",
80 icon: <GithubMark />,
81 soon: false,
82};
83
84export default function NewProject({ loaderData, actionData }: Route.ComponentProps) {
85 const [source, setSource] = useState<Source>("empty");
86 const navigate = useNavigate();
87 const sources = loaderData.github ? SOURCES.map((option) => (option.id === "mirror" ? GITHUB_SOURCE : option)) : SOURCES;
88 const workspace = loaderData.selected;
89 const [name, setName] = useState("");
90 return (
91 <main className="mx-auto max-w-2xl px-4 py-12">
92 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
93 <Box size={18} />
94 </span>
95 <h1 className="mt-4 text-2xl font-semibold tracking-tight">New project</h1>
96 <p className="mt-1.5 text-sm text-muted">
97 A project is what you build and run. Its code lives in its source; its deployments, environments, secrets and
98 variables belong to the project.
99 </p>
100
101 <Form method="post" className="mt-8 space-y-8">
102 <FieldSet>
103 <FieldLegend>Where its code comes from</FieldLegend>
104 <RadioGroup
105 name="source"
106 value={source}
107 onValueChange={(value) =>
108 value === "github" ? navigate("/new/github") : setSource(value as Source)
109 }
110 aria-label="Where its code comes from"
111 className="gap-3 sm:grid-cols-3"
112 >
113 {sources.map((option) => (
114 <RadioCard
115 key={option.id}
116 value={option.id}
117 title={option.title}
118 description={option.text}
119 icon={option.icon}
120 disabled={option.soon}
121 badge={option.soon ? "Soon" : undefined}
122 />
123 ))}
124 </RadioGroup>
125 </FieldSet>
126
127 {source === "import" && (
128 <Field className="animate-fade-in">
129 <FieldLabel htmlFor="importUrl">Repository to import</FieldLabel>
130 <Input id="importUrl" name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
131 <FieldDescription>The address of a public repository. Its default branch is copied, up to 40 MB.</FieldDescription>
132 </Field>
133 )}
134
135 <Field>
136 <FieldLabel htmlFor="name">Name</FieldLabel>
137 <InputGroup className="h-10">
138 <input type="hidden" name="workspace" value={workspace} />
139 <InputAddon className="max-w-[55%] shrink-0 gap-2 bg-surface px-2.5 font-medium text-fg sm:max-w-[45%]">
140 <span className="truncate">{loaderData.name}</span>
141 </InputAddon>
142 <InputAddon className="border-l border-line px-2 font-mono text-base text-faint">/</InputAddon>
143 <Input
144 id="name"
145 name="name"
146 required
147 autoFocus
148 maxLength={100}
149 placeholder="my-app"
150 value={name}
151 onChange={(event) => setName(event.target.value)}
152 className="pl-1 font-mono"
153 />
154 </InputGroup>
155 <FieldDescription>
156 The project and its repository share it:{" "}
157 <span className="font-mono text-muted">
158 g1t.sh/{workspace}/<span className={name ? "text-fg" : undefined}>{name || "name"}</span>
159 </span>
160 </FieldDescription>
161 </Field>
162
163 <Field>
164 <FieldLabel htmlFor="description">
165 Description <span className="font-normal text-faint">(optional)</span>
166 </FieldLabel>
167 <Input id="description" name="description" maxLength={200} placeholder="What it is, in a line" />
168 </Field>
169
170 <FieldSet>
171 <FieldLegend>Who can see it</FieldLegend>
172 <RadioGroup name="visibility" defaultValue="public" aria-label="Who can see it" className="gap-3 sm:grid-cols-2">
173 <RadioCard
174 value="public"
175 icon={<Globe />}
176 title="Public"
177 description="Anyone can see its code and open issues."
178 />
179 <RadioCard
180 value="private"
181 icon={<Lock />}
182 title="Private"
183 description="Only members of the workspace."
184 />
185 </RadioGroup>
186 </FieldSet>
187
188 <ErrorText>{actionData?.error}</ErrorText>
189 <SubmitButton variant="accent" pending={source === "import" ? "Importing…" : "Creating…"}>
190 Create project
191 </SubmitButton>
192 </Form>
193 </main>
194 );
195}