Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
409 lines
14 KiB
TypeScript
409 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")}`;
|
|
};
|
|
|
|
/**
|
|
* 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">
|
|
{formatTime(fraction * duration)} / {formatTime(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={`${formatTime(fraction * duration)} de ${formatTime(duration)}`}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|