Skip to content
167 linesCodeBlameRaw
1import { Form, Link, redirect } from "react-router";
2
3import type { Route } from "./+types/new";
4import { page } from "../../lib/meta";
5import { planHref } from "../../components/start-plan";
6import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
7import { billing, identity } from "../../lib/services.server";
8import { InvitationList } from "../../components/invitation-list";
9import { answerInvitation, loadInvitations } from "../../lib/invitations.server";
10import { declinedLine } from "../../lib/invitations";
11import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
12
13export function meta(args: Route.MetaArgs) {
14 return page(args, { title: "New workspace · g1t" });
15}
16
17export async function loader({ request, context }: Route.LoaderArgs) {
18 const user = requireUser(context, request);
19 const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner").map((membership) => membership.slug);
20 // A person owns at most one free workspace; identity refuses a second
21 // either way, so a failure here only leaves the form up.
22 const first = (user.workspaces ?? []).length === 0;
23 const [free, invitations] = await Promise.all([
24 owned.length > 0 ? billing.freeWorkspaces(owned).catch(() => [] as string[]) : Promise.resolve([] as string[]),
25 // Someone without a workspace may have been invited to one.
26 first ? loadInvitations(user) : Promise.resolve([]),
27 ]);
28 const next = nextPath(request);
29 return { user, first, free, invitations, next: next === "/" ? null : next };
30}
31
32export async function action({ request, context }: Route.ActionArgs) {
33 assertSameOrigin(request);
34 const user = requireUser(context, request);
35 const form = await request.formData();
36 // Accepting or declining an invitation shown here (lib/invitations.server.ts).
37 const answered = await answerInvitation(user, form, request);
38 if (answered) return answered;
39 const result = await identity.createWorkspace(
40 user,
41 String(form.get("slug") ?? ""),
42 String(form.get("displayName") ?? ""),
43 );
44 if (!result.ok) return { error: result.error.message };
45 // Someone sent here on their way elsewhere carries on to it.
46 const next = nextPath(request);
47 throw redirect(next === "/" ? `/${result.value.slug}` : next);
48}
49
50export default function NewWorkspace({
51 loaderData,
52 actionData,
53}: Route.ComponentProps) {
54 const { user, first, free, invitations, next } = loaderData;
55 const said = actionData as { error?: string; invitationError?: string; invitationId?: string; declined?: string } | undefined;
56 return (
57 <main className="mx-auto max-w-lg px-4 py-12">
58 <h1 className="text-xl font-semibold">
59 {first ? "Create your workspace or ask to join one" : "New workspace"}
60 </h1>
61 {first && (
62 <section className="mt-4">
63 {said?.declined !== undefined && (
64 <p className="mb-3 text-sm text-muted" role="status">
65 {declinedLine(said.declined)}
66 </p>
67 )}
68 {invitations.length > 0 ? (
69 <>
70 <h2 className="text-sm font-medium">You are invited</h2>
71 <p className="mt-1 mb-3 text-sm text-muted">Accept an invitation to join that workspace, or decline it.</p>
72 <InvitationList invitations={invitations} error={said?.invitationError} errorFor={said?.invitationId} next={next} />
73 </>
74 ) : (
75 <p className="text-sm text-muted">
76 To join a team that is on g1t already, ask one of its workspace's owners to invite you by your username,{" "}
77 <span className="font-mono text-fg">{user.username}</span>. Their invitation shows here and in your inbox.
78 </p>
79 )}
80 <h2 className="mt-8 text-sm font-medium">Or create your own</h2>
81 </section>
82 )}
83 <p className="mt-2 text-sm text-muted">
84 {first && "Everything on g1t lives in a workspace, so this comes first. "}
85 A workspace holds repositories, the people who work on them and the
86 access tokens that automate them, and is the first part of every
87 address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>.
88 Use one for yourself, and one for each team or company you work with.
89 </p>
90 {free.length > 0 ? (
91 <OneFreeWorkspace free={free} />
92 ) : (
93 <>
94 <Form method="post" className="mt-8 space-y-4">
95 <Field
96 label="Name in URLs"
97 hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days."
98 >
99 <div className="flex items-center gap-2 font-mono text-sm">
100 <span className="text-muted">g1t.sh/</span>
101 <Input
102 name="slug"
103 required
104 autoFocus
105 maxLength={39}
106 defaultValue={first ? user.username : ""}
107 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
108 />
109 </div>
110 </Field>
111 <Field label="Display name (optional)">
112 <Input name="displayName" maxLength={80} />
113 </Field>
114 <p className="text-xs text-faint">
115 A new workspace is free. Each person can own one free workspace; more need the plan on the ones you have.
116 </p>
117 <ErrorText>{said?.error}</ErrorText>
118 <SubmitButton pending="Creating…">Create workspace</SubmitButton>
119 </Form>
120 </>
121 )}
122 </main>
123 );
124}
125
126/** Why there is no form: the person owns a free workspace already. */
127function OneFreeWorkspace({ free }: { free: string[] }) {
128 const [first] = free;
129 return (
130 <section className="mt-8 rounded-xl border border-line bg-surface p-5">
131 <h2 className="font-medium">You already own a free workspace</h2>
132 <p className="mt-2 text-sm text-muted">
133 Each person can own one workspace that is not on the g1t plan.{" "}
134 {free.length === 1 ? (
135 <>
136 Yours is <span className="font-mono text-fg">{first}</span>.
137 </>
138 ) : (
139 <>
140 You own {free.length} from before (
141 {free.map((slug, i) => (
142 <span key={slug}>
143 {i > 0 && ", "}
144 <span className="font-mono text-fg">{slug}</span>
145 </span>
146 ))}
147 ), and keep them all.
148 </>
149 )}{" "}
150 A new workspace starts free, so to make one, start the plan on {free.length === 1 ? "it" : "each of them"}, or delete{" "}
151 {free.length === 1 ? "it" : "the ones"} you no longer use.
152 </p>
153 <div className="mt-4 flex flex-wrap items-center gap-3">
154 <ButtonLink to={planHref(first)}>Start the plan on {first}</ButtonLink>
155 <Link to={`/${first}/-/settings`} className="text-sm text-muted hover:text-fg">
156 Workspace settings
157 </Link>
158 </div>
159 <p className="mt-4 text-xs text-faint">
160 The plan is one price for everyone in the workspace, never per person.{" "}
161 <Link to="/pricing" className="underline underline-offset-2 hover:text-fg">
162 Pricing
163 </Link>
164 </p>
165 </section>
166 );
167}