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-a69aeabc4b0deeb97 | 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"; | |
| Merge branch 'site-fixes' into fast-push | 9 | import { DeployLink } from "./deploy"; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 10 | import { Input, SubmitButton } from "./ui"; |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 11 | import { Button } from "./ui/button"; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 12 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 13 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 14 | import { Hint } from "./ui/hint"; | |
| 15 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 16 | ||
| 17 | const 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 | */ | |
| 28 | export 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"> | |
| Merge branch 'site-fixes' into fast-push | 35 | <DeployLink |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 36 | href={link.url} |
| Merge branch 'site-fixes' into fast-push | 37 | rel="nofollow" |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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> | |
| Merge branch 'site-fixes' into fast-push | 42 | </DeployLink> |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 43 | </li> |
| 44 | ))} | |
| 45 | </ul> | |
| 46 | ); | |
| 47 | } | |
| 48 | ||
| 49 | type Row = { id: number; label: string; url: string }; | |
| 50 | let nextRow = 1; | |
| 51 | const 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 | */ | |
| 58 | export 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"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 107 | <Button |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 108 | type="button" |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 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}`}> |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 110 | <X size={14} /> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 111 | </Button> |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 | */ | |
| 140 | export 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 | ||
| 186 | const 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. */ | |
| 195 | const 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 | */ | |
| 209 | export 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 | */ | |
| 253 | export 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.