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