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.

151 linesCodeBlameRaw
1import { FOLIO_KINDS, isFolioKind, type FolioKind, type FolioTemplate } from "@g1t/contracts";
2import { useState } from "react";
3import { Form, Link, data, useNavigation, useSearchParams } from "react-router";
4
5import type { Route } from "./+types/templates";
6import { useFoliosAction, useFoliosData } from "../../../components/folios/actions";
7import { FOLIO_KIND_UI } from "../../../components/folios/kinds";
8import { SectionTitle, TemplateCard } from "../../../components/folios/parts";
9import { EmptyState, ErrorText } from "../../../components/ui";
10import { Button } from "../../../components/ui/button";
11import { SelectField } from "../../../components/ui/select";
12import { canDo } from "../../../lib/folios";
13import { page } from "../../../lib/meta";
14import { folios } from "../../../lib/services.server";
15import { requireUser, roleIn } from "../../../lib/session.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Templates · Artifacts · ${params.owner} · g1t` });
19}
20
21export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ templates: FolioTemplate[] | null }> {
22 const viewer = requireUser(context, request);
23 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24 const found = await folios.templates(params.owner.toLowerCase(), viewer).catch(() => null);
25 return { templates: found?.ok ? found.value : null };
26}
27
28/** g1t's templates and the workspace's own, by kind; start an artifact from one in Private or a space you can add to. */
29export default function FolioTemplates({ loaderData, params }: Route.ComponentProps) {
30 const slug = params.owner.toLowerCase();
31 const layout = useFoliosData();
32 const { send, error } = useFoliosAction(slug);
33 const [search, setSearch] = useSearchParams();
34 const kind: FolioKind | null = isFolioKind(search.get("kind")) ? (search.get("kind") as FolioKind) : null;
35 const writable = (layout?.sidebar?.spaces ?? []).filter((s) => canDo(s.viewer_role, "edit"));
36 const [space, setSpace] = useState("private");
37 const navigation = useNavigation();
38 // The template an artifact is being started from, while it is.
39 const starting =
40 navigation.state !== "idle" && navigation.formAction?.includes("/-/artifacts/new/") ? String(navigation.formData?.get("template") ?? "") : null;
41 const all = (loaderData.templates ?? []).filter((t) => !kind || t.kind === kind);
42 const builtin = all.filter((t) => t.builtin);
43 const own = all.filter((t) => !t.builtin);
44 const startButton = (t: FolioTemplate) =>
45 FOLIO_KIND_UI[t.kind].ready ? (
46 <Form method="post" action={`/${slug}/-/artifacts/new/${t.kind}`}>
47 <input type="hidden" name="template" value={t.id} />
48 {space !== "private" && <input type="hidden" name="space" value={space} />}
49 <Button
50 type="submit"
51 disabled={starting != null}
52 aria-busy={starting === t.id}
53 variant="link"
54 size="inline"
55 className="text-xs disabled:text-faint disabled:no-underline"
56 >
57 {starting === t.id ? "Starting…" : "Use template"}
58 </Button>
59 </Form>
60 ) : (
61 <span className="text-xs text-faint">Coming soon</span>
62 );
63 return (
64 <div className="mx-auto max-w-5xl">
65 <div className="flex flex-wrap items-end justify-between gap-4">
66 <div>
67 <h1 className="text-xl font-semibold tracking-tight">Templates</h1>
68 <p className="mt-1 text-sm text-muted">Start with the structure already there. Save any artifact as a template from its ⋯ menu.</p>
69 </div>
70 <div className="flex flex-wrap items-center gap-2">
71 <SelectField
72 aria-label="Kind"
73 value={kind ?? "any"}
74 onValueChange={(v) => setSearch(v === "any" ? {} : { kind: v }, { preventScrollReset: true })}
75 options={[{ value: "any", label: "Every kind" }, ...FOLIO_KINDS.map((k) => ({ value: k, label: FOLIO_KIND_UI[k].label }))]}
76 className="h-9 min-w-32"
77 />
78 <label className="flex items-center gap-2 text-sm text-muted">
79 Goes in
80 <SelectField
81 aria-label="Where it goes"
82 value={space}
83 onValueChange={setSpace}
84 options={[{ value: "private", label: "Private" }, ...writable.map((s) => ({ value: s.id, label: s.name }))]}
85 className="h-9 min-w-36"
86 />
87 </label>
88 </div>
89 </div>
90 {error && (
91 <div className="mt-3">
92 <ErrorText>{error}</ErrorText>
93 </div>
94 )}
95 {loaderData.templates === null ? (
96 <div className="mt-8">
97 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
98 </div>
99 ) : (
100 <>
101 <section className="mt-8">
102 <SectionTitle>From g1t</SectionTitle>
103 {builtin.length === 0 ? (
104 <p className="text-sm text-muted">
105 None for {kind ? FOLIO_KIND_UI[kind].label.toLowerCase() : "this kind"} yet.{" "}
106 <Link to="?" className="text-accent hover:underline">
107 See every kind
108 </Link>
109 </p>
110 ) : (
111 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
112 {builtin.map((t) => (
113 <TemplateCard key={t.id} template={t} action={startButton(t)} />
114 ))}
115 </div>
116 )}
117 </section>
118 <section className="mt-10 mb-6">
119 <SectionTitle>Your workspace&apos;s</SectionTitle>
120 {own.length === 0 ? (
121 <p className="text-sm text-muted">None yet. Open an artifact, then choose Save as template from its ⋯ menu.</p>
122 ) : (
123 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
124 {own.map((t) => (
125 <TemplateCard
126 key={t.id}
127 template={t}
128 action={
129 <>
130 {startButton(t)}
131 <Button
132 type="button"
133 onClick={() => send("delete_template", { template_id: t.id })}
134 variant="link"
135 size="inline"
136 className="ml-auto text-xs text-faint hover:text-danger font-normal"
137 >
138 Delete
139 </Button>
140 </>
141 }
142 />
143 ))}
144 </div>
145 )}
146 </section>
147 </>
148 )}
149 </div>
150 );
151}