Skip to content
File

Blob: src/client/pages/create-project-page.tsx

typescript226 lines
1import { Save } from "lucide-react";
2import { useState } from "react";
3import { useNavigate } from "react-router-dom";
4import { Breadcrumbs, Button, ButtonLink, Card, ErrorBanner, Input, PageHeader } from "@/client/components/ui";
5import { buildProjectSlug, formatApiError, getApiClient, inferRepositoryProvider } from "@/client/lib";
6import { useToast } from "@/client/toast";
7 
8import { DEFAULT_DISPATCH_MODE, type DispatchMode } from "@/contracts";
9 
10interface ProjectFormState {
11 name: string;
12 projectSlug: string;
13 repoUrl: string;
14 defaultBranch: string;
15 configPath: string;
16 repoToken: string;
17 dispatchMode: DispatchMode;
18}
19 
20const initialFormState: ProjectFormState = {
21 name: "",
22 projectSlug: "",
23 repoUrl: "",
24 defaultBranch: "main",
25 configPath: ".anvil.yml",
26 repoToken: "",
27 dispatchMode: DEFAULT_DISPATCH_MODE,
28};
29 
30export const CreateProjectPage = () => {
31 const navigate = useNavigate();
32 const { pushToast } = useToast();
33 const [form, setForm] = useState<ProjectFormState>(initialFormState);
34 const [error, setError] = useState<string | null>(null);
35 const [submitting, setSubmitting] = useState(false);
36 const [slugTouched, setSlugTouched] = useState(false);
37 
38 const updateField = (field: keyof ProjectFormState, value: ProjectFormState[keyof ProjectFormState]) => {
39 setForm((current) => {
40 const next = {
41 ...current,
42 [field]: value,
43 };
44 if (field === "name" && !slugTouched) {
45 next.projectSlug = buildProjectSlug(String(value));
46 }
47 return next;
48 });
49 };
50 
51 return (
52 <div className="animate-slide-up space-y-5">
53 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "New Project" }]} />
54
55 <PageHeader
56 label="Project Setup"
57 title="Create a project"
58 description="Connect a repository over HTTPS, choose the default branch, and point anvil at the repo-defined config file."
59 />
60
61 <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_260px]">
62 <div>
63 <Card>
64 <form
65 className="space-y-4"
66 onSubmit={(event) => {
67 event.preventDefault();
68 setSubmitting(true);
69 setError(null);
70
71 void getApiClient()
72 .createProject({
73 name: form.name,
74 projectSlug: form.projectSlug,
75 repoUrl: form.repoUrl,
76 defaultBranch: form.defaultBranch,
77 configPath: form.configPath || undefined,
78 repoToken: form.repoToken ? form.repoToken : undefined,
79 dispatchMode: form.dispatchMode,
80 })
81 .then((response) => {
82 pushToast({
83 tone: "success",
84 title: "Project created",
85 message: `${response.project.ownerSlug}/${response.project.projectSlug} is now tracked.`,
86 });
87 navigate("/app/projects", { replace: true });
88 })
89 .catch((reason: unknown) => {
90 const message = formatApiError(reason);
91 setError(message);
92 pushToast({
93 tone: "error",
94 title: "Project creation failed",
95 message,
96 });
97 })
98 .finally(() => {
99 setSubmitting(false);
100 });
101 }}
102 >
103 <div className="grid gap-4 sm:grid-cols-2">
104 <Input
105 label="Project name"
106 value={form.name}
107 onChange={(event) => updateField("name", event.target.value)}
108 placeholder="Docs Forge"
109 helperText="Operator-facing project label shown across the shell."
110 required
111 />
112 <Input
113 label="Project slug"
114 value={form.projectSlug}
115 onChange={(event) => {
116 setSlugTouched(true);
117 updateField("projectSlug", buildProjectSlug(event.target.value));
118 }}
119 placeholder="docs-forge"
120 helperText="Stable owner-scoped identifier used in URLs."
121 required
122 />
123 </div>
124
125 <Input
126 label="Repository URL"
127 value={form.repoUrl}
128 onChange={(event) => updateField("repoUrl", event.target.value)}
129 placeholder="https://github.com/owner/repo"
130 helperText="HTTPS clone URL only in v1."
131 required
132 />
133
134 <div className="grid gap-4 sm:grid-cols-2">
135 <Input
136 label="Default branch"
137 value={form.defaultBranch}
138 onChange={(event) => updateField("defaultBranch", event.target.value)}
139 placeholder="main"
140 helperText="Branch used for manual runs unless overridden."
141 required
142 />
143 <Input
144 label="Config path"
145 value={form.configPath}
146 onChange={(event) => updateField("configPath", event.target.value)}
147 placeholder=".anvil.yml"
148 helperText="Repository-defined pipeline config path."
149 />
150 </div>
151
152 <Input
153 label="Repository token"
154 type="password"
155 value={form.repoToken}
156 onChange={(event) => updateField("repoToken", event.target.value)}
157 placeholder="Optional personal access token"
158 helperText="Leave empty for public repos."
159 />
160
161 {error ? <ErrorBanner message={error} /> : null}
162
163 <div className="flex flex-wrap gap-3 pt-2">
164 <Button
165 variant="primary"
166 type="submit"
167 disabled={submitting}
168 loading={submitting}
169 icon={!submitting ? <Save className="h-4 w-4" /> : undefined}
170 >
171 Create Project
172 </Button>
173 <ButtonLink to="/app/projects" variant="ghost">
174 Cancel
175 </ButtonLink>
176 </div>
177 </form>
178 </Card>
179 </div>
180
181 {/* Sidebar */}
182 <div className="space-y-4">
183 <Card className="p-4">
184 <p className="text-xs uppercase tracking-[0.2em] text-zinc-500">Repository host</p>
185 <p className="mt-2 text-lg font-semibold text-zinc-100">
186 {form.repoUrl ? inferRepositoryProvider(form.repoUrl) : "not set"}
187 </p>
188 </Card>
189
190 <Card className="p-4">
191 <p className="text-xs uppercase tracking-[0.2em] text-zinc-500">Dispatch</p>
192 <div
193 className="mt-2 flex rounded-lg border border-zinc-800/70 bg-zinc-900/80 p-0.5"
194 role="radiogroup"
195 aria-label="Dispatch mode"
196 >
197 {(["queue", "workflows"] as const).map((option) => (
198 <button
199 key={option}
200 type="button"
201 role="radio"
202 aria-checked={form.dispatchMode === option}
203 className={[
204 "flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors",
205 form.dispatchMode === option
206 ? "bg-accent-500/15 text-accent-300"
207 : "text-zinc-500 hover:bg-zinc-800/80 hover:text-zinc-200",
208 ].join(" ")}
209 onClick={() => updateField("dispatchMode", option)}
210 >
211 {option === "queue" ? "Queue" : "Workflows"}
212 </button>
213 ))}
214 </div>
215 <p className="mt-2.5 text-xs leading-relaxed text-zinc-500">
216 {form.dispatchMode === "queue"
217 ? "Runs are dispatched via Cloudflare Queues with at-least-once delivery."
218 : "Runs use Cloudflare Workflows for durable execution with automatic retries."}
219 </p>
220 </Card>
221 </div>
222 </div>
223 </div>
224 );
225};