| 1 | import { Globe, ImagePlus } from "lucide-react"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | |
| 4 | import { cn } from "../lib/cn"; |
| 5 | import type { CardSource } from "../lib/production-image"; |
| 6 | import { DeployLink } from "./deploy"; |
| 7 | import { 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 | */ |
| 18 | export 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. */ |
| 114 | function 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 | } |