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