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