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
@@ -1,8 +1,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getProjectVideo } from "@/lib/project-videos";
|
import { getProjectVideo } from "@/lib/project-showcase";
|
||||||
import { ProjectVideo } from "./ProjectVideo";
|
import { ProjectMedia } from "./showcase/ProjectMedia";
|
||||||
|
|
||||||
type CoverProject = { id: string; title: string };
|
type CoverProject = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
demoUrl?: string | null;
|
||||||
|
githubUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** Capturas de tela publicadas dos projetos que têm interface pública. */
|
/** Capturas de tela publicadas dos projetos que têm interface pública. */
|
||||||
const SCREENSHOTS: Record<string, string | undefined> = {
|
const SCREENSHOTS: Record<string, string | undefined> = {
|
||||||
@@ -36,13 +41,7 @@ export function ProjectCover({
|
|||||||
const video = getProjectVideo(project.id);
|
const video = getProjectVideo(project.id);
|
||||||
const src = SCREENSHOTS[project.id];
|
const src = SCREENSHOTS[project.id];
|
||||||
|
|
||||||
if (video) {
|
if (video) return <ProjectMedia project={project} />;
|
||||||
return (
|
|
||||||
<div className="ds-cover">
|
|
||||||
<ProjectVideo video={video} title={project.title} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import {
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
getProjectVideo,
|
|
||||||
PROJECT_VIDEO_IDS,
|
|
||||||
type ProjectVideoAsset,
|
|
||||||
} from "@/lib/project-videos";
|
|
||||||
import { ProjectVideo } from "./ProjectVideo";
|
import { ProjectVideo } from "./ProjectVideo";
|
||||||
|
|
||||||
let reducedMotion = false;
|
let reducedMotion = false;
|
||||||
@@ -13,59 +7,24 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
|
|||||||
useReducedMotion: () => reducedMotion,
|
useReducedMotion: () => reducedMotion,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const video: ProjectVideoAsset = {
|
const video = getShowcaseAssets("nutriscan", "desktop");
|
||||||
poster: "/videos/demo/desktop.webp",
|
|
||||||
width: 1440,
|
|
||||||
height: 900,
|
|
||||||
sources: [
|
|
||||||
{ src: "/videos/demo/desktop.webm", type: 'video/webm; codecs="vp9"' },
|
|
||||||
{ src: "/videos/demo/desktop.mp4", type: "video/mp4" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
type Callback = IntersectionObserverCallback;
|
|
||||||
let callbacks: Callback[] = [];
|
|
||||||
const originalObserver = global.IntersectionObserver;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
reducedMotion = false;
|
reducedMotion = false;
|
||||||
callbacks = [];
|
|
||||||
// observador controlável: o teste decide quando o card entra e sai da tela
|
|
||||||
global.IntersectionObserver = class {
|
|
||||||
constructor(callback: Callback) {
|
|
||||||
callbacks.push(callback);
|
|
||||||
}
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
takeRecords() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} as unknown as typeof IntersectionObserver;
|
|
||||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.IntersectionObserver = originalObserver;
|
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setVisible(isIntersecting: boolean) {
|
|
||||||
act(() => {
|
|
||||||
for (const callback of callbacks) {
|
|
||||||
callback(
|
|
||||||
[{ isIntersecting } as IntersectionObserverEntry],
|
|
||||||
{} as IntersectionObserver,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ProjectVideo", () => {
|
describe("ProjectVideo", () => {
|
||||||
it("renders a muted looping inline video that downloads nothing before it is visible", () => {
|
it("renders a muted looping inline video that downloads nothing until it is active", () => {
|
||||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
const { container } = render(
|
||||||
|
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||||
|
);
|
||||||
const element = container.querySelector("video");
|
const element = container.querySelector("video");
|
||||||
|
|
||||||
expect(element).not.toBeNull();
|
expect(element).not.toBeNull();
|
||||||
@@ -75,29 +34,47 @@ describe("ProjectVideo", () => {
|
|||||||
expect(element).toHaveAttribute("preload", "none");
|
expect(element).toHaveAttribute("preload", "none");
|
||||||
expect(element).toHaveAttribute("poster", video.poster);
|
expect(element).toHaveAttribute("poster", video.poster);
|
||||||
expect(element?.muted).toBe(true);
|
expect(element?.muted).toBe(true);
|
||||||
|
expect(element).toHaveAttribute("data-ready", "false");
|
||||||
expect(container.querySelectorAll("source")).toHaveLength(0);
|
expect(container.querySelectorAll("source")).toHaveLength(0);
|
||||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attaches the sources and plays once the card enters the viewport, then pauses when it leaves", () => {
|
it("attaches the sources and plays when it becomes the active card, then pauses when it stops being", () => {
|
||||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
const { container, rerender } = render(
|
||||||
|
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
setVisible(true);
|
rerender(<ProjectVideo video={video} title="Demo" active />);
|
||||||
const sources = [...container.querySelectorAll("source")];
|
const sources = [...container.querySelectorAll("source")];
|
||||||
expect(sources.map((source) => source.getAttribute("src"))).toEqual([
|
expect(sources.map((source) => source.getAttribute("src"))).toEqual(
|
||||||
"/videos/demo/desktop.webm",
|
video.sources.map((source) => source.src),
|
||||||
"/videos/demo/desktop.mp4",
|
);
|
||||||
]);
|
|
||||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
||||||
|
|
||||||
setVisible(false);
|
rerender(<ProjectVideo video={video} title="Demo" active={false} />);
|
||||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
||||||
|
// sai de cena, mas o arquivo já baixado fica: voltar a tocar não baixa de novo
|
||||||
|
expect(container.querySelectorAll("source")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fades the video in once it is playing", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProjectVideo video={video} title="Demo" active />,
|
||||||
|
);
|
||||||
|
const element = container.querySelector("video") as HTMLVideoElement;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
element.dispatchEvent(new Event("playing"));
|
||||||
|
});
|
||||||
|
expect(element).toHaveAttribute("data-ready", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
|
it("shows only the poster, with no video element, when the user prefers reduced motion", () => {
|
||||||
reducedMotion = true;
|
reducedMotion = true;
|
||||||
const { container } = render(<ProjectVideo video={video} title="Demo" />);
|
const { container } = render(
|
||||||
|
<ProjectVideo video={video} title="Demo" active />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(container.querySelector("video")).toBeNull();
|
expect(container.querySelector("video")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
@@ -106,26 +83,3 @@ describe("ProjectVideo", () => {
|
|||||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getProjectVideo", () => {
|
|
||||||
it("returns undefined for projects without a recorded video", () => {
|
|
||||||
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(PROJECT_VIDEO_IDS)(
|
|
||||||
"has webm, mp4 and poster on disk for %s, each within 3 MB",
|
|
||||||
(id) => {
|
|
||||||
const asset = getProjectVideo(id);
|
|
||||||
expect(asset).toBeDefined();
|
|
||||||
const files = [
|
|
||||||
asset?.poster,
|
|
||||||
...(asset?.sources.map((s) => s.src) ?? []),
|
|
||||||
];
|
|
||||||
for (const file of files) {
|
|
||||||
const path = join(process.cwd(), "public", file ?? "");
|
|
||||||
expect(existsSync(path)).toBe(true);
|
|
||||||
expect(statSync(path).size).toBeLessThanOrEqual(3 * 1024 * 1024);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -3,52 +3,48 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
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
|
* Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que
|
||||||
* (IntersectionObserver); fora da tela ele pausa. Com prefers-reduced-motion só o poster aparece.
|
* 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({
|
export function ProjectVideo({
|
||||||
video,
|
video,
|
||||||
title,
|
title,
|
||||||
|
active,
|
||||||
}: {
|
}: {
|
||||||
video: ProjectVideoAsset;
|
video: ShowcaseAssets;
|
||||||
title: string;
|
title: string;
|
||||||
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const element = ref.current;
|
const element = ref.current;
|
||||||
if (reducedMotion || !element) return;
|
if (!element || !attached) return;
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
if (playing) {
|
||||||
setActivated(true);
|
element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real
|
||||||
return;
|
element.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar
|
||||||
}
|
}
|
||||||
const observer = new IntersectionObserver(
|
}, [attached, playing]);
|
||||||
([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]);
|
|
||||||
|
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
return (
|
return (
|
||||||
@@ -73,11 +69,13 @@ export function ProjectVideo({
|
|||||||
poster={video.poster}
|
poster={video.poster}
|
||||||
width={video.width}
|
width={video.width}
|
||||||
height={video.height}
|
height={video.height}
|
||||||
|
data-ready={ready}
|
||||||
|
onPlaying={() => setReady(true)}
|
||||||
aria-label={`Demonstração em vídeo de ${title}`}
|
aria-label={`Demonstração em vídeo de ${title}`}
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
disableRemotePlayback
|
disableRemotePlayback
|
||||||
>
|
>
|
||||||
{activated &&
|
{attached &&
|
||||||
video.sources.map((source) => (
|
video.sources.map((source) => (
|
||||||
<source key={source.src} src={source.src} type={source.type} />
|
<source key={source.src} src={source.src} type={source.type} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
|
|
||||||
|
describe("displayUrl", () => {
|
||||||
|
it("shows the site address without the protocol, www or trailing slash", () => {
|
||||||
|
expect(displayUrl({ demoUrl: "https://www.example.com.br/" })).toEqual({
|
||||||
|
text: "example.com.br",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
expect(displayUrl({ demoUrl: "https://app.example.com/painel/" })).toEqual({
|
||||||
|
text: "app.example.com/painel",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks plain http as not secure", () => {
|
||||||
|
expect(displayUrl({ demoUrl: "http://localhost:3000" })).toEqual({
|
||||||
|
text: "localhost:3000",
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the code address when there is no live demo", () => {
|
||||||
|
expect(
|
||||||
|
displayUrl({ demoUrl: null, githubUrl: "https://github.com/user/repo" }),
|
||||||
|
).toEqual({ text: "github.com/user/repo", secure: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an address that does not parse and uses the next one", () => {
|
||||||
|
expect(
|
||||||
|
displayUrl({
|
||||||
|
demoUrl: "isso não é uma url",
|
||||||
|
githubUrl: "https://github.com/a/b",
|
||||||
|
}),
|
||||||
|
).toEqual({ text: "github.com/a/b", secure: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there is nothing to show", () => {
|
||||||
|
expect(displayUrl({})).toBeNull();
|
||||||
|
expect(displayUrl({ demoUrl: "", githubUrl: null })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BrowserFrame", () => {
|
||||||
|
it("draws the three dots, the address and the screen content", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame url={{ text: "site.com", secure: true }}>
|
||||||
|
<p>conteúdo</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("site.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("conteúdo")).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll(".ds-bframe-dots i")).toHaveLength(3);
|
||||||
|
expect(container.querySelector(".ds-bframe-url svg")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the decorative bar out of the accessibility tree", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<BrowserFrame url={{ text: "site.com", secure: true }}>
|
||||||
|
<p>x</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-bframe-bar")).toHaveAttribute(
|
||||||
|
"aria-hidden",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a neutral label and no lock when the address is unknown or not secure", () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<BrowserFrame url={null}>
|
||||||
|
<p>x</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("pré-visualização")).toBeInTheDocument();
|
||||||
|
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<BrowserFrame url={{ text: "localhost", secure: false }}>
|
||||||
|
<p>x</p>
|
||||||
|
</BrowserFrame>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Endereço mostrado na barra: o do site publicado, ou o do código quando não há demonstração. */
|
||||||
|
export function displayUrl(project: {
|
||||||
|
demoUrl?: string | null;
|
||||||
|
githubUrl?: string | null;
|
||||||
|
}): { text: string; secure: boolean } | null {
|
||||||
|
for (const raw of [project.demoUrl, project.githubUrl]) {
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||||
|
return {
|
||||||
|
text: `${url.host.replace(/^www\./, "")}${path}`,
|
||||||
|
secure: url.protocol === "https:",
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Janela de navegador em volta da captura: três pontos, barra com o endereço real e a tela.
|
||||||
|
* A barra é decoração (aria-hidden); o endereço também aparece como texto no próprio card.
|
||||||
|
*/
|
||||||
|
export function BrowserFrame({
|
||||||
|
url,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
url: { text: string; secure: boolean } | null;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`ds-bframe ${className}`.trim()} style={style}>
|
||||||
|
<div className="ds-bframe-bar" aria-hidden="true">
|
||||||
|
<span className="ds-bframe-dots">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<span className="ds-bframe-url">
|
||||||
|
{url?.secure && (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="2.5" y="5.5" width="7" height="5" />
|
||||||
|
<path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>{url?.text ?? "pré-visualização"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta). */
|
||||||
|
export function PhoneFrame({
|
||||||
|
children,
|
||||||
|
poster,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
poster?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`ds-phone ${className}`.trim()} style={style}>
|
||||||
|
<div
|
||||||
|
className="ds-phone-screen"
|
||||||
|
style={poster ? { backgroundImage: `url(${poster})` } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import {
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import {
|
||||||
|
chapterFraction,
|
||||||
|
getShowcaseConfig,
|
||||||
|
mobileFractionAt,
|
||||||
|
} from "@/lib/project-showcase";
|
||||||
|
import { ShowcaseProvider, useShowcaseCard } from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
let reducedMotion = false;
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => reducedMotion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Card({ id }: { id: string }) {
|
||||||
|
const { frameRef, open } = useShowcaseCard(id);
|
||||||
|
return (
|
||||||
|
<article className="ds-card">
|
||||||
|
<div ref={frameRef} />
|
||||||
|
<button type="button" onClick={open}>
|
||||||
|
ampliar {id}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
const lavacao = getShowcaseConfig("lavacao-047");
|
||||||
|
|
||||||
|
function setup(id = "lavacao-047") {
|
||||||
|
render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<Card id="sev-modaintima" />
|
||||||
|
<Card id="lavacao-047" />
|
||||||
|
<Card id="nutriscan" />
|
||||||
|
<Card id="as-servicos" />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` }));
|
||||||
|
return screen.getByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** jsdom não carrega mídia: o teste dá ao <video> uma duração e deixa o componente reagir como no navegador. */
|
||||||
|
function loadVideo(dialog: HTMLElement, duration = 30) {
|
||||||
|
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||||
|
Object.defineProperty(video, "duration", {
|
||||||
|
configurable: true,
|
||||||
|
value: duration,
|
||||||
|
});
|
||||||
|
let time = 0;
|
||||||
|
Object.defineProperty(video, "currentTime", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => time,
|
||||||
|
set: (value: number) => {
|
||||||
|
time = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
video.dispatchEvent(new Event("loadedmetadata"));
|
||||||
|
});
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
}));
|
||||||
|
// jsdom ainda não implementa o <dialog> modal
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal(
|
||||||
|
this: HTMLDialogElement,
|
||||||
|
) {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reducedMotion = false;
|
||||||
|
jest
|
||||||
|
.spyOn(HTMLMediaElement.prototype, "play")
|
||||||
|
.mockImplementation(function play(this: HTMLMediaElement) {
|
||||||
|
// jsdom não toca nada: o teste mantém `paused` e os eventos como o navegador faria
|
||||||
|
Object.defineProperty(this, "paused", {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
this.dispatchEvent(new Event("play"));
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
jest
|
||||||
|
.spyOn(HTMLMediaElement.prototype, "pause")
|
||||||
|
.mockImplementation(function pause(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, "paused", {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
this.dispatchEvent(new Event("pause"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
document.documentElement.classList.remove("ds-lb-open");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectLightbox", () => {
|
||||||
|
it("opens a modal dialog for the clicked project, locks page scroll and puts focus on close", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
|
||||||
|
expect(dialog).toHaveAttribute("open");
|
||||||
|
expect(dialog).toHaveAccessibleName("Lavação 047");
|
||||||
|
expect(dialog).toHaveAttribute("data-lenis-prevent");
|
||||||
|
expect(document.documentElement).toHaveClass("ds-lb-open");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Fechar" }),
|
||||||
|
).toHaveFocus();
|
||||||
|
expect(within(dialog).getByText("02 / 05")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the desktop video in a browser frame by default and the phone version on request", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
const toggle = within(dialog).getByRole("group", {
|
||||||
|
name: "Versão do site",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(toggle).getByRole("button", { name: "Computador" }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(dialog.querySelector(".ds-lb-frame")).not.toBeNull();
|
||||||
|
expect(dialog.querySelector('source[src$="desktop.webm"]')).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(within(toggle).getByRole("button", { name: "Celular" }));
|
||||||
|
expect(
|
||||||
|
within(toggle).getByRole("button", { name: "Celular" }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
|
||||||
|
expect(dialog.querySelector('source[src$="mobile.webm"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the same point of the site when switching devices", () => {
|
||||||
|
if (!lavacao) throw new Error("config ausente");
|
||||||
|
const dialog = setup();
|
||||||
|
const desktop = loadVideo(dialog, lavacao.durations.desktop);
|
||||||
|
|
||||||
|
// meio do vídeo desktop
|
||||||
|
desktop.currentTime = lavacao.durations.desktop * 0.5;
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||||
|
const phone = loadVideo(dialog, lavacao.durations.mobile);
|
||||||
|
|
||||||
|
expect(phone.currentTime).toBeCloseTo(
|
||||||
|
mobileFractionAt(lavacao, 0.5) * lavacao.durations.mobile,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jumps to a chapter and marks it as current", () => {
|
||||||
|
if (!lavacao) throw new Error("config ausente");
|
||||||
|
const dialog = setup();
|
||||||
|
const video = loadVideo(dialog, lavacao.durations.desktop);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(video.currentTime).toBeCloseTo(
|
||||||
|
chapterFraction(lavacao, 2, "desktop") * lavacao.durations.desktop,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
|
||||||
|
).toHaveAttribute("aria-current", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles play and pause with an accessible name", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
loadVideo(dialog);
|
||||||
|
|
||||||
|
const play = within(dialog).getByRole("button", { name: "Reproduzir" });
|
||||||
|
fireEvent.click(play);
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Pausar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Reproduzir" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeks with the range slider", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
const video = loadVideo(dialog, 30);
|
||||||
|
|
||||||
|
fireEvent.change(
|
||||||
|
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
|
||||||
|
{ target: { value: "500" } },
|
||||||
|
);
|
||||||
|
expect(video.currentTime).toBeCloseTo(15, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves between projects and disables the arrows at both ends", () => {
|
||||||
|
const dialog = setup("sev-modaintima");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Projeto anterior" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(dialog).getByRole("button", { name: "Próximo projeto" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByRole("dialog"), { key: "ArrowLeft" });
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the site and the code when they exist", () => {
|
||||||
|
const dialog = setup("lavacao-047");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
|
||||||
|
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
|
||||||
|
expect(
|
||||||
|
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
|
||||||
|
setup();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Fechar" }));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(document.documentElement).not.toHaveClass("ds-lb-open");
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
const dialog = setup();
|
||||||
|
fireEvent.pointerDown(dialog);
|
||||||
|
fireEvent.click(dialog); // clique no fundo (o próprio <dialog>)
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
const again = setup();
|
||||||
|
act(() => {
|
||||||
|
again.removeAttribute("open"); // o Esc nativo fecha o <dialog> e só então dispara `close`
|
||||||
|
again.dispatchEvent(new Event("close"));
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(document.documentElement).not.toHaveClass("ds-lb-open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not autoplay for users who prefer reduced motion", () => {
|
||||||
|
reducedMotion = true;
|
||||||
|
const dialog = setup();
|
||||||
|
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectLightbox: revisão", () => {
|
||||||
|
it("survives React StrictMode in development: the double effect must not close the window it just opened", () => {
|
||||||
|
// o `close` real é assíncrono: o teste reproduz isso para pegar o evento atrasado
|
||||||
|
HTMLDialogElement.prototype.close = function close(
|
||||||
|
this: HTMLDialogElement,
|
||||||
|
) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
queueMicrotask(() => this.dispatchEvent(new Event("close")));
|
||||||
|
};
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
<StrictMode>
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<Card id="lavacao-047" />
|
||||||
|
</ShowcaseProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAttribute("open");
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
HTMLDialogElement.prototype.close = function close(
|
||||||
|
this: HTMLDialogElement,
|
||||||
|
) {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a late close event from a dialog that is open again", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
act(() => {
|
||||||
|
dialog.dispatchEvent(new Event("close"));
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not close when a press starts inside the panel and is released on the backdrop", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
fireEvent.pointerDown(
|
||||||
|
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
|
||||||
|
);
|
||||||
|
fireEvent.click(dialog);
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays paused after switching device when the visitor had paused", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
loadVideo(dialog);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||||
|
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
|
||||||
|
expect(
|
||||||
|
within(dialog).getByRole("button", { name: "Reproduzir" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps playing after switching device when the video was playing", () => {
|
||||||
|
const dialog = setup();
|
||||||
|
loadVideo(dialog);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||||
|
expect(dialog.querySelector("video")).toHaveAttribute("autoplay");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the position when the visitor flips the device twice before the new video loads", () => {
|
||||||
|
if (!lavacao) throw new Error("config ausente");
|
||||||
|
const dialog = setup();
|
||||||
|
const desktop = loadVideo(dialog, lavacao.durations.desktop);
|
||||||
|
desktop.currentTime = lavacao.durations.desktop * 0.5;
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||||
|
// o vídeo do celular ainda não carregou (sem duração): volta de novo para o computador
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Computador" }));
|
||||||
|
const back = loadVideo(dialog, lavacao.durations.desktop);
|
||||||
|
|
||||||
|
expect(back.currentTime / lavacao.durations.desktop).toBeCloseTo(0.5, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves focus to the opposite arrow when the pressed one reaches its end", () => {
|
||||||
|
const dialog = setup("nutriscan");
|
||||||
|
// anterior, anterior: chega ao primeiro e o botão "Anterior" fica desabilitado
|
||||||
|
let current = within(dialog).getByRole("button", {
|
||||||
|
name: "Projeto anterior",
|
||||||
|
});
|
||||||
|
current.focus();
|
||||||
|
fireEvent.click(current);
|
||||||
|
current = within(screen.getByRole("dialog")).getByRole("button", {
|
||||||
|
name: "Projeto anterior",
|
||||||
|
});
|
||||||
|
current.focus();
|
||||||
|
fireEvent.click(current);
|
||||||
|
|
||||||
|
const after = within(screen.getByRole("dialog"));
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
||||||
|
expect(
|
||||||
|
after.getByRole("button", { name: "Projeto anterior" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
after.getByRole("button", { name: "Próximo projeto" }),
|
||||||
|
).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces which project is on screen", () => {
|
||||||
|
const dialog = setup("nutriscan");
|
||||||
|
expect(dialog.querySelector("[aria-live='polite']")).toHaveTextContent(
|
||||||
|
"NutriScan, projeto 3 de 5",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores arrow keys combined with modifiers, which belong to the browser", () => {
|
||||||
|
const dialog = setup("lavacao-047");
|
||||||
|
fireEvent.keyDown(dialog, { key: "ArrowRight", altKey: true });
|
||||||
|
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import { ProjectMedia } from "./ProjectMedia";
|
||||||
|
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
let reducedMotion = false;
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => reducedMotion,
|
||||||
|
}));
|
||||||
|
jest.mock("./ProjectLightbox", () => ({
|
||||||
|
ProjectLightbox: () => <div role="dialog" aria-label="ampliado" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
const nutriscan = projects.find((project) => project.id === "nutriscan");
|
||||||
|
if (!nutriscan) throw new Error("projeto ausente");
|
||||||
|
|
||||||
|
type Callback = IntersectionObserverCallback;
|
||||||
|
const callbacks: Callback[] = [];
|
||||||
|
const Original = global.IntersectionObserver;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
reducedMotion = false;
|
||||||
|
callbacks.length = 0;
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
constructor(callback: Callback) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as unknown as typeof IntersectionObserver;
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
global.IntersectionObserver = Original;
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderCard() {
|
||||||
|
const view = render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<article className="ds-card">
|
||||||
|
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
|
||||||
|
</article>
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
const frame = view.container.querySelector(".ds-frame") as HTMLElement;
|
||||||
|
frame.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top: 300,
|
||||||
|
height: 300,
|
||||||
|
bottom: 600,
|
||||||
|
left: 0,
|
||||||
|
right: 400,
|
||||||
|
width: 400,
|
||||||
|
x: 0,
|
||||||
|
y: 300,
|
||||||
|
}) as DOMRect;
|
||||||
|
return { ...view, frame };
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(frame: HTMLElement) {
|
||||||
|
act(() => {
|
||||||
|
for (const callback of callbacks)
|
||||||
|
callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
target: frame,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
isIntersecting: true,
|
||||||
|
} as unknown as IntersectionObserverEntry,
|
||||||
|
],
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectMedia", () => {
|
||||||
|
it("frames the video with the project's address and a button that enlarges it", () => {
|
||||||
|
renderCard();
|
||||||
|
|
||||||
|
expect(screen.getByText("nutriscan.evandro.dev.br")).toBeInTheDocument();
|
||||||
|
const expand = screen.getByRole("button", {
|
||||||
|
name: "Ampliar o vídeo de NutriScan",
|
||||||
|
});
|
||||||
|
expect(expand).toHaveAttribute("aria-haspopup", "dialog");
|
||||||
|
fireEvent.click(expand);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "ampliado" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays only while the card is the active one", () => {
|
||||||
|
const { frame, container } = renderCard();
|
||||||
|
const video = container.querySelector("video") as HTMLVideoElement;
|
||||||
|
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||||
|
"data-active",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
expect(video.querySelectorAll("source")).toHaveLength(0);
|
||||||
|
|
||||||
|
show(frame);
|
||||||
|
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||||
|
"data-active",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(video.querySelectorAll("source")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the visitor pause the videos that play on their own, and resume them", () => {
|
||||||
|
const { frame, container } = renderCard();
|
||||||
|
show(frame);
|
||||||
|
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||||
|
"data-active",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Pausar os vídeos dos projetos" }),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||||
|
"data-active",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Retomar os vídeos dos projetos" }),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||||
|
"data-active",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no pause button when nothing plays on its own (reduced motion)", () => {
|
||||||
|
reducedMotion = true;
|
||||||
|
renderCard();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /vídeos dos projetos/ }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing for a project without a recorded video", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<ProjectMedia project={{ id: "twins-lab", title: "Twins Lab" }} />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-frame")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing, without crashing, when used outside the provider", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Ampliar o vídeo de NutriScan" }),
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".ds-frame")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import { getProjectVideo, getShowcaseConfig } from "@/lib/project-showcase";
|
||||||
|
import { ProjectVideo } from "../ProjectVideo";
|
||||||
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
|
import { useShowcaseCard } from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capa do card com vídeo: o vídeo dentro de uma moldura de navegador, com brilho da cor do site quando é o
|
||||||
|
* card que toca, e o botão que amplia (a tela inteira é o botão).
|
||||||
|
*/
|
||||||
|
export function ProjectMedia({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
demoUrl?: string | null;
|
||||||
|
githubUrl?: string | null;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { frameRef, active, open, userPaused, toggleAutoplay } =
|
||||||
|
useShowcaseCard(project.id);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const video = getProjectVideo(project.id);
|
||||||
|
const config = getShowcaseConfig(project.id);
|
||||||
|
if (!video || !config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={frameRef}
|
||||||
|
className="ds-frame"
|
||||||
|
data-active={active}
|
||||||
|
style={{ "--accent": config.accent } as CSSProperties}
|
||||||
|
>
|
||||||
|
<BrowserFrame url={displayUrl(project)}>
|
||||||
|
<div
|
||||||
|
className="ds-frame-screen"
|
||||||
|
style={{ backgroundImage: `url(${video.poster})` }}
|
||||||
|
>
|
||||||
|
<ProjectVideo video={video} title={project.title} active={active} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ds-frame-expand"
|
||||||
|
onClick={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={`Ampliar o vídeo de ${project.title}`}
|
||||||
|
>
|
||||||
|
<span className="ds-frame-chip" aria-hidden="true">
|
||||||
|
Ampliar
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!reducedMotion && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ds-frame-pause"
|
||||||
|
onClick={toggleAutoplay}
|
||||||
|
aria-label={
|
||||||
|
userPaused
|
||||||
|
? "Retomar os vídeos dos projetos"
|
||||||
|
: "Pausar os vídeos dos projetos"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
{userPaused ? (
|
||||||
|
<path d="M4 2.5v11l9-5.5z" />
|
||||||
|
) : (
|
||||||
|
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||||
|
import {
|
||||||
|
buildShowcaseItems,
|
||||||
|
ShowcaseProvider,
|
||||||
|
useShowcaseCard,
|
||||||
|
} from "./ShowcaseProvider";
|
||||||
|
|
||||||
|
let reducedMotion = false;
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: () => reducedMotion,
|
||||||
|
}));
|
||||||
|
jest.mock("./ProjectLightbox", () => ({
|
||||||
|
ProjectLightbox: ({
|
||||||
|
index,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => (
|
||||||
|
<div role="dialog" aria-label="ampliado">
|
||||||
|
aberto:{index}
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type FakeObserver = {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
targets: Set<Element>;
|
||||||
|
};
|
||||||
|
let observers: FakeObserver[] = [];
|
||||||
|
const OriginalObserver = global.IntersectionObserver;
|
||||||
|
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
private readonly state: FakeObserver;
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.state = { callback, targets: new Set() };
|
||||||
|
observers.push(this.state);
|
||||||
|
}
|
||||||
|
observe(element: Element) {
|
||||||
|
this.state.targets.add(element);
|
||||||
|
}
|
||||||
|
unobserve(element: Element) {
|
||||||
|
this.state.targets.delete(element);
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this.state.targets.clear();
|
||||||
|
}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisibility(element: Element, ratio: number) {
|
||||||
|
for (const observer of observers) {
|
||||||
|
if (!observer.targets.has(element)) continue;
|
||||||
|
act(() => {
|
||||||
|
observer.callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
target: element,
|
||||||
|
intersectionRatio: ratio,
|
||||||
|
isIntersecting: ratio > 0,
|
||||||
|
} as IntersectionObserverEntry,
|
||||||
|
],
|
||||||
|
observer as unknown as IntersectionObserver,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(element: Element, top: number) {
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top,
|
||||||
|
height: 300,
|
||||||
|
bottom: top + 300,
|
||||||
|
left: 0,
|
||||||
|
right: 400,
|
||||||
|
width: 400,
|
||||||
|
x: 0,
|
||||||
|
y: top,
|
||||||
|
}) as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(40);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ id }: { id: string }) {
|
||||||
|
const { frameRef, active, open } = useShowcaseCard(id);
|
||||||
|
return (
|
||||||
|
<article className="ds-card">
|
||||||
|
<div ref={frameRef} data-testid={id} data-active={active} />
|
||||||
|
<button type="button" onClick={open}>
|
||||||
|
ampliar {id}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = getFeaturedProjects(null);
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const view = render(
|
||||||
|
<ShowcaseProvider projects={projects}>
|
||||||
|
<Card id="sev-modaintima" />
|
||||||
|
<Card id="lavacao-047" />
|
||||||
|
<Card id="nutriscan" />
|
||||||
|
</ShowcaseProvider>,
|
||||||
|
);
|
||||||
|
const frames = {
|
||||||
|
sev: screen.getByTestId("sev-modaintima"),
|
||||||
|
lavacao: screen.getByTestId("lavacao-047"),
|
||||||
|
nutri: screen.getByTestId("nutriscan"),
|
||||||
|
};
|
||||||
|
// tela de 768px de altura (jsdom): o meio fica em 384
|
||||||
|
place(frames.sev, 100); // centro 250
|
||||||
|
place(frames.lavacao, 230); // centro 380
|
||||||
|
place(frames.nutri, 900); // fora da tela
|
||||||
|
return { view, frames };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
reducedMotion = false;
|
||||||
|
observers = [];
|
||||||
|
global.IntersectionObserver =
|
||||||
|
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
global.IntersectionObserver = OriginalObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isActive = (element: Element) =>
|
||||||
|
element.getAttribute("data-active") === "true";
|
||||||
|
|
||||||
|
describe("ShowcaseProvider", () => {
|
||||||
|
it("lists only projects that have a recorded showcase, in showcase order", () => {
|
||||||
|
const items = buildShowcaseItems(projects);
|
||||||
|
expect(items.map((item) => item.config.id)).toEqual([
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
"nutriscan",
|
||||||
|
"sc-plus",
|
||||||
|
"as-servicos",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
buildShowcaseItems(projects.filter((p) => p.id === "twins-lab")),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
flush();
|
||||||
|
expect(Object.values(frames).some(isActive)).toBe(false);
|
||||||
|
|
||||||
|
setVisibility(frames.sev, 0.8);
|
||||||
|
setVisibility(frames.lavacao, 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.lavacao)).toBe(true);
|
||||||
|
expect(isActive(frames.sev)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count a card with less than a third on screen", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.sev, 0.2);
|
||||||
|
setVisibility(frames.lavacao, 0.1);
|
||||||
|
flush();
|
||||||
|
expect(Object.values(frames).some(isActive)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the video to the card under the mouse and takes it back when the mouse leaves", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.sev, 0.8);
|
||||||
|
setVisibility(frames.lavacao, 0.8);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
const sevCard = frames.sev.closest(".ds-card") as HTMLElement;
|
||||||
|
fireEvent.pointerEnter(sevCard, { pointerType: "mouse" });
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.sev)).toBe(true);
|
||||||
|
expect(isActive(frames.lavacao)).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(sevCard, { pointerType: "mouse" });
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.lavacao)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores touch pointers: there is no hover on a phone", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.sev, 0.8);
|
||||||
|
setVisibility(frames.lavacao, 0.8);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
fireEvent.pointerEnter(frames.sev.closest(".ds-card") as HTMLElement, {
|
||||||
|
pointerType: "touch",
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.sev)).toBe(false);
|
||||||
|
expect(isActive(frames.lavacao)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows keyboard focus inside a card", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.sev, 0.8);
|
||||||
|
setVisibility(frames.lavacao, 0.8);
|
||||||
|
flush();
|
||||||
|
|
||||||
|
fireEvent.focusIn(
|
||||||
|
screen.getByRole("button", { name: "ampliar sev-modaintima" }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.sev)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-evaluates on scroll", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.sev, 0.8);
|
||||||
|
setVisibility(frames.lavacao, 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.lavacao)).toBe(true);
|
||||||
|
|
||||||
|
place(frames.sev, 230);
|
||||||
|
place(frames.lavacao, 600);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("scroll"));
|
||||||
|
});
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.sev)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses every card while the enlarged window is open, and resumes after", () => {
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.lavacao, 0.8);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.lavacao)).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
||||||
|
);
|
||||||
|
flush();
|
||||||
|
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||||
|
"aberto:1",
|
||||||
|
);
|
||||||
|
expect(isActive(frames.lavacao)).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||||
|
flush();
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
expect(isActive(frames.lavacao)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never plays a card for users who prefer reduced motion", () => {
|
||||||
|
reducedMotion = true;
|
||||||
|
const { frames } = setup();
|
||||||
|
setVisibility(frames.lavacao, 1);
|
||||||
|
flush();
|
||||||
|
expect(isActive(frames.lavacao)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops listening when it unmounts", () => {
|
||||||
|
const { view, frames } = setup();
|
||||||
|
setVisibility(frames.lavacao, 1);
|
||||||
|
view.unmount();
|
||||||
|
expect(observers.every((observer) => observer.targets.size === 0)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||||
|
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
|
||||||
|
import { ProjectLightbox } from "./ProjectLightbox";
|
||||||
|
import { pickActiveCard } from "./showcase-math";
|
||||||
|
import "./showcase.css";
|
||||||
|
|
||||||
|
export interface ShowcaseItem {
|
||||||
|
config: ShowcaseConfig;
|
||||||
|
project: PortfolioProjectView;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShowcaseApi {
|
||||||
|
register(id: string, element: HTMLElement): () => void;
|
||||||
|
setHover(id: string, hovered: boolean): void;
|
||||||
|
open(id: string): void;
|
||||||
|
/** Pausa (ou retoma) os vídeos que tocam sozinhos nos cards: quem não quer movimento na página tem como desligar. */
|
||||||
|
toggleAutoplay(): void;
|
||||||
|
items: ShowcaseItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveState {
|
||||||
|
activeId: string | null;
|
||||||
|
userPaused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiContext = createContext<ShowcaseApi | null>(null);
|
||||||
|
const ActiveContext = createContext<ActiveState>({
|
||||||
|
activeId: null,
|
||||||
|
userPaused: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Só aparece como card com vídeo o projeto que existe na lista e tem vídeo gravado. */
|
||||||
|
export function buildShowcaseItems(
|
||||||
|
projects: PortfolioProjectView[],
|
||||||
|
): ShowcaseItem[] {
|
||||||
|
const items: ShowcaseItem[] = [];
|
||||||
|
for (const config of SHOWCASE) {
|
||||||
|
const project = projects.find((candidate) => candidate.id === config.id);
|
||||||
|
if (project) items.push({ config, project });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
|
||||||
|
export function useShowcaseItems() {
|
||||||
|
return useContext(ApiContext)?.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abre a janela ampliada de um projeto (nada acontece fora do provedor). */
|
||||||
|
export function useShowcaseOpen() {
|
||||||
|
const api = useContext(ApiContext);
|
||||||
|
return useCallback((id: string) => api?.open(id), [api]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Um vídeo por vez nos cards: toca o card sob o mouse (ou com foco); sem mouse sobre nenhum, o mais perto do
|
||||||
|
* centro da tela. Também guarda qual card está ampliado e mostra a janela ampliada.
|
||||||
|
*/
|
||||||
|
export function ShowcaseProvider({
|
||||||
|
projects,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
projects: PortfolioProjectView[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const items = useMemo(() => buildShowcaseItems(projects), [projects]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [userPaused, setUserPaused] = useState(false);
|
||||||
|
|
||||||
|
const cards = useRef(
|
||||||
|
new Map<string, { el: HTMLElement; visible: boolean }>(),
|
||||||
|
);
|
||||||
|
const hovered = useRef<string | null>(null);
|
||||||
|
const paused = useRef(false);
|
||||||
|
const queued = useRef(false);
|
||||||
|
const frame = useRef(0);
|
||||||
|
const observer = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
const evaluate = useCallback(() => {
|
||||||
|
queued.current = false;
|
||||||
|
if (paused.current || document.hidden) {
|
||||||
|
setActiveId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const middle = window.innerHeight / 2;
|
||||||
|
const candidates = [...cards.current].map(([id, { el, visible }]) => {
|
||||||
|
// só quem está à vista entra na disputa: os outros nem têm o layout lido a cada quadro de rolagem
|
||||||
|
if (!visible) return { id, visible, distance: Number.POSITIVE_INFINITY };
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
visible,
|
||||||
|
distance: Math.abs(rect.top + rect.height / 2 - middle),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setActiveId(pickActiveCard(candidates, hovered.current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const schedule = useCallback(() => {
|
||||||
|
if (queued.current) return;
|
||||||
|
queued.current = true;
|
||||||
|
frame.current = requestAnimationFrame(evaluate);
|
||||||
|
}, [evaluate]);
|
||||||
|
|
||||||
|
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
|
||||||
|
useEffect(() => {
|
||||||
|
paused.current = reducedMotion || openId !== null || userPaused;
|
||||||
|
schedule();
|
||||||
|
}, [schedule, openId, reducedMotion, userPaused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("scroll", schedule, { passive: true });
|
||||||
|
window.addEventListener("resize", schedule);
|
||||||
|
document.addEventListener("visibilitychange", schedule);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", schedule);
|
||||||
|
window.removeEventListener("resize", schedule);
|
||||||
|
document.removeEventListener("visibilitychange", schedule);
|
||||||
|
cancelAnimationFrame(frame.current);
|
||||||
|
queued.current = false;
|
||||||
|
observer.current?.disconnect();
|
||||||
|
observer.current = null;
|
||||||
|
};
|
||||||
|
}, [schedule]);
|
||||||
|
|
||||||
|
const api = useMemo<ShowcaseApi>(
|
||||||
|
() => ({
|
||||||
|
items,
|
||||||
|
register(id, element) {
|
||||||
|
if (!observer.current && typeof IntersectionObserver !== "undefined") {
|
||||||
|
observer.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const card of cards.current.values()) {
|
||||||
|
// um card só vale como "à vista" com pelo menos um terço dele na tela
|
||||||
|
if (card.el === entry.target)
|
||||||
|
card.visible = entry.intersectionRatio >= 0.33;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
{ threshold: [0, 0.33, 0.66, 1] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cards.current.set(id, { el: element, visible: false });
|
||||||
|
observer.current?.observe(element);
|
||||||
|
return () => {
|
||||||
|
observer.current?.unobserve(element);
|
||||||
|
cards.current.delete(id);
|
||||||
|
if (hovered.current === id) hovered.current = null;
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setHover(id, isHovered) {
|
||||||
|
if (isHovered) hovered.current = id;
|
||||||
|
else if (hovered.current === id) hovered.current = null;
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
open(id) {
|
||||||
|
setOpenId(id);
|
||||||
|
},
|
||||||
|
toggleAutoplay() {
|
||||||
|
setUserPaused((paused) => !paused);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[items, schedule],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeState = useMemo(
|
||||||
|
() => ({ activeId, userPaused }),
|
||||||
|
[activeId, userPaused],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openIndex = openId
|
||||||
|
? items.findIndex((item) => item.config.id === openId)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiContext.Provider value={api}>
|
||||||
|
<ActiveContext.Provider value={activeState}>
|
||||||
|
{children}
|
||||||
|
{openIndex >= 0 && (
|
||||||
|
<ProjectLightbox
|
||||||
|
items={items}
|
||||||
|
index={openIndex}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ActiveContext.Provider>
|
||||||
|
</ApiContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liga um card à vitrine: devolve a ref da moldura, se ele é o card que toca agora e como ampliá-lo. */
|
||||||
|
export function useShowcaseCard(id: string) {
|
||||||
|
const api = useContext(ApiContext);
|
||||||
|
const { activeId, userPaused } = useContext(ActiveContext);
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = frameRef.current;
|
||||||
|
if (!api || !element) return;
|
||||||
|
const unregister = api.register(id, element);
|
||||||
|
const card = element.closest<HTMLElement>(".ds-card") ?? element;
|
||||||
|
const enter = (event: PointerEvent) => {
|
||||||
|
// no toque não existe "passar o mouse": só o mouse e a caneta mandam na vitrine
|
||||||
|
if (event.pointerType !== "touch") api.setHover(id, true);
|
||||||
|
};
|
||||||
|
const leave = () => api.setHover(id, false);
|
||||||
|
const focusIn = () => api.setHover(id, true);
|
||||||
|
const focusOut = (event: FocusEvent) => {
|
||||||
|
if (!card.contains(event.relatedTarget as Node | null))
|
||||||
|
api.setHover(id, false);
|
||||||
|
};
|
||||||
|
card.addEventListener("pointerenter", enter);
|
||||||
|
card.addEventListener("pointerleave", leave);
|
||||||
|
card.addEventListener("focusin", focusIn);
|
||||||
|
card.addEventListener("focusout", focusOut);
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
card.removeEventListener("pointerenter", enter);
|
||||||
|
card.removeEventListener("pointerleave", leave);
|
||||||
|
card.removeEventListener("focusin", focusIn);
|
||||||
|
card.removeEventListener("focusout", focusOut);
|
||||||
|
};
|
||||||
|
}, [api, id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
frameRef,
|
||||||
|
active: activeId === id,
|
||||||
|
userPaused,
|
||||||
|
toggleAutoplay: useCallback(() => api?.toggleAutoplay(), [api]),
|
||||||
|
open: useCallback(() => api?.open(id), [api, id]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
easeToward,
|
||||||
|
locateInReel,
|
||||||
|
pickActiveCard,
|
||||||
|
reelStarts,
|
||||||
|
videoProgress,
|
||||||
|
} from "./showcase-math";
|
||||||
|
|
||||||
|
const card = (id: string, visible: boolean, distance: number) => ({
|
||||||
|
id,
|
||||||
|
visible,
|
||||||
|
distance,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickActiveCard", () => {
|
||||||
|
it("plays the visible card closest to the middle of the screen", () => {
|
||||||
|
expect(
|
||||||
|
pickActiveCard(
|
||||||
|
[card("a", true, 300), card("b", true, 80), card("c", false, 5)],
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the hovered card win over a closer one, but only while it is visible", () => {
|
||||||
|
const cards = [card("a", true, 10), card("b", true, 400)];
|
||||||
|
expect(pickActiveCard(cards, "b")).toBe("b");
|
||||||
|
expect(
|
||||||
|
pickActiveCard([card("a", true, 10), card("b", false, 0)], "b"),
|
||||||
|
).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first card on a tie, so the left one of a row wins", () => {
|
||||||
|
expect(
|
||||||
|
pickActiveCard(
|
||||||
|
[card("left", true, 120), card("right", true, 120.3)],
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toBe("left");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays nothing when no card is on screen", () => {
|
||||||
|
expect(pickActiveCard([card("a", false, 0)], null)).toBeNull();
|
||||||
|
expect(pickActiveCard([], null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locateInReel", () => {
|
||||||
|
const weights = [1, 2, 1];
|
||||||
|
|
||||||
|
it("maps overall progress to a project and a position inside it", () => {
|
||||||
|
expect(locateInReel(weights, 0)).toEqual({ index: 0, local: 0 });
|
||||||
|
expect(locateInReel(weights, 0.125)).toEqual({ index: 0, local: 0.5 });
|
||||||
|
const middle = locateInReel(weights, 0.5);
|
||||||
|
expect(middle.index).toBe(1);
|
||||||
|
expect(middle.local).toBeCloseTo(0.5);
|
||||||
|
expect(locateInReel(weights, 1)).toEqual({ index: 2, local: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps progress outside the pinned range", () => {
|
||||||
|
expect(locateInReel(weights, -3)).toEqual({ index: 0, local: 0 });
|
||||||
|
expect(locateInReel(weights, 7)).toEqual({ index: 2, local: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copes with an empty reel", () => {
|
||||||
|
expect(locateInReel([], 0.4)).toEqual({ index: 0, local: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives each project the share of the scroll that its weight asks for", () => {
|
||||||
|
expect(reelStarts(weights)).toEqual([0, 0.25, 0.75]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("videoProgress", () => {
|
||||||
|
it("holds still at the start and at the end of each project's stretch", () => {
|
||||||
|
expect(videoProgress(0)).toBe(0);
|
||||||
|
expect(videoProgress(0.1)).toBe(0);
|
||||||
|
expect(videoProgress(0.5)).toBeCloseTo(0.5);
|
||||||
|
expect(videoProgress(0.9)).toBe(1);
|
||||||
|
expect(videoProgress(1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("easeToward", () => {
|
||||||
|
it("moves toward the target and never overshoots", () => {
|
||||||
|
const first = easeToward(0, 1, 1 / 60);
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
expect(first).toBeLessThan(1);
|
||||||
|
expect(easeToward(0.5, 0.5, 1)).toBe(0.5);
|
||||||
|
expect(easeToward(0, 1, 10)).toBeCloseTo(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not depend on the frame rate", () => {
|
||||||
|
let sixty = 0;
|
||||||
|
for (let i = 0; i < 60; i += 1) sixty = easeToward(sixty, 1, 1 / 60);
|
||||||
|
let thirty = 0;
|
||||||
|
for (let i = 0; i < 30; i += 1) thirty = easeToward(thirty, 1, 1 / 30);
|
||||||
|
expect(sixty).toBeCloseTo(thirty, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stands still when no time has passed", () => {
|
||||||
|
expect(easeToward(0.2, 1, 0)).toBe(0.2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/** Contas puras da vitrine (sem DOM), separadas para serem testadas uma a uma. */
|
||||||
|
|
||||||
|
export interface CardCandidate {
|
||||||
|
id: string;
|
||||||
|
visible: boolean;
|
||||||
|
/** Distância do centro do card ao centro da tela, em pixels. */
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qual card toca: o que está sob o mouse (se visível); senão o visível mais perto do centro da tela.
|
||||||
|
* Empate fica com o primeiro da lista (ordem do DOM: esquerda antes de direita).
|
||||||
|
*/
|
||||||
|
export function pickActiveCard(
|
||||||
|
candidates: CardCandidate[],
|
||||||
|
hoveredId: string | null,
|
||||||
|
): string | null {
|
||||||
|
const visible = candidates.filter((candidate) => candidate.visible);
|
||||||
|
if (hoveredId && visible.some((candidate) => candidate.id === hoveredId))
|
||||||
|
return hoveredId;
|
||||||
|
let best: CardCandidate | null = null;
|
||||||
|
for (const candidate of visible) {
|
||||||
|
if (!best || candidate.distance < best.distance - 0.5) best = candidate;
|
||||||
|
}
|
||||||
|
return best?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReelPosition {
|
||||||
|
/** Projeto em cena. */
|
||||||
|
index: number;
|
||||||
|
/** Quanto do trecho do projeto já passou (0 a 1). */
|
||||||
|
local: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Em que projeto e em que ponto dele a rolagem está, dado o avanço total `t` (0 a 1) e o peso de cada trecho. */
|
||||||
|
export function locateInReel(weights: number[], t: number): ReelPosition {
|
||||||
|
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
if (weights.length === 0 || total <= 0) return { index: 0, local: 0 };
|
||||||
|
const position = Math.min(1, Math.max(0, t)) * total;
|
||||||
|
let start = 0;
|
||||||
|
for (let index = 0; index < weights.length; index += 1) {
|
||||||
|
const end = start + weights[index];
|
||||||
|
if (position < end || index === weights.length - 1) {
|
||||||
|
const local = (position - start) / weights[index];
|
||||||
|
return { index, local: Math.min(1, Math.max(0, local)) };
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return { index: weights.length - 1, local: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Início do trecho de cada projeto, como fração (0 a 1) do avanço total. */
|
||||||
|
export function reelStarts(weights: number[]): number[] {
|
||||||
|
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
let acc = 0;
|
||||||
|
return weights.map((weight) => {
|
||||||
|
const start = total > 0 ? acc / total : 0;
|
||||||
|
acc += weight;
|
||||||
|
return start;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fração do trecho de cada projeto em que o vídeo fica parado, no começo e no fim. */
|
||||||
|
export const REEL_HOLD = 0.1;
|
||||||
|
|
||||||
|
/** Avanço total (0 a 1) em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||||
|
export function reelOffset(weights: number[], index: number, local: number) {
|
||||||
|
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
if (total <= 0 || index < 0 || index >= weights.length) return 0;
|
||||||
|
return reelStarts(weights)[index] + (local * weights[index]) / total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O trecho de cada projeto tem uma pausa no começo e outra no fim: o vídeo só anda no meio.
|
||||||
|
* Isso dá tempo de ler o título antes de o site começar a rolar e de descansar antes da troca.
|
||||||
|
*/
|
||||||
|
export function videoProgress(local: number, hold = REEL_HOLD): number {
|
||||||
|
const span = 1 - hold * 2;
|
||||||
|
return Math.min(1, Math.max(0, (local - hold) / span));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aproxima `current` de `target` de forma suave, independente da taxa de quadros (`tau` em segundos). */
|
||||||
|
export function easeToward(
|
||||||
|
current: number,
|
||||||
|
target: number,
|
||||||
|
deltaSeconds: number,
|
||||||
|
tau = 0.07,
|
||||||
|
): number {
|
||||||
|
const factor = 1 - Math.exp(-Math.max(0, deltaSeconds) / tau);
|
||||||
|
return current + (target - current) * factor;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
|||||||
|
import { existsSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
chapterAnchors,
|
||||||
|
chapterFraction,
|
||||||
|
chapterIndexAt,
|
||||||
|
desktopFractionAt,
|
||||||
|
getOgImagePath,
|
||||||
|
getProjectVideo,
|
||||||
|
getScrubSource,
|
||||||
|
getShowcaseAssets,
|
||||||
|
mobileFractionAt,
|
||||||
|
PROJECT_VIDEO_IDS,
|
||||||
|
SHOWCASE,
|
||||||
|
} from "./project-showcase";
|
||||||
|
|
||||||
|
const MAX_BYTES = 3 * 1024 * 1024;
|
||||||
|
const publicFile = (path: string) => join(process.cwd(), "public", path);
|
||||||
|
|
||||||
|
describe("showcase chapters", () => {
|
||||||
|
it.each(SHOWCASE)("$id has ordered chapters inside both videos", (config) => {
|
||||||
|
const [first] = config.chapters;
|
||||||
|
expect(first.desktop).toBe(0);
|
||||||
|
expect(first.mobile).toBe(0);
|
||||||
|
config.chapters.forEach((chapter, index) => {
|
||||||
|
expect(chapter.label.trim()).not.toBe("");
|
||||||
|
expect(chapter.desktop).toBeLessThan(config.durations.desktop);
|
||||||
|
expect(chapter.mobile).toBeLessThan(config.durations.mobile);
|
||||||
|
if (index > 0) {
|
||||||
|
expect(chapter.desktop).toBeGreaterThan(
|
||||||
|
config.chapters[index - 1].desktop,
|
||||||
|
);
|
||||||
|
expect(chapter.mobile).toBeGreaterThan(
|
||||||
|
config.chapters[index - 1].mobile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(config.weight).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(SHOWCASE)("$id keeps the two timelines in sync", (config) => {
|
||||||
|
const anchors = chapterAnchors(config);
|
||||||
|
expect(anchors[0]).toEqual([0, 0]);
|
||||||
|
expect(anchors[anchors.length - 1]).toEqual([1, 1]);
|
||||||
|
for (const fraction of [0, 0.13, 0.5, 0.77, 1]) {
|
||||||
|
const mobile = mobileFractionAt(config, fraction);
|
||||||
|
expect(mobile).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mobile).toBeLessThanOrEqual(1);
|
||||||
|
expect(desktopFractionAt(config, mobile)).toBeCloseTo(fraction, 9);
|
||||||
|
}
|
||||||
|
config.chapters.forEach((_, index) => {
|
||||||
|
expect(
|
||||||
|
mobileFractionAt(config, chapterFraction(config, index, "desktop")),
|
||||||
|
).toBeCloseTo(chapterFraction(config, index, "mobile"), 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the chapter on screen", () => {
|
||||||
|
const lavacao = SHOWCASE.find((config) => config.id === "lavacao-047");
|
||||||
|
expect(lavacao).toBeDefined();
|
||||||
|
if (!lavacao) return;
|
||||||
|
expect(chapterIndexAt(lavacao, 0)).toBe(0);
|
||||||
|
expect(chapterIndexAt(lavacao, 3 / lavacao.durations.desktop)).toBe(1);
|
||||||
|
expect(chapterIndexAt(lavacao, 10 / lavacao.durations.desktop)).toBe(2);
|
||||||
|
expect(chapterIndexAt(lavacao, 1)).toBe(lavacao.chapters.length - 1);
|
||||||
|
expect(chapterIndexAt(lavacao, 2)).toBe(lavacao.chapters.length - 1);
|
||||||
|
expect(chapterIndexAt(lavacao, -1)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showcase assets", () => {
|
||||||
|
it("returns undefined for projects without a recorded video", () => {
|
||||||
|
expect(getProjectVideo("reddit-explorer")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(PROJECT_VIDEO_IDS)(
|
||||||
|
"has every file on disk for %s, each video within 3 MB",
|
||||||
|
(id) => {
|
||||||
|
const files = [
|
||||||
|
...(["desktop", "mobile"] as const).flatMap((device) => {
|
||||||
|
const assets = getShowcaseAssets(id, device);
|
||||||
|
return [assets.poster, ...assets.sources.map((s) => s.src)];
|
||||||
|
}),
|
||||||
|
getScrubSource(id, "desktop"),
|
||||||
|
getScrubSource(id, "mobile"),
|
||||||
|
getOgImagePath(id),
|
||||||
|
];
|
||||||
|
for (const file of files) {
|
||||||
|
expect(existsSync(publicFile(file))).toBe(true);
|
||||||
|
expect(statSync(publicFile(file)).size).toBeLessThanOrEqual(MAX_BYTES);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Vitrine dos projetos: tudo o que os cards, a vitrine animada e a janela ampliada sabem sobre os vídeos.
|
||||||
|
*
|
||||||
|
* Arquivos em public/videos/<id>/ (gravados com scripts/record-project-videos.mjs):
|
||||||
|
* - desktop.{webm,mp4,webp} e mobile.{webm,mp4,webp}: reprodução normal, até 3 MB cada vídeo;
|
||||||
|
* - scrub-desktop.mp4 e scrub-mobile.mp4: mesmos vídeos com um quadro-chave a cada 6 quadros, para a imagem
|
||||||
|
* acompanhar a rolagem sem travar quando o tempo é mudado a cada quadro;
|
||||||
|
* - og.jpg: capa 1200x630 das imagens de compartilhamento.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ShowcaseChapter {
|
||||||
|
label: string;
|
||||||
|
/** Segundo do vídeo desktop em que o trecho começa. */
|
||||||
|
desktop: number;
|
||||||
|
/** Segundo do vídeo mobile em que o mesmo trecho começa (as páginas têm alturas diferentes). */
|
||||||
|
mobile: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowcaseConfig {
|
||||||
|
id: string;
|
||||||
|
/** Cor de destaque do site, usada só no brilho ao redor do vídeo. */
|
||||||
|
accent: string;
|
||||||
|
/** Duração dos vídeos gravados, em segundos (os capítulos são medidos nelas). */
|
||||||
|
durations: { desktop: number; mobile: number };
|
||||||
|
/** Comprimento da rolagem deste projeto na vitrine, em alturas de tela. */
|
||||||
|
weight: number;
|
||||||
|
chapters: readonly ShowcaseChapter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordem da vitrine = ordem dos cards na home. Capítulos marcados nos quadros dos próprios vídeos. */
|
||||||
|
export const SHOWCASE: readonly ShowcaseConfig[] = [
|
||||||
|
{
|
||||||
|
id: "sev-modaintima",
|
||||||
|
accent: "#c2303a",
|
||||||
|
durations: { desktop: 17.9, mobile: 30.1 },
|
||||||
|
weight: 1,
|
||||||
|
chapters: [
|
||||||
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "Categorias", desktop: 2.7, mobile: 1.9 },
|
||||||
|
{ label: "As favoritas", desktop: 7.2, mobile: 5.6 },
|
||||||
|
{ label: "Nossa essência", desktop: 10.7, mobile: 8.6 },
|
||||||
|
{ label: "Coleção completa", desktop: 13.4, mobile: 11 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lavacao-047",
|
||||||
|
accent: "#4aa3ff",
|
||||||
|
durations: { desktop: 30.07, mobile: 30.1 },
|
||||||
|
weight: 1.4,
|
||||||
|
chapters: [
|
||||||
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "Tabela de preços", desktop: 1.5, mobile: 1.9 },
|
||||||
|
{ label: "Do sujo ao brilho", desktop: 5.5, mobile: 5.6 },
|
||||||
|
{ label: "Como funciona", desktop: 13.5, mobile: 13.2 },
|
||||||
|
{ label: "Galeria e mapa", desktop: 18.5, mobile: 17.5 },
|
||||||
|
{ label: "Perguntas e contato", desktop: 24, mobile: 22 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nutriscan",
|
||||||
|
accent: "#2f9e5f",
|
||||||
|
durations: { desktop: 15.67, mobile: 26.4 },
|
||||||
|
weight: 0.9,
|
||||||
|
chapters: [
|
||||||
|
{ label: "Busca", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "Por que confiar", desktop: 2.3, mobile: 3 },
|
||||||
|
{ label: "Quatro etapas", desktop: 7, mobile: 8 },
|
||||||
|
{ label: "Apoio ao projeto", desktop: 10.9, mobile: 16.3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sc-plus",
|
||||||
|
accent: "#22c55e",
|
||||||
|
durations: { desktop: 13, mobile: 19.67 },
|
||||||
|
weight: 0.85,
|
||||||
|
chapters: [
|
||||||
|
{ label: "Abertura", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "Sobre", desktop: 1.5, mobile: 2.2 },
|
||||||
|
{ label: "Áreas e projetos", desktop: 3.8, mobile: 4.6 },
|
||||||
|
{ label: "Apoiadores e participação", desktop: 7.5, mobile: 12.8 },
|
||||||
|
{ label: "Contato", desktop: 10.4, mobile: 16.9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "as-servicos",
|
||||||
|
accent: "#2563eb",
|
||||||
|
durations: { desktop: 5.07, mobile: 7.7 },
|
||||||
|
weight: 0.6,
|
||||||
|
chapters: [
|
||||||
|
{ label: "Três áreas", desktop: 0, mobile: 0 },
|
||||||
|
{ label: "Critérios de entrega", desktop: 1.5, mobile: 3 },
|
||||||
|
{ label: "Contato direto", desktop: 2.4, mobile: 4.4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROJECT_VIDEO_IDS = SHOWCASE.map((config) => config.id);
|
||||||
|
|
||||||
|
const byId = new Map(SHOWCASE.map((config) => [config.id, config]));
|
||||||
|
|
||||||
|
export function getShowcaseConfig(id: string): ShowcaseConfig | undefined {
|
||||||
|
return byId.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Device = "desktop" | "mobile";
|
||||||
|
|
||||||
|
export interface ShowcaseAssets {
|
||||||
|
poster: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sources: Array<{ src: string; type: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShowcaseAssets(id: string, device: Device) {
|
||||||
|
const base = `/videos/${id}/${device}`;
|
||||||
|
return {
|
||||||
|
poster: `${base}.webp`,
|
||||||
|
width: device === "desktop" ? 1440 : 390,
|
||||||
|
height: device === "desktop" ? 900 : 844,
|
||||||
|
sources: [
|
||||||
|
{ src: `${base}.webm`, type: 'video/webm; codecs="vp9"' },
|
||||||
|
{ src: `${base}.mp4`, type: "video/mp4" },
|
||||||
|
],
|
||||||
|
} satisfies ShowcaseAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrubSource(id: string, device: Device) {
|
||||||
|
return `/videos/${id}/scrub-${device}.mp4`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOgImagePath(id: string) {
|
||||||
|
return `/videos/${id}/og.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vídeo desktop de um projeto da vitrine: é o que as capas dos cards tocam. */
|
||||||
|
export function getProjectVideo(id: string): ShowcaseAssets | undefined {
|
||||||
|
return byId.has(id) ? getShowcaseAssets(id, "desktop") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------
|
||||||
|
// Capítulos e sincronia entre as duas telas
|
||||||
|
// ---------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Pontos [fração do desktop, fração do mobile] que dizem onde cada capítulo começa em cada vídeo. */
|
||||||
|
export function chapterAnchors(
|
||||||
|
config: ShowcaseConfig,
|
||||||
|
): Array<[number, number]> {
|
||||||
|
const { desktop, mobile } = config.durations;
|
||||||
|
const points: Array<[number, number]> = [[0, 0]];
|
||||||
|
for (const chapter of config.chapters) {
|
||||||
|
const point: [number, number] = [
|
||||||
|
chapter.desktop / desktop,
|
||||||
|
chapter.mobile / mobile,
|
||||||
|
];
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (point[0] > last[0] && point[1] > last[1]) points.push(point);
|
||||||
|
}
|
||||||
|
points.push([1, 1]);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(
|
||||||
|
points: Array<[number, number]>,
|
||||||
|
value: number,
|
||||||
|
from: 0 | 1,
|
||||||
|
): number {
|
||||||
|
const to = from === 0 ? 1 : 0;
|
||||||
|
const x = Math.min(1, Math.max(0, value));
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const [a, b] = [points[i - 1], points[i]];
|
||||||
|
if (x <= b[from]) {
|
||||||
|
const span = b[from] - a[from];
|
||||||
|
const ratio = span === 0 ? 0 : (x - a[from]) / span;
|
||||||
|
return a[to] + ratio * (b[to] - a[to]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fração do vídeo mobile que mostra o mesmo ponto do site que a fração `fraction` do vídeo desktop. */
|
||||||
|
export function mobileFractionAt(config: ShowcaseConfig, fraction: number) {
|
||||||
|
return interpolate(chapterAnchors(config), fraction, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O contrário: fração do desktop para uma fração do vídeo mobile. */
|
||||||
|
export function desktopFractionAt(config: ShowcaseConfig, fraction: number) {
|
||||||
|
return interpolate(chapterAnchors(config), fraction, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capítulo em exibição quando o vídeo desktop está na fração `fraction`. */
|
||||||
|
export function chapterIndexAt(config: ShowcaseConfig, fraction: number) {
|
||||||
|
const seconds = Math.min(1, Math.max(0, fraction)) * config.durations.desktop;
|
||||||
|
let index = 0;
|
||||||
|
config.chapters.forEach((chapter, i) => {
|
||||||
|
if (seconds >= chapter.desktop - 1e-6) index = i;
|
||||||
|
});
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fração (0 a 1) do vídeo `device` em que o capítulo `index` começa. */
|
||||||
|
export function chapterFraction(
|
||||||
|
config: ShowcaseConfig,
|
||||||
|
index: number,
|
||||||
|
device: Device,
|
||||||
|
) {
|
||||||
|
const chapter = config.chapters[index];
|
||||||
|
if (!chapter) return 0;
|
||||||
|
return device === "desktop"
|
||||||
|
? chapter.desktop / config.durations.desktop
|
||||||
|
: chapter.mobile / config.durations.mobile;
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/**
|
|
||||||
* Vídeos de rolagem dos projetos: public/videos/<id>/desktop.{webm,mp4,webp}.
|
|
||||||
* Gravados com scripts/record-project-videos.mjs; só entram aqui os ids que têm os três arquivos.
|
|
||||||
*/
|
|
||||||
export interface ProjectVideoAsset {
|
|
||||||
poster: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
sources: Array<{ src: string; type: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PROJECT_VIDEO_IDS = [
|
|
||||||
"sev-modaintima",
|
|
||||||
"lavacao-047",
|
|
||||||
"nutriscan",
|
|
||||||
"sc-plus",
|
|
||||||
"as-servicos",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const recorded = new Set<string>(PROJECT_VIDEO_IDS);
|
|
||||||
|
|
||||||
export function getProjectVideo(id: string): ProjectVideoAsset | undefined {
|
|
||||||
if (!recorded.has(id)) return undefined;
|
|
||||||
const base = `/videos/${id}/desktop`;
|
|
||||||
return {
|
|
||||||
poster: `${base}.webp`,
|
|
||||||
width: 1440,
|
|
||||||
height: 900,
|
|
||||||
sources: [
|
|
||||||
{ src: `${base}.webm`, type: 'video/webm; codecs="vp9"' },
|
|
||||||
{ src: `${base}.mp4`, type: "video/mp4" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user