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.

296 linesCodeBlameRaw
1import { BookOpen, Check, ChevronDown, Globe, Link2, Pencil, Plus, Rocket, X } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3import { useFetcher } from "react-router";
4
5import { MAX_PROJECT_LINKS, type Project, type ProjectLinks } from "@g1t/contracts";
6
7import { cn } from "../lib/cn";
8import { KIND_CHOICES, type KindChoice, type ShownLink, choiceOf, linksToShow } from "../lib/project-kind";
9import { DeployLink } from "./deploy";
10import { Input, SubmitButton } from "./ui";
11import { Button } from "./ui/button";
12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
13import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
14import { Hint } from "./ui/hint";
15import { RadioGroup, RadioOption } from "./ui/radio-group";
16
17const LINK_ICON: Record<ShownLink["type"], ReactNode> = {
18 homepage: <Globe size={14} />,
19 docs: <BookOpen size={14} />,
20 production: <Rocket size={14} />,
21 custom: <Link2 size={14} />,
22};
23
24/**
25 * A project's homepage, docs and other links, one to a line, as its About
26 * shows them. Addresses already on the page are given in `shown` and left out.
27 */
28export function LinkList({ links, shown = [], className }: { links: ProjectLinks; shown?: (string | null | undefined)[]; className?: string }) {
29 const list = linksToShow(links, shown);
30 if (list.length === 0) return null;
31 return (
32 <ul className={cn("space-y-1.5 text-sm", className)}>
33 {list.map((link) => (
34 <li key={link.key} className="min-w-0">
35 <DeployLink
36 href={link.url}
37 rel="nofollow"
38 className="flex min-w-0 items-center gap-2 text-fg-soft hover:text-accent [&_svg]:shrink-0 [&_svg]:text-faint"
39 >
40 {LINK_ICON[link.type]}
41 <span className="truncate">{link.label}</span>
42 </DeployLink>
43 </li>
44 ))}
45 </ul>
46 );
47}
48
49type Row = { id: number; label: string; url: string };
50let nextRow = 1;
51const rowsOf = (links: ProjectLinks["custom"]): Row[] => links.map((link) => ({ id: nextRow++, ...link }));
52
53/**
54 * The fields for a project's links: its homepage, its docs, and rows of
55 * others, each a label and an address, added and removed in place. Posts
56 * `homepage`, `docsUrl`, and `linkLabel`/`linkUrl` per row.
57 */
58export function LinkFields({ links, compact = false }: { links: ProjectLinks; compact?: boolean }) {
59 const [rows, setRows] = useState<Row[]>(() => rowsOf(links.custom));
60 const label = "mb-1.5 block text-sm font-medium text-muted";
61 return (
62 <div className="space-y-4">
63 {/* Says the rows below are the whole list, so removing every row clears them. */}
64 <input type="hidden" name="links" value="rows" />
65 <div className={cn("grid gap-4", !compact && "md:grid-cols-2")}>
66 <label className="block">
67 <span className={label}>Homepage</span>
68 <Input
69 name="homepage"
70 type="text"
71 inputMode="url"
72 defaultValue={links.homepageInherited ? "" : (links.homepage ?? "")}
73 placeholder={(links.homepageInherited && links.homepage) || "https://example.com"}
74 maxLength={255}
75 />
76 <span className="mt-1.5 block text-xs text-faint">
77 {links.homepageInherited ? "Follows the repository's website. Give one to change it here." : "Its site, or production's domain."}
78 </span>
79 </label>
80 <label className="block">
81 <span className={label}>Docs</span>
82 <Input name="docsUrl" type="text" inputMode="url" defaultValue={links.docs ?? ""} placeholder="https://docs.example.com" maxLength={255} />
83 <span className="mt-1.5 block text-xs text-faint">Where its documentation is read.</span>
84 </label>
85 </div>
86 <fieldset>
87 <legend className={label}>Other links</legend>
88 {rows.length > 0 && (
89 <ul className="space-y-2">
90 {rows.map((row, index) => (
91 <li key={row.id} className="flex items-center gap-2">
92 <div className="w-32 shrink-0 sm:w-40">
93 <Input name="linkLabel" defaultValue={row.label} placeholder="Label" maxLength={40} aria-label={`Link ${index + 1} label`} />
94 </div>
95 <div className="min-w-0 grow">
96 <Input
97 name="linkUrl"
98 type="text"
99 inputMode="url"
100 defaultValue={row.url}
101 placeholder="https://"
102 maxLength={255}
103 aria-label={`Link ${index + 1} address`}
104 />
105 </div>
106 <Hint label="Remove this link">
107 <Button
108 type="button"
109 onClick={() => setRows((current) => current.filter((one) => one.id !== row.id))}
110 variant="ghost"
111 size="inline"
112 className="p-1.5 text-faint"
113 aria-label={`Remove link ${index + 1}`}
114 >
115 <X size={14} />
116 </Button>
117 </Hint>
118 </li>
119 ))}
120 </ul>
121 )}
122 {rows.length < MAX_PROJECT_LINKS ? (
123 <button
124 type="button"
125 onClick={() => setRows((current) => [...current, { id: nextRow++, label: "", url: "" }])}
126 className={cn("inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg", rows.length > 0 && "mt-2")}
127 >
128 <Plus size={13} />
129 Add a link
130 </button>
131 ) : (
132 <p className="mt-2 text-xs text-faint">{MAX_PROJECT_LINKS} links is the most a project keeps.</p>
133 )}
134 <p className="mt-1.5 text-xs text-faint">A status page, a registry listing, a chat: anything with an http or https address.</p>
135 </fieldset>
136 </div>
137 );
138}
139
140/**
141 * What a project is, as a choice of one, with production's address beside
142 * Deploys itself. Posts `choice` and `productionUrl`. `blocked` says
143 * why a choice that stops it deploying cannot be saved yet.
144 */
145export function KindFields({
146 project,
147 choice,
148 onChoice,
149}: {
150 project: Pick<Project, "detected" | "productionUrl" | "setting">;
151 choice: KindChoice | null;
152 onChoice: (choice: KindChoice) => void;
153}) {
154 return (
155 <RadioGroup name="choice" value={choice ?? ""} onValueChange={(value) => onChoice(value as KindChoice)} aria-label="What it is" className="gap-3">
156 {KIND_CHOICES.map((option) => (
157 <div key={option.value}>
158 <RadioOption
159 value={option.value}
160 label={option.label}
161 description={
162 option.value === "auto" ? (
163 <>
164 Detected: {DETECTED[project.detected.kind]}. {project.detected.reason.detail}
165 </>
166 ) : (
167 option.hint
168 )
169 }
170 />
171 {option.value === "elsewhere" && choice === "elsewhere" && (
172 <div className="mt-2 ml-6.5 max-w-md">
173 <Input
174 name="productionUrl"
175 type="text"
176 inputMode="url"
177 defaultValue={project.productionUrl ?? ""}
178 placeholder="https://example.com"
179 aria-label="Production's address"
180 maxLength={255}
181 />
182 <span className="mt-1 block text-xs text-faint">Production's address, shown on its overview and wherever it is listed.</span>
183 </div>
184 )}
185 </div>
186 ))}
187 </RadioGroup>
188 );
189}
190
191const DETECTED: Record<Project["kind"], string> = {
192 app: "an app",
193 library: "a library",
194 tool: "a tool",
195 docs: "documentation",
196 other: "something else",
197};
198
199/** The one-click choices the overview offers, without Detect, in order. */
200const MENU: { value: KindChoice; label: string }[] = [
201 { value: "g1t", label: "Hosted on g1t.page" },
202 { value: "elsewhere", label: "Deploys itself" },
203 { value: "library", label: "Library or package" },
204 { value: "tool", label: "Tool or CLI" },
205 { value: "docs", label: "Documentation" },
206 { value: "other", label: "Something else" },
207];
208
209/**
210 * The badge of what a project is, which a person who may change its
211 * settings opens to change it in one click. Posts `intent=kind` and
212 * `choice` to the page's action.
213 */
214export function KindMenu({ project, label, canChange }: { project: Pick<Project, "setting" | "detected" | "kindReason">; label: string; canChange: boolean }) {
215 const fetcher = useFetcher<{ error?: string }>();
216 const current = choiceOf(project.setting);
217 const badge = "inline-flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs font-medium text-fg-soft ring-1 ring-line";
218 if (!canChange) {
219 return (
220 <Hint label={project.kindReason.detail}>
221 <span className={badge}>{label}</span>
222 </Hint>
223 );
224 }
225 const choose = (choice: KindChoice) => fetcher.submit({ intent: "kind", choice }, { method: "post" });
226 return (
227 <span className="inline-flex items-center gap-2">
228 <DropdownMenu>
229 <DropdownMenuTrigger className={cn(badge, "transition-colors hover:ring-line-strong", fetcher.state !== "idle" && "opacity-60")}>
230 {label}
231 <ChevronDown size={12} className="text-faint" />
232 </DropdownMenuTrigger>
233 <DropdownMenuContent align="start" className="w-64">
234 <DropdownMenuLabel>{project.kindReason.detail}</DropdownMenuLabel>
235 <DropdownMenuSeparator />
236 {MENU.map((option) => (
237 <DropdownMenuItem key={option.value} onSelect={() => choose(option.value)}>
238 <span className="grow">{option.label}</span>
239 {current === option.value && <Check size={14} />}
240 </DropdownMenuItem>
241 ))}
242 <DropdownMenuSeparator />
243 <DropdownMenuItem onSelect={() => choose("auto")}>
244 <span className="grow">Detect automatically</span>
245 {current === "auto" && <Check size={14} />}
246 </DropdownMenuItem>
247 </DropdownMenuContent>
248 </DropdownMenu>
249 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
250 </span>
251 );
252}
253
254/**
255 * The About editor: a project's description and links, from its overview.
256 * Posts `intent=about` to the page's action, and closes once saved.
257 */
258export function AboutEditor({ project, action }: { project: Pick<Project, "description" | "links" | "name">; action?: string }) {
259 const fetcher = useFetcher<{ error?: string; notice?: string }>();
260 const [open, setOpen] = useState(false);
261 const saved = fetcher.state === "idle" && fetcher.data != null && !fetcher.data.error;
262 useEffect(() => {
263 if (saved) setOpen(false);
264 }, [saved, fetcher.data]);
265 return (
266 <Dialog open={open} onOpenChange={setOpen}>
267 <Hint label="Edit the description and links">
268 <DialogTrigger asChild>
269 <Button variant="outline" size="sm" aria-label="Edit project details">
270 <Pencil size={14} /> Edit details
271 </Button>
272 </DialogTrigger>
273 </Hint>
274 <DialogContent className="max-w-xl">
275 <DialogHeader>
276 <DialogTitle>Edit {project.name}'s About</DialogTitle>
277 <DialogDescription>Its description and links show here, on its cards, and in its workspace's Projects.</DialogDescription>
278 </DialogHeader>
279 <fetcher.Form method="post" action={action} className="space-y-5">
280 <input type="hidden" name="intent" value="about" />
281 <label className="block">
282 <span className="mb-1.5 block text-sm font-medium text-muted">Description</span>
283 <Input name="description" defaultValue={project.description ?? ""} placeholder="What it is, in a line" maxLength={200} />
284 </label>
285 <LinkFields links={project.links} compact />
286 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
287 <DialogFooter>
288 <SubmitButton fetcher={fetcher} pending="Saving…">
289 Save
290 </SubmitButton>
291 </DialogFooter>
292 </fetcher.Form>
293 </DialogContent>
294 </Dialog>
295 );
296}