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