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:
Evandro Fonseca JuniorandClaude Sonnet 5.5 committed 2026-10-08 16:36:53 -03:00
1 parent 60dc2f1878
commit a1a1e91135
18 files changed
+3564 -155

No files matched your search

+30 -32
View File
@@ -3,52 +3,48 @@
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { ProjectVideoAsset } from "@/lib/project-videos";
import type { ShowcaseAssets } from "@/lib/project-showcase";
/**
* Vídeo de rolagem do projeto na capa do card. Nada de vídeo é baixado até o card entrar na tela
* (IntersectionObserver); fora da tela ele pausa. Com prefers-reduced-motion só o poster aparece.
* Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que
* está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o
* card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece.
* O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar.
*/
export function ProjectVideo({
video,
title,
active,
}: {
video: ProjectVideoAsset;
video: ShowcaseAssets;
title: string;
active: boolean;
}) {
const reducedMotion = useReducedMotion();
const ref = useRef<HTMLVideoElement>(null);
const [activated, setActivated] = useState(false);
const [attached, setAttached] = useState(false);
const [ready, setReady] = useState(false);
const playing = active && !reducedMotion;
useEffect(() => {
if (playing) setAttached(true);
}, [playing]);
useEffect(() => {
// as fontes entram depois do primeiro render: o elemento precisa ler a lista nova
if (attached) ref.current?.load();
}, [attached]);
useEffect(() => {
const element = ref.current;
if (reducedMotion || !element) return;
if (typeof IntersectionObserver === "undefined") {
setActivated(true);
return;
if (!element || !attached) return;
if (playing) {
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
element.play().catch(() => {});
} else {
element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setActivated(true);
element.play().catch(() => {});
} else {
element.pause();
}
},
{ rootMargin: "120px 0px", threshold: 0.2 },
);
observer.observe(element);
return () => observer.disconnect();
}, [reducedMotion]);
useEffect(() => {
const element = ref.current;
if (!activated || reducedMotion || !element) return;
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
element.load();
element.play().catch(() => {});
}, [activated, reducedMotion]);
}, [attached, playing]);
if (reducedMotion) {
return (
@@ -73,11 +69,13 @@ export function ProjectVideo({
poster={video.poster}
width={video.width}
height={video.height}
data-ready={ready}
onPlaying={() => setReady(true)}
aria-label={`Demonstração em vídeo de ${title}`}
disablePictureInPicture
disableRemotePlayback
>
{activated &&
{attached &&
video.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}