|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import Image from 'next/image'; |
| 4 | +import { useEffect, useState } from 'react'; |
| 5 | + |
| 6 | +import logo from '@/public/logo.png'; |
| 7 | + |
| 8 | +// The chunk often lands in a couple hundred ms, which reads as a flicker |
| 9 | +// rather than a load; hold the overlay long enough for it to look intentional. |
| 10 | +const MIN_VISIBLE_MS = 650; |
| 11 | +const FADE_MS = 400; |
| 12 | + |
| 13 | +export function LoaderVisual() { |
| 14 | + return ( |
| 15 | + <div className="flex h-dvh flex-col items-center justify-center gap-5"> |
| 16 | + <Image |
| 17 | + src={logo} |
| 18 | + alt="" |
| 19 | + width={40} |
| 20 | + height={36} |
| 21 | + priority |
| 22 | + className="animate-spin [animation-duration:1.8s] motion-reduce:animate-none" |
| 23 | + /> |
| 24 | + <p |
| 25 | + role="status" |
| 26 | + className="text-fd-muted-foreground text-[0.8125rem] tracking-tight" |
| 27 | + > |
| 28 | + Loading playground… |
| 29 | + </p> |
| 30 | + </div> |
| 31 | + ); |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * Covers the page until `ready` resolves, then fades out. Sits over the |
| 36 | + * playground rather than swapping with it, so the editor and the first PDF |
| 37 | + * render happen behind the overlay instead of popping in after it. |
| 38 | + */ |
| 39 | +export function PlaygroundLoader({ ready }: { ready: Promise<unknown> }) { |
| 40 | + const [done, setDone] = useState(false); |
| 41 | + const [gone, setGone] = useState(false); |
| 42 | + |
| 43 | + useEffect(() => { |
| 44 | + let alive = true; |
| 45 | + const held = new Promise((resolve) => setTimeout(resolve, MIN_VISIBLE_MS)); |
| 46 | + |
| 47 | + Promise.all([ready, held]).then(() => { |
| 48 | + if (alive) setDone(true); |
| 49 | + }); |
| 50 | + |
| 51 | + return () => { |
| 52 | + alive = false; |
| 53 | + }; |
| 54 | + }, [ready]); |
| 55 | + |
| 56 | + useEffect(() => { |
| 57 | + if (!done) return undefined; |
| 58 | + // not `transitionend`: it never fires when the user prefers reduced motion |
| 59 | + const timer = setTimeout(() => setGone(true), FADE_MS); |
| 60 | + return () => clearTimeout(timer); |
| 61 | + }, [done]); |
| 62 | + |
| 63 | + if (gone) return null; |
| 64 | + |
| 65 | + return ( |
| 66 | + <div |
| 67 | + aria-hidden={done} |
| 68 | + style={{ transitionDuration: `${FADE_MS}ms` }} |
| 69 | + className={`bg-fd-background fixed inset-0 z-50 transition-opacity ease-out ${ |
| 70 | + done ? 'pointer-events-none opacity-0' : 'opacity-100' |
| 71 | + }`} |
| 72 | + > |
| 73 | + <LoaderVisual /> |
| 74 | + </div> |
| 75 | + ); |
| 76 | +} |
0 commit comments