Skip to content
295 linesCodeBlameRaw

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-a69aeabc4b0deeb971import { 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";
Merge branch 'site-fixes' into fast-push9import { DeployLink } from "./deploy";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9710import { 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.11import { Button } from "./ui/button";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9712import { 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">
Merge branch 'site-fixes' into fast-push35 <DeployLink
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9736 href={link.url}
Merge branch 'site-fixes' into fast-push37 rel="nofollow"
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9738 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-push42 </DeployLink>
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9743 </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">
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-a69aeabc4b0deeb97108 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-a69aeabc4b0deeb97110 <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-a69aeabc4b0deeb97112 </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}

This file's history is long; its oldest lines are credited to the oldest commit read.