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.

128 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 = navigation.state !== "idle" && navigation.formAction?.includes("/-/artifacts/new/") ? String(navigation.formData?.get("template") ?? "") : null;
40 const all = (loaderData.templates ?? []).filter((t) => !kind || t.kind === kind);
41 const builtin = all.filter((t) => t.builtin);
42 const own = all.filter((t) => !t.builtin);
43 const startButton = (t: FolioTemplate) =>
44 FOLIO_KIND_UI[t.kind].ready ? (
45 <Form method="post" action={`/${slug}/-/artifacts/new/${t.kind}`}>
46 <input type="hidden" name="template" value={t.id} />
47 {space !== "private" && <input type="hidden" name="space" value={space} />}
48 <Button type="submit" disabled={starting != null} aria-busy={starting === t.id} variant="link" size="inline" className="text-xs disabled:text-faint disabled:no-underline">
49 {starting === t.id ? "Starting…" : "Use template"}
50 </Button>
51 </Form>
52 ) : (
53 <span className="text-xs text-faint">Coming soon</span>
54 );
55 return (
56 <div className="mx-auto max-w-5xl">
57 <div className="flex flex-wrap items-end justify-between gap-4">
58 <div>
59 <h1 className="text-xl font-semibold tracking-tight">Templates</h1>
60 <p className="mt-1 text-sm text-muted">Start with the structure already there. Save any artifact as a template from its ⋯ menu.</p>
61 </div>
62 <div className="flex flex-wrap items-center gap-2">
63 <SelectField
64 aria-label="Kind"
65 value={kind ?? "any"}
66 onValueChange={(v) => setSearch(v === "any" ? {} : { kind: v }, { preventScrollReset: true })}
67 options={[{ value: "any", label: "Every kind" }, ...FOLIO_KINDS.map((k) => ({ value: k, label: FOLIO_KIND_UI[k].label }))]}
68 className="h-9 min-w-32"
69 />
70 <label className="flex items-center gap-2 text-sm text-muted">
71 Goes in
72 <SelectField aria-label="Where it goes" value={space} onValueChange={setSpace} options={[{ value: "private", label: "Private" }, ...writable.map((s) => ({ value: s.id, label: s.name }))]} className="h-9 min-w-36" />
73 </label>
74 </div>
75 </div>
76 {error && (
77 <div className="mt-3">
78 <ErrorText>{error}</ErrorText>
79 </div>
80 )}
81 {loaderData.templates === null ? (
82 <div className="mt-8">
83 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
84 </div>
85 ) : (
86 <>
87 <section className="mt-8">
88 <SectionTitle>From g1t</SectionTitle>
89 {builtin.length === 0 ? (
90 <p className="text-sm text-muted">
91 None for {kind ? FOLIO_KIND_UI[kind].label.toLowerCase() : "this kind"} yet. <Link to="?" className="text-accent hover:underline">See every kind</Link>
92 </p>
93 ) : (
94 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
95 {builtin.map((t) => (
96 <TemplateCard key={t.id} template={t} action={startButton(t)} />
97 ))}
98 </div>
99 )}
100 </section>
101 <section className="mt-10 mb-6">
102 <SectionTitle>Your workspace&apos;s</SectionTitle>
103 {own.length === 0 ? (
104 <p className="text-sm text-muted">None yet. Open an artifact, then choose Save as template from its ⋯ menu.</p>
105 ) : (
106 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
107 {own.map((t) => (
108 <TemplateCard
109 key={t.id}
110 template={t}
111 action={
112 <>
113 {startButton(t)}
114 <Button type="button" onClick={() => send("delete_template", { template_id: t.id })} variant="link" size="inline" className="ml-auto text-xs text-faint hover:text-danger font-normal">
115 Delete
116 </Button>
117 </>
118 }
119 />
120 ))}
121 </div>
122 )}
123 </section>
124 </>
125 )}
126 </div>
127 );
128}