Skip to content

g1t/apps/web/app/components/project-about.tsx

296 lines12,582 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.