Skip to content

g1t/apps/web/app/components/project-head.tsx

338 lines12,933 bytesCodeBlame

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-a69aeabc4b0deeb971/**
2 * The top of a project's overview for what g1t does not deploy: an app
3 * deployed elsewhere, an app nobody has said where it runs, docs, and
4 * anything else. An app on g1t keeps its production card, and a library
5 * or a tool its packages (routes/repo/overview.tsx).
6 */
7import { ArrowUpRight, BookOpen, Box, ChevronDown, GitCommitHorizontal, Globe, Rocket } from "lucide-react";
8import { type ReactNode, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import type { DeployStatus, Project } from "@g1t/contracts";
12
13import { host, StatusDot } from "./deploy";
14import { ButtonLink, Input, SubmitButton, TimeAgo } from "./ui";
15import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
16import { Hint } from "./ui/hint";
17
18/**
19 * A deployment reported to g1t from outside it, such as by a pipeline that
20 * deploys production itself. When there is one for production, the card
21 * shows it.
22 */
23export type ExternalDeployment = {
24 environment: string;
25 url: string | null;
26 /** success, failure, error, pending, queued, in_progress or inactive. */
27 state: string;
28 sha: string;
29 created_at: string;
30};
31
32/** A reported deployment's state, as g1t's own deployments show theirs. */
33export function deploymentStatus(state: string): DeployStatus {
34 switch (state) {
35 case "success":
36 return "ready";
37 case "failure":
38 case "error":
39 return "failed";
40 case "in_progress":
41 return "building";
42 case "inactive":
43 return "replaced";
44 default:
45 return "queued";
46 }
47}
48
49type Head = {
50 project: Project;
51 base: string;
52 /** May change the project's settings: what it is, its links. */
53 canChange: boolean;
54 /** May turn on Deployments. */
55 canDeploy: boolean;
56};
57
58export function HeadLabel({ icon, children }: { icon: ReactNode; children: ReactNode }) {
59 return (
60 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase [&_svg]:size-3.25 [&_svg]:text-accent">
61 {icon}
62 {children}
63 </p>
64 );
65}
66
67function BigLink({ url }: { url: string }) {
68 return (
69 <a href={url} rel="noopener noreferrer" className="mt-2 flex min-w-0 items-center gap-1.5 font-mono text-lg font-medium hover:text-accent">
70 <span className="truncate">{host(url).replace(/\/$/, "")}</span>
71 <ArrowUpRight size={16} className="shrink-0 text-faint" />
72 </a>
73 );
74}
75
76/** An address, saved for the project in place: production's, or its docs'. */
77function AddressForm({
78 field,
79 placeholder,
80 label,
81 extra,
82}: {
83 field: "productionUrl" | "docsUrl";
84 placeholder: string;
85 label: string;
86 /** Hidden fields sent with it. */
87 extra: Record<string, string>;
88}) {
89 const fetcher = useFetcher<{ error?: string }>();
90 return (
91 <fetcher.Form method="post" className="mt-3 max-w-lg">
92 {Object.entries(extra).map(([name, value]) => (
93 <input key={name} type="hidden" name={name} value={value} />
94 ))}
95 <div className="flex items-center gap-2">
96 <div className="min-w-0 grow">
97 <Input name={field} type="text" inputMode="url" required placeholder={placeholder} aria-label={label} maxLength={255} />
98 </div>
99 <SubmitButton fetcher={fetcher} variant="quiet" pending="Saving…">
100 Save
101 </SubmitButton>
102 </div>
103 {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>}
104 </fetcher.Form>
105 );
106}
107
108/** Deploying on g1t, offered as an option and never as a step. */
109function DeployQuietly({ base, canDeploy, what }: { base: string; canDeploy: boolean; what: string }) {
110 if (!canDeploy) return null;
111 return (
112 <p className="mt-4 text-xs text-faint">
113 g1t can deploy {what} on g1t.page instead, with a preview for every pull request.{" "}
114 <Link to={`${base}/settings/deployments`} className="underline-offset-4 hover:text-fg hover:underline">
115 Deployment settings
116 </Link>
117 </p>
118 );
119}
120
121/**
122 * An app deployed by its own pipeline: production at the address it was
123 * given, with the latest reported deployment, or else the default branch's
124 * latest commit and its checks.
125 */
126export function ElsewhereHead({
127 project,
128 base,
129 canChange,
130 canDeploy,
131 deployment,
132 commit,
133 checks,
134}: Head & {
135 deployment?: ExternalDeployment | null;
136 commit: { hash: string; authoredAt: string } | null;
137 /** The commit's checks, as a badge; left out until there is one to show. */
138 checks?: ReactNode;
139}) {
140 const url = deployment?.url ?? project.productionUrl;
141 return (
142 <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6">
143 <div className="min-w-0">
144 <HeadLabel icon={<Rocket />}>Production</HeadLabel>
145 {url ? (
146 <>
147 <BigLink url={url} />
148 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
149 {deployment ? (
150 <>
151 <StatusDot status={deploymentStatus(deployment.state)} />
152 <span className="inline-flex items-center gap-1 font-mono">
153 <GitCommitHorizontal size={13} className="text-faint" />
154 {deployment.sha.slice(0, 7)}
155 </span>
156 <span>
157 deployed <TimeAgo at={deployment.created_at} />
158 </span>
159 </>
160 ) : (
161 <>
162 <span>Deployed by its own pipeline</span>
163 {commit && (
164 <span className="inline-flex items-center gap-1.5">
165 <span className="text-faint">·</span>
166 <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg">
167 <GitCommitHorizontal size={13} className="text-faint" />
168 {commit.hash.slice(0, 7)}
169 </Link>
170 {checks}
171 <TimeAgo at={commit.authoredAt} />
172 </span>
173 )}
174 </>
175 )}
176 </p>
177 </>
178 ) : canChange ? (
179 <>
180 <p className="mt-2 text-lg font-medium">Where is production?</p>
181 <p className="mt-1 max-w-lg text-sm text-muted">
182 {project.name} is deployed by its own pipeline. Give production's address, and this card links to it.
183 </p>
184 <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} />
185 </>
186 ) : (
187 <p className="mt-2 text-sm text-muted">Deployed by its own pipeline.</p>
188 )}
189 <DeployQuietly base={base} canDeploy={canDeploy} what="it" />
190 </div>
191 {url && (
192 <div className="flex shrink-0 items-center gap-2">
193 <ButtonLink to={url} variant="accent" reloadDocument>
194 Visit
195 <ArrowUpRight size={14} />
196 </ButtonLink>
197 </div>
198 )}
199 </div>
200 );
201}
202
203const NOT_DEPLOYED: { value: string; label: string }[] = [
204 { value: "library", label: "A library or package" },
205 { value: "tool", label: "A tool or CLI" },
206 { value: "docs", label: "Documentation" },
207 { value: "other", label: "Something else" },
208];
209
210/**
211 * An app that nobody has said where it runs, with Deployments off: one
212 * question, answered in place, instead of a push to turn Deployments on.
213 */
214export function WhereItRuns({ project, base, canChange, canDeploy }: Head) {
215 const fetcher = useFetcher<{ error?: string }>();
216 const [elsewhere, setElsewhere] = useState(false);
217 if (!canChange) {
218 return (
219 <div className="p-5 sm:p-6">
220 <HeadLabel icon={<Globe />}>Homepage</HeadLabel>
221 {project.links.homepage ? <BigLink url={project.links.homepage} /> : <p className="mt-2 text-sm text-muted">No homepage given.</p>}
222 </div>
223 );
224 }
225 return (
226 <div className="p-5 sm:p-6">
227 <HeadLabel icon={<Rocket />}>Production</HeadLabel>
228 <p className="mt-2 text-lg font-medium">Where does {project.name} run?</p>
229 <p className="mt-1 max-w-xl text-sm text-muted">
230 Say once, and this page follows: production where it is deployed, or its packages and releases if it isn't
231 deployed at all. <span className="text-faint">{project.detected.reason.detail}</span>
232 </p>
233 <div className="mt-4 flex flex-wrap items-center gap-2">
234 {canDeploy && (
235 <ButtonLink to={`${base}/settings/deployments`} variant="quiet">
236 <Rocket size={14} />
237 Deploy on g1t
238 </ButtonLink>
239 )}
240 <button
241 type="button"
242 onClick={() => setElsewhere((open) => !open)}
243 aria-expanded={elsewhere}
244 className="inline-flex items-center justify-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg"
245 >
246 <Globe size={14} />
247 It's deployed elsewhere
248 </button>
249 <DropdownMenu>
250 <DropdownMenuTrigger className="inline-flex items-center justify-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg">
251 <Box size={14} />
252 It isn't deployed
253 <ChevronDown size={13} className="text-faint" />
254 </DropdownMenuTrigger>
255 <DropdownMenuContent align="start">
256 {NOT_DEPLOYED.map((option) => (
257 <DropdownMenuItem key={option.value} onSelect={() => fetcher.submit({ intent: "kind", choice: option.value }, { method: "post" })}>
258 {option.label}
259 </DropdownMenuItem>
260 ))}
261 </DropdownMenuContent>
262 </DropdownMenu>
263 </div>
264 {elsewhere && (
265 <AddressForm field="productionUrl" placeholder="Production's address, such as https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} />
266 )}
267 {fetcher.data?.error && <p className="mt-2 text-xs text-danger">{fetcher.data.error}</p>}
268 </div>
269 );
270}
271
272/** Documentation: where it is read, or a place to say so. */
273export function DocsHead({ project, base, canChange, canDeploy }: Head) {
274 const url = project.links.docs ?? project.productionUrl ?? project.links.homepage;
275 return (
276 <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6">
277 <div className="min-w-0">
278 <HeadLabel icon={<BookOpen />}>Documentation</HeadLabel>
279 {url ? (
280 <BigLink url={url} />
281 ) : canChange ? (
282 <>
283 <p className="mt-2 text-lg font-medium">Where are its docs read?</p>
284 <p className="mt-1 max-w-lg text-sm text-muted">Give the address, and this card and its listing link to it.</p>
285 <AddressForm field="docsUrl" placeholder="https://docs.example.com" label="Where its docs are read" extra={{ intent: "about" }} />
286 </>
287 ) : (
288 <p className="mt-2 text-sm text-muted">Its docs are in its repository.</p>
289 )}
290 <DeployQuietly base={base} canDeploy={canDeploy && project.runs == null} what="its docs as a site" />
291 </div>
292 {url && (
293 <div className="flex shrink-0 items-center gap-2">
294 <ButtonLink to={url} variant="accent" reloadDocument>
295 Read
296 <ArrowUpRight size={14} />
297 </ButtonLink>
298 </div>
299 )}
300 </div>
301 );
302}
303
304/** Anything else: its homepage, or where to add its links. */
305export function OtherHead({ project, base, canChange }: Omit<Head, "canDeploy">) {
306 const url = project.links.homepage ?? project.links.docs ?? project.links.custom[0]?.url ?? null;
307 return (
308 <div className="p-5 sm:p-6">
309 <HeadLabel icon={<Globe />}>Homepage</HeadLabel>
310 {url ? (
311 <BigLink url={url} />
312 ) : canChange ? (
313 <p className="mt-2 text-sm text-muted">
314 No links yet.{" "}
315 <Link to={`${base}/settings#links`} className="text-accent hover:underline">
316 Add a homepage, docs or others
317 </Link>{" "}
318 and they show here, on its card and in its workspace's Projects.
319 </p>
320 ) : (
321 <p className="mt-2 text-sm text-muted">No homepage given.</p>
322 )}
323 </div>
324 );
325}
326
327/** Production as g1t serves it, with the project's homepage when that is somewhere else. */
328export function AlsoAt({ url }: { url: string | null }) {
329 if (!url) return null;
330 return (
331 <Hint label="Its homepage">
332 <a href={url} rel="noopener noreferrer" className="inline-flex items-center gap-1 text-faint hover:text-fg">
333 <Globe size={12} />
334 {host(url).replace(/\/$/, "")}
335 </a>
336 </Hint>
337 );
338}

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