Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

157 linesCodeBlameRaw
1import { Globe, ImagePlus } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3
4import { cn } from "../lib/cn";
5import type { CardSource } from "../lib/production-image";
6import { DeployLink } from "./deploy";
7import { ButtonLink } from "./ui";
8
9/**
10 * A thumbnail of production, linking to it: a screenshot (taken once per
11 * deploy on g1t.page, or of the address of production deployed
12 * elsewhere), else an image someone uploaded (lib/production-image.ts
13 * `cardSources`). Each source is tried in turn, the next when one does not
14 * load. The browser frame and address stay visible in every state.
15 * Until an image loads, a quiet page stands in; to someone who may change the project's
16 * settings it offers to add an image.
17 */
18export function ProductionShot({
19 sources,
20 href,
21 label,
22 note,
23 addImage,
24 className,
25}: {
26 /** The images to try, in order; empty for none. */
27 sources: CardSource[];
28 href: string | null;
29 label: string;
30 /** What the empty frame says, such as when a screenshot comes; none for a quiet frame. */
31 note?: string | null;
32 /** Where an image is added, for someone who may; null for anyone else. */
33 addImage?: string | null;
34 className?: string;
35}) {
36 const image = useRef<HTMLImageElement>(null);
37 const [at, setAt] = useState(0);
38 const [loaded, setLoaded] = useState(false);
39 // The picture last shown, kept under the next one until that has loaded
40 // and faded in: a new deploy's screenshot replaces the old one rather
41 // than the frame going blank while it comes.
42 const [shown, setShown] = useState<CardSource | null>(null);
43 const key = sources.map((source) => source.src).join("\n");
44 const source = sources[at] ?? null;
45 const under = source && shown && shown.src !== source.src ? shown : null;
46
47 // Other sources (another deploy, a new upload): from the first again.
48 useEffect(() => {
49 setAt(0);
50 setLoaded(false);
51 }, [key]);
52
53 // An image that settled before hydration fired its events unheard.
54 useEffect(() => {
55 const img = image.current;
56 if (!img?.complete) return;
57 if (img.naturalWidth > 0) setLoaded(true);
58 else setAt((index) => index + 1);
59 }, [source?.src]);
60
61 // Once the new picture has faded in over it (300 ms), the old one goes.
62 useEffect(() => {
63 if (!loaded || !source || shown?.src === source.src) return;
64 const timer = setTimeout(() => setShown(source), 350);
65 return () => clearTimeout(timer);
66 // eslint-disable-next-line react-hooks/exhaustive-deps -- `source` is the one `src` names
67 }, [loaded, source?.src, shown?.src]);
68
69 const empty = source == null;
70 const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null;
71
72 const frame = (
73 <span className={cn("block w-full overflow-hidden rounded-lg border border-line bg-bg", href && "transition-colors group-hover:border-line-strong")}>
74 <span className="flex h-7 items-center gap-1.5 border-b border-line bg-surface px-2.5">
75 <span aria-hidden="true" className="flex shrink-0 gap-1.5">
76 <span className="size-1.5 rounded-full bg-line-strong" />
77 <span className="size-1.5 rounded-full bg-line-strong" />
78 <span className="size-1.5 rounded-full bg-line-strong" />
79 </span>
80 {label && <span className="ml-1.5 min-w-0 flex-1 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-muted">{label}</span>}
81 </span>
82 <span className="relative block aspect-16/10 w-full overflow-hidden">
83 {!loaded && !under && <Placeholder waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
84 {under && (
85 <img
86 key={under.src}
87 src={under.src}
88 alt=""
89 aria-hidden="true"
90 width={1280}
91 height={800}
92 className="absolute inset-0 size-full object-cover object-top"
93 />
94 )}
95 {source && (
96 <img
97 key={source.src}
98 ref={image}
99 src={source.src}
100 alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
101 width={1280}
102 height={800}
103 decoding="async"
104 onLoad={() => setLoaded(true)}
105 onError={() => {
106 setLoaded(false);
107 setAt((index) => index + 1);
108 }}
109 className={cn("absolute inset-0 size-full object-cover object-top transition-opacity duration-300", loaded ? "opacity-100" : "opacity-0")}
110 />
111 )}
112 </span>
113 </span>
114 );
115
116 return (
117 <div className={cn("relative", className)}>
118 {href ? (
119 <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}>
120 {frame}
121 </DeployLink>
122 ) : (
123 frame
124 )}
125 {empty && addImage && (
126 // Beside the link, not in it: a link holds no other link.
127 <span className="pointer-events-none absolute inset-x-0 top-7 bottom-0 flex flex-col items-center justify-center gap-2 px-3 text-center">
128 <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface">
129 <ImagePlus size={13} />
130 Add an image of your app
131 </ButtonLink>
132 {note && <span className="text-[0.6875rem] text-faint">{note}</span>}
133 </span>
134 )}
135 </div>
136 );
137}
138
139/** Faint page content inside the persistent browser frame while no picture is available. */
140function Placeholder({ waiting, note }: { waiting: string | null; note: string | null }) {
141 const line = waiting ?? note;
142 return (
143 <span className="absolute inset-0 flex flex-col">
144 <span className="relative flex grow flex-col gap-2 p-3.5">
145 <span className="h-2 w-2/5 rounded-full bg-line/80" />
146 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
147 <span className="h-1.5 w-3/5 rounded-full bg-line/50" />
148 {line && (
149 <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
150 <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
151 {line}
152 </span>
153 )}
154 </span>
155 </span>
156 );
157}