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.

165 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
74 className={cn(
75 "block w-full overflow-hidden rounded-lg border border-line bg-bg",
76 href && "transition-colors group-hover:border-line-strong",
77 )}
78 >
79 <span className="flex h-7 items-center gap-1.5 border-b border-line bg-surface px-2.5">
80 <span aria-hidden="true" className="flex shrink-0 gap-1.5">
81 <span className="size-1.5 rounded-full bg-line-strong" />
82 <span className="size-1.5 rounded-full bg-line-strong" />
83 <span className="size-1.5 rounded-full bg-line-strong" />
84 </span>
85 {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>}
86 </span>
87 <span className="relative block aspect-16/10 w-full overflow-hidden">
88 {!loaded && !under && <Placeholder waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
89 {under && (
90 <img
91 key={under.src}
92 src={under.src}
93 alt=""
94 aria-hidden="true"
95 width={1280}
96 height={800}
97 className="absolute inset-0 size-full object-cover object-top"
98 />
99 )}
100 {source && (
101 <img
102 key={source.src}
103 ref={image}
104 src={source.src}
105 alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
106 width={1280}
107 height={800}
108 decoding="async"
109 onLoad={() => setLoaded(true)}
110 onError={() => {
111 setLoaded(false);
112 setAt((index) => index + 1);
113 }}
114 className={cn(
115 "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
116 loaded ? "opacity-100" : "opacity-0",
117 )}
118 />
119 )}
120 </span>
121 </span>
122 );
123
124 return (
125 <div className={cn("relative", className)}>
126 {href ? (
127 <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}>
128 {frame}
129 </DeployLink>
130 ) : (
131 frame
132 )}
133 {empty && addImage && (
134 // Beside the link, not in it: a link holds no other link.
135 <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">
136 <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface">
137 <ImagePlus size={13} />
138 Add an image of your app
139 </ButtonLink>
140 {note && <span className="text-[0.6875rem] text-faint">{note}</span>}
141 </span>
142 )}
143 </div>
144 );
145}
146
147/** Faint page content inside the persistent browser frame while no picture is available. */
148function Placeholder({ waiting, note }: { waiting: string | null; note: string | null }) {
149 const line = waiting ?? note;
150 return (
151 <span className="absolute inset-0 flex flex-col">
152 <span className="relative flex grow flex-col gap-2 p-3.5">
153 <span className="h-2 w-2/5 rounded-full bg-line/80" />
154 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
155 <span className="h-1.5 w-3/5 rounded-full bg-line/50" />
156 {line && (
157 <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
158 <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
159 {line}
160 </span>
161 )}
162 </span>
163 </span>
164 );
165}