Skip to content
137 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 const key = sources.map((source) => source.src).join("\n");
40 const source = sources[at] ?? null;
41
42 // Other sources (another deploy, a new upload): from the first again.
43 useEffect(() => {
44 setAt(0);
45 setLoaded(false);
46 }, [key]);
47
48 // An image that settled before hydration fired its events unheard.
49 useEffect(() => {
50 const img = image.current;
51 if (!img?.complete) return;
52 if (img.naturalWidth > 0) setLoaded(true);
53 else setAt((index) => index + 1);
54 }, [source?.src]);
55
56 const empty = source == null;
57 const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null;
58
59 const frame = (
60 <span
61 className={cn(
62 "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg",
63 href && "transition-colors group-hover:border-line-strong",
64 )}
65 >
66 {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
67 {source && (
68 <img
69 key={source.src}
70 ref={image}
71 src={source.src}
72 alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
73 width={1280}
74 height={800}
75 decoding="async"
76 onLoad={() => setLoaded(true)}
77 onError={() => {
78 setLoaded(false);
79 setAt((index) => index + 1);
80 }}
81 className={cn(
82 "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
83 loaded ? "opacity-100" : "opacity-0",
84 )}
85 />
86 )}
87 </span>
88 );
89
90 return (
91 <div className={cn("relative", className)}>
92 {href ? (
93 <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}>
94 {frame}
95 </DeployLink>
96 ) : (
97 frame
98 )}
99 {empty && addImage && (
100 // Beside the link, not in it: a link holds no other link.
101 <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">
102 <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface">
103 <ImagePlus size={13} />
104 Add an image of your app
105 </ButtonLink>
106 {note && <span className="text-[0.6875rem] text-faint">{note}</span>}
107 </span>
108 )}
109 </div>
110 );
111}
112
113/** The frame without a picture: a browser's bar with the address, and faint lines of a page. */
114function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) {
115 const line = waiting ?? note;
116 return (
117 <span className="absolute inset-0 flex flex-col">
118 <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5">
119 <span className="size-1.5 rounded-full bg-line-strong" />
120 <span className="size-1.5 rounded-full bg-line-strong" />
121 <span className="size-1.5 rounded-full bg-line-strong" />
122 {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>}
123 </span>
124 <span className="relative flex grow flex-col gap-2 p-3.5">
125 <span className="h-2 w-2/5 rounded-full bg-line/80" />
126 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
127 <span className="h-1.5 w-3/5 rounded-full bg-line/50" />
128 {line && (
129 <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
130 <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
131 {line}
132 </span>
133 )}
134 </span>
135 </span>
136 );
137}