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