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