g1t/apps/web/app/components/project-about.tsx
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 'projects-kind-and-links' | 1 | import { BookOpen, Check, ChevronDown, Globe, Link2, Pencil, Plus, Rocket, X } from "lucide-react"; |
| 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | import { useFetcher } from "react-router"; | |
| 4 | ||
| 5 | import { MAX_PROJECT_LINKS, type Project, type ProjectLinks } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { cn } from "../lib/cn"; | |
| 8 | import { KIND_CHOICES, type KindChoice, type ShownLink, choiceOf, linksToShow } from "../lib/project-kind"; | |
| 9 | import { Input, SubmitButton } from "./ui"; | |
| 10 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 11 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 12 | import { Hint } from "./ui/hint"; | |
| 13 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 14 | ||
| 15 | const 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 | */ | |
| 26 | export 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 | ||
| 47 | type Row = { id: number; label: string; url: string }; | |
| 48 | let nextRow = 1; | |
| 49 | const 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 | */ | |
| 56 | export 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 | */ | |
| 141 | export 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 | ||
| 187 | const 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. */ | |
| 196 | const 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 | */ | |
| 210 | export 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 | */ | |
| 254 | export 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.