g1t/apps/web/app/components/project-about.tsx
| 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 | } |