diff --git a/app/components/ProjectCover.tsx b/app/components/ProjectCover.tsx index 993f939..852e539 100644 --- a/app/components/ProjectCover.tsx +++ b/app/components/ProjectCover.tsx @@ -1,8 +1,13 @@ import Image from "next/image"; -import { getProjectVideo } from "@/lib/project-videos"; -import { ProjectVideo } from "./ProjectVideo"; +import { getProjectVideo } from "@/lib/project-showcase"; +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. */ const SCREENSHOTS: Record = { @@ -36,13 +41,7 @@ export function ProjectCover({ const video = getProjectVideo(project.id); const src = SCREENSHOTS[project.id]; - if (video) { - return ( -
- -
- ); - } + if (video) return ; if (src) { return ( diff --git a/app/components/ProjectVideo.test.tsx b/app/components/ProjectVideo.test.tsx index a4b5405..da1f834 100644 --- a/app/components/ProjectVideo.test.tsx +++ b/app/components/ProjectVideo.test.tsx @@ -1,11 +1,5 @@ -import { existsSync, statSync } from "node:fs"; -import { join } from "node:path"; import { act, render, screen } from "@testing-library/react"; -import { - getProjectVideo, - PROJECT_VIDEO_IDS, - type ProjectVideoAsset, -} from "@/lib/project-videos"; +import { getShowcaseAssets } from "@/lib/project-showcase"; import { ProjectVideo } from "./ProjectVideo"; let reducedMotion = false; @@ -13,59 +7,24 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({ useReducedMotion: () => reducedMotion, })); -const video: ProjectVideoAsset = { - 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; +const video = getShowcaseAssets("nutriscan", "desktop"); beforeEach(() => { 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, "pause").mockImplementation(() => {}); jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); }); afterEach(() => { - global.IntersectionObserver = originalObserver; jest.restoreAllMocks(); }); -function setVisible(isIntersecting: boolean) { - act(() => { - for (const callback of callbacks) { - callback( - [{ isIntersecting } as IntersectionObserverEntry], - {} as IntersectionObserver, - ); - } - }); -} - describe("ProjectVideo", () => { - it("renders a muted looping inline video that downloads nothing before it is visible", () => { - const { container } = render(); + it("renders a muted looping inline video that downloads nothing until it is active", () => { + const { container } = render( + , + ); const element = container.querySelector("video"); expect(element).not.toBeNull(); @@ -75,29 +34,47 @@ describe("ProjectVideo", () => { expect(element).toHaveAttribute("preload", "none"); expect(element).toHaveAttribute("poster", video.poster); expect(element?.muted).toBe(true); + expect(element).toHaveAttribute("data-ready", "false"); expect(container.querySelectorAll("source")).toHaveLength(0); expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled(); }); - it("attaches the sources and plays once the card enters the viewport, then pauses when it leaves", () => { - const { container } = render(); + it("attaches the sources and plays when it becomes the active card, then pauses when it stops being", () => { + const { container, rerender } = render( + , + ); - setVisible(true); + rerender(); const sources = [...container.querySelectorAll("source")]; - expect(sources.map((source) => source.getAttribute("src"))).toEqual([ - "/videos/demo/desktop.webm", - "/videos/demo/desktop.mp4", - ]); + expect(sources.map((source) => source.getAttribute("src"))).toEqual( + video.sources.map((source) => source.src), + ); expect(HTMLMediaElement.prototype.load).toHaveBeenCalled(); expect(HTMLMediaElement.prototype.play).toHaveBeenCalled(); - setVisible(false); + rerender(); 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( + , + ); + 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", () => { reducedMotion = true; - const { container } = render(); + const { container } = render( + , + ); expect(container.querySelector("video")).toBeNull(); expect( @@ -106,26 +83,3 @@ describe("ProjectVideo", () => { 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); - } - }, - ); -}); diff --git a/app/components/ProjectVideo.tsx b/app/components/ProjectVideo.tsx index b38adc8..2e11f24 100644 --- a/app/components/ProjectVideo.tsx +++ b/app/components/ProjectVideo.tsx @@ -3,52 +3,48 @@ import Image from "next/image"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; -import type { ProjectVideoAsset } from "@/lib/project-videos"; +import type { ShowcaseAssets } from "@/lib/project-showcase"; /** - * Vídeo de rolagem do projeto na capa do card. Nada de vídeo é baixado até o card entrar na tela - * (IntersectionObserver); fora da tela ele pausa. Com prefers-reduced-motion só o poster aparece. + * Vídeo de rolagem na capa do card. Quem decide se ele toca é a vitrine (`active`: só um card por vez, o que + * está sob o mouse ou o mais perto do centro da tela). Nada do vídeo é baixado antes da primeira vez que o + * card fica ativo; fora de cena ele pausa. Com prefers-reduced-motion só o poster aparece. + * O poster também fica por baixo (fundo da tela da moldura): o vídeo aparece em fade quando começa a tocar. */ export function ProjectVideo({ video, title, + active, }: { - video: ProjectVideoAsset; + video: ShowcaseAssets; title: string; + active: boolean; }) { const reducedMotion = useReducedMotion(); const ref = useRef(null); - const [activated, setActivated] = useState(false); + const [attached, setAttached] = useState(false); + const [ready, setReady] = useState(false); + const playing = active && !reducedMotion; + + useEffect(() => { + if (playing) setAttached(true); + }, [playing]); + + useEffect(() => { + // as fontes entram depois do primeiro render: o elemento precisa ler a lista nova + if (attached) ref.current?.load(); + }, [attached]); useEffect(() => { const element = ref.current; - if (reducedMotion || !element) return; - if (typeof IntersectionObserver === "undefined") { - setActivated(true); - return; + if (!element || !attached) return; + if (playing) { + element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real + element.play().catch(() => {}); + } else { + element.pause(); // também cancela um autoplay que ainda esperava o arquivo chegar } - const observer = new IntersectionObserver( - ([entry]) => { - if (entry?.isIntersecting) { - setActivated(true); - element.play().catch(() => {}); - } else { - element.pause(); - } - }, - { rootMargin: "120px 0px", threshold: 0.2 }, - ); - observer.observe(element); - return () => observer.disconnect(); - }, [reducedMotion]); - - useEffect(() => { - const element = ref.current; - if (!activated || reducedMotion || !element) return; - element.muted = true; // o React não reflete `muted` no DOM e o autoplay exige o valor real - element.load(); - element.play().catch(() => {}); - }, [activated, reducedMotion]); + }, [attached, playing]); if (reducedMotion) { return ( @@ -73,11 +69,13 @@ export function ProjectVideo({ poster={video.poster} width={video.width} height={video.height} + data-ready={ready} + onPlaying={() => setReady(true)} aria-label={`Demonstração em vídeo de ${title}`} disablePictureInPicture disableRemotePlayback > - {activated && + {attached && video.sources.map((source) => ( ))} diff --git a/app/components/showcase/BrowserFrame.test.tsx b/app/components/showcase/BrowserFrame.test.tsx new file mode 100644 index 0000000..84fc83e --- /dev/null +++ b/app/components/showcase/BrowserFrame.test.tsx @@ -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( + +

conteúdo

+
, + ); + + 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( + +

x

+
, + ); + 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( + +

x

+
, + ); + expect(screen.getByText("pré-visualização")).toBeInTheDocument(); + expect(container.querySelector(".ds-bframe-url svg")).toBeNull(); + + rerender( + +

x

+
, + ); + expect(container.querySelector(".ds-bframe-url svg")).toBeNull(); + }); +}); diff --git a/app/components/showcase/BrowserFrame.tsx b/app/components/showcase/BrowserFrame.tsx new file mode 100644 index 0000000..86866b2 --- /dev/null +++ b/app/components/showcase/BrowserFrame.tsx @@ -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 ( +
+ + {children} +
+ ); +} diff --git a/app/components/showcase/PhoneFrame.tsx b/app/components/showcase/PhoneFrame.tsx new file mode 100644 index 0000000..808e820 --- /dev/null +++ b/app/components/showcase/PhoneFrame.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} diff --git a/app/components/showcase/ProjectLightbox.test.tsx b/app/components/showcase/ProjectLightbox.test.tsx new file mode 100644 index 0000000..1ffb082 --- /dev/null +++ b/app/components/showcase/ProjectLightbox.test.tsx @@ -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 ( +
+
+ +
+ ); +} + +const projects = getFeaturedProjects(null); +const lavacao = getShowcaseConfig("lavacao-047"); + +function setup(id = "lavacao-047") { + render( + + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` })); + return screen.getByRole("dialog"); +} + +/** jsdom não carrega mídia: o teste dá ao