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