Files

422 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}