422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
"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";
|
||
import "./lightbox.css";
|
||
|
||
const formatTime = (seconds: number) => {
|
||
const whole = Math.max(0, Math.floor(seconds));
|
||
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
||
};
|
||
|
||
/** A duração do vídeo já chegou? Antes dos metadados ela é 0 (ou NaN), e "0:00 / 0:00" parece um vídeo vazio. */
|
||
const isKnown = (duration: number) => Number.isFinite(duration) && duration > 0;
|
||
|
||
/** "0:07 / 0:30"; "–:– / –:–" enquanto a duração não carrega. */
|
||
const clock = (elapsed: number, duration: number) =>
|
||
isKnown(duration)
|
||
? `${formatTime(elapsed)} / ${formatTime(duration)}`
|
||
: "–:– / –:–";
|
||
|
||
/**
|
||
* Vídeo ampliado: janela modal nativa (<dialog>: 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<HTMLDialogElement>(null);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
const previousRef = useRef<HTMLButtonElement>(null);
|
||
const nextRef = useRef<HTMLButtonElement>(null);
|
||
const pendingFraction = useRef<number | null>(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<Device>(() =>
|
||
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);
|
||
|
||
// lê a duração e aplica o ponto pendente da troca de aparelho; vale para o `loadedmetadata` e para o vídeo que já
|
||
// chegou com os metadados (cache, preload): nesse caso o evento passou antes de alguém ouvir e não volta
|
||
const readMetadata = useCallback(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
setDuration(video.duration);
|
||
if (pendingFraction.current !== null) {
|
||
video.currentTime = pendingFraction.current * video.duration;
|
||
pendingFraction.current = null;
|
||
}
|
||
}, []);
|
||
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: o <video> é outro elemento a cada projeto/aparelho (key)
|
||
useEffect(() => {
|
||
if ((videoRef.current?.readyState ?? 0) >= 1) readMetadata();
|
||
}, [config.id, device, readMetadata]);
|
||
|
||
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) {
|
||
setDuration(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 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<HTMLDialogElement>) => {
|
||
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<HTMLDialogElement>) => {
|
||
pressedOnBackdrop.current = event.target === event.currentTarget;
|
||
};
|
||
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>) => {
|
||
if (event.target === event.currentTarget && pressedOnBackdrop.current)
|
||
dialogRef.current?.close();
|
||
pressedOnBackdrop.current = false;
|
||
};
|
||
|
||
const video = (
|
||
<video
|
||
key={`${config.id}-${device}`}
|
||
ref={videoRef}
|
||
className="ds-lb-video"
|
||
autoPlay={autoplay}
|
||
muted
|
||
loop
|
||
playsInline
|
||
preload={saveData ? "metadata" : "auto"}
|
||
poster={assets.poster}
|
||
width={assets.width}
|
||
height={assets.height}
|
||
onLoadedMetadata={readMetadata}
|
||
onPlay={() => setPlaying(true)}
|
||
onPause={() => setPlaying(false)}
|
||
onTimeUpdate={handleTime}
|
||
aria-label={`Vídeo de ${project.title}, versão ${device === "desktop" ? "para computador" : "para celular"}`}
|
||
>
|
||
{assets.sources.map((source) => (
|
||
<source key={source.src} src={source.src} type={source.type} />
|
||
))}
|
||
</video>
|
||
);
|
||
|
||
return (
|
||
<dialog
|
||
ref={dialogRef}
|
||
className="ds-lb"
|
||
aria-labelledby={titleId}
|
||
data-lenis-prevent
|
||
style={{ "--accent": config.accent } as CSSProperties}
|
||
onClose={(event) => {
|
||
// 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}
|
||
>
|
||
<div className="ds-lb-panel">
|
||
<p className="sr-only" aria-live="polite">
|
||
{`${project.title}, projeto ${index + 1} de ${items.length}`}
|
||
</p>
|
||
<header className="ds-lb-head">
|
||
<div className="ds-lb-heading">
|
||
<p className="ds-lb-count">
|
||
{String(index + 1).padStart(2, "0")} /{" "}
|
||
{String(items.length).padStart(2, "0")}
|
||
</p>
|
||
<h2 id={titleId} className="ds-lb-title">
|
||
{project.title}
|
||
</h2>
|
||
</div>
|
||
|
||
<fieldset className="ds-lb-toggle">
|
||
<legend className="sr-only">Versão do site</legend>
|
||
{(["desktop", "mobile"] as const).map((option) => (
|
||
<button
|
||
key={option}
|
||
type="button"
|
||
aria-pressed={device === option}
|
||
onClick={() => switchDevice(option)}
|
||
>
|
||
{option === "desktop" ? "Computador" : "Celular"}
|
||
</button>
|
||
))}
|
||
</fieldset>
|
||
|
||
<div className="ds-lb-nav">
|
||
<button
|
||
ref={previousRef}
|
||
type="button"
|
||
onClick={() => navigate(index - 1)}
|
||
disabled={index === 0}
|
||
aria-label="Projeto anterior"
|
||
>
|
||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||
<path d="M10 3 5 8l5 5" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
ref={nextRef}
|
||
type="button"
|
||
onClick={() => navigate(index + 1)}
|
||
disabled={index === items.length - 1}
|
||
aria-label="Próximo projeto"
|
||
>
|
||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||
<path d="m6 3 5 5-5 5" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
ref={closeRef}
|
||
type="button"
|
||
onClick={() => dialogRef.current?.close()}
|
||
aria-label="Fechar"
|
||
>
|
||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||
<path d="m3 3 10 10M13 3 3 13" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="ds-lb-stage" data-device={device}>
|
||
{device === "desktop" ? (
|
||
<BrowserFrame url={url} className="ds-lb-frame">
|
||
<div
|
||
className="ds-lb-screen"
|
||
style={{ backgroundImage: `url(${assets.poster})` }}
|
||
>
|
||
{video}
|
||
</div>
|
||
</BrowserFrame>
|
||
) : (
|
||
<PhoneFrame className="ds-lb-phone" poster={assets.poster}>
|
||
{video}
|
||
</PhoneFrame>
|
||
)}
|
||
</div>
|
||
|
||
<div className="ds-lb-controls">
|
||
<button
|
||
type="button"
|
||
className="ds-lb-play"
|
||
onClick={togglePlay}
|
||
aria-label={playing ? "Pausar" : "Reproduzir"}
|
||
>
|
||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||
{playing ? (
|
||
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
|
||
) : (
|
||
<path d="M4 2.5v11l9-5.5z" />
|
||
)}
|
||
</svg>
|
||
</button>
|
||
<span className="ds-lb-time" aria-hidden="true">
|
||
{clock(fraction * duration, duration)}
|
||
</span>
|
||
<input
|
||
type="range"
|
||
className="ds-lb-range"
|
||
min={0}
|
||
max={1000}
|
||
step={1}
|
||
value={Math.round(fraction * 1000)}
|
||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||
seekTo(Number(event.target.value) / 1000)
|
||
}
|
||
aria-label="Posição do vídeo"
|
||
aria-valuetext={
|
||
isKnown(duration)
|
||
? `${formatTime(fraction * duration)} de ${formatTime(duration)}`
|
||
: "duração ainda não carregada"
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<ol className="ds-lb-chapters" aria-label="Trechos do site">
|
||
{config.chapters.map((entry, i) => (
|
||
<li key={entry.label}>
|
||
<button
|
||
type="button"
|
||
aria-current={i === chapter ? "true" : undefined}
|
||
onClick={() => seekTo(chapterFraction(config, i, device))}
|
||
>
|
||
<span aria-hidden="true">{String(i + 1).padStart(2, "0")}</span>{" "}
|
||
{entry.label}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
|
||
<div className="ds-lb-links">
|
||
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||
Ver case
|
||
<span className="sr-only"> de {project.title}</span>
|
||
</Link>
|
||
{canOpenSite && (
|
||
<a
|
||
className="ds-link ds-link-text"
|
||
href={project.demoUrl ?? undefined}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
Abrir site
|
||
<span className="sr-only"> (abre em nova aba)</span>
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
);
|
||
}
|