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