flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/routes/new.tsx

169 lines7,081 bytesCodeBlame
1import { Box, Download, GitBranch, Sparkles } from "lucide-react";
2import { useState } from "react";
3import { Form, redirect, useNavigation } from "react-router";
4
5import type { Route } from "./+types/new";
6import { Button, ErrorText, Field, Input } from "../components/ui";
7import { repos } from "../lib/services.server";
8import { assertSameOrigin, requireUser } from "../lib/session.server";
9
10export function meta({}: Route.MetaArgs) {
11 return [{ title: "New project · g1t" }];
12}
13
14export function loader({ request, context }: Route.LoaderArgs) {
15 const user = requireUser(context, request);
16 const workspaces = (user.workspaces ?? []).map((membership) => membership.slug);
17 // Projects live in a workspace, so there has to be one first.
18 if (workspaces.length === 0) throw redirect("/workspaces/new");
19 const asked = new URL(request.url).searchParams.get("workspace");
20 return {
21 workspaces,
22 selected: asked && workspaces.includes(asked) ? asked : workspaces[0],
23 };
24}
25
26export async function action({ request, context }: Route.ActionArgs) {
27 assertSameOrigin(request);
28 const user = requireUser(context, request);
29 const form = await request.formData();
30 // A project hosted on g1t: its repository is made with it, and the
31 // project takes the repository's name.
32 const result = await repos.create(user, {
33 namespace: String(form.get("workspace") ?? ""),
34 name: String(form.get("name") ?? ""),
35 description: String(form.get("description") ?? ""),
36 isPrivate: form.get("visibility") === "private",
37 importUrl: form.get("source") === "import" ? String(form.get("importUrl") ?? "").trim() || undefined : undefined,
38 });
39 if (!result.ok) return { error: result.error.message };
40 throw redirect(`/${result.value.namespace}/${result.value.name}`);
41}
42
43type Source = "empty" | "import";
44
45const SOURCES: { id: Source | "mirror"; title: string; text: string; icon: React.ReactNode; soon?: boolean }[] = [
46 {
47 id: "empty",
48 title: "Start empty",
49 text: "A new repository on g1t. Push to it, or hand an agent the first issue.",
50 icon: <Sparkles size={16} />,
51 },
52 {
53 id: "import",
54 title: "Import code",
55 text: "Copy a public repository from GitHub or any git host into a new one on g1t.",
56 icon: <Download size={16} />,
57 },
58 {
59 id: "mirror",
60 title: "Mirror GitHub, GitLab or Bitbucket",
61 text: "Keep the code where it is and get g1t's deployments and agents on it.",
62 icon: <GitBranch size={16} />,
63 soon: true,
64 },
65];
66
67export default function NewProject({ loaderData, actionData }: Route.ComponentProps) {
68 const [source, setSource] = useState<Source>("empty");
69 const busy = useNavigation().state === "submitting";
70 return (
71 <main className="mx-auto max-w-2xl px-4 py-12">
72 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
73 <Box size={18} />
74 </span>
75 <h1 className="mt-4 text-2xl font-semibold tracking-tight">New project</h1>
76 <p className="mt-1.5 text-sm text-muted">
77 A project is what you build and run. Its code lives in its source; its deployments, environments, secrets and
78 variables belong to the project.
79 </p>
80
81 <Form method="post" className="mt-8 space-y-8">
82 <fieldset>
83 <legend className="mb-3 text-sm font-medium text-muted">Where its code comes from</legend>
84 <div className="grid gap-3 sm:grid-cols-3">
85 {SOURCES.map((option) => {
86 const chosen = source === option.id;
87 return (
88 <label
89 key={option.id}
90 className={`relative rounded-xl border p-4 transition-colors ${
91 option.soon
92 ? "cursor-not-allowed border-dashed border-line opacity-60"
93 : chosen
94 ? "cursor-pointer border-accent bg-accent/5"
95 : "cursor-pointer border-line hover:border-line-strong"
96 }`}
97 >
98 <input
99 type="radio"
100 name="source"
101 value={option.id}
102 checked={chosen}
103 disabled={option.soon}
104 onChange={() => !option.soon && setSource(option.id as Source)}
105 className="sr-only"
106 />
107 <span className={chosen ? "text-accent" : "text-muted"}>{option.icon}</span>
108 <span className="mt-2 block text-sm font-medium">{option.title}</span>
109 <span className="mt-1 block text-xs text-muted">{option.text}</span>
110 {option.soon && (
111 <span className="absolute top-3 right-3 rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line">
112 Soon
113 </span>
114 )}
115 </label>
116 );
117 })}
118 </div>
119 </fieldset>
120
121 {source === "import" && (
122 <Field
123 label="Repository to import"
124 hint="The address of a public repository. Its default branch is copied, up to 40 MB."
125 >
126 <Input name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
127 </Field>
128 )}
129
130 <Field label="Name" hint="The project and its repository share it: g1t.sh/<workspace>/<name>.">
131 <div className="flex items-center gap-2 font-mono text-sm">
132 <select
133 name="workspace"
134 defaultValue={loaderData.selected}
135 aria-label="Workspace"
136 className="rounded-md border border-line bg-bg px-2 py-2 text-sm"
137 >
138 {loaderData.workspaces.map((slug) => (
139 <option key={slug} value={slug}>
140 {slug}
141 </option>
142 ))}
143 </select>
144 <span className="text-muted">/</span>
145 <Input name="name" required autoFocus maxLength={100} placeholder="my-app" />
146 </div>
147 </Field>
148 <Field label="Description (optional)">
149 <Input name="description" maxLength={200} placeholder="What it is, in a line" />
150 </Field>
151 <fieldset className="space-y-2 text-sm">
152 <legend className="mb-2 font-medium text-muted">Who can see it</legend>
153 <label className="flex items-center gap-2">
154 <input type="radio" name="visibility" value="public" defaultChecked className="accent-accent" />
155 Public: anyone can see its code and open issues
156 </label>
157 <label className="flex items-center gap-2">
158 <input type="radio" name="visibility" value="private" className="accent-accent" />
159 Private: only members of the workspace
160 </label>
161 </fieldset>
162 <ErrorText>{actionData?.error}</ErrorText>
163 <Button type="submit" variant="accent" disabled={busy}>
164 Create project
165 </Button>
166 </Form>
167 </main>
168 );
169}