Skip to content
90 linesCodeBlameRaw
1import { Globe } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3
4import { cn } from "../lib/cn";
5import { DeployLink } from "./deploy";
6
7/**
8 * A thumbnail of production as it is now, linking to it. The screenshot is
9 * taken once per deploy; until it has loaded, or when there is none yet, a
10 * quiet frame with the address stands in.
11 */
12export function ProductionShot({
13 src,
14 href,
15 label,
16 className,
17}: {
18 /** Null when nothing is deployed yet. */
19 src: string | null;
20 href: string | null;
21 label: string;
22 className?: string;
23}) {
24 const image = useRef<HTMLImageElement>(null);
25 const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
26
27 // An image that settled before hydration fired its events unheard.
28 useEffect(() => {
29 const img = image.current;
30 if (!img?.complete) return;
31 setState(img.naturalWidth > 0 ? "loaded" : "failed");
32 }, [src]);
33
34 const frame = (
35 <span
36 className={cn(
37 "relative block aspect-[16/10] w-full overflow-hidden rounded-lg border border-line bg-bg",
38 href && "transition-colors group-hover:border-line-strong",
39 )}
40 >
41 {state !== "loaded" && <Placeholder label={label} waiting={src != null && state === "loading"} />}
42 {src && state !== "failed" && (
43 <img
44 ref={image}
45 src={src}
46 alt={`Screenshot of ${label}`}
47 width={1280}
48 height={800}
49 decoding="async"
50 onLoad={() => setState("loaded")}
51 onError={() => setState("failed")}
52 className={cn(
53 "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
54 state === "loaded" ? "opacity-100" : "opacity-0",
55 )}
56 />
57 )}
58 </span>
59 );
60
61 return href ? (
62 <DeployLink href={href} className={cn("group block", className)} aria-label={`Visit ${label}`}>
63 {frame}
64 </DeployLink>
65 ) : (
66 <div className={className}>{frame}</div>
67 );
68}
69
70function Placeholder({ label, waiting }: { label: string; waiting: boolean }) {
71 return (
72 <span className="absolute inset-0 flex flex-col">
73 <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5">
74 <span className="size-1.5 rounded-full bg-line-strong" />
75 <span className="size-1.5 rounded-full bg-line-strong" />
76 <span className="size-1.5 rounded-full bg-line-strong" />
77 <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>
78 </span>
79 <span className="relative flex grow flex-col gap-2 p-3.5">
80 <span className="h-2 w-2/5 rounded-full bg-line/80" />
81 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
82 <span className="h-1.5 w-3/5 rounded-full bg-line/50" />
83 <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
84 <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
85 {waiting ? "Taking a screenshot…" : "A screenshot appears after the next deploy"}
86 </span>
87 </span>
88 </span>
89 );
90}