Skip to content
295 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))} variant="ghost" size="inline" className="p-1.5 text-faint" aria-label={`Remove link ${index + 1}`}>
110 <X size={14} />
111 </Button>
112 </Hint>
113 </li>
114 ))}
115 </ul>
116 )}
117 {rows.length < MAX_PROJECT_LINKS ? (
118 <button
119 type="button"
120 onClick={() => setRows((current) => [...current, { id: nextRow++, label: "", url: "" }])}
121 className={cn("inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg", rows.length > 0 && "mt-2")}
122 >
123 <Plus size={13} />
124 Add a link
125 </button>
126 ) : (
127 <p className="mt-2 text-xs text-faint">{MAX_PROJECT_LINKS} links is the most a project keeps.</p>
128 )}
129 <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>
130 </fieldset>
131 </div>
132 );
133}
134
135/**
136 * What a project is, as a choice of one, with production's address beside
137 * Deployed elsewhere. Posts `choice` and `productionUrl`. `blocked` says
138 * why a choice that stops it deploying cannot be saved yet.
139 */
140export function KindFields({
141 project,
142 choice,
143 onChoice,
144}: {
145 project: Pick<Project, "detected" | "productionUrl" | "setting">;
146 choice: KindChoice | null;
147 onChoice: (choice: KindChoice) => void;
148}) {
149 return (
150 <RadioGroup name="choice" value={choice ?? ""} onValueChange={(value) => onChoice(value as KindChoice)} aria-label="What it is" className="gap-3">
151 {KIND_CHOICES.map((option) => (
152 <div key={option.value}>
153 <RadioOption
154 value={option.value}
155 label={option.label}
156 description={
157 option.value === "auto" ? (
158 <>
159 Detected: {DETECTED[project.detected.kind]}. {project.detected.reason.detail}
160 </>
161 ) : (
162 option.hint
163 )
164 }
165 />
166 {option.value === "elsewhere" && choice === "elsewhere" && (
167 <div className="mt-2 ml-6.5 max-w-md">
168 <Input
169 name="productionUrl"
170 type="text"
171 inputMode="url"
172 defaultValue={project.productionUrl ?? ""}
173 placeholder="https://example.com"
174 aria-label="Production's address"
175 maxLength={255}
176 />
177 <span className="mt-1 block text-xs text-faint">Production's address, shown on its overview and wherever it is listed.</span>
178 </div>
179 )}
180 </div>
181 ))}
182 </RadioGroup>
183 );
184}
185
186const DETECTED: Record<Project["kind"], string> = {
187 app: "an app",
188 library: "a library",
189 tool: "a tool",
190 docs: "documentation",
191 other: "something else",
192};
193
194/** The one-click choices the overview offers, without Detect, in order. */
195const MENU: { value: KindChoice; label: string }[] = [
196 { value: "g1t", label: "Deployed on g1t" },
197 { value: "elsewhere", label: "Deployed elsewhere" },
198 { value: "library", label: "Library or package" },
199 { value: "tool", label: "Tool or CLI" },
200 { value: "docs", label: "Documentation" },
201 { value: "other", label: "Something else" },
202];
203
204/**
205 * The badge of what a project is, which a person who may change its
206 * settings opens to change it in one click. Posts `intent=kind` and
207 * `choice` to the page's action.
208 */
209export function KindMenu({ project, label, canChange }: { project: Pick<Project, "setting" | "detected" | "kindReason">; label: string; canChange: boolean }) {
210 const fetcher = useFetcher<{ error?: string }>();
211 const current = choiceOf(project.setting);
212 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";
213 if (!canChange) {
214 return (
215 <Hint label={project.kindReason.detail}>
216 <span className={badge}>{label}</span>
217 </Hint>
218 );
219 }
220 const choose = (choice: KindChoice) => fetcher.submit({ intent: "kind", choice }, { method: "post" });
221 return (
222 <span className="inline-flex items-center gap-2">
223 <DropdownMenu>
224 <DropdownMenuTrigger className={cn(badge, "transition-colors hover:ring-line-strong", fetcher.state !== "idle" && "opacity-60")}>
225 {label}
226 <ChevronDown size={12} className="text-faint" />
227 </DropdownMenuTrigger>
228 <DropdownMenuContent align="start" className="w-64">
229 <DropdownMenuLabel>{project.kindReason.detail}</DropdownMenuLabel>
230 <DropdownMenuSeparator />
231 {MENU.map((option) => (
232 <DropdownMenuItem key={option.value} onSelect={() => choose(option.value)}>
233 <span className="grow">{option.label}</span>
234 {current === option.value && <Check size={14} />}
235 </DropdownMenuItem>
236 ))}
237 <DropdownMenuSeparator />
238 <DropdownMenuItem onSelect={() => choose("auto")}>
239 <span className="grow">Detect automatically</span>
240 {current === "auto" && <Check size={14} />}
241 </DropdownMenuItem>
242 </DropdownMenuContent>
243 </DropdownMenu>
244 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
245 </span>
246 );
247}
248
249/**
250 * The About editor: a project's description and links, from its overview.
251 * Posts `intent=about` to the page's action, and closes once saved.
252 */
253export function AboutEditor({ project }: { project: Pick<Project, "description" | "descriptionInherited" | "links" | "name"> }) {
254 const fetcher = useFetcher<{ error?: string; notice?: string }>();
255 const [open, setOpen] = useState(false);
256 const saved = fetcher.state === "idle" && fetcher.data != null && !fetcher.data.error;
257 useEffect(() => {
258 if (saved) setOpen(false);
259 }, [saved, fetcher.data]);
260 return (
261 <Dialog open={open} onOpenChange={setOpen}>
262 <Hint label="Edit the description and links">
263 <DialogTrigger className="rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" aria-label="Edit About">
264 <Pencil size={14} />
265 </DialogTrigger>
266 </Hint>
267 <DialogContent className="max-w-xl">
268 <DialogHeader>
269 <DialogTitle>Edit {project.name}'s About</DialogTitle>
270 <DialogDescription>Its description and links show here, on its cards, and in its workspace's Projects.</DialogDescription>
271 </DialogHeader>
272 <fetcher.Form method="post" className="space-y-5">
273 <input type="hidden" name="intent" value="about" />
274 <label className="block">
275 <span className="mb-1.5 block text-sm font-medium text-muted">Description</span>
276 <Input
277 name="description"
278 defaultValue={project.descriptionInherited ? "" : (project.description ?? "")}
279 placeholder={(project.descriptionInherited && project.description) || "What it is, in a line"}
280 maxLength={200}
281 />
282 {project.descriptionInherited && <span className="mt-1.5 block text-xs text-faint">Follows the repository's description while empty.</span>}
283 </label>
284 <LinkFields links={project.links} compact />
285 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
286 <DialogFooter>
287 <SubmitButton fetcher={fetcher} pending="Saving…">
288 Save
289 </SubmitButton>
290 </DialogFooter>
291 </fetcher.Form>
292 </DialogContent>
293 </Dialog>
294 );
295}