Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

73 linesCodeBlameRaw
1import { FOLIO_KIND_NOUNS, isFolioKind, type FolioKind } from "@g1t/contracts";
2import { Form, data, redirect, useNavigation } from "react-router";
3
4import type { Route } from "./+types/new";
5import { FOLIO_KIND_UI, KindIcon } from "../../../components/folios/kinds";
6import { ErrorText } from "../../../components/ui";
7import { Button } from "../../../components/ui/button";
8import { page } from "../../../lib/meta";
9import { folios } from "../../../lib/services.server";
10import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
11
12export function meta({ params, ...args }: Route.MetaArgs) {
13 return page(args, { title: `New ${isFolioKind(params.kind) ? FOLIO_KIND_NOUNS[params.kind] : "artifact"} · ${params.owner} · g1t` });
14}
15
16/** The kinds that can be made now; the rest aren't found until their phase ships. */
17function kindOf(value: string | undefined): FolioKind {
18 if (!isFolioKind(value) || !FOLIO_KIND_UI[value].ready) throw data(null, { status: 404 });
19 return value;
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs) {
23 const viewer = requireUser(context, request);
24 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
25 return { kind: kindOf(params.kind) };
26}
27
28/**
29 * Makes an artifact, then goes to it: `POST -/artifacts/new/<kind>` with
30 * optional `space`, `parent` and `template` (form fields, or the
31 * address's search). Only a post makes one, so a prefetched or repeated
32 * link never does; the form's button is busy until it lands.
33 */
34export async function action({ params, context, request }: Route.ActionArgs) {
35 assertSameOrigin(request);
36 const viewer = requireUser(context, request);
37 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
38 const kind = kindOf(params.kind);
39 const form = await request.formData().catch(() => new FormData());
40 const search = new URL(request.url).searchParams;
41 const field = (name: string) => String(form.get(name) ?? search.get(name) ?? "").trim() || null;
42 const made = await folios.create(params.owner.toLowerCase(), viewer, {
43 kind,
44 space_id: field("space"),
45 parent_id: field("parent"),
46 template_id: field("template"),
47 });
48 if (!made.ok) return { error: made.error.message };
49 throw redirect(made.value.path);
50}
51
52/** Opened as a link (from outside a page that posts): one button, so nothing is made by just visiting. */
53export default function NewFolio({ loaderData, actionData }: Route.ComponentProps) {
54 const { kind } = loaderData;
55 const busy = useNavigation().state !== "idle";
56 return (
57 <div className="mx-auto mt-10 flex max-w-md flex-col items-center text-center">
58 <KindIcon kind={kind} size={22} box={48} />
59 <h1 className="mt-4 text-xl font-semibold tracking-tight">New {FOLIO_KIND_NOUNS[kind]}</h1>
60 <p className="mt-1 text-sm text-muted">It starts in your Private, where only you can see it, unless you chose a space.</p>
61 {actionData?.error && (
62 <div className="mt-4">
63 <ErrorText>{actionData.error}</ErrorText>
64 </div>
65 )}
66 <Form method="post" className="mt-6">
67 <Button type="submit" disabled={busy} variant="accent" className="px-4 disabled:opacity-60">
68 {busy ? "Starting…" : `Start the ${FOLIO_KIND_NOUNS[kind]}`}
69 </Button>
70 </Form>
71 </div>
72 );
73}