g1t/apps/web/app/routes/repo/settings.tsx

212 lines9,683 bytesCodeBlame
1import { Box, Code2, GitBranch, Rocket } from "lucide-react";
2import { useState } from "react";
3import { Form, Link } from "react-router";
4
5import type { DeploysSetting } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings";
8import { page } from "../../lib/meta";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
11import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
12import { DEPLOYS_CHOICES } from "../../lib/project-kind";
13import { deployments, identity, projects } from "../../lib/services.server";
14import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
15import { requireCapability, requireInsider } from "../../lib/access.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` });
19}
20
21export async function loader({ params, context }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 // Maintain and up; to anyone without a role here the page does not exist.
24 await requireInsider(context, params, "manage_settings");
25 const [project, deploys] = await Promise.all([
26 projects.get(params.owner, params.repo, viewer),
27 // Whether Deployments are on: needs Admin, so unknown (null) below it.
28 deployments.settings({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null),
29 ]);
30 const found = unwrap(project);
31 // Who made it is kept as an id: named by identity, or left out if it cannot say.
32 const names = await identity.usernames([found.createdBy]).catch(() => ({}) as Record<string, string>);
33 return {
34 project: found,
35 deploymentsOn: deploys?.ok ? deploys.value.enabled : null,
36 mine: viewer?.id === found.createdBy,
37 creator: names[found.createdBy] ?? null,
38 };
39}
40
41export async function action({ request, params, context }: Route.ActionArgs) {
42 assertSameOrigin(request);
43 const user = requireUser(context, request);
44 await requireCapability(context, params, "manage_settings");
45 const form = await request.formData();
46 const saved = await projects.update(user, params.owner, params.repo, {
47 name: String(form.get("name") ?? ""),
48 // Blank, or the reset, and it follows the repository's description again.
49 description: form.get("inherit") === "description" ? null : String(form.get("description") ?? ""),
50 rootDir: String(form.get("rootDir") ?? ""),
51 deploys: (form.get("deploys") as DeploysSetting | null) ?? undefined,
52 });
53 return saved.ok ? { saved: true as const } : { error: saved.error.message };
54}
55
56export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) {
57 const { project, deploymentsOn, mine, creator } = loaderData;
58 const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys);
59 // Not deploying while Deployments are on is refused: they are turned off first.
60 const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true;
61 const base = `/${params.owner}/${params.repo}`;
62 const source = project.source.kind === "hosted" ? project.source : null;
63 return (
64 <div className="max-w-4xl">
65 <RepoSettingsHeading base={base} />
66 <Form method="post" className="space-y-8">
67 {/* Enter in a field saves: the form's first submit button is the one
68 Enter presses, and otherwise it would be the description reset. */}
69 <button type="submit" name="intent" value="save" tabIndex={-1} aria-hidden="true" className="absolute size-0 overflow-hidden opacity-0" />
70 <section className="space-y-4">
71 <h2 className="flex items-center gap-2 text-sm font-medium">
72 <Box size={15} className="text-accent" />
73 Project
74 </h2>
75 <div className="grid gap-4 md:grid-cols-2">
76 <Field label="Name" hint={`Its address stays g1t.sh/${project.workspace}/${project.slug}.`}>
77 <Input name="name" defaultValue={project.name} required maxLength={100} />
78 </Field>
79 <div>
80 <Field
81 label="Description"
82 hint={
83 project.descriptionInherited
84 ? "Follows the repository's description. Write one to give the project its own."
85 : "The project's own. Empty follows the repository's description."
86 }
87 >
88 <Input
89 key={`${project.descriptionInherited}:${project.description ?? ""}`}
90 name="description"
91 defaultValue={project.descriptionInherited ? "" : (project.description ?? "")}
92 maxLength={200}
93 placeholder={(project.descriptionInherited && project.description) || "What it is, in a line"}
94 />
95 </Field>
96 {!project.descriptionInherited && (
97 <SubmitButton
98 name="inherit"
99 value="description"
100 pending="Resetting…"
101 className="mt-1.5 inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg disabled:opacity-50"
102 >
103 Use the repository's description
104 </SubmitButton>
105 )}
106 </div>
107 </div>
108 </section>
109
110 <section className="space-y-4">
111 <h2 className="flex items-center gap-2 text-sm font-medium">
112 <Code2 size={15} className="text-accent" />
113 Source
114 </h2>
115 {source && (
116 <div className="rounded-xl border border-line bg-surface p-4 text-sm">
117 <div className="flex flex-wrap items-center justify-between gap-3">
118 <div>
119 <Link to={`${base}/code`} className="font-mono hover:text-accent">
120 {source.repo.namespace}/{source.repo.name}
121 </Link>
122 <p className="mt-0.5 text-xs text-muted">
123 Hosted on g1t · <GitBranch size={11} className="inline" /> {source.defaultBranch}
124 </p>
125 </div>
126 <Link to={`${base}/settings/repository`} className="text-xs text-muted hover:text-fg">
127 Repository settings
128 </Link>
129 </div>
130 <p className="mt-3 text-xs text-faint">
131 Mirroring a repository from GitHub, GitLab or Bitbucket as a project's source is coming next.
132 </p>
133 </div>
134 )}
135 <Field
136 label="Root directory"
137 hint="Where in the repository this project lives, such as apps/web. Builds run there. Empty for the whole repository."
138 >
139 <Input name="rootDir" defaultValue={source?.rootDir ?? ""} placeholder="apps/web" className="font-mono" />
140 </Field>
141 </section>
142
143 <section className="space-y-4" id="deploys">
144 <h2 className="flex items-center gap-2 text-sm font-medium">
145 <Rocket size={15} className="text-accent" />
146 Deployments for this project
147 </h2>
148 <RadioGroup
149 name="deploys"
150 value={deploys}
151 onValueChange={(value) => setDeploys(value as DeploysSetting)}
152 aria-label="Deployments for this project"
153 className="gap-3"
154 >
155 {DEPLOYS_CHOICES.map((choice) => (
156 <RadioOption
157 key={choice.value}
158 value={choice.value}
159 label={choice.label}
160 description={
161 choice.value === "auto" ? (
162 <>
163 {project.detected.kind === "library" ? "Detected: a library, so it doesn't deploy." : "Detected: an app, so it deploys."}{" "}
164 {project.detected.reason.detail}
165 </>
166 ) : (
167 choice.hint
168 )
169 }
170 />
171 ))}
172 </RadioGroup>
173 {blocked && (
174 <p className="rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft">
175 Deployments are on for {project.name}. Turn them off in{" "}
176 <Link to={`${base}/settings/deployments`} className="text-fg underline underline-offset-4">
177 Deployments settings
178 </Link>{" "}
179 first, then choose Doesn't deploy here.
180 </p>
181 )}
182 <p className="text-xs text-faint">
183 A project that doesn't deploy shows its packages and releases on its overview instead of production. Its
184 Deployments page stays in the sidebar.
185 </p>
186 </section>
187
188 <div className="flex items-center gap-3">
189 <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}>
190 Save
191 </SubmitButton>
192 {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>}
193 <span className="ml-auto text-xs text-faint">
194 Created{" "}
195 {mine ? (
196 "by you "
197 ) : creator ? (
198 <>
199 by{" "}
200 <Link to={`/${creator}`} className="text-muted hover:text-fg">
201 {creator}
202 </Link>{" "}
203 </>
204 ) : null}
205 <TimeAgo at={project.createdAt} />
206 </span>
207 </div>
208 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
209 </Form>
210 </div>
211 );
212}