feat(showcase): moldura de navegador, um video por vez e video ampliado nos cards
- cards com moldura de navegador (endereco real) e brilho na cor do site no card que toca - um video por vez: o do card sob o mouse/foco, ou o mais perto do centro da tela; nada baixa antes de tocar, e um botao pausa os videos que se mexem sozinhos - janela ampliada em <dialog> nativo: troca computador/celular mantendo o ponto do site, capitulos clicaveis, controles proprios, setas entre projetos e foco devolvido ao fechar - dados e capitulos de cada projeto em lib/project-showcase.ts (substitui project-videos.ts) Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
60dc2f1878
commit
a1a1e91135
18 files changed
+3564
-155
No files matched your search
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
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 (<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);
|
||||
|
||||
const [device, setDevice] = useState<Device>(() =>
|
||||
window.matchMedia("(max-width: 639px)").matches ? "mobile" : "desktop",
|
||||
);
|
||||
// o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento
|
||||
const [autoplay, setAutoplay] = useState(
|
||||
() => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
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<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 = (
|
||||
// biome-ignore lint/a11y/useMediaCaption: demonstração sem áudio; a descrição está no título e nos capítulos
|
||||
<video
|
||||
key={`${config.id}-${device}`}
|
||||
ref={videoRef}
|
||||
className="ds-lb-video"
|
||||
autoPlay={autoplay}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
onLoadedMetadata={handleMetadata}
|
||||
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>
|
||||
<h3 id={titleId} className="ds-lb-title">
|
||||
{project.title}
|
||||
</h3>
|
||||
</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"
|
||||
className="ds-lb-close"
|
||||
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">
|
||||
{canOpenSite && (
|
||||
<a
|
||||
className="ds-link ds-link-accent"
|
||||
href={project.demoUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir projeto
|
||||
<span className="sr-only"> (abre em nova aba)</span>
|
||||
</a>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Ver codigo
|
||||
<span className="sr-only"> (abre em nova aba)</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user