Skip to content
118 linesCodeBlameRaw
1import type { DocTemplate } from "@g1t/contracts";
2import { useState } from "react";
3import { data, useNavigate } from "react-router";
4
5import type { Route } from "./+types/templates";
6import { docsRequest, useDocsAction, useDocsData } from "../../../components/docs/actions";
7import { SectionTitle, TemplateCard } from "../../../components/docs/parts";
8import { ErrorText } from "../../../components/ui";
9import { SelectField } from "../../../components/ui/select";
10import { canDo } from "../../../lib/docs";
11import { page } from "../../../lib/meta";
12import { docs } from "../../../lib/services.server";
13import { requireUser, roleIn } from "../../../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Templates · Docs · ${params.owner} · g1t` });
17}
18
19export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ templates: DocTemplate[] }> {
20 const viewer = requireUser(context, request);
21 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22 const found = await docs.templates(params.owner.toLowerCase(), viewer).catch(() => null);
23 return { templates: found?.ok ? found.value : [] };
24}
25
26/** g1t's templates and the workspace's own; start a page from one in any space you can write in. */
27export default function DocsTemplates({ loaderData, params }: Route.ComponentProps) {
28 const slug = params.owner.toLowerCase();
29 const layout = useDocsData();
30 const navigate = useNavigate();
31 const { send, error: actionError } = useDocsAction(slug);
32 const writable = (layout?.sidebar?.spaces ?? []).filter((s) => canDo(s.viewer_role, "edit"));
33 const [space, setSpace] = useState(writable.find((s) => s.is_default)?.id ?? writable[0]?.id ?? "");
34 const [error, setError] = useState<string | null>(null);
35 // The template a page is being started from, while it is.
36 const [starting, setStarting] = useState<string | null>(null);
37 const use = async (template: string) => {
38 if (starting) return;
39 setStarting(template);
40 setError(null);
41 const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: space, template_id: template } });
42 if (made.ok) {
43 await navigate(made.value.path);
44 return;
45 }
46 setStarting(null);
47 setError(made.error.message);
48 };
49 const startButton = (id: string) => (
50 <button
51 type="button"
52 disabled={!space || starting != null}
53 aria-busy={starting === id}
54 onClick={() => use(id)}
55 className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline"
56 >
57 {starting === id ? "Starting…" : "Use template"}
58 </button>
59 );
60 const builtin = loaderData.templates.filter((t) => t.builtin);
61 const own = loaderData.templates.filter((t) => !t.builtin);
62 return (
63 <div className="mx-auto max-w-5xl">
64 <div className="flex flex-wrap items-end justify-between gap-4">
65 <div>
66 <h1 className="text-xl font-semibold tracking-tight">Templates</h1>
67 <p className="mt-1 text-sm text-muted">Start a page with its structure already there. Save any page as a template from its ⋯ menu.</p>
68 </div>
69 {writable.length > 0 && (
70 <label className="flex items-center gap-2 text-sm text-muted">
71 New pages go in
72 <SelectField aria-label="Space" value={space} onValueChange={setSpace} options={writable.map((s) => ({ value: s.id, label: s.name }))} className="h-9 min-w-40" />
73 </label>
74 )}
75 </div>
76 {(error || actionError) && (
77 <div className="mt-3">
78 <ErrorText>{error ?? actionError}</ErrorText>
79 </div>
80 )}
81 <section className="mt-8">
82 <SectionTitle>From g1t</SectionTitle>
83 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
84 {builtin.map((t) => (
85 <TemplateCard
86 key={t.id}
87 template={t}
88 action={startButton(t.id)}
89 />
90 ))}
91 </div>
92 </section>
93 <section className="mt-10">
94 <SectionTitle>Your workspace&apos;s</SectionTitle>
95 {own.length === 0 ? (
96 <p className="text-sm text-muted">None yet. Open a page, then choose Save as template from its ⋯ menu.</p>
97 ) : (
98 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
99 {own.map((t) => (
100 <TemplateCard
101 key={t.id}
102 template={t}
103 action={
104 <>
105 {startButton(t.id)}
106 <button type="button" onClick={() => send("delete_template", { template_id: t.id })} className="ml-auto text-xs text-faint hover:text-danger">
107 Delete
108 </button>
109 </>
110 }
111 />
112 ))}
113 </div>
114 )}
115 </section>
116 </div>
117 );
118}