"use client"; import Link from "next/link"; import { type ChangeEvent, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, useCallback, useEffect, useId, useRef, useState, } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { isMobileViewport, isSaveData } from "@/lib/device"; import { chapterFraction, chapterIndexAt, type Device, desktopFractionAt, getShowcaseAssets, mobileFractionAt, } from "@/lib/project-showcase"; import { BrowserFrame, displayUrl } from "./BrowserFrame"; import { PhoneFrame } from "./PhoneFrame"; import type { ShowcaseItem } from "./ShowcaseProvider"; const formatTime = (seconds: number) => { const whole = Math.max(0, Math.floor(seconds)); return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`; }; /** * Vídeo ampliado: janela modal nativa (: foco preso, Esc fecha, foco volta ao botão que abriu), * com troca desktop/celular que mantém o ponto do site, capítulos clicáveis e controles próprios. */ export function ProjectLightbox({ items, index, onClose, onNavigate, }: { items: ShowcaseItem[]; index: number; onClose: () => void; onNavigate: (index: number) => void; }) { const { config, project } = items[index]; const reducedMotion = useReducedMotion(); const titleId = useId(); const dialogRef = useRef(null); const videoRef = useRef(null); const closeRef = useRef(null); const previousRef = useRef(null); const nextRef = useRef(null); const pendingFraction = useRef(null); const pressedOnBackdrop = useRef(false); // celular abaixo de 768 px (lib/device.ts); a troca de aparelho continua à escolha do visitante const [device, setDevice] = useState(() => isMobileViewport() ? "mobile" : "desktop", ); // o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento // "economizar dados": o arquivo só chega quando o visitante pede (play), não antes const [saveData] = useState(isSaveData); const [autoplay, setAutoplay] = useState( () => !window.matchMedia("(prefers-reduced-motion: reduce)").matches && !saveData, ); const wantsToPlay = useRef(autoplay); const [playing, setPlaying] = useState(false); const [fraction, setFraction] = useState(0); const [duration, setDuration] = useState(0); useEffect(() => { if (!reducedMotion) return; wantsToPlay.current = false; setAutoplay(false); }, [reducedMotion]); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (!dialog.open) dialog.showModal(); closeRef.current?.focus(); // travar a rolagem da página tira a barra de rolagem clássica: o espaço dela é devolvido para nada pular const root = document.documentElement; root.style.setProperty( "--ds-scrollbar", `${Math.max(0, window.innerWidth - root.clientWidth)}px`, ); root.classList.add("ds-lb-open"); return () => { root.classList.remove("ds-lb-open"); root.style.removeProperty("--ds-scrollbar"); // sem dialog.close() aqui: no StrictMode do dev a limpeza roda no meio da abertura e o evento `close` // (assíncrono) chegaria depois de reaberto; sair do DOM já basta para soltar a janela modal }; }, []); // o marcador acompanha o vídeo quadro a quadro enquanto ele toca (timeupdate só chega umas 4 vezes por segundo) useEffect(() => { if (!playing) return; let id = 0; const tick = () => { const current = videoRef.current; if (current?.duration) setFraction(current.currentTime / current.duration); id = requestAnimationFrame(tick); }; id = requestAnimationFrame(tick); return () => cancelAnimationFrame(id); }, [playing]); const assets = getShowcaseAssets(config.id, device); const desktopFraction = device === "desktop" ? fraction : desktopFractionAt(config, fraction); const chapter = chapterIndexAt(config, desktopFraction); const url = displayUrl(project); const canOpenSite = project.demoUrl && (project.demoAvailability === "available" || project.demoAvailability === "unknown"); const switchDevice = (next: Device) => { if (next === device) return; const video = videoRef.current; // se o novo vídeo ainda não tem duração (duplo clique), vale a posição já gravada no estado const current = video?.duration ? video.currentTime / video.duration : fraction; pendingFraction.current = next === "mobile" ? mobileFractionAt(config, current) : desktopFractionAt(config, current); setFraction(pendingFraction.current); setPlaying(false); setAutoplay(wantsToPlay.current); setDevice(next); }; const navigate = (next: number) => { if (next < 0 || next >= items.length) return; pendingFraction.current = null; setFraction(0); setPlaying(false); setAutoplay(wantsToPlay.current); // o botão que acabou de chegar ao limite fica desabilitado: o foco passa para o do outro lado const active = document.activeElement; if (active === previousRef.current && next === 0) nextRef.current?.focus(); if (active === nextRef.current && next === items.length - 1) previousRef.current?.focus(); onNavigate(next); }; const handleMetadata = () => { const video = videoRef.current; if (!video) return; setDuration(video.duration); if (pendingFraction.current !== null) { video.currentTime = pendingFraction.current * video.duration; pendingFraction.current = null; } }; const handleTime = () => { const video = videoRef.current; if (video?.duration) setFraction(video.currentTime / video.duration); }; const togglePlay = () => { const video = videoRef.current; if (!video) return; wantsToPlay.current = video.paused; if (video.paused) video.play().catch(() => {}); else video.pause(); }; const seekTo = useCallback((next: number) => { const video = videoRef.current; if (!video?.duration) return; video.currentTime = Math.min(1, Math.max(0, next)) * video.duration; setFraction(next); }, []); const onKeyDown = (event: KeyboardEvent) => { if (event.target instanceof HTMLInputElement) return; // Alt+Seta é "voltar/avançar" do navegador if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; if (event.key === "ArrowLeft") navigate(index - 1); if (event.key === "ArrowRight") navigate(index + 1); }; // clique no fundo escuro fecha, mas só se o toque também começou nele (arrastar a barra de posição e soltar fora não conta) const onBackdropPointerDown = (event: PointerEvent) => { pressedOnBackdrop.current = event.target === event.currentTarget; }; const onBackdropClick = (event: MouseEvent) => { if (event.target === event.currentTarget && pressedOnBackdrop.current) dialogRef.current?.close(); pressedOnBackdrop.current = false; }; const video = ( ); return ( { // um `close` atrasado de uma janela que já foi reaberta (StrictMode) não pode fechar a nova if (!event.currentTarget.open) onClose(); }} onPointerDown={onBackdropPointerDown} onClick={onBackdropClick} onKeyDown={onKeyDown} >

{`${project.title}, projeto ${index + 1} de ${items.length}`}

{String(index + 1).padStart(2, "0")} /{" "} {String(items.length).padStart(2, "0")}

{project.title}

Versão do site {(["desktop", "mobile"] as const).map((option) => ( ))}
{device === "desktop" ? (
{video}
) : ( {video} )}
) => seekTo(Number(event.target.value) / 1000) } aria-label="Posição do vídeo" aria-valuetext={`${formatTime(fraction * duration)} de ${formatTime(duration)}`} />
    {config.chapters.map((entry, i) => (
  1. ))}
Ver case de {project.title} {canOpenSite && ( Abrir site (abre em nova aba) )}
); }