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.

68 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, { kind, space_id: field("space"), parent_id: field("parent"), template_id: field("template") });
43 if (!made.ok) return { error: made.error.message };
44 throw redirect(made.value.path);
45}
46
47/** Opened as a link (from outside a page that posts): one button, so nothing is made by just visiting. */
48export default function NewFolio({ loaderData, actionData }: Route.ComponentProps) {
49 const { kind } = loaderData;
50 const busy = useNavigation().state !== "idle";
51 return (
52 <div className="mx-auto mt-10 flex max-w-md flex-col items-center text-center">
53 <KindIcon kind={kind} size={22} box={48} />
54 <h1 className="mt-4 text-xl font-semibold tracking-tight">New {FOLIO_KIND_NOUNS[kind]}</h1>
55 <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>
56 {actionData?.error && (
57 <div className="mt-4">
58 <ErrorText>{actionData.error}</ErrorText>
59 </div>
60 )}
61 <Form method="post" className="mt-6">
62 <Button type="submit" disabled={busy} variant="accent" className="px-4 disabled:opacity-60">
63 {busy ? "Starting…" : `Start the ${FOLIO_KIND_NOUNS[kind]}`}
64 </Button>
65 </Form>
66 </div>
67 );
68}