Compare commits
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -41,6 +41,30 @@ corepack [email protected] start
|
||||
| `pnpm test:watch` | Executa Jest em modo watch. |
|
||||
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
|
||||
|
||||
## Contagem de projetos do GitHub
|
||||
|
||||
O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHub (`lib/github-stats.ts`), consultada só no servidor e renovada uma vez por dia (`revalidate = 86400` em `app/page.tsx`). Conta repositórios próprios, públicos e privados, sem forks e sem arquivados.
|
||||
|
||||
Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece.
|
||||
|
||||
## Vitrine de vídeos dos projetos
|
||||
|
||||
Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`.
|
||||
|
||||
- **Cards.** O vídeo de rolagem fica numa moldura de navegador com o endereço real do site. Só um vídeo toca por vez: o do card sob o mouse (ou com foco) e, sem mouse, o mais perto do centro da tela. Nada é baixado até o card tocar pela primeira vez, e um botão pausa os vídeos que se mexem sozinhos. Clicar na moldura abre o vídeo ampliado (`<dialog>` nativo, com troca computador/celular que mantém o ponto do site, capítulos clicáveis e controles próprios).
|
||||
- **Vitrine animada.** Logo abaixo dos cards, uma cena presa na rolagem (`position: sticky`) em que o site de cada projeto rola dentro de um notebook, com um celular ao lado, conforme o visitante rola a página. Os capítulos de cada projeto (`chapters` em `lib/project-showcase.ts`, marcados nos quadros dos vídeos) nomeiam o trecho na tela e mantêm notebook e celular no mesmo ponto do site. Só o projeto em cena e os vizinhos baixam vídeo, e nada baixa com "economizar dados" ligado. Com `prefers-reduced-motion` ou sem JavaScript ela some e os cards continuam mostrando os posters.
|
||||
- **Capa de compartilhamento.** `/cases/<id>` usa o poster do projeto como fundo da imagem Open Graph (`lib/og-card.tsx`), quando existe.
|
||||
|
||||
Arquivos de cada projeto em `public/videos/<id>/`:
|
||||
|
||||
| Arquivo | Para quê |
|
||||
| --- | --- |
|
||||
| `desktop.{webm,mp4,webp}` e `mobile.{webm,mp4,webp}` | cards e vídeo ampliado (até 3 MB cada vídeo) |
|
||||
| `scrub-desktop.mp4` e `scrub-mobile.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem |
|
||||
| `og.jpg` | capa 1200x630 de compartilhamento |
|
||||
|
||||
Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs`; para gerar os `scrub-*.mp4`, `scripts/encode-scrub-video.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho de cada script). Depois de gravar, marque os capítulos do projeto em `lib/project-showcase.ts`.
|
||||
|
||||
## Stack
|
||||
|
||||
| Área | Pacotes usados |
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
|
||||
import {
|
||||
OG_CONTENT_TYPE,
|
||||
OG_SIZE,
|
||||
OgCard,
|
||||
readProjectOgBackground,
|
||||
} from "@/lib/og-card";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
|
||||
function getCaseStudy(slug: string) {
|
||||
@@ -46,6 +51,7 @@ export default async function Image({
|
||||
eyebrow="Case de engenharia de software"
|
||||
title={caseStudy.title}
|
||||
description={caseStudy.summary}
|
||||
backgroundImage={readProjectOgBackground(caseStudy.id)}
|
||||
/>,
|
||||
OG_SIZE,
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function generateMetadata({
|
||||
description: caseStudy.summary,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
locale: "pt_BR",
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
url,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
import Hero, { roundedRepoFloor } from "./Hero";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
@@ -81,4 +81,40 @@ describe("Hero", () => {
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the real GitHub repo count with the public/private breakdown", () => {
|
||||
render(<Hero stats={{ repos: { total: 68, public: 25, private: 43 } }} />);
|
||||
|
||||
expect(screen.getByText("projetos desenvolvidos")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mais de")).toBeInTheDocument();
|
||||
expect(screen.getByText("65")).toBeInTheDocument();
|
||||
expect(screen.getByText("25 públicos · 43 privados")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/projetos publicados/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/demonstração online/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rounds the repo count down so 'mais de' is always true", () => {
|
||||
expect(roundedRepoFloor(68)).toBe(65);
|
||||
expect(roundedRepoFloor(66)).toBe(65);
|
||||
expect(roundedRepoFloor(65)).toBe(60);
|
||||
expect(roundedRepoFloor(6)).toBe(5);
|
||||
expect(roundedRepoFloor(5)).toBe(0);
|
||||
});
|
||||
|
||||
it("hides the repo count while the total is too small to round", () => {
|
||||
render(<Hero stats={{ repos: { total: 4, public: 2, private: 2 } }} />);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/projetos desenvolvidos/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the repo count when the GitHub lookup is unavailable", () => {
|
||||
render(<Hero stats={{ years: 5 }} />);
|
||||
|
||||
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/projetos desenvolvidos/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,30 +17,61 @@ const SOCIAL_LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
|
||||
/**
|
||||
* Números reais, calculados em app/page.tsx (nada digitado à mão): os anos vêm de public/portfolio-data.json;
|
||||
* repos vem da API do GitHub (lib/github-stats.ts) e some quando nem a consulta nem o último retrato estão disponíveis.
|
||||
*/
|
||||
export type HeroStats = {
|
||||
years?: number;
|
||||
projects?: number;
|
||||
demos?: number;
|
||||
repos?: { total: number; public: number; private: number };
|
||||
};
|
||||
|
||||
function statItems(stats?: HeroStats) {
|
||||
type StatItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
prefix?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
/** "Mais de N" só é verdade se N for estritamente menor que o total: arredonda para baixo, em múltiplos de 5. */
|
||||
export function roundedRepoFloor(total: number) {
|
||||
return Math.floor((total - 1) / 5) * 5;
|
||||
}
|
||||
|
||||
function statItems(stats?: HeroStats): StatItem[] {
|
||||
if (!stats) return [];
|
||||
return [
|
||||
stats.years ? [String(stats.years), "anos de experiência"] : null,
|
||||
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
|
||||
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
|
||||
].filter((item): item is [string, string] => item !== null);
|
||||
const { repos } = stats;
|
||||
const repoFloor = repos ? roundedRepoFloor(repos.total) : 0;
|
||||
const items: Array<StatItem | null> = [
|
||||
stats.years
|
||||
? { value: String(stats.years), label: "anos de experiência" }
|
||||
: null,
|
||||
repos && repoFloor > 0
|
||||
? {
|
||||
prefix: "Mais de",
|
||||
value: String(repoFloor),
|
||||
label: "projetos desenvolvidos",
|
||||
detail: `${repos.public} públicos · ${repos.private} privados`,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
return items.filter((item): item is StatItem => item !== null);
|
||||
}
|
||||
|
||||
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
||||
const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" ");
|
||||
const TITLE =
|
||||
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
|
||||
" ",
|
||||
);
|
||||
const ACCENT = new Set([1, 2]);
|
||||
|
||||
/** O que cada camada da cena significa, em linguagem simples. */
|
||||
const CAPTIONS = [
|
||||
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
|
||||
["As integrações", "Os programas que você já usa conversando entre si, sem retrabalho."],
|
||||
[
|
||||
"As integrações",
|
||||
"Os programas que você já usa conversando entre si, sem retrabalho.",
|
||||
],
|
||||
["Os dados", "Suas informações guardadas, organizadas e seguras."],
|
||||
] as const;
|
||||
|
||||
@@ -119,12 +150,22 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||
{items.length > 0 && (
|
||||
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
|
||||
{items.map(([value, label]) => (
|
||||
{items.map(({ prefix, value, label, detail }) => (
|
||||
<div key={label} className="flex flex-col-reverse">
|
||||
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
|
||||
{label}
|
||||
{detail && (
|
||||
<span className="block text-[12px] text-[rgba(244,242,237,.55)]">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
|
||||
{prefix && (
|
||||
<span className="mr-sp-2 text-[13px] font-normal tracking-normal text-[rgba(244,242,237,.64)]">
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -151,9 +192,7 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={
|
||||
link.href.startsWith("http") ? "_blank" : undefined
|
||||
}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import Image from "next/image";
|
||||
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<string, string | undefined> = {
|
||||
@@ -31,8 +38,11 @@ export function ProjectCover({
|
||||
project: CoverProject;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const video = getProjectVideo(project.id);
|
||||
const src = SCREENSHOTS[project.id];
|
||||
|
||||
if (video) return <ProjectMedia project={project} />;
|
||||
|
||||
if (src) {
|
||||
return (
|
||||
<div className="ds-cover">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { ProjectVideo } from "./ProjectVideo";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const video = getShowcaseAssets("nutriscan", "desktop");
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectVideo", () => {
|
||||
it("renders a muted looping inline video that downloads nothing until it is active", () => {
|
||||
const { container } = render(
|
||||
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||
);
|
||||
const element = container.querySelector("video");
|
||||
|
||||
expect(element).not.toBeNull();
|
||||
expect(element).toHaveAttribute("autoplay");
|
||||
expect(element).toHaveAttribute("loop");
|
||||
expect(element).toHaveAttribute("playsinline");
|
||||
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 when it becomes the active card, then pauses when it stops being", () => {
|
||||
const { container, rerender } = render(
|
||||
<ProjectVideo video={video} title="Demo" active={false} />,
|
||||
);
|
||||
|
||||
rerender(<ProjectVideo video={video} title="Demo" active />);
|
||||
const sources = [...container.querySelectorAll("source")];
|
||||
expect(sources.map((source) => source.getAttribute("src"))).toEqual(
|
||||
video.sources.map((source) => source.src),
|
||||
);
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled();
|
||||
|
||||
rerender(<ProjectVideo video={video} title="Demo" active={false} />);
|
||||
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", () => {
|
||||
reducedMotion = true;
|
||||
const { container } = render(
|
||||
<ProjectVideo video={video} title="Demo" active />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Captura de tela de Demo" }),
|
||||
).toBeInTheDocument();
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { ShowcaseAssets } from "@/lib/project-showcase";
|
||||
|
||||
/**
|
||||
* 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: ShowcaseAssets;
|
||||
title: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
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 (!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
|
||||
}
|
||||
}, [attached, playing]);
|
||||
|
||||
if (reducedMotion) {
|
||||
return (
|
||||
<Image
|
||||
src={video.poster}
|
||||
alt={`Captura de tela de ${title}`}
|
||||
width={video.width}
|
||||
height={video.height}
|
||||
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
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
|
||||
>
|
||||
{attached &&
|
||||
video.sources.map((source) => (
|
||||
<source key={source.src} src={source.src} type={source.type} />
|
||||
))}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { ProjectCover } from "./ProjectCover";
|
||||
import { Section } from "./Section";
|
||||
import { ProjectReel } from "./showcase/ProjectReel";
|
||||
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
|
||||
|
||||
export default function Projects({
|
||||
projects,
|
||||
@@ -36,129 +38,139 @@ export default function Projects({
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="ds-grid-fluid">
|
||||
{projects.map((project) => {
|
||||
const caseStudy = caseStudies.find(
|
||||
(entry) => entry.id === project.id,
|
||||
);
|
||||
return (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: "some" }}
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<ProjectCover project={project} />
|
||||
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<div className="ds-grid-fluid">
|
||||
{projects.map((project) => {
|
||||
const caseStudy = caseStudies.find(
|
||||
(entry) => entry.id === project.id,
|
||||
);
|
||||
return (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: "some" }}
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<ProjectCover project={project} />
|
||||
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||
<div className="max-w-[560px]">
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<MagneticButton
|
||||
as="span"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||
>
|
||||
{project.status}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
<MagneticButton
|
||||
as="span"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||
>
|
||||
{project.status}
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">
|
||||
{caseStudy?.summary ?? project.description}
|
||||
</p>
|
||||
{caseStudy && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">O desafio</p>
|
||||
<p className="ds-copy">{caseStudy.impact}</p>
|
||||
</div>
|
||||
)}
|
||||
{caseStudy?.role && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">Minha participação</p>
|
||||
<p className="ds-copy">{caseStudy.role}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
|
||||
<p className="ds-copy">
|
||||
{caseStudy?.summary ?? project.description}
|
||||
</p>
|
||||
{caseStudy && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.2}
|
||||
>
|
||||
Entender a solução
|
||||
</MagneticButton>
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">O desafio</p>
|
||||
<p className="ds-copy">{caseStudy.impact}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver codigo
|
||||
</MagneticButton>
|
||||
{caseStudy?.role && (
|
||||
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||
<p className="ds-kicker">Minha participação</p>
|
||||
<p className="ds-copy">{caseStudy.role}</p>
|
||||
</div>
|
||||
)}
|
||||
{project.demoUrl &&
|
||||
(project.demoAvailability === "available" ||
|
||||
project.demoAvailability === "unknown") && (
|
||||
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div
|
||||
className="mt-sp-6 flex flex-wrap gap-sp-3"
|
||||
data-reel="top"
|
||||
>
|
||||
{caseStudy && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
strength={0.2}
|
||||
>
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
Entender a solução
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoAvailability === "unavailable" && (
|
||||
<span className="ds-link ds-link-accent" aria-disabled="true">
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{project.githubUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver codigo
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoUrl &&
|
||||
(project.demoAvailability === "available" ||
|
||||
project.demoAvailability === "unknown") && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir projeto
|
||||
{project.demoAvailability === "unknown" && (
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
Disponibilidade não verificada
|
||||
</span>
|
||||
)}
|
||||
</MagneticButton>
|
||||
)}
|
||||
{project.demoAvailability === "unavailable" && (
|
||||
<span
|
||||
className="ds-link ds-link-accent"
|
||||
aria-disabled="true"
|
||||
>
|
||||
Demo indisponível na última verificação
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>
|
||||
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
|
||||
<MagneticButton
|
||||
|
||||
@@ -11,20 +11,30 @@ describe("TextDiffuseReveal", () => {
|
||||
});
|
||||
|
||||
it("keeps the whole sentence readable once for assistive technology", () => {
|
||||
const { container } = render(<TextDiffuseReveal text="Software feito a partir do seu negócio" />);
|
||||
const { container } = render(
|
||||
<TextDiffuseReveal text="Software feito a partir do seu negócio" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Software feito a partir do seu negócio")).toHaveClass("sr-only");
|
||||
expect(
|
||||
screen.getByText("Software feito a partir do seu negócio"),
|
||||
).toHaveClass("sr-only");
|
||||
const words = container.querySelectorAll(".text-reveal-word");
|
||||
expect(words).toHaveLength(7);
|
||||
expect(words[0].closest("[aria-hidden='true']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the title as shown only after it enters the viewport", () => {
|
||||
const { container, rerender } = render(<TextDiffuseReveal text="Reveal me" />);
|
||||
expect(container.querySelector(".text-reveal")).not.toHaveAttribute("data-shown");
|
||||
const { container, rerender } = render(
|
||||
<TextDiffuseReveal text="Reveal me" />,
|
||||
);
|
||||
expect(container.querySelector(".text-reveal")).not.toHaveAttribute(
|
||||
"data-shown",
|
||||
);
|
||||
|
||||
mockInView = true;
|
||||
rerender(<TextDiffuseReveal text="Reveal me" />);
|
||||
expect(container.querySelector(".text-reveal")).toHaveAttribute("data-shown");
|
||||
expect(container.querySelector(".text-reveal")).toHaveAttribute(
|
||||
"data-shown",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,11 @@ export function TextDiffuseReveal({
|
||||
if (token === "\n") return <br key={key} />;
|
||||
if (/^\s+$/.test(token)) return " ";
|
||||
return (
|
||||
<span key={key} className="text-reveal-word" style={{ "--i": word++ } as CSSProperties}>
|
||||
<span
|
||||
key={key}
|
||||
className="text-reveal-word"
|
||||
style={{ "--i": word++ } as CSSProperties}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -51,9 +51,7 @@ describe("home fragment navigation", () => {
|
||||
restoredPosition = options.top ?? restoredPosition;
|
||||
});
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(restoredPosition).toBe(0);
|
||||
});
|
||||
|
||||
@@ -62,17 +60,13 @@ describe("home fragment navigation", () => {
|
||||
.spyOn(window, "scrollTo")
|
||||
.mockImplementation(() => {});
|
||||
const firstVisit = render(<HomeNavigation />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
firstVisit.unmount();
|
||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||
|
||||
scrollTo.mockClear();
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(window.history.scrollRestoration).not.toBe("manual");
|
||||
});
|
||||
@@ -87,9 +81,7 @@ describe("home fragment navigation", () => {
|
||||
render(<HomeNavigation />);
|
||||
position = 600;
|
||||
fireEvent.wheel(window, { deltaY: 600 });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(position).toBe(600);
|
||||
});
|
||||
|
||||
@@ -99,14 +91,10 @@ describe("home fragment navigation", () => {
|
||||
const contact = screen.getByRole("region", { name: "Contato" });
|
||||
expect(contact).not.toHaveFocus();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(contact).toHaveFocus();
|
||||
screen.getByRole("button", { name: "Read biography" }).focus();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read biography" }),
|
||||
).toHaveFocus();
|
||||
@@ -118,18 +106,14 @@ describe("home fragment navigation", () => {
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
fireEvent.wheel(window, { deltaY: 200 });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
const link = screen.getByRole("link", { name: "Sobre" });
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
@@ -151,9 +135,7 @@ describe("home fragment navigation", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
PortfolioProjectView,
|
||||
@@ -15,7 +16,6 @@ import LighthouseMetrics from "../LighthouseMetrics";
|
||||
import Projects from "../Projects";
|
||||
import Skills from "../Skills";
|
||||
import { WorkModalities } from "../WorkModalities";
|
||||
import { useEffect, useRef } from "react";
|
||||
import ExperienceDisclosure from "./ExperienceDisclosure";
|
||||
import { useCinematicScroll } from "./useCinematicScroll";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
@@ -48,7 +48,10 @@ export default function HeroField() {
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const rect = section.getBoundingClientRect();
|
||||
const span = Math.max(rect.height - window.innerHeight, window.innerHeight * 0.8);
|
||||
const span = Math.max(
|
||||
rect.height - window.innerHeight,
|
||||
window.innerHeight * 0.8,
|
||||
);
|
||||
const p = Math.min(1, Math.max(0, -rect.top / span));
|
||||
progressRef.current = p;
|
||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||
@@ -83,7 +86,9 @@ export default function HeroField() {
|
||||
if (!supportsWebGL2()) return;
|
||||
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
||||
if (typeof window.requestIdleCallback === "function") {
|
||||
const id = window.requestIdleCallback(() => setMount(true), { timeout: 2500 });
|
||||
const id = window.requestIdleCallback(() => setMount(true), {
|
||||
timeout: 2500,
|
||||
});
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const id = setTimeout(() => setMount(true), 1200);
|
||||
|
||||
@@ -42,7 +42,13 @@ class FieldBuilder {
|
||||
alpha: number[] = [];
|
||||
constructor(private density: number) {}
|
||||
|
||||
point(x: number, y: number, z: number, style: PointStyle = {}, to?: [number, number, number]) {
|
||||
point(
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
style: PointStyle = {},
|
||||
to?: [number, number, number],
|
||||
) {
|
||||
this.base.push(x, y, z);
|
||||
this.to.push(...(to ?? [x, y, z]));
|
||||
this.move.push(to ? 1 : 0);
|
||||
@@ -50,36 +56,89 @@ class FieldBuilder {
|
||||
this.size.push(style.size ?? 0.02);
|
||||
this.alpha.push(style.alpha ?? 0.7);
|
||||
}
|
||||
line(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
|
||||
line(
|
||||
y: Layer,
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
step: number,
|
||||
style?: PointStyle,
|
||||
) {
|
||||
const s = step * this.density;
|
||||
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
|
||||
for (let i = 0; i <= n; i++) this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
|
||||
for (let i = 0; i <= n; i++)
|
||||
this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
|
||||
}
|
||||
rect(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
|
||||
rect(
|
||||
y: Layer,
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
step: number,
|
||||
style?: PointStyle,
|
||||
) {
|
||||
this.line(y, x0, z0, x1, z0, step, style);
|
||||
this.line(y, x1, z0, x1, z1, step, style);
|
||||
this.line(y, x1, z1, x0, z1, step, style);
|
||||
this.line(y, x0, z1, x0, z0, step, style);
|
||||
}
|
||||
fill(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) {
|
||||
fill(
|
||||
y: Layer,
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
step: number,
|
||||
style?: PointStyle,
|
||||
) {
|
||||
const s = step * this.density;
|
||||
for (let x = x0; x <= x1 + 1e-6; x += s)
|
||||
for (let z = z0; z <= z1 + 1e-6; z += s)
|
||||
this.point(x + (Math.random() - 0.5) * s * 0.3, y, z + (Math.random() - 0.5) * s * 0.3, style);
|
||||
this.point(
|
||||
x + (Math.random() - 0.5) * s * 0.3,
|
||||
y,
|
||||
z + (Math.random() - 0.5) * s * 0.3,
|
||||
style,
|
||||
);
|
||||
}
|
||||
ring(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
|
||||
const n = Math.max(8, Math.round((Math.PI * 2 * r) / (step * this.density)));
|
||||
ring(
|
||||
y: Layer,
|
||||
cx: number,
|
||||
cz: number,
|
||||
r: number,
|
||||
step: number,
|
||||
style?: PointStyle,
|
||||
) {
|
||||
const n = Math.max(
|
||||
8,
|
||||
Math.round((Math.PI * 2 * r) / (step * this.density)),
|
||||
);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
|
||||
}
|
||||
}
|
||||
disc(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) {
|
||||
for (let rr = r; rr > 0.005; rr -= step * this.density) this.ring(y, cx, cz, rr, step, style);
|
||||
disc(
|
||||
y: Layer,
|
||||
cx: number,
|
||||
cz: number,
|
||||
r: number,
|
||||
step: number,
|
||||
style?: PointStyle,
|
||||
) {
|
||||
for (let rr = r; rr > 0.005; rr -= step * this.density)
|
||||
this.ring(y, cx, cz, rr, step, style);
|
||||
this.point(cx, y, cz, style);
|
||||
}
|
||||
/** partículas que correm de a até b (a posição é calculada no shader) */
|
||||
flow(a: [number, number, number], b: [number, number, number], count: number, style?: PointStyle) {
|
||||
flow(
|
||||
a: [number, number, number],
|
||||
b: [number, number, number],
|
||||
count: number,
|
||||
style?: PointStyle,
|
||||
) {
|
||||
const n = Math.max(2, Math.round(count / this.density));
|
||||
for (let i = 0; i < n; i++) this.point(...a, style, b);
|
||||
}
|
||||
@@ -91,12 +150,17 @@ function drawScreen(f: FieldBuilder) {
|
||||
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
|
||||
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
|
||||
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
|
||||
for (const x of [-1.9, -1.72, -1.54]) f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
|
||||
for (const x of [-1.9, -1.72, -1.54])
|
||||
f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
|
||||
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
|
||||
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
|
||||
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
|
||||
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
|
||||
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { tone: 1, alpha: 0.95, size: 0.022 }); // botão
|
||||
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, {
|
||||
tone: 1,
|
||||
alpha: 0.95,
|
||||
size: 0.022,
|
||||
}); // botão
|
||||
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
|
||||
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
|
||||
for (const [x0, x1] of [
|
||||
@@ -127,9 +191,18 @@ function drawIntegrations(f: FieldBuilder) {
|
||||
const ux = x / len;
|
||||
const uz = z / len;
|
||||
// linha da central até o sistema, e mensagens correndo nos dois sentidos
|
||||
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { alpha: 0.35 });
|
||||
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { tone: 1, alpha: 0.95, size: 0.026 });
|
||||
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { alpha: 0.9, size: 0.024 });
|
||||
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, {
|
||||
alpha: 0.35,
|
||||
});
|
||||
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, {
|
||||
tone: 1,
|
||||
alpha: 0.95,
|
||||
size: 0.026,
|
||||
});
|
||||
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, {
|
||||
alpha: 0.9,
|
||||
size: 0.024,
|
||||
});
|
||||
const [nx, nz] = NODES[(k + 1) % NODES.length];
|
||||
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
|
||||
});
|
||||
@@ -144,12 +217,15 @@ function drawData(f: FieldBuilder) {
|
||||
const cols = [x0, -0.9, 0.2, 1.1, x1];
|
||||
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
|
||||
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
|
||||
for (let z = z0 + 0.32; z < z1; z += 0.33) f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
|
||||
for (const x of cols.slice(1, -1)) f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
|
||||
for (let z = z0 + 0.32; z < z1; z += 0.33)
|
||||
f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
|
||||
for (const x of cols.slice(1, -1))
|
||||
f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
|
||||
for (let z = z0 + 0.48; z < z1; z += 0.33) {
|
||||
for (let c = 0; c < cols.length - 1; c++) {
|
||||
const start = cols[c] + 0.12;
|
||||
const width = (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
|
||||
const width =
|
||||
(cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
|
||||
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
|
||||
}
|
||||
}
|
||||
@@ -173,10 +249,16 @@ function buildField(dense: boolean) {
|
||||
const s = Math.sqrt(1 - u * u);
|
||||
const d = 4 + Math.random() * 7;
|
||||
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
|
||||
rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4);
|
||||
rand.set(
|
||||
[Math.random(), Math.random(), Math.random(), Math.random()],
|
||||
n * 4,
|
||||
);
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(f.base, 3));
|
||||
geometry.setAttribute(
|
||||
"position",
|
||||
new THREE.Float32BufferAttribute(f.base, 3),
|
||||
);
|
||||
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
|
||||
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
|
||||
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
|
||||
@@ -314,7 +396,12 @@ const LABEL_ANCHORS: [number, Layer, number][] = [
|
||||
[2, -1, 1.3],
|
||||
];
|
||||
|
||||
function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps) {
|
||||
function Field({
|
||||
progressRef,
|
||||
labelsRef,
|
||||
onReady,
|
||||
onError,
|
||||
}: HeroFieldSceneProps) {
|
||||
const { gl, camera, size } = useThree();
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const dense = useMemo(
|
||||
@@ -397,11 +484,14 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
|
||||
const time = frame.clock.elapsedTime;
|
||||
|
||||
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
|
||||
s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
s.progress +=
|
||||
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
|
||||
const p = s.progress;
|
||||
const aspect = size.width / size.height;
|
||||
const wide = aspect > 1.1;
|
||||
const tanHalf = Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2));
|
||||
const tanHalf = Math.tan(
|
||||
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
|
||||
);
|
||||
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
|
||||
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
|
||||
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
|
||||
@@ -416,7 +506,8 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
|
||||
u.uFade.value = 1 - smooth(0.93, 1, p);
|
||||
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
|
||||
u.uScale.value =
|
||||
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME);
|
||||
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
|
||||
Math.sqrt(frameDist / FRAME);
|
||||
|
||||
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
|
||||
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
|
||||
@@ -425,16 +516,22 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
|
||||
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
|
||||
g.rotation.set(
|
||||
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
|
||||
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + s.tilt.x * 0.15 * (1 - face),
|
||||
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
|
||||
s.tilt.x * 0.15 * (1 - face),
|
||||
0,
|
||||
);
|
||||
const settle = 1 - smooth(0, 0.28, p);
|
||||
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
|
||||
g.position.set(wide ? 1.7 * settle : 0, (wide ? -0.1 : -rest * tanHalf * 0.38) * settle, 0);
|
||||
g.position.set(
|
||||
wide ? 1.7 * settle : 0,
|
||||
(wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
|
||||
0,
|
||||
);
|
||||
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
|
||||
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
|
||||
const spread = u.uSpread.value;
|
||||
const ahead = [spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
|
||||
const ahead =
|
||||
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
|
||||
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
|
||||
g.updateMatrixWorld();
|
||||
|
||||
@@ -445,17 +542,24 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
|
||||
.applyMatrix4(g.matrixWorld);
|
||||
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
|
||||
g.worldToLocal(helpers.hit);
|
||||
s.mouse.lerp(helpers.target.set(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10));
|
||||
s.mouse.lerp(
|
||||
helpers.target.set(helpers.hit.x, helpers.hit.z),
|
||||
1 - Math.exp(-dt * 10),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
s.mouse.lerp(
|
||||
helpers.target.set(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.1),
|
||||
helpers.target.set(
|
||||
Math.sin(time * 0.31) * 1.5,
|
||||
Math.sin(time * 0.23 + 1.3) * 1.1,
|
||||
),
|
||||
1 - Math.exp(-dt * 1.5),
|
||||
);
|
||||
}
|
||||
u.uMouse.value.copy(s.mouse);
|
||||
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
|
||||
u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
u.uMouseAmt.value +=
|
||||
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
|
||||
|
||||
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
|
||||
const labels = labelsRef.current ?? [];
|
||||
@@ -463,7 +567,10 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps
|
||||
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
|
||||
const label = labels[index];
|
||||
if (!label) return;
|
||||
helpers.anchor.set(x, layer * u.uSpread.value, z).applyMatrix4(g.matrixWorld).project(camera);
|
||||
helpers.anchor
|
||||
.set(x, layer * u.uSpread.value, z)
|
||||
.applyMatrix4(g.matrixWorld)
|
||||
.project(camera);
|
||||
const px = ((helpers.anchor.x + 1) / 2) * size.width;
|
||||
const py = ((1 - helpers.anchor.y) / 2) * size.height;
|
||||
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
|
||||
@@ -505,7 +612,11 @@ export default function HeroFieldScene(props: HeroFieldSceneProps) {
|
||||
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
|
||||
dpr={[1, 1.75]}
|
||||
frameloop={props.visible ? "always" : "never"}
|
||||
gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }}
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<Field {...props} />
|
||||
</Canvas>
|
||||
|
||||
@@ -2,107 +2,141 @@
|
||||
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => void;
|
||||
type Transition = (
|
||||
tl: gsap.core.Timeline,
|
||||
prev: HTMLElement,
|
||||
next: HTMLElement,
|
||||
touch: boolean,
|
||||
) => void;
|
||||
|
||||
/*
|
||||
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
|
||||
* da rolagem (rolar para cima volta o "vídeo").
|
||||
* Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada
|
||||
* para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada
|
||||
* "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais.
|
||||
*
|
||||
* Quem segura as seções paradas durante uma passagem:
|
||||
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
|
||||
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
|
||||
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
|
||||
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
|
||||
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
|
||||
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
|
||||
*
|
||||
* Regras que mantêm tudo estável:
|
||||
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
|
||||
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
|
||||
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
|
||||
* - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências.
|
||||
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
|
||||
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
|
||||
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
|
||||
*/
|
||||
|
||||
const vh = () => window.innerHeight;
|
||||
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
|
||||
const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`;
|
||||
const visibleCenter = (el: HTMLElement) => () =>
|
||||
`50% ${el.offsetHeight - vh() / 2}px`;
|
||||
|
||||
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
|
||||
export function pageTop(el: HTMLElement) {
|
||||
function pageTop(el: HTMLElement) {
|
||||
let top = 0;
|
||||
for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null)
|
||||
for (
|
||||
let node: HTMLElement | null = el;
|
||||
node;
|
||||
node = node.offsetParent as HTMLElement | null
|
||||
)
|
||||
top += node.offsetTop;
|
||||
return top;
|
||||
}
|
||||
|
||||
// a seção que sai fica parada na tela enquanto a próxima entra
|
||||
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
|
||||
tl.fromTo(prev, { y: 0 }, { y: vh }, 0);
|
||||
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
|
||||
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
|
||||
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
|
||||
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
|
||||
|
||||
// uma linguagem diferente por passagem, na ordem das seções da home
|
||||
const TRANSITIONS: Transition[] = [
|
||||
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
|
||||
if (touch) {
|
||||
tl.fromTo(
|
||||
next,
|
||||
{
|
||||
opacity: 0.3,
|
||||
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
|
||||
},
|
||||
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
holdNext(tl, next);
|
||||
const band = (open: number) => () =>
|
||||
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
|
||||
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo(
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ opacity: 0, clipPath: band(0.36) },
|
||||
{ opacity: 1, clipPath: band(1), duration: 0.65 },
|
||||
0.35,
|
||||
);
|
||||
},
|
||||
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
const center = () => `at 50% ${vh() / 2}px`;
|
||||
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
{ scale: 1.18, opacity: 0.2 },
|
||||
0,
|
||||
)
|
||||
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
|
||||
.fromTo(
|
||||
next,
|
||||
{ clipPath: () => `circle(0px ${center()})` },
|
||||
{
|
||||
clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
|
||||
ease: "power1.in",
|
||||
},
|
||||
0,
|
||||
);
|
||||
},
|
||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo(
|
||||
);
|
||||
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
|
||||
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
|
||||
const radius = () =>
|
||||
touch
|
||||
? Math.hypot(window.innerWidth / 2, vh())
|
||||
: Math.hypot(window.innerWidth / 2, vh() / 2);
|
||||
if (!touch) holdNext(tl, next);
|
||||
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ xPercent: 100 },
|
||||
{ xPercent: 0, ease: "power2.out" },
|
||||
{ clipPath: () => `circle(0px ${center()})` },
|
||||
{
|
||||
clipPath: () => `circle(${radius()}px ${center()})`,
|
||||
ease: "power1.in",
|
||||
},
|
||||
0,
|
||||
);
|
||||
},
|
||||
// IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece.
|
||||
// modalidades → IA: corte lateral, uma tela empurra a outra
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ xPercent: 0, opacity: 1 },
|
||||
{ xPercent: -35, opacity: 0.3 },
|
||||
0,
|
||||
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
|
||||
},
|
||||
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
|
||||
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
|
||||
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
|
||||
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
|
||||
next,
|
||||
{ yPercent: 0, y: () => vh() * 0.35 },
|
||||
{ y: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
(tl, prev, next, touch) => {
|
||||
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
|
||||
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
|
||||
if (!touch)
|
||||
tl.fromTo(
|
||||
next,
|
||||
{ y: () => vh() * 0.35 },
|
||||
{ y: 0, ease: "power2.out" },
|
||||
0,
|
||||
);
|
||||
},
|
||||
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 0).fromTo(
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ opacity: 1, xPercent: 0 },
|
||||
{ opacity: 0.25, xPercent: 20 },
|
||||
0,
|
||||
).fromTo(
|
||||
next,
|
||||
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
|
||||
{ xPercent: 0, rotation: 0, ease: "power2.out" },
|
||||
@@ -110,9 +144,8 @@ const TRANSITIONS: Transition[] = [
|
||||
);
|
||||
},
|
||||
// sobre → contato: o contato cresce do centro, como um zoom de câmera
|
||||
(tl, prev, next) => {
|
||||
holdPrev(tl, prev);
|
||||
holdNext(tl, next);
|
||||
(tl, prev, next, touch) => {
|
||||
if (!touch) holdNext(tl, next);
|
||||
tl.fromTo(
|
||||
prev,
|
||||
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
|
||||
@@ -122,9 +155,9 @@ const TRANSITIONS: Transition[] = [
|
||||
.fromTo(
|
||||
next,
|
||||
{
|
||||
scale: 0.55,
|
||||
scale: touch ? 0.8 : 0.55,
|
||||
clipPath: "inset(0% round 56px)",
|
||||
transformOrigin: () => `50% ${vh() / 2}px`,
|
||||
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
|
||||
},
|
||||
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
|
||||
0,
|
||||
@@ -133,7 +166,8 @@ const TRANSITIONS: Transition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
||||
const easeInOutCubic = (t: number) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
|
||||
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
@@ -144,11 +178,12 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
let cleanup = () => {};
|
||||
|
||||
(async () => {
|
||||
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
import("lenis"),
|
||||
]);
|
||||
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
|
||||
await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
import("lenis"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -158,12 +193,35 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
gsap.ticker.add(tick);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
|
||||
const touch = window.matchMedia(
|
||||
"(hover: none) and (pointer: coarse)",
|
||||
).matches;
|
||||
const scenes = Array.from(main.children) as HTMLElement[];
|
||||
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
|
||||
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
|
||||
const sceneTops = () => {
|
||||
let top = pageTop(main);
|
||||
return scenes.map((scene) => {
|
||||
const current = top;
|
||||
top += scene.offsetHeight;
|
||||
return current;
|
||||
});
|
||||
};
|
||||
const stick = () => {
|
||||
for (const scene of scenes)
|
||||
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
|
||||
};
|
||||
|
||||
const context = gsap.context(() => {
|
||||
scenes.forEach((scene, index) => {
|
||||
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
|
||||
gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 });
|
||||
gsap.set(scene, {
|
||||
position: "sticky",
|
||||
zIndex: index === 0 ? 30 : index + 1,
|
||||
});
|
||||
});
|
||||
stick();
|
||||
TRANSITIONS.forEach((transition, index) => {
|
||||
const prev = scenes[index];
|
||||
const next = scenes[index + 1];
|
||||
@@ -172,8 +230,8 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
scrollTrigger: {
|
||||
trigger: next,
|
||||
start: () => pageTop(next) - vh(),
|
||||
end: () => pageTop(next),
|
||||
start: () => sceneTops()[index + 1] - vh(),
|
||||
end: () => sceneTops()[index + 1],
|
||||
scrub: true,
|
||||
invalidateOnRefresh: true,
|
||||
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
|
||||
@@ -182,30 +240,48 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
onEnter: () => next.classList.add("is-drawing"),
|
||||
},
|
||||
});
|
||||
transition(tl, prev, next);
|
||||
transition(tl, prev, next, touch);
|
||||
});
|
||||
}, main);
|
||||
|
||||
// Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na
|
||||
// tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior;
|
||||
// depois não aparece atrás de transparências, e o navegador não pinta o que está coberto.
|
||||
let tops: number[] = [];
|
||||
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
|
||||
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
|
||||
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
|
||||
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
|
||||
let frame = 0;
|
||||
const syncVisibility = () => {
|
||||
frame = 0;
|
||||
const y = window.scrollY;
|
||||
const tops = sceneTops();
|
||||
scenes.forEach((scene, index) => {
|
||||
const from = index === 0 ? -Infinity : tops[index] - vh();
|
||||
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
|
||||
const from =
|
||||
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
|
||||
const to =
|
||||
index === scenes.length - 1
|
||||
? Infinity
|
||||
: tops[index + 1] + vh() * 0.25;
|
||||
const visibility = y >= from && y <= to ? "" : "hidden";
|
||||
if (scene.style.visibility !== visibility) scene.style.visibility = visibility;
|
||||
if (scene.style.visibility !== visibility)
|
||||
scene.style.visibility = visibility;
|
||||
});
|
||||
};
|
||||
const measure = () => {
|
||||
tops = scenes.map(pageTop);
|
||||
syncVisibility();
|
||||
const queueSync = () => {
|
||||
if (!frame) frame = requestAnimationFrame(syncVisibility);
|
||||
};
|
||||
measure();
|
||||
ScrollTrigger.addEventListener("refresh", measure);
|
||||
window.addEventListener("scroll", syncVisibility, { passive: true });
|
||||
const onRefresh = () => {
|
||||
stick();
|
||||
queueSync();
|
||||
};
|
||||
syncVisibility();
|
||||
ScrollTrigger.addEventListener("refresh", onRefresh);
|
||||
window.addEventListener("scroll", queueSync, { passive: true });
|
||||
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
|
||||
});
|
||||
resizeObserver.observe(main);
|
||||
|
||||
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
|
||||
const onClick = (event: MouseEvent) => {
|
||||
@@ -218,26 +294,52 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||
if (!(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self")) return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const url = new URL(link.href);
|
||||
if (url.origin !== location.origin || url.pathname !== location.pathname || !url.hash)
|
||||
if (
|
||||
url.origin !== location.origin ||
|
||||
url.pathname !== location.pathname ||
|
||||
!url.hash
|
||||
)
|
||||
return;
|
||||
let target: HTMLElement | null = null;
|
||||
try {
|
||||
target = document.getElementById(decodeURIComponent(url.hash.slice(1)));
|
||||
target = document.getElementById(
|
||||
decodeURIComponent(url.hash.slice(1)),
|
||||
);
|
||||
} catch {}
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
|
||||
// alvos internos descontam o cabeçalho fixo
|
||||
const scene = scenes.find((candidate) => candidate.contains(target));
|
||||
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
|
||||
const index = scenes.findIndex((candidate) =>
|
||||
candidate.contains(target),
|
||||
);
|
||||
const offset =
|
||||
Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).scrollPaddingTop,
|
||||
) || 0;
|
||||
const sceneTop = index < 0 ? 0 : sceneTops()[index];
|
||||
const top =
|
||||
!scene || scene === scenes[0]
|
||||
index <= 0
|
||||
? 0
|
||||
: Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset);
|
||||
: Math.max(
|
||||
sceneTop,
|
||||
scenes[index] === target
|
||||
? 0
|
||||
: sceneTop +
|
||||
pageTop(target) -
|
||||
pageTop(scenes[index]) -
|
||||
offset,
|
||||
);
|
||||
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
|
||||
lenis.scrollTo(top, {
|
||||
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
|
||||
@@ -253,10 +355,16 @@ export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("click", onClick, true);
|
||||
ScrollTrigger.removeEventListener("refresh", measure);
|
||||
window.removeEventListener("scroll", syncVisibility);
|
||||
ScrollTrigger.removeEventListener("refresh", onRefresh);
|
||||
window.removeEventListener("scroll", queueSync);
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(refreshTimer);
|
||||
resizeObserver.disconnect();
|
||||
context.revert();
|
||||
for (const scene of scenes) scene.style.visibility = "";
|
||||
for (const scene of scenes) {
|
||||
scene.style.visibility = "";
|
||||
scene.style.top = "";
|
||||
}
|
||||
gsap.ticker.remove(tick);
|
||||
lenis.destroy();
|
||||
};
|
||||
|
||||
@@ -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,374 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { reelOffset, videoProgress } from "./showcase-math";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const PIN = 800;
|
||||
const CONTAINER = 7000;
|
||||
const SCROLLABLE = CONTAINER - PIN;
|
||||
const HEADER = 60;
|
||||
let reelTop = HEADER;
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const weights = [
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
|
||||
|
||||
const originalRect = Element.prototype.getBoundingClientRect;
|
||||
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
if (this.classList.contains("ds-reel")) return CONTAINER;
|
||||
if (this.classList.contains("ds-reel-pin")) return PIN;
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||
if (this.classList.contains("ds-reel"))
|
||||
return {
|
||||
top: reelTop,
|
||||
bottom: reelTop + CONTAINER,
|
||||
height: CONTAINER,
|
||||
left: 0,
|
||||
right: 1000,
|
||||
width: 1000,
|
||||
x: 0,
|
||||
y: reelTop,
|
||||
} as DOMRect;
|
||||
return originalRect.call(this);
|
||||
};
|
||||
window.scrollTo = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Element.prototype.getBoundingClientRect = originalRect;
|
||||
if (originalOffsetHeight)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
originalOffsetHeight,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
reelTop = HEADER;
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderReel() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** jsdom não carrega mídia: cada <video> da vitrine ganha duração e prontidão para aceitar buscas. */
|
||||
function readyVideos() {
|
||||
for (const video of document.querySelectorAll("video")) {
|
||||
Object.defineProperty(video, "readyState", {
|
||||
configurable: true,
|
||||
value: 4,
|
||||
});
|
||||
Object.defineProperty(video, "duration", { configurable: true, value: 30 });
|
||||
let time = 0;
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
configurable: true,
|
||||
get: () => time,
|
||||
set: (value: number) => {
|
||||
time = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTo(progress: number) {
|
||||
reelTop = HEADER - progress * SCROLLABLE;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
}
|
||||
|
||||
const sources = () =>
|
||||
[...document.querySelectorAll(".ds-reel-layer video")].map((video) =>
|
||||
video.getAttribute("src"),
|
||||
);
|
||||
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
describe("ProjectReel", () => {
|
||||
it("renders nothing for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
renderReel();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("lists every project by name, starting on the first with its chapters", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
for (const name of [
|
||||
"SEV Moda Íntima",
|
||||
"Lavação 047",
|
||||
"NutriScan",
|
||||
"SC Plus Aquicultura",
|
||||
"AS Serviços",
|
||||
])
|
||||
expect(within(nav).getByRole("button", { name })).toBeInTheDocument();
|
||||
expect(
|
||||
within(nav).getByRole("button", { name: "SEV Moda Íntima" }),
|
||||
).toHaveAttribute("aria-current", "true");
|
||||
expect(
|
||||
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
|
||||
renderReel();
|
||||
const layers = [...document.querySelectorAll(".ds-reel-layer")];
|
||||
expect(layers).toHaveLength(5);
|
||||
for (const layer of layers.slice(1)) {
|
||||
expect(layer).toHaveAttribute("aria-hidden", "true");
|
||||
expect(layer).toHaveAttribute("inert");
|
||||
}
|
||||
expect(layers[0]).not.toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
// meio do trecho da Lavação (segundo projeto)
|
||||
const t = reelOffset(weights, 1, 0.5);
|
||||
scrollTo(t);
|
||||
|
||||
const layer = activeLayer();
|
||||
expect(layer).toHaveTextContent("Lavação 047");
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const p = videoProgress(0.5);
|
||||
const desktop = layer.querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
const phone = layer.querySelector(
|
||||
".ds-phone-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
|
||||
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
|
||||
});
|
||||
|
||||
it("names the chapter on screen and counts them", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
|
||||
|
||||
const layer = activeLayer();
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
||||
/^0\d\/06$/,
|
||||
);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
layer.querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the first and last frame at the ends of the pinned range", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
scrollTo(0);
|
||||
const first = document.querySelector(
|
||||
'.ds-reel-layer[data-state="active"] .ds-reel-screen video',
|
||||
) as HTMLVideoElement;
|
||||
expect(first.currentTime).toBe(0);
|
||||
|
||||
scrollTo(1);
|
||||
expect(activeLayer()).toHaveTextContent("AS Serviços");
|
||||
const last = activeLayer().querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(last.currentTime).toBeCloseTo(30, 1);
|
||||
});
|
||||
|
||||
it("downloads nothing while the page is far from the reel", () => {
|
||||
reelTop = 6000;
|
||||
renderReel();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
let loaded = sources().filter(Boolean);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
|
||||
).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
||||
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
||||
|
||||
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
||||
scrollTo(1);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
loaded = sources().filter(Boolean);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("as-servicos/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("only downloads where the scroll settles, not every project it flies past", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
// atravessa vários projetos em menos de 300 ms
|
||||
for (const project of [1, 2, 3]) {
|
||||
reelTop = HEADER - reelOffset(weights, project, 0.5) * SCROLLABLE;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(60);
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(true); // vizinho do projeto onde parou
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not download the hidden laptop video on a phone-sized screen", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("max-width: 639px"),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
try {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loaded.some((src) => src?.includes("scrub-desktop.mp4"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("respects the data saver: no scroll video is downloaded", () => {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: { saveData: true },
|
||||
});
|
||||
try {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
|
||||
const html = renderToString(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(html).toContain("ds-reel-placeholder");
|
||||
expect(html).toContain("--reel-sum");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).not.toContain("<video");
|
||||
});
|
||||
|
||||
it("scrolls to the start of a project when its button is pressed", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: /NutriScan/ }));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1200);
|
||||
});
|
||||
|
||||
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||
const last = calls[calls.length - 1][0] as { top: number };
|
||||
const expected =
|
||||
HEADER - HEADER + reelOffset(weights, 2, 0) * SCROLLABLE + 2;
|
||||
expect(last.top).toBeCloseTo(expected, 0);
|
||||
});
|
||||
|
||||
it("scrolls to a chapter inside the project when its link is pressed", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ }));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1200);
|
||||
});
|
||||
|
||||
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||
const last = calls[calls.length - 1][0] as { top: number };
|
||||
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
|
||||
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import {
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import {
|
||||
easeToward,
|
||||
locateInReel,
|
||||
REEL_HOLD,
|
||||
reelOffset,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
|
||||
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
||||
let headerCache = { width: -1, height: 60 };
|
||||
const headerHeight = () => {
|
||||
if (headerCache.width !== window.innerWidth)
|
||||
headerCache = {
|
||||
width: window.innerWidth,
|
||||
height:
|
||||
Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--site-header-height",
|
||||
),
|
||||
) || 60,
|
||||
};
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */
|
||||
function usePhoneOnly() {
|
||||
const [phoneOnly, setPhoneOnly] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia("(max-width: 639px)");
|
||||
const update = () => setPhoneOnly(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
return phoneOnly;
|
||||
}
|
||||
|
||||
/** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */
|
||||
const saveDataOn = () =>
|
||||
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection
|
||||
?.saveData === true;
|
||||
|
||||
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
|
||||
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||
if (!video || video.readyState < 1 || !video.duration) return;
|
||||
const target = Math.min(
|
||||
video.duration - 0.001,
|
||||
Math.max(0, fraction * video.duration),
|
||||
);
|
||||
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
|
||||
video.currentTime = target;
|
||||
}
|
||||
|
||||
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
|
||||
function prime(video: HTMLVideoElement) {
|
||||
if (video.dataset.primed) return;
|
||||
video.dataset.primed = "true";
|
||||
video
|
||||
.play()
|
||||
.then(() => video.pause())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
let stopScroll = () => {};
|
||||
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
|
||||
function scrollToSmoothly(top: number) {
|
||||
stopScroll();
|
||||
const from = window.scrollY;
|
||||
const distance = top - from;
|
||||
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
|
||||
const started = performance.now();
|
||||
let id = 0;
|
||||
const stop = () => {
|
||||
cancelAnimationFrame(id);
|
||||
window.removeEventListener("wheel", stop);
|
||||
window.removeEventListener("touchstart", stop);
|
||||
};
|
||||
stopScroll = stop;
|
||||
window.addEventListener("wheel", stop, { passive: true });
|
||||
window.addEventListener("touchstart", stop, { passive: true });
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - started) / duration);
|
||||
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
|
||||
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
|
||||
if (t < 1) id = requestAnimationFrame(step);
|
||||
else stop();
|
||||
};
|
||||
id = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
|
||||
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
|
||||
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
|
||||
* mostram o mesmo trecho do site ao mesmo tempo.
|
||||
*
|
||||
* Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel.
|
||||
*/
|
||||
export function ProjectReel() {
|
||||
const items = useShowcaseItems();
|
||||
const openProject = useShowcaseOpen();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [chapter, setChapter] = useState(0);
|
||||
const [near, setNear] = useState(false);
|
||||
const [settled, setSettled] = useState(0);
|
||||
const [saveData, setSaveData] = useState(false);
|
||||
const phoneOnly = usePhoneOnly();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
const indexRef = useRef<HTMLOListElement>(null);
|
||||
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
const phoneVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
const bars = useRef<Array<HTMLElement | null>>([]);
|
||||
const live = useRef({
|
||||
target: [] as number[],
|
||||
smooth: [] as number[],
|
||||
active: 0,
|
||||
chapter: 0,
|
||||
raf: 0,
|
||||
last: 0,
|
||||
near: false,
|
||||
});
|
||||
|
||||
const weights = useMemo(
|
||||
() => items.map((item) => item.config.weight),
|
||||
[items],
|
||||
);
|
||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
|
||||
useEffect(() => {
|
||||
setSaveData(saveDataOn());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por
|
||||
// um projeto no clique do índice não pode baixar todos os do caminho)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettled(active), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active]);
|
||||
|
||||
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
|
||||
useEffect(() => {
|
||||
const list = indexRef.current;
|
||||
const button = list?.children[active]?.querySelector("button");
|
||||
if (
|
||||
!list ||
|
||||
!button ||
|
||||
list.scrollWidth <= list.clientWidth ||
|
||||
typeof list.scrollTo !== "function"
|
||||
)
|
||||
return;
|
||||
list.scrollTo({
|
||||
left: Math.max(0, button.offsetLeft - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [active]);
|
||||
|
||||
const isAttached = useCallback(
|
||||
(index: number) => near && !saveData && Math.abs(index - settled) <= 1,
|
||||
[near, saveData, settled],
|
||||
);
|
||||
|
||||
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
||||
useEffect(() => {
|
||||
items.forEach((_, index) => {
|
||||
if (isAttached(index)) return;
|
||||
for (const video of [
|
||||
desktopVideos.current[index],
|
||||
phoneVideos.current[index],
|
||||
])
|
||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||
video.load();
|
||||
});
|
||||
}, [items, isAttached]);
|
||||
|
||||
const frame = useCallback(
|
||||
(now: number) => {
|
||||
const state = live.current;
|
||||
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
||||
state.last = now;
|
||||
let moving = false;
|
||||
items.forEach((item, index) => {
|
||||
if (Math.abs(index - state.active) > 1) return;
|
||||
const before = state.smooth[index] ?? 0;
|
||||
const next = easeToward(before, state.target[index] ?? 0, seconds);
|
||||
state.smooth[index] = next;
|
||||
if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true;
|
||||
seekVideo(desktopVideos.current[index], next);
|
||||
seekVideo(
|
||||
phoneVideos.current[index],
|
||||
mobileFractionAt(item.config, next),
|
||||
);
|
||||
const bar = bars.current[index];
|
||||
if (bar) bar.style.setProperty("--p", next.toFixed(4));
|
||||
});
|
||||
const current = items[state.active];
|
||||
if (current) {
|
||||
const index = chapterIndexAt(
|
||||
current.config,
|
||||
state.smooth[state.active] ?? 0,
|
||||
);
|
||||
if (index !== state.chapter) {
|
||||
state.chapter = index;
|
||||
setChapter(index);
|
||||
}
|
||||
}
|
||||
state.raf = moving ? requestAnimationFrame(frame) : 0;
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||
const syncVideos = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
const at = live.current.smooth[index] ?? live.current.target[index] ?? 0;
|
||||
seekVideo(desktopVideos.current[index], at);
|
||||
if (item)
|
||||
seekVideo(
|
||||
phoneVideos.current[index],
|
||||
mobileFractionAt(item.config, at),
|
||||
);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const pin = pinRef.current;
|
||||
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
|
||||
if (!container || !pin || items.length === 0 || !container.offsetHeight)
|
||||
return;
|
||||
const state = live.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollable = rect.height - pin.offsetHeight;
|
||||
const progress =
|
||||
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
|
||||
const { index, local } = locateInReel(weights, progress);
|
||||
state.target = items.map((_, i) =>
|
||||
i < index ? 1 : i === index ? videoProgress(local) : 0,
|
||||
);
|
||||
const near =
|
||||
rect.top < window.innerHeight * 2 && rect.bottom > -window.innerHeight;
|
||||
if (near !== state.near) {
|
||||
state.near = near;
|
||||
setNear(near);
|
||||
}
|
||||
|
||||
if (index !== state.active) {
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
setActive(index);
|
||||
setChapter(state.chapter);
|
||||
}
|
||||
if (state.near && !state.raf) {
|
||||
state.last = 0;
|
||||
state.raf = requestAnimationFrame(frame);
|
||||
}
|
||||
}, [frame, items, weights]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || reducedMotion || items.length === 0) return;
|
||||
let pending = 0;
|
||||
const schedule = () => {
|
||||
if (!pending)
|
||||
pending = requestAnimationFrame(() => {
|
||||
pending = 0;
|
||||
measure();
|
||||
});
|
||||
};
|
||||
schedule();
|
||||
window.addEventListener("scroll", schedule, { passive: true });
|
||||
window.addEventListener("resize", schedule);
|
||||
const state = live.current;
|
||||
return () => {
|
||||
window.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
cancelAnimationFrame(pending);
|
||||
cancelAnimationFrame(state.raf);
|
||||
state.raf = 0;
|
||||
};
|
||||
}, [mounted, reducedMotion, items.length, measure]);
|
||||
|
||||
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||
const scrollToReel = (index: number, local: number) => {
|
||||
const container = containerRef.current;
|
||||
const pin = pinRef.current;
|
||||
if (!container || !pin) return;
|
||||
const scrollable = container.offsetHeight - pin.offsetHeight;
|
||||
const top = container.getBoundingClientRect().top + window.scrollY;
|
||||
// 2px além: garante que a conta caia dentro do trecho
|
||||
scrollToSmoothly(
|
||||
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
|
||||
);
|
||||
};
|
||||
|
||||
const goTo = (index: number) => scrollToReel(index, 0);
|
||||
const goToChapter = (index: number, chapterIndex: number) => {
|
||||
const fraction = chapterFraction(
|
||||
items[index].config,
|
||||
chapterIndex,
|
||||
"desktop",
|
||||
);
|
||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||
};
|
||||
|
||||
if (items.length === 0 || (mounted && reducedMotion)) return null;
|
||||
|
||||
// antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine
|
||||
// aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com
|
||||
// prefers-reduced-motion e em telas baixas
|
||||
if (!mounted)
|
||||
return (
|
||||
<div
|
||||
className="ds-reel ds-reel-placeholder"
|
||||
aria-hidden="true"
|
||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
|
||||
const current = items[active];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="ds-reel"
|
||||
aria-label="Vitrine animada dos projetos"
|
||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="ds-reel-pin" ref={pinRef}>
|
||||
<div className="ds-reel-grid">
|
||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||
<p className="ds-reel-hint">
|
||||
<span aria-hidden="true" className="ds-reel-hint-line" />
|
||||
Em movimento
|
||||
<span className="ds-reel-hint-sub">Role para avançar</span>
|
||||
</p>
|
||||
<ol ref={indexRef}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item.config.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-label={item.project.title}
|
||||
onClick={() => goTo(index)}
|
||||
>
|
||||
<span className="ds-reel-num">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="ds-reel-name">{item.project.title}</span>
|
||||
<i
|
||||
className="ds-reel-bar"
|
||||
aria-hidden="true"
|
||||
ref={(element) => {
|
||||
bars.current[index] = element;
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{index === active && (
|
||||
<ol
|
||||
className="ds-reel-chapters"
|
||||
aria-label={`Trechos de ${item.project.title}`}
|
||||
>
|
||||
{item.config.chapters.map((entry, i) => (
|
||||
<li key={entry.label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={i === chapter ? "true" : undefined}
|
||||
onClick={() => goToChapter(index, i)}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="ds-reel-stage">
|
||||
{items.map((item, index) => {
|
||||
const { config, project } = item;
|
||||
const isActive = index === active;
|
||||
const attach = isAttached(index);
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className="ds-reel-layer"
|
||||
data-state={
|
||||
isActive ? "active" : index < active ? "before" : "after"
|
||||
}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
aria-hidden={isActive ? undefined : true}
|
||||
inert={!isActive}
|
||||
>
|
||||
<div className="ds-reel-set">
|
||||
<div className="ds-reel-devices">
|
||||
<BrowserFrame
|
||||
url={displayUrl(project)}
|
||||
className="ds-reel-laptop"
|
||||
>
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={{ backgroundImage: `url(${desktop.poster})` }}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
||||
<video
|
||||
ref={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach && !phoneOnly
|
||||
? getScrubSource(config.id, "desktop")
|
||||
: undefined
|
||||
}
|
||||
poster={desktop.poster}
|
||||
muted
|
||||
playsInline
|
||||
preload={attach && !phoneOnly ? "auto" : "none"}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={() => syncVideos(index)}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={phone.poster}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
||||
<video
|
||||
ref={(element) => {
|
||||
phoneVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach
|
||||
? getScrubSource(config.id, "mobile")
|
||||
: undefined
|
||||
}
|
||||
poster={phone.poster}
|
||||
muted
|
||||
playsInline
|
||||
preload={attach ? "auto" : "none"}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={() => syncVideos(index)}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
|
||||
<div className="ds-reel-caption">
|
||||
<p className="ds-kicker">
|
||||
{project.category} · {project.status}
|
||||
</p>
|
||||
<h3 className="ds-reel-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="ds-reel-sub">{project.subtitle}</p>
|
||||
)}
|
||||
<div className="ds-reel-chapter">
|
||||
<span className="ds-reel-chapter-count">
|
||||
{String((isActive ? chapter : 0) + 1).padStart(
|
||||
2,
|
||||
"0",
|
||||
)}
|
||||
/{String(config.chapters.length).padStart(2, "0")}
|
||||
</span>
|
||||
<strong>
|
||||
{config.chapters[isActive ? chapter : 0].label}
|
||||
</strong>
|
||||
<ol className="ds-reel-ticks" aria-hidden="true">
|
||||
{config.chapters.map((entry, tick) => (
|
||||
<li
|
||||
key={entry.label}
|
||||
data-state={
|
||||
!isActive || tick > chapter
|
||||
? "todo"
|
||||
: tick === chapter
|
||||
? "now"
|
||||
: "done"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="ds-reel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ds-link ds-link-accent"
|
||||
onClick={() => openProject(config.id)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
Ampliar vídeo
|
||||
</button>
|
||||
{project.demoUrl &&
|
||||
project.demoAvailability !== "unavailable" && (
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir projeto
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
(abre em nova aba)
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{current
|
||||
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -290,9 +290,11 @@ textarea {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */
|
||||
@container (min-width: 880px) {
|
||||
/* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */
|
||||
@container (min-width: 880px) {
|
||||
.ds-section-meta {
|
||||
max-width: none;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
@@ -983,7 +985,11 @@ textarea {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(55% 65% at 78% 38%, rgba(204, 255, 0, 0.11), transparent 70%),
|
||||
radial-gradient(
|
||||
55% 65% at 78% 38%,
|
||||
rgba(204, 255, 0, 0.11),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
@@ -1007,7 +1013,9 @@ textarea {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0 0 / 22px 22px;
|
||||
background:
|
||||
radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0
|
||||
0 / 22px 22px;
|
||||
mask: radial-gradient(38% 46% at 74% 50%, #000, transparent 75%);
|
||||
transition: opacity 1.2s var(--ease-out);
|
||||
}
|
||||
@@ -1032,11 +1040,20 @@ textarea {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(10, 10, 10, 0.86) 0%, rgba(10, 10, 10, 0.55) 36%, transparent 62%),
|
||||
linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%);
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(10, 10, 10, 0.86) 0%,
|
||||
rgba(10, 10, 10, 0.55) 36%,
|
||||
transparent 62%
|
||||
), linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%);
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
background: linear-gradient(0deg, rgba(10, 10, 10, 0.92) 0 30%, rgba(10, 10, 10, 0.5) 65%, rgba(10, 10, 10, 0.7));
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(10, 10, 10, 0.92) 0 30%,
|
||||
rgba(10, 10, 10, 0.5) 65%,
|
||||
rgba(10, 10, 10, 0.7)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,7 +1175,11 @@ textarea {
|
||||
pointer-events: none;
|
||||
/* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */
|
||||
background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%);
|
||||
opacity: clamp(0, min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14), 1);
|
||||
opacity: clamp(
|
||||
0,
|
||||
min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14),
|
||||
1
|
||||
);
|
||||
|
||||
& li {
|
||||
--mid: calc((var(--from) + var(--to)) / 2);
|
||||
@@ -1169,7 +1190,16 @@ textarea {
|
||||
letter-spacing: -0.035em;
|
||||
text-align: center;
|
||||
text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9);
|
||||
opacity: clamp(0, min((var(--hero-p) - var(--from)) * 14, (var(--to) - var(--hero-p)) * 14), 1);
|
||||
opacity: clamp(
|
||||
0,
|
||||
min(
|
||||
(var(--hero-p) - var(--from)) *
|
||||
14,
|
||||
(var(--to) - var(--hero-p)) *
|
||||
14
|
||||
),
|
||||
1
|
||||
);
|
||||
translate: 0 calc((var(--hero-p) - var(--mid)) * -160px);
|
||||
}
|
||||
|
||||
@@ -1203,7 +1233,8 @@ textarea {
|
||||
|
||||
/* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e perde o foco */
|
||||
.hero-content {
|
||||
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08));
|
||||
transform: translate3d(0, calc(var(--hero-p) * -16vh), 0)
|
||||
scale(calc(1 - var(--hero-p) * 0.08));
|
||||
transform-origin: 0 50%;
|
||||
opacity: calc(1 - var(--hero-p) * 4.2);
|
||||
filter: blur(calc(var(--hero-p) * 18px));
|
||||
@@ -1253,7 +1284,12 @@ html.lenis {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--ink);
|
||||
|
||||
& img {
|
||||
& video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
& img,
|
||||
& video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -1261,7 +1297,8 @@ html.lenis {
|
||||
transition: transform 0.7s var(--ease-out);
|
||||
}
|
||||
|
||||
.ds-card:hover & img {
|
||||
.ds-card:hover & img,
|
||||
.ds-card:hover & video {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
@@ -1277,8 +1314,10 @@ html.lenis {
|
||||
color: var(--paper);
|
||||
background:
|
||||
radial-gradient(120% 90% at 100% 0%, var(--slash-dim), transparent 60%),
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100% 32px,
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 32px 100%,
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100%
|
||||
32px,
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 /
|
||||
32px 100%,
|
||||
var(--ink);
|
||||
|
||||
& .ds-cover-mark {
|
||||
@@ -1486,16 +1525,25 @@ html.lenis {
|
||||
aparecem uma única vez, sem acompanhar cada quadro da rolagem */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* só com as transições ativas (html.lenis): sem o script, o diagrama aparece normalmente */
|
||||
html.lenis .quality-lab:not(.is-drawing) :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
|
||||
html.lenis .quality-lab:not(.is-drawing)
|
||||
:is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.quality-lab.is-drawing .quality-blueprint :is(.quality-dimensions path, .quality-browser-frame, .quality-wire, .quality-browser-button) {
|
||||
.quality-lab.is-drawing
|
||||
.quality-blueprint
|
||||
:is(
|
||||
.quality-dimensions path,
|
||||
.quality-browser-frame,
|
||||
.quality-wire,
|
||||
.quality-browser-button
|
||||
) {
|
||||
stroke-dasharray: 1800; /* maior que o maior contorno (moldura: ~1600) */
|
||||
animation: quality-draw 1.1s var(--ease-out) both;
|
||||
}
|
||||
|
||||
.quality-lab.is-drawing :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
|
||||
.quality-lab.is-drawing
|
||||
:is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) {
|
||||
animation: quality-fade 0.6s var(--ease-out) both;
|
||||
}
|
||||
}
|
||||
@@ -1786,14 +1834,17 @@ html.lenis {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||
::view-transition-group(page) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||
::view-transition-old(page) {
|
||||
animation: page-out 280ms cubic-bezier(0.7, 0, 0.84, 0) both;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||
::view-transition-new(page) {
|
||||
animation: page-in 640ms var(--ease-out) 160ms both;
|
||||
}
|
||||
@@ -1815,7 +1866,12 @@ html.lenis {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(page),
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||
::view-transition-old(page) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
||||
::view-transition-new(page) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { loadGithubRepoCounts } from "@/lib/github-stats";
|
||||
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||
|
||||
/** A contagem do GitHub é consultada no servidor e renovada uma vez por dia. */
|
||||
export const revalidate = 86_400;
|
||||
|
||||
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
|
||||
function yearsOfExperience() {
|
||||
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
|
||||
@@ -8,16 +12,15 @@ function yearsOfExperience() {
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const projects = await loadFeaturedProjects();
|
||||
const [projects, repos] = await Promise.all([
|
||||
loadFeaturedProjects(),
|
||||
loadGithubRepoCounts(),
|
||||
]);
|
||||
return (
|
||||
<CinematicHome
|
||||
heroStats={{
|
||||
years: yearsOfExperience(),
|
||||
projects: projects.length,
|
||||
demos: projects.filter(
|
||||
(project) =>
|
||||
project.demoUrl && project.demoAvailability !== "unavailable",
|
||||
).length,
|
||||
repos: repos ?? undefined,
|
||||
}}
|
||||
projects={projects}
|
||||
experiences={portfolio.experiences}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function generateMetadata({
|
||||
canonical: url,
|
||||
},
|
||||
openGraph: {
|
||||
locale: "pt_BR",
|
||||
locale: "pt_BR",
|
||||
title: service.seoTitle,
|
||||
description: service.metaDescription,
|
||||
url,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ViewTransition } from "react";
|
||||
|
||||
/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */
|
||||
/**
|
||||
* O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css).
|
||||
* update="none": a hidratação e a revelação de Suspense também são transições do React e animariam a página
|
||||
* inteira na chegada (a tela piscava duas vezes); só a troca de página anima.
|
||||
*/
|
||||
export default function Template({ children }: { children: React.ReactNode }) {
|
||||
return <ViewTransition name="page">{children}</ViewTransition>;
|
||||
return (
|
||||
<ViewTransition name="page" update="none">
|
||||
{children}
|
||||
</ViewTransition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"checkedAt": "2026-10-08T18:25:53Z",
|
||||
"total": 68,
|
||||
"public": 25,
|
||||
"private": 43
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
countOwnRepos,
|
||||
GITHUB_STATS_REVALIDATE_SECONDS,
|
||||
loadGithubRepoCounts,
|
||||
loadGithubStatsSnapshot,
|
||||
} from "./github-stats";
|
||||
|
||||
const repo = (
|
||||
overrides: Partial<{ private: boolean; fork: boolean; archived: boolean }>,
|
||||
) => ({ private: false, fork: false, archived: false, ...overrides });
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
const noSnapshot = async () => null;
|
||||
const snapshot = { total: 68, public: 25, private: 43 };
|
||||
|
||||
describe("countOwnRepos", () => {
|
||||
it("counts public and private repos and ignores forks and archived ones", () => {
|
||||
const counts = countOwnRepos([
|
||||
repo({}),
|
||||
repo({}),
|
||||
repo({ private: true }),
|
||||
repo({ fork: true }),
|
||||
repo({ private: true, fork: true }),
|
||||
repo({ archived: true }),
|
||||
repo({ private: true, archived: true }),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 3, public: 2, private: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadGithubRepoCounts", () => {
|
||||
it("returns null without a token and without a snapshot, and never calls the API", async () => {
|
||||
const fetchImpl = jest.fn();
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts(undefined, fetchImpl, noSnapshot),
|
||||
).resolves.toBeNull();
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the last known snapshot without a token", async () => {
|
||||
const fetchImpl = jest.fn();
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts(undefined, fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual(snapshot);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the live count over the snapshot", async () => {
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse([repo({}), repo({ private: true })]));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual({ total: 2, public: 1, private: 1 });
|
||||
});
|
||||
|
||||
it("sends the token only to the GitHub API and revalidates daily", async () => {
|
||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse([repo({})]));
|
||||
|
||||
await loadGithubRepoCounts("secret-token", fetchImpl, noSnapshot);
|
||||
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(new URL(url).origin).toBe("https://api.github.com");
|
||||
expect(url).toContain("affiliation=owner");
|
||||
expect(url).toContain("visibility=all");
|
||||
expect(init.headers.authorization).toBe("Bearer secret-token");
|
||||
expect(init.next.revalidate).toBe(GITHUB_STATS_REVALIDATE_SECONDS);
|
||||
expect(GITHUB_STATS_REVALIDATE_SECONDS).toBe(86_400);
|
||||
});
|
||||
|
||||
it("walks every page before counting", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, () => repo({}));
|
||||
const secondPage = [repo({ private: true }), repo({ fork: true })];
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(firstPage))
|
||||
.mockResolvedValueOnce(jsonResponse(secondPage));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("token", fetchImpl, noSnapshot),
|
||||
).resolves.toEqual({
|
||||
total: 101,
|
||||
public: 100,
|
||||
private: 1,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the snapshot when the API rejects the token", async () => {
|
||||
const fetchImpl = jest
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("bad", fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("uses the snapshot on network failure instead of breaking the page", async () => {
|
||||
const fetchImpl = jest.fn().mockRejectedValue(new Error("offline"));
|
||||
|
||||
await expect(
|
||||
loadGithubRepoCounts("token", fetchImpl, async () => snapshot),
|
||||
).resolves.toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadGithubStatsSnapshot", () => {
|
||||
it("reads a coherent snapshot from data/github-stats.json", async () => {
|
||||
const counts = await loadGithubStatsSnapshot();
|
||||
|
||||
expect(counts).not.toBeNull();
|
||||
expect(counts?.total).toBe((counts?.public ?? 0) + (counts?.private ?? 0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Contagem de repositórios próprios no GitHub (públicos + privados), consultada só no servidor.
|
||||
* O token (GITHUB_STATS_TOKEN, fine-grained, somente leitura de metadados) nunca recebe prefixo NEXT_PUBLIC_
|
||||
* e este módulo só é importado por componentes de servidor: ele não chega ao navegador.
|
||||
*
|
||||
* Sem token, ou se a API falhar, vale o último retrato conhecido em data/github-stats.json.
|
||||
*/
|
||||
|
||||
export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60;
|
||||
|
||||
const REPOS_ENDPOINT = "https://api.github.com/user/repos";
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 20;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface GithubRepoCounts {
|
||||
total: number;
|
||||
public: number;
|
||||
private: number;
|
||||
}
|
||||
|
||||
interface RepoSummary {
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
type CachedRequestInit = RequestInit & { next: { revalidate: number } };
|
||||
type FetchLike = (url: string, init: CachedRequestInit) => Promise<Response>;
|
||||
|
||||
/** Só repositórios meus: forks e arquivados ficam de fora. */
|
||||
export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts {
|
||||
let publicCount = 0;
|
||||
let privateCount = 0;
|
||||
for (const repo of repos) {
|
||||
if (repo.fork || repo.archived) continue;
|
||||
if (repo.private) privateCount += 1;
|
||||
else publicCount += 1;
|
||||
}
|
||||
return {
|
||||
total: publicCount + privateCount,
|
||||
public: publicCount,
|
||||
private: privateCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGithubRepoCounts(
|
||||
token: string | undefined,
|
||||
fetchImpl: FetchLike,
|
||||
): Promise<GithubRepoCounts | null> {
|
||||
if (!token) return null;
|
||||
|
||||
const repos: RepoSummary[] = [];
|
||||
try {
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
const url = `${REPOS_ENDPOINT}?affiliation=owner&visibility=all&per_page=${PAGE_SIZE}&page=${page}`;
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"user-agent": "evandro-dev-portfolio",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
next: { revalidate: GITHUB_STATS_REVALIDATE_SECONDS },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const batch = (await response.json()) as RepoSummary[];
|
||||
if (!Array.isArray(batch)) return null;
|
||||
repos.push(
|
||||
...batch.map(({ private: isPrivate, fork, archived }) => ({
|
||||
private: isPrivate,
|
||||
fork,
|
||||
archived,
|
||||
})),
|
||||
);
|
||||
if (batch.length < PAGE_SIZE) return countOwnRepos(repos);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Último retrato conhecido (data/github-stats.json); `null` se o arquivo faltar ou estiver incoerente. */
|
||||
export async function loadGithubStatsSnapshot(): Promise<GithubRepoCounts | null> {
|
||||
try {
|
||||
const path = resolve(process.cwd(), "data/github-stats.json");
|
||||
const raw = JSON.parse(await readFile(path, "utf8")) as GithubRepoCounts;
|
||||
const counts = [raw.total, raw.public, raw.private];
|
||||
if (!counts.every((n) => Number.isInteger(n) && n >= 0)) return null;
|
||||
if (raw.total !== raw.public + raw.private) return null;
|
||||
return { total: raw.total, public: raw.public, private: raw.private };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadGithubRepoCounts(
|
||||
token: string | undefined = process.env.GITHUB_STATS_TOKEN,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
readSnapshot: () => Promise<GithubRepoCounts | null> = loadGithubStatsSnapshot,
|
||||
): Promise<GithubRepoCounts | null> {
|
||||
return (await fetchGithubRepoCounts(token, fetchImpl)) ?? readSnapshot();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { OgCard, readProjectOgBackground } from "./og-card";
|
||||
|
||||
describe("readProjectOgBackground", () => {
|
||||
it("returns the recorded cover of a showcase project as a JPEG data URI", () => {
|
||||
const uri = readProjectOgBackground("nutriscan");
|
||||
expect(uri).toMatch(/^data:image\/jpeg;base64,/);
|
||||
expect((uri ?? "").length).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
it("returns undefined for projects without a recorded cover", () => {
|
||||
expect(readProjectOgBackground("twins-lab")).toBeUndefined();
|
||||
expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OgCard", () => {
|
||||
const props = { eyebrow: "Case", title: "Título", description: "Resumo." };
|
||||
|
||||
it("keeps the plain dark card when there is no background", () => {
|
||||
const { container } = render(<OgCard {...props} />);
|
||||
// só a marca
|
||||
expect(container.querySelectorAll("img")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("puts the project cover behind the text with a darkening gradient", () => {
|
||||
const { container } = render(
|
||||
<OgCard {...props} backgroundImage="data:image/jpeg;base64,AAAA" />,
|
||||
);
|
||||
const images = [...container.querySelectorAll("img")];
|
||||
expect(images).toHaveLength(2);
|
||||
expect(images[0]).toHaveAttribute("src", "data:image/jpeg;base64,AAAA");
|
||||
expect(images[0]).toHaveAttribute("alt", "");
|
||||
expect(container.innerHTML).toContain("linear-gradient");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase";
|
||||
|
||||
const brandMark = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/brand/mark-dark.png"),
|
||||
@@ -8,14 +9,25 @@ const brandMark = `data:image/png;base64,${readFileSync(
|
||||
export const OG_SIZE = { width: 1200, height: 630 } as const;
|
||||
export const OG_CONTENT_TYPE = "image/png";
|
||||
|
||||
/** Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), como data URI; `undefined` se não existir. */
|
||||
export function readProjectOgBackground(id: string): string | undefined {
|
||||
if (!PROJECT_VIDEO_IDS.includes(id)) return undefined;
|
||||
const path = join(process.cwd(), "public", getOgImagePath(id));
|
||||
if (!existsSync(path)) return undefined;
|
||||
return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`;
|
||||
}
|
||||
|
||||
export function OgCard({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
backgroundImage,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Imagem de fundo (data URI), escurecida da esquerda para a direita para o texto continuar legível. */
|
||||
backgroundImage?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -32,6 +44,30 @@ export function OgCard({
|
||||
fontFamily: "Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{backgroundImage && (
|
||||
<>
|
||||
{/* biome-ignore lint/performance/noImgElement: o ImageResponse (satori) só desenha <img> */}
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
width={OG_SIZE.width}
|
||||
height={OG_SIZE.height}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: OG_SIZE.width,
|
||||
height: OG_SIZE.height,
|
||||
display: "flex",
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(5,5,5,0.97) 0%, rgba(5,5,5,0.9) 48%, rgba(5,5,5,0.58) 100%)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<img
|
||||
src={brandMark}
|
||||
alt="Evandro.dev.br"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -75,7 +75,9 @@ it("requires sources for factual statements and resolves every source ID", () =>
|
||||
for (const id of ["reddit-explorer", "gcc-installer"] as const) {
|
||||
const { boundaries } = getDetail(id);
|
||||
expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0);
|
||||
expect(boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0)).toBe(true);
|
||||
expect(
|
||||
boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,16 @@ const nextConfig: NextConfig = {
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
{
|
||||
// vídeos da vitrine (46 MB): sem o hash no nome, um dia de cache e revalidação em segundo plano na semana seguinte
|
||||
source: "/videos/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=86400, stale-while-revalidate=604800",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
"@tailwindcss/oxide",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"brace-expansion@<1.1.21": "1.1.21",
|
||||
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -32,7 +36,7 @@
|
||||
"lenis": "^1.3.26",
|
||||
"lucide-react": "0.542.0",
|
||||
"motion": "12.23.12",
|
||||
"next": "16.3.3",
|
||||
"next": "16.3.8",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
|
||||
@@ -4,6 +4,10 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
brace-expansion@<1.1.21: 1.1.21
|
||||
brace-expansion@>=2.0.0 <2.1.7: 2.1.7
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -30,8 +34,8 @@ importers:
|
||||
specifier: 12.23.12
|
||||
version: 12.23.12([email protected]([email protected]))([email protected])
|
||||
next:
|
||||
specifier: 16.3.3
|
||||
version: 16.3.3(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
@@ -706,53 +710,53 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/[email protected].3':
|
||||
resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==}
|
||||
'@next/[email protected].8':
|
||||
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1254,11 +1258,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
[email protected]8:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
[email protected].21:
|
||||
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
|
||||
@@ -2094,8 +2098,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==}
|
||||
[email protected].8:
|
||||
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3433,30 +3437,30 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3': {}
|
||||
'@next/[email protected].8': {}
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@next/[email protected].3':
|
||||
'@next/[email protected].8':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/[email protected]':
|
||||
@@ -3950,12 +3954,12 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
[email protected]8:
|
||||
[email protected].21:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
[email protected].4:
|
||||
[email protected].7:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -4887,11 +4891,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
brace-expansion: 1.1.21
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.4
|
||||
brace-expansion: 2.1.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4925,9 +4929,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].3(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
[email protected].8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@next/env': 16.3.3
|
||||
'@next/env': 16.3.8
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.21
|
||||
caniuse-lite: 1.0.30001810
|
||||
@@ -4936,14 +4940,14 @@ snapshots:
|
||||
react-dom: 19.1.0([email protected])
|
||||
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.3
|
||||
'@next/swc-darwin-x64': 16.3.3
|
||||
'@next/swc-linux-arm64-gnu': 16.3.3
|
||||
'@next/swc-linux-arm64-musl': 16.3.3
|
||||
'@next/swc-linux-x64-gnu': 16.3.3
|
||||
'@next/swc-linux-x64-musl': 16.3.3
|
||||
'@next/swc-win32-arm64-msvc': 16.3.3
|
||||
'@next/swc-win32-x64-msvc': 16.3.3
|
||||
'@next/swc-darwin-arm64': 16.3.8
|
||||
'@next/swc-darwin-x64': 16.3.8
|
||||
'@next/swc-linux-arm64-gnu': 16.3.8
|
||||
'@next/swc-linux-arm64-musl': 16.3.8
|
||||
'@next/swc-linux-x64-gnu': 16.3.8
|
||||
'@next/swc-linux-x64-musl': 16.3.8
|
||||
'@next/swc-win32-arm64-msvc': 16.3.8
|
||||
'@next/swc-win32-x64-msvc': 16.3.8
|
||||
sharp: 0.35.4(@types/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
@@ -94,6 +94,41 @@
|
||||
"teamSize": "Individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lavacao-047",
|
||||
"title": "Lavação 047",
|
||||
"subtitle": "Site e painel para lavação automotiva",
|
||||
"description": "Site da Lavação 047, em Garuva-SC, com tabela de preços por porte do veículo, pedido direto pelo WhatsApp, mapa de localização e painel do dono para editar preços, textos e fotos sem novo deploy.",
|
||||
"longDescription": "Landing page em Next.js 16 com cena de rolagem do sujo ao brilho, galeria de trabalhos por serviço e FAQ montado a partir dos dados reais do negócio. O conteúdo salvo no painel alimenta a página, os dados estruturados, o sitemap e o cartão de compartilhamento. Respeita prefers-reduced-motion e guarda conteúdo e imagens em Redis e Blob privado, com arquivo local como alternativa.",
|
||||
"technologies": [
|
||||
"Next.js 16",
|
||||
"React 19",
|
||||
"TypeScript",
|
||||
"Tailwind CSS 4",
|
||||
"Motion",
|
||||
"Upstash Redis",
|
||||
"Vercel Blob"
|
||||
],
|
||||
"features": [
|
||||
"Tabela de preços por porte do veículo",
|
||||
"Pedido pelo WhatsApp com mensagem pronta",
|
||||
"Painel do dono para preços, textos, fotos e mapa",
|
||||
"Galeria de trabalhos por serviço",
|
||||
"SEO com JSON-LD, sitemap e llms.txt",
|
||||
"Mapa de localização com OpenStreetMap"
|
||||
],
|
||||
"category": "web",
|
||||
"level": "senior",
|
||||
"status": "Em Produção",
|
||||
"demoUrl": "https://landing-page-lavacao047.vercel.app",
|
||||
"githubUrl": null,
|
||||
"badges": ["Site e painel", "Em Produção"],
|
||||
"featured": true,
|
||||
"metrics": {
|
||||
"complexity": 4,
|
||||
"teamSize": "Individual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nutriscan",
|
||||
"title": "NutriScan",
|
||||
@@ -155,7 +190,13 @@
|
||||
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
|
||||
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
|
||||
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
|
||||
"technologies": ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
|
||||
"technologies": [
|
||||
"Astro",
|
||||
"MongoDB",
|
||||
"Mongoose",
|
||||
"TypeScript",
|
||||
"Reddit API"
|
||||
],
|
||||
"features": [
|
||||
"Coleta dos posts em alta do r/artificial",
|
||||
"Upsert por id no MongoDB",
|
||||
@@ -181,7 +222,12 @@
|
||||
"subtitle": "Landing page institucional em Next.js 15",
|
||||
"description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
|
||||
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
|
||||
"technologies": ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
|
||||
"technologies": [
|
||||
"Next.js 15",
|
||||
"React 19",
|
||||
"Tailwind CSS 4",
|
||||
"TypeScript"
|
||||
],
|
||||
"features": [
|
||||
"Landing page institucional",
|
||||
"Metadados e URL canônica",
|
||||
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Recodifica um vídeo para a vitrine animada, em que o tempo do vídeo muda a cada quadro da rolagem.
|
||||
* Para a busca ser instantânea o vídeo precisa de um quadro-chave a cada 6 quadros e nenhum B-frame; o tamanho
|
||||
* de cada arquivo fica em até 3 MB (sobe o CRF e, se preciso, reduz a imagem até caber).
|
||||
*
|
||||
* ffmpeg-static não é dependência do site: instale-o numa pasta temporária e rode o script a partir dela
|
||||
* (as dependências são resolvidas a partir da pasta onde o comando foi executado):
|
||||
*
|
||||
* mkdir rec && cd rec && npm init -y && npm i ffmpeg-static
|
||||
* node <repo>/scripts/encode-scrub-video.mjs --in desktop-original.mp4 --out <repo>/public/videos/<id>/scrub-desktop.mp4 --size 1120x700
|
||||
* node <repo>/scripts/encode-scrub-video.mjs --in mobile-original.mp4 --out <repo>/public/videos/<id>/scrub-mobile.mp4 --size 390x844
|
||||
*
|
||||
* Use como entrada o vídeo de melhor qualidade que existir (a entrada de record-project-videos.mjs antes do corte
|
||||
* para MP4/WebM), não os MP4 já comprimidos de public/videos.
|
||||
*
|
||||
* A capa de compartilhamento (og.jpg, 1200x630) sai do poster desktop:
|
||||
* ffmpeg -i public/videos/<id>/desktop.webp -vf "scale=1200:750:flags=lanczos,crop=1200:630:0:0" -q:v 4 public/videos/<id>/og.jpg
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { statSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
|
||||
const require = createRequire(join(process.cwd(), "noop.js"));
|
||||
const ffmpegPath = require("ffmpeg-static");
|
||||
|
||||
const args = Object.fromEntries(
|
||||
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
||||
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
|
||||
return acc;
|
||||
}, []),
|
||||
);
|
||||
const { in: input, out: output } = args;
|
||||
const [width, height] = (args.size ?? "1120x700").split("x").map(Number);
|
||||
const MAX_BYTES = Number(args["max-mb"] ?? 3) * 1024 * 1024;
|
||||
|
||||
if (!input || !output || !width || !height) {
|
||||
console.error(
|
||||
"uso: --in <video> --out <video.mp4> --size 1120x700 [--max-mb 3]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function encode(w, h, crf) {
|
||||
const result = spawnSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
input,
|
||||
"-vf",
|
||||
`scale=${w}:${h}:flags=lanczos,fps=24`,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-g",
|
||||
"6",
|
||||
"-keyint_min",
|
||||
"6",
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-bf",
|
||||
"0",
|
||||
"-refs",
|
||||
"1",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (result.status !== 0) throw new Error(result.stderr);
|
||||
return statSync(output).size;
|
||||
}
|
||||
|
||||
for (const scale of [1, 0.9, 0.8]) {
|
||||
const w = Math.round((width * scale) / 2) * 2;
|
||||
const h = Math.round((height * scale) / 2) * 2;
|
||||
for (const crf of [28, 31, 34, 37]) {
|
||||
const bytes = encode(w, h, crf);
|
||||
if (bytes <= MAX_BYTES) {
|
||||
console.log(
|
||||
`${output}: ${w}x${h}, crf ${crf}, ${Math.round(bytes / 1024)} KB`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error("não coube no limite de tamanho, nem reduzindo a imagem");
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Grava um vídeo de rolagem em velocidade constante de uma página (Playwright, headless com GPU) e codifica
|
||||
* MP4 (H.264), WebM (VP9) e um poster WebP, cada vídeo com até 3 MB. É assim que nascem os arquivos de public/videos/<id>/.
|
||||
*
|
||||
* Playwright e ffmpeg não são dependências do site. Instale-os numa pasta temporária e rode o script a partir dela:
|
||||
*
|
||||
* mkdir rec && cd rec && npm init -y && npm i playwright ffmpeg-static && npx playwright install chromium
|
||||
* node <repo>/scripts/record-project-videos.mjs --slug lavacao-047 --url https://exemplo.com --out ./out
|
||||
*
|
||||
* Opções: --viewports desktop,mobile (1440x900 e 390x844), --speed 300 (px/s), --max 30 (segundos), --settle 3000 (ms),
|
||||
* --software 1 (rasterização por software; perde quadros em páginas pesadas).
|
||||
*
|
||||
* O relógio do vídeo do Playwright não acompanha o relógio real, então o corte entre o aquecimento da página e a
|
||||
* rolagem usa quadros magenta (início) e ciano (fim) injetados na própria página e procurados no vídeo.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
|
||||
// resolve playwright e ffmpeg-static a partir da pasta onde o comando foi executado, não da pasta do script
|
||||
const require = createRequire(join(process.cwd(), "noop.js"));
|
||||
const ffmpegPath = require("ffmpeg-static");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const args = Object.fromEntries(
|
||||
process.argv.slice(2).reduce((acc, cur, i, all) => {
|
||||
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
|
||||
return acc;
|
||||
}, []),
|
||||
);
|
||||
const slug = args.slug;
|
||||
const url = args.url;
|
||||
const outRoot = args.out;
|
||||
const viewportNames = (args.viewports ?? "desktop,mobile").split(",");
|
||||
const SPEED = Number(args.speed ?? 300); // px/s
|
||||
const MAX_SECONDS = Number(args.max ?? 30);
|
||||
const SETTLE_MS = Number(args.settle ?? 3000);
|
||||
const MAX_BYTES = 3 * 1024 * 1024;
|
||||
const FPS = 30;
|
||||
|
||||
const VIEWPORTS = {
|
||||
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
|
||||
mobile: { width: 390, height: 844, isMobile: true, hasTouch: true },
|
||||
};
|
||||
const MOBILE_UA =
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const outDir = join(outRoot, slug);
|
||||
const rawRoot = join(outRoot, ".raw", slug);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
function ff(argv) {
|
||||
const r = spawnSync(
|
||||
ffmpegPath,
|
||||
["-hide_banner", "-loglevel", "error", "-y", ...argv],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (r.status !== 0) throw new Error(`ffmpeg falhou: ${r.stderr}`);
|
||||
}
|
||||
|
||||
async function dismissPopups(page) {
|
||||
return page.evaluate(() => {
|
||||
const done = [];
|
||||
const roots = document.querySelectorAll(
|
||||
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]',
|
||||
);
|
||||
for (const root of roots) {
|
||||
const rect = root.getBoundingClientRect();
|
||||
const style = getComputedStyle(root);
|
||||
if (rect.width < 80 || rect.height < 24) continue;
|
||||
if (
|
||||
style.visibility === "hidden" ||
|
||||
style.display === "none" ||
|
||||
Number(style.opacity) === 0
|
||||
)
|
||||
continue;
|
||||
if (!["fixed", "sticky"].includes(style.position)) continue;
|
||||
const buttons = [
|
||||
...root.querySelectorAll('button,a[role="button"],[role="button"]'),
|
||||
];
|
||||
const text = (b) =>
|
||||
(b.innerText || b.getAttribute("aria-label") || "").trim();
|
||||
const pick =
|
||||
buttons.find((b) =>
|
||||
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i.test(
|
||||
text(b),
|
||||
),
|
||||
) ||
|
||||
buttons.find((b) =>
|
||||
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i.test(
|
||||
text(b),
|
||||
),
|
||||
);
|
||||
if (pick) {
|
||||
done.push(text(pick));
|
||||
pick.click();
|
||||
}
|
||||
}
|
||||
return done;
|
||||
});
|
||||
}
|
||||
|
||||
async function flash(page, color) {
|
||||
await page.evaluate(
|
||||
(c) =>
|
||||
new Promise((resolve) => {
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = `position:fixed;inset:0;z-index:2147483647;pointer-events:none;background:${c}`;
|
||||
document.documentElement.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.remove();
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}, 320);
|
||||
}),
|
||||
color,
|
||||
);
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
// decodifica o vídeo cru em miniaturas 2x2 e procura as sequências magenta (início) e ciano (fim)
|
||||
function findMarkers(rawPath) {
|
||||
const r = spawnSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
rawPath,
|
||||
"-vf",
|
||||
`fps=${FPS},scale=2:2:flags=area,format=rgb24`,
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 * 64 },
|
||||
);
|
||||
const buf = r.stdout;
|
||||
const frames = Math.floor(buf.length / 12);
|
||||
const kind = (i) => {
|
||||
let R = 0,
|
||||
G = 0,
|
||||
B = 0;
|
||||
for (let p = 0; p < 4; p++) {
|
||||
R += buf[i * 12 + p * 3];
|
||||
G += buf[i * 12 + p * 3 + 1];
|
||||
B += buf[i * 12 + p * 3 + 2];
|
||||
}
|
||||
R /= 4;
|
||||
G /= 4;
|
||||
B /= 4;
|
||||
if (R > 190 && G < 80 && B > 190) return "m";
|
||||
if (R < 80 && G > 190 && B > 190) return "c";
|
||||
return "-";
|
||||
};
|
||||
const seq = Array.from({ length: frames }, (_, i) => kind(i)).join("");
|
||||
const mStart = seq.indexOf("mmm");
|
||||
if (mStart < 0) throw new Error("marcador magenta não encontrado no vídeo");
|
||||
let mEnd = mStart;
|
||||
while (seq[mEnd + 1] === "m") mEnd += 1;
|
||||
const cStart = seq.indexOf("ccc", mEnd);
|
||||
if (cStart < 0) throw new Error("marcador ciano não encontrado no vídeo");
|
||||
return {
|
||||
scrollStart: (mEnd + 2) / FPS,
|
||||
scrollEnd: (cStart - 1) / FPS,
|
||||
frames,
|
||||
mStart,
|
||||
mEnd,
|
||||
cStart,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitImages(page, timeout = 15000) {
|
||||
await page
|
||||
.evaluate(
|
||||
(ms) =>
|
||||
Promise.race([
|
||||
Promise.all(
|
||||
[...document.images].map((img) =>
|
||||
img.complete
|
||||
? null
|
||||
: new Promise((res) => {
|
||||
img.onload = img.onerror = res;
|
||||
}),
|
||||
),
|
||||
),
|
||||
new Promise((res) => setTimeout(res, ms)),
|
||||
]),
|
||||
timeout,
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function recordOne(browser, vpName) {
|
||||
const vp = VIEWPORTS[vpName];
|
||||
const rawDir = join(rawRoot, vpName);
|
||||
rmSync(rawDir, { recursive: true, force: true });
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: vp.isMobile,
|
||||
hasTouch: vp.hasTouch,
|
||||
userAgent: vp.isMobile ? MOBILE_UA : undefined,
|
||||
locale: "pt-BR",
|
||||
timezoneId: "America/Sao_Paulo",
|
||||
reducedMotion: "no-preference",
|
||||
recordVideo: { dir: rawDir, size: { width: vp.width, height: vp.height } },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// sem rolagem suave do CSS (o scroll é guiado por tempo) e sem o indicador do Next em dev
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
"html,body{scroll-behavior:auto!important}nextjs-portal,#__next-build-watcher,[data-nextjs-toast]{display:none!important}";
|
||||
document.addEventListener("DOMContentLoaded", () =>
|
||||
document.head.appendChild(style),
|
||||
);
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const logs = [];
|
||||
page.on("pageerror", (e) => logs.push(`pageerror: ${e.message}`));
|
||||
page.on(
|
||||
"console",
|
||||
(m) =>
|
||||
m.type() === "error" &&
|
||||
logs.push(`console.error: ${m.text().slice(0, 160)}`),
|
||||
);
|
||||
const tStart = Date.now();
|
||||
|
||||
const response = await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 20000 })
|
||||
.catch(() => {});
|
||||
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
||||
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
|
||||
const popups1 = await dismissPopups(page);
|
||||
|
||||
// passe de aquecimento: força imagens preguiçosas e conteúdo sob demanda; depois volta ao topo (essa parte é cortada do vídeo)
|
||||
const warm = await page.evaluate(async () => {
|
||||
const step = Math.round(window.innerHeight * 0.8);
|
||||
let guard = 0;
|
||||
while (
|
||||
window.scrollY + window.innerHeight <
|
||||
document.documentElement.scrollHeight - 2 &&
|
||||
guard++ < 400
|
||||
) {
|
||||
window.scrollBy(0, step);
|
||||
await new Promise((r) => setTimeout(r, 140));
|
||||
}
|
||||
return document.documentElement.scrollHeight;
|
||||
});
|
||||
await sleep(1200);
|
||||
await waitImages(page);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await sleep(1800);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await sleep(600);
|
||||
const popups2 = await dismissPopups(page);
|
||||
|
||||
const metrics = await page.evaluate(() => ({
|
||||
scrollY: window.scrollY,
|
||||
height: document.documentElement.scrollHeight,
|
||||
view: window.innerHeight,
|
||||
title: document.title,
|
||||
hasHorizontalOverflow:
|
||||
document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
}));
|
||||
const maxScroll = Math.max(0, metrics.height - metrics.view);
|
||||
const speed = Math.max(SPEED, maxScroll / (MAX_SECONDS - 2));
|
||||
const expectedScrollSeconds = maxScroll / speed;
|
||||
|
||||
// marcadores visuais (magenta no início, ciano no fim): o relógio do vídeo do Playwright não é confiável, então o corte é feito pelos quadros
|
||||
await flash(page, "rgb(255,0,255)");
|
||||
const t0 = Date.now() - tStart;
|
||||
await sleep(1000);
|
||||
const result = await page.evaluate(
|
||||
(speedPxS) =>
|
||||
new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
const tick = (now) => {
|
||||
const maxNow =
|
||||
document.documentElement.scrollHeight - window.innerHeight;
|
||||
const y = Math.min(((now - start) / 1000) * speedPxS, maxNow);
|
||||
window.scrollTo(0, y);
|
||||
if (y >= maxNow - 0.5)
|
||||
resolve({ endY: window.scrollY, maxNow, ms: now - start });
|
||||
else requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}),
|
||||
speed,
|
||||
);
|
||||
await sleep(1000);
|
||||
const t1 = Date.now() - tStart;
|
||||
await flash(page, "rgb(0,255,255)");
|
||||
await context.close();
|
||||
|
||||
const rawFile = readdirSync(rawDir).find((f) => f.endsWith(".webm"));
|
||||
const rawPath = join(rawDir, rawFile);
|
||||
const marks = findMarkers(rawPath);
|
||||
const startAt = marks.scrollStart.toFixed(3);
|
||||
const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3);
|
||||
const wallSeconds = (t1 - t0) / 1000;
|
||||
|
||||
const trimmed = join(rawDir, "trimmed.mp4");
|
||||
// intermediário de alta qualidade, só para os cortes
|
||||
ff([
|
||||
"-ss",
|
||||
startAt,
|
||||
"-i",
|
||||
rawPath,
|
||||
"-t",
|
||||
dur,
|
||||
"-an",
|
||||
"-r",
|
||||
String(FPS),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"14",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
trimmed,
|
||||
]);
|
||||
|
||||
const sizes = {};
|
||||
const encode = (kind, scale) => {
|
||||
const out = join(outDir, `${vpName}.${kind}`);
|
||||
const vf = scale
|
||||
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
|
||||
: [];
|
||||
const crfs = kind === "mp4" ? [26, 30, 34, 38] : [33, 37, 41, 46];
|
||||
for (const crf of crfs) {
|
||||
if (kind === "mp4") {
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
...vf,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
out,
|
||||
]);
|
||||
} else {
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
...vf,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
"2",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
out,
|
||||
]);
|
||||
}
|
||||
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
|
||||
if (sizes[kind].bytes <= MAX_BYTES) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const kind of ["mp4", "webm"]) {
|
||||
if (!encode(kind, null)) {
|
||||
for (const scale of [0.85, 0.7]) if (encode(kind, scale)) break;
|
||||
}
|
||||
}
|
||||
ff([
|
||||
"-i",
|
||||
join(outDir, `${vpName}.mp4`),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-c:v",
|
||||
"libwebp",
|
||||
"-quality",
|
||||
"80",
|
||||
join(outDir, `${vpName}.webp`),
|
||||
]);
|
||||
|
||||
// folha de contato para revisão visual (fora da pasta de saída)
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
"-vf",
|
||||
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${vpName === "mobile" ? 195 : 480}:-1,tile=${vpName === "mobile" ? 8 : 4}x2`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
join(rawRoot, `${vpName}-contact.png`),
|
||||
]);
|
||||
|
||||
return {
|
||||
vp: vpName,
|
||||
status: response?.status(),
|
||||
title: metrics.title,
|
||||
pageHeight: metrics.height,
|
||||
warmHeight: warm,
|
||||
speedPxS: Math.round(speed),
|
||||
scrollSeconds: +(result.ms / 1000).toFixed(1),
|
||||
expectedScrollSeconds: +expectedScrollSeconds.toFixed(1),
|
||||
endY: Math.round(result.endY),
|
||||
maxNow: Math.round(result.maxNow),
|
||||
durationS: +dur,
|
||||
wallSeconds: +wallSeconds.toFixed(1),
|
||||
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
|
||||
popupsClosed: [...popups1, ...popups2],
|
||||
horizontalOverflow: metrics.hasHorizontalOverflow,
|
||||
sizesKB: Object.fromEntries(
|
||||
Object.entries(sizes).map(([k, v]) => [
|
||||
k,
|
||||
{ kb: Math.round(v.bytes / 1024), crf: v.crf, scale: v.scale },
|
||||
]),
|
||||
),
|
||||
errors: logs.slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
// "novo headless" do Chromium completo (headless=new) usa a GPU real; o headless-shell cai em rasterização por software e perde quadros
|
||||
const software = args.software === "1";
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
channel: software ? undefined : "chromium",
|
||||
args: software
|
||||
? [
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
]
|
||||
: [
|
||||
"--use-angle=d3d11",
|
||||
"--enable-gpu-rasterization",
|
||||
"--ignore-gpu-blocklist",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
],
|
||||
});
|
||||
const report = [];
|
||||
for (const vpName of viewportNames) {
|
||||
try {
|
||||
report.push(await recordOne(browser, vpName));
|
||||
} catch (e) {
|
||||
report.push({ vp: vpName, error: String(e).slice(0, 400) });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ slug, url, report }, null, 2));
|
||||
@@ -21,30 +21,68 @@ const BASE = (args.base ?? "https://evandro.dev.br").replace(/\/$/, "");
|
||||
const SITE = "https://evandro.dev.br"; // origem canônica declarada no código
|
||||
const UA = "Mozilla/5.0 (compatible; seo-audit/1.0)";
|
||||
const AI_BOTS = [
|
||||
["Googlebot", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"],
|
||||
["bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"],
|
||||
["OAI-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot"],
|
||||
["GPTBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)"],
|
||||
["ChatGPT-User", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)"],
|
||||
["Claude-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; [email protected])"],
|
||||
["ClaudeBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +[email protected])"],
|
||||
["PerplexityBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"],
|
||||
[
|
||||
"Googlebot",
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
],
|
||||
[
|
||||
"bingbot",
|
||||
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
||||
],
|
||||
[
|
||||
"OAI-SearchBot",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot",
|
||||
],
|
||||
[
|
||||
"GPTBot",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
|
||||
],
|
||||
[
|
||||
"ChatGPT-User",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)",
|
||||
],
|
||||
[
|
||||
"Claude-SearchBot",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; [email protected])",
|
||||
],
|
||||
[
|
||||
"ClaudeBot",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; [email protected])",
|
||||
],
|
||||
[
|
||||
"PerplexityBot",
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
|
||||
],
|
||||
];
|
||||
|
||||
const local = (url) => url.replace(SITE, BASE); // permite auditar localhost com URLs canônicas de produção
|
||||
const get = async (url, ua = UA, init = {}) => {
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(url, { headers: { "user-agent": ua, "accept-encoding": "gzip, br" }, redirect: "manual", ...init });
|
||||
const res = await fetch(url, {
|
||||
headers: { "user-agent": ua, "accept-encoding": "gzip, br" },
|
||||
redirect: "manual",
|
||||
...init,
|
||||
});
|
||||
const body = init.method === "HEAD" ? "" : await res.text();
|
||||
return { res, body, ms: Math.round(performance.now() - t0) };
|
||||
};
|
||||
|
||||
const attrs = (tag) => {
|
||||
const out = {};
|
||||
for (const m of tag.matchAll(/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
|
||||
for (const m of tag.matchAll(
|
||||
/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
|
||||
))
|
||||
out[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
|
||||
return out;
|
||||
};
|
||||
const decode = (s) => s.replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/g, ">").trim();
|
||||
const decode = (s) =>
|
||||
s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.trim();
|
||||
const strip = (html) =>
|
||||
decode(
|
||||
html
|
||||
@@ -57,16 +95,25 @@ const words = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
|
||||
|
||||
function parsePage(html) {
|
||||
const metas = [...html.matchAll(/<meta\b[^>]*>/gi)].map((m) => attrs(m[0]));
|
||||
const meta = (key, val = "name") => metas.find((m) => m[val]?.toLowerCase() === key)?.content;
|
||||
const meta = (key, val = "name") =>
|
||||
metas.find((m) => m[val]?.toLowerCase() === key)?.content;
|
||||
const links = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => attrs(m[0]));
|
||||
const headings = [...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({ level: +m[1], text: strip(m[2]) }));
|
||||
const headings = [
|
||||
...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi),
|
||||
].map((m) => ({ level: +m[1], text: strip(m[2]) }));
|
||||
const imgs = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => attrs(m[0]));
|
||||
const anchors = [...html.matchAll(/<a\b[^>]*>/gi)].map((m) => attrs(m[0]));
|
||||
const ld = [...html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
|
||||
const ld = [
|
||||
...html.matchAll(
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
const main = html.match(/<main\b[\s\S]*?<\/main>/i)?.[0] ?? html;
|
||||
const firstH1 = main.search(/<h1\b/i);
|
||||
const afterH1 = firstH1 >= 0 ? main.slice(firstH1) : main;
|
||||
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => strip(m[1])).find((t) => words(t) >= 8);
|
||||
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)]
|
||||
.map((m) => strip(m[1]))
|
||||
.find((t) => words(t) >= 8);
|
||||
return {
|
||||
title: decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""),
|
||||
description: meta("description"),
|
||||
@@ -74,9 +121,19 @@ function parsePage(html) {
|
||||
viewport: meta("viewport"),
|
||||
lang: html.match(/<html[^>]*\blang=["']([^"']+)/i)?.[1],
|
||||
canonical: links.find((l) => l.rel === "canonical")?.href,
|
||||
alternates: links.filter((l) => l.rel === "alternate" && l.hreflang).map((l) => l.hreflang),
|
||||
og: Object.fromEntries(metas.filter((m) => m.property?.startsWith("og:")).map((m) => [m.property, m.content])),
|
||||
tw: Object.fromEntries(metas.filter((m) => m.name?.startsWith("twitter:")).map((m) => [m.name, m.content])),
|
||||
alternates: links
|
||||
.filter((l) => l.rel === "alternate" && l.hreflang)
|
||||
.map((l) => l.hreflang),
|
||||
og: Object.fromEntries(
|
||||
metas
|
||||
.filter((m) => m.property?.startsWith("og:"))
|
||||
.map((m) => [m.property, m.content]),
|
||||
),
|
||||
tw: Object.fromEntries(
|
||||
metas
|
||||
.filter((m) => m.name?.startsWith("twitter:"))
|
||||
.map((m) => [m.name, m.content]),
|
||||
),
|
||||
headings,
|
||||
imgs,
|
||||
anchors,
|
||||
@@ -105,7 +162,13 @@ function jsonLdFacts(blocks) {
|
||||
if (Array.isArray(o)) return o.forEach(walk);
|
||||
if (o && typeof o === "object") {
|
||||
const keys = Object.keys(o);
|
||||
if (keys.length === 1 && keys[0] === "@id" && !ids.has(o["@id"]) && !String(o["@id"]).startsWith(SITE)) dangling.push(o["@id"]);
|
||||
if (
|
||||
keys.length === 1 &&
|
||||
keys[0] === "@id" &&
|
||||
!ids.has(o["@id"]) &&
|
||||
!String(o["@id"]).startsWith(SITE)
|
||||
)
|
||||
dangling.push(o["@id"]);
|
||||
Object.values(o).forEach(walk);
|
||||
}
|
||||
};
|
||||
@@ -114,7 +177,8 @@ function jsonLdFacts(blocks) {
|
||||
}
|
||||
|
||||
const checks = []; // {scope, id, group, ok, detail}
|
||||
const add = (scope, group, id, ok, detail = "") => checks.push({ scope, group, id, ok: Boolean(ok), detail });
|
||||
const add = (scope, group, id, ok, detail = "") =>
|
||||
checks.push({ scope, group, id, ok: Boolean(ok), detail });
|
||||
|
||||
async function auditUrl(url, siteTitles, siteDescs) {
|
||||
const target = local(url);
|
||||
@@ -122,20 +186,70 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
const scope = url.replace(SITE, "") || "/";
|
||||
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
|
||||
if (res.status !== 200) return null;
|
||||
add(scope, "técnico", "html-comprimido", /br|gzip/.test(res.headers.get("content-encoding") ?? "") || BASE.startsWith("http://localhost"), res.headers.get("content-encoding") ?? "sem compressão");
|
||||
add(scope, "técnico", "ttfb-menor-800ms", ms < 800, `${ms} ms (HTML completo)`);
|
||||
add(
|
||||
scope,
|
||||
"técnico",
|
||||
"html-comprimido",
|
||||
/br|gzip/.test(res.headers.get("content-encoding") ?? "") ||
|
||||
BASE.startsWith("http://localhost"),
|
||||
res.headers.get("content-encoding") ?? "sem compressão",
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"técnico",
|
||||
"ttfb-menor-800ms",
|
||||
ms < 800,
|
||||
`${ms} ms (HTML completo)`,
|
||||
);
|
||||
const p = parsePage(body);
|
||||
|
||||
add(scope, "on-page", "lang-pt-BR", p.lang?.toLowerCase() === "pt-br", p.lang ?? "ausente");
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"lang-pt-BR",
|
||||
p.lang?.toLowerCase() === "pt-br",
|
||||
p.lang ?? "ausente",
|
||||
);
|
||||
add(scope, "on-page", "viewport", Boolean(p.viewport));
|
||||
add(scope, "on-page", "title-30-65", p.title.length >= 30 && p.title.length <= 65, `${p.title.length} car.: ${p.title}`);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"title-30-65",
|
||||
p.title.length >= 30 && p.title.length <= 65,
|
||||
`${p.title.length} car.: ${p.title}`,
|
||||
);
|
||||
add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title);
|
||||
siteTitles.add(p.title);
|
||||
add(scope, "on-page", "description-70-165", !!p.description && p.description.length >= 70 && p.description.length <= 165, `${p.description?.length ?? 0} car.`);
|
||||
add(scope, "on-page", "description-unica", !!p.description && !siteDescs.has(p.description));
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"description-70-165",
|
||||
!!p.description &&
|
||||
p.description.length >= 70 &&
|
||||
p.description.length <= 165,
|
||||
`${p.description?.length ?? 0} car.`,
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"description-unica",
|
||||
!!p.description && !siteDescs.has(p.description),
|
||||
);
|
||||
if (p.description) siteDescs.add(p.description);
|
||||
add(scope, "on-page", "canonical-propria", p.canonical === url || p.canonical === `${url}/`, p.canonical ?? "ausente");
|
||||
add(scope, "on-page", "indexavel", !/noindex/i.test(p.robots ?? "") && !/noindex/i.test(res.headers.get("x-robots-tag") ?? ""));
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"canonical-propria",
|
||||
p.canonical === url || p.canonical === `${url}/`,
|
||||
p.canonical ?? "ausente",
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"indexavel",
|
||||
!/noindex/i.test(p.robots ?? "") &&
|
||||
!/noindex/i.test(res.headers.get("x-robots-tag") ?? ""),
|
||||
);
|
||||
const h1 = p.headings.filter((h) => h.level === 1);
|
||||
add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`);
|
||||
let skipped = false;
|
||||
@@ -144,57 +258,205 @@ async function auditUrl(url, siteTitles, siteDescs) {
|
||||
return h.level;
|
||||
}, 0);
|
||||
add(scope, "on-page", "hierarquia-de-titulos", !skipped);
|
||||
add(scope, "on-page", "imagens-com-alt", p.imgs.every((i) => i.alt !== undefined), `${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`);
|
||||
const internal = p.anchors.filter((a) => a.href && (a.href.startsWith("/") || a.href.startsWith(SITE)) && !a.href.startsWith("//"));
|
||||
add(scope, "on-page", "links-internos-5+", new Set(internal.map((a) => a.href.split("#")[0])).size >= 5, `${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"imagens-com-alt",
|
||||
p.imgs.every((i) => i.alt !== undefined),
|
||||
`${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`,
|
||||
);
|
||||
const internal = p.anchors.filter(
|
||||
(a) =>
|
||||
a.href &&
|
||||
(a.href.startsWith("/") || a.href.startsWith(SITE)) &&
|
||||
!a.href.startsWith("//"),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"links-internos-5+",
|
||||
new Set(internal.map((a) => a.href.split("#")[0])).size >= 5,
|
||||
`${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`,
|
||||
);
|
||||
const external = p.anchors.filter((a) => a.target === "_blank");
|
||||
// noreferrer também implica noopener
|
||||
add(scope, "on-page", "externos-com-noopener", external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")), `${external.length} links _blank; sem rel: ${external.filter((a) => !/noopener|noreferrer/.test(a.rel ?? "")).map((a) => a.href).slice(0, 2).join(" ")}`);
|
||||
add(
|
||||
scope,
|
||||
"on-page",
|
||||
"externos-com-noopener",
|
||||
external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")),
|
||||
`${external.length} links _blank; sem rel: ${external
|
||||
.filter((a) => !/noopener|noreferrer/.test(a.rel ?? ""))
|
||||
.map((a) => a.href)
|
||||
.slice(0, 2)
|
||||
.join(" ")}`,
|
||||
);
|
||||
|
||||
const og = p.og, tw = p.tw;
|
||||
const missingOg = ["og:title", "og:description", "og:url", "og:image", "og:type", "og:site_name", "og:locale"].filter((k) => !og[k]);
|
||||
add(scope, "social", "og-completo", missingOg.length === 0, missingOg.length ? `faltam ${missingOg.join(",")}` : "");
|
||||
add(scope, "social", "twitter-card", tw["twitter:card"] === "summary_large_image" && !!tw["twitter:title"] && !!tw["twitter:image"]);
|
||||
const og = p.og,
|
||||
tw = p.tw;
|
||||
const missingOg = [
|
||||
"og:title",
|
||||
"og:description",
|
||||
"og:url",
|
||||
"og:image",
|
||||
"og:type",
|
||||
"og:site_name",
|
||||
"og:locale",
|
||||
].filter((k) => !og[k]);
|
||||
add(
|
||||
scope,
|
||||
"social",
|
||||
"og-completo",
|
||||
missingOg.length === 0,
|
||||
missingOg.length ? `faltam ${missingOg.join(",")}` : "",
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"social",
|
||||
"twitter-card",
|
||||
tw["twitter:card"] === "summary_large_image" &&
|
||||
!!tw["twitter:title"] &&
|
||||
!!tw["twitter:image"],
|
||||
);
|
||||
if (og["og:image"]) {
|
||||
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(() => null);
|
||||
add(scope, "social", "og-image-acessivel", img?.res.status === 200 && /^image\//.test(img.res.headers.get("content-type") ?? ""), `${img?.res.status} ${img?.res.headers.get("content-type")}`);
|
||||
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(
|
||||
() => null,
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"social",
|
||||
"og-image-acessivel",
|
||||
img?.res.status === 200 &&
|
||||
/^image\//.test(img.res.headers.get("content-type") ?? ""),
|
||||
`${img?.res.status} ${img?.res.headers.get("content-type")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ld = jsonLdFacts(p.ld);
|
||||
add(scope, "dados-estruturados", "json-ld-valido", p.ld.length > 0 && ld.errors.length === 0, ld.errors.join("; ") || `${p.ld.length} blocos`);
|
||||
add(scope, "dados-estruturados", "referencias-resolvem", ld.dangling.length === 0, ld.dangling.join(", "));
|
||||
add(scope, "dados-estruturados", "sem-faqpage-nem-localbusiness", !ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)), ld.types.join(","));
|
||||
if (scope !== "/") add(scope, "dados-estruturados", "breadcrumb", ld.types.includes("BreadcrumbList"));
|
||||
add(scope, "dados-estruturados", "person-com-sameas", !ld.types.includes("Person") || ld.nodes.some((n) => n["@type"] === "Person" && Array.isArray(n.sameAs) && n.sameAs.length >= 2));
|
||||
add(
|
||||
scope,
|
||||
"dados-estruturados",
|
||||
"json-ld-valido",
|
||||
p.ld.length > 0 && ld.errors.length === 0,
|
||||
ld.errors.join("; ") || `${p.ld.length} blocos`,
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"dados-estruturados",
|
||||
"referencias-resolvem",
|
||||
ld.dangling.length === 0,
|
||||
ld.dangling.join(", "),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"dados-estruturados",
|
||||
"sem-faqpage-nem-localbusiness",
|
||||
!ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)),
|
||||
ld.types.join(","),
|
||||
);
|
||||
if (scope !== "/")
|
||||
add(
|
||||
scope,
|
||||
"dados-estruturados",
|
||||
"breadcrumb",
|
||||
ld.types.includes("BreadcrumbList"),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"dados-estruturados",
|
||||
"person-com-sameas",
|
||||
!ld.types.includes("Person") ||
|
||||
ld.nodes.some(
|
||||
(n) =>
|
||||
n["@type"] === "Person" &&
|
||||
Array.isArray(n.sameAs) &&
|
||||
n.sameAs.length >= 2,
|
||||
),
|
||||
);
|
||||
|
||||
add(scope, "aeo-geo", "conteudo-no-html-sem-js", p.wordCount >= 250, `${p.wordCount} palavras no <main> do HTML`);
|
||||
add(scope, "aeo-geo", "resposta-direta-cedo", p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70, `${p.firstAnswerWords} palavras no 1º parágrafo após o H1`);
|
||||
const names = new Set(ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name));
|
||||
add(scope, "aeo-geo", "entidade-autor-consistente", names.size <= 1 && (scope === "/" ? names.size === 1 : true), [...names].join(","));
|
||||
add(
|
||||
scope,
|
||||
"aeo-geo",
|
||||
"conteudo-no-html-sem-js",
|
||||
p.wordCount >= 250,
|
||||
`${p.wordCount} palavras no <main> do HTML`,
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"aeo-geo",
|
||||
"resposta-direta-cedo",
|
||||
p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70,
|
||||
`${p.firstAnswerWords} palavras no 1º parágrafo após o H1`,
|
||||
);
|
||||
const names = new Set(
|
||||
ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name),
|
||||
);
|
||||
add(
|
||||
scope,
|
||||
"aeo-geo",
|
||||
"entidade-autor-consistente",
|
||||
names.size <= 1 && (scope === "/" ? names.size === 1 : true),
|
||||
[...names].join(","),
|
||||
);
|
||||
return { scope, p, ld };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sm = await get(`${BASE}/sitemap.xml`);
|
||||
const urls = [...sm.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
|
||||
add("site", "técnico", "sitemap-200", sm.res.status === 200 && urls.length > 0, `${urls.length} URLs`);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"sitemap-200",
|
||||
sm.res.status === 200 && urls.length > 0,
|
||||
`${urls.length} URLs`,
|
||||
);
|
||||
|
||||
const robots = await get(`${BASE}/robots.txt`);
|
||||
add("site", "técnico", "robots-com-sitemap", /sitemap:\s*https?:\/\//i.test(robots.body));
|
||||
add("site", "técnico", "robots-nao-bloqueia-tudo", !/disallow:\s*\/\s*$/im.test(robots.body));
|
||||
const home = await get(`${BASE}/`);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"robots-com-sitemap",
|
||||
/sitemap:\s*https?:\/\//i.test(robots.body),
|
||||
);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"robots-nao-bloqueia-tudo",
|
||||
!/disallow:\s*\/\s*$/im.test(robots.body),
|
||||
);
|
||||
add("site", "técnico", "http-para-https-ou-local", true);
|
||||
|
||||
for (const [name, ua] of AI_BOTS) {
|
||||
const r = await get(`${BASE}/`, ua);
|
||||
add("site", "aeo-geo", `acesso-${name}`, r.res.status === 200, `HTTP ${r.res.status}`);
|
||||
add(
|
||||
"site",
|
||||
"aeo-geo",
|
||||
`acesso-${name}`,
|
||||
r.res.status === 200,
|
||||
`HTTP ${r.res.status}`,
|
||||
);
|
||||
}
|
||||
// A Cloudflare (Email Address Obfuscation) troca e-mails por /cdn-cgi/l/email-protection para quem é robô: buscadores e IA não veem o contato
|
||||
const botHome = await get(`${BASE}/`, AI_BOTS[0][1]);
|
||||
add("site", "aeo-geo", "email-visivel-para-robos", !/email-protection|\[email( |\s)protected\]/.test(botHome.body), /email-protection/.test(botHome.body) ? "HTML servido a robôs tem e-mail ofuscado" : "");
|
||||
add(
|
||||
"site",
|
||||
"aeo-geo",
|
||||
"email-visivel-para-robos",
|
||||
!/email-protection|\[email( |\s)protected\]/.test(botHome.body),
|
||||
/email-protection/.test(botHome.body)
|
||||
? "HTML servido a robôs tem e-mail ofuscado"
|
||||
: "",
|
||||
);
|
||||
const llms = await get(`${BASE}/llms.txt`);
|
||||
// informativo: a política do projeto exclui llms.txt (nenhum sistema de IA o usa, segundo o Google); ausência é o esperado
|
||||
add("site", "aeo-geo", "llms-txt-ausente-por-politica", llms.res.status === 404, `HTTP ${llms.res.status}`);
|
||||
add(
|
||||
"site",
|
||||
"aeo-geo",
|
||||
"llms-txt-ausente-por-politica",
|
||||
llms.res.status === 404,
|
||||
`HTTP ${llms.res.status}`,
|
||||
);
|
||||
|
||||
const titles = new Set();
|
||||
const descs = new Set();
|
||||
@@ -206,52 +468,100 @@ async function main() {
|
||||
for (const pg of pages.filter(Boolean))
|
||||
for (const a of pg.p.anchors) {
|
||||
const h = a.href?.split("#")[0];
|
||||
if (h && (h.startsWith("/") && !h.startsWith("//"))) targets.add(h);
|
||||
if (h?.startsWith("/") && !h.startsWith("//")) targets.add(h);
|
||||
}
|
||||
let broken = 0;
|
||||
const brokenList = [];
|
||||
for (const t of targets) {
|
||||
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(() => null);
|
||||
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(
|
||||
() => null,
|
||||
);
|
||||
if (!r || r.res.status >= 400) {
|
||||
broken++;
|
||||
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
|
||||
}
|
||||
}
|
||||
add("site", "técnico", "sem-links-internos-quebrados", broken === 0, `${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"sem-links-internos-quebrados",
|
||||
broken === 0,
|
||||
`${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`,
|
||||
);
|
||||
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
|
||||
const orphanCandidates = [...inSitemap].filter((u) => {
|
||||
const path = u.replace(SITE, "") || "/";
|
||||
return path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path);
|
||||
return (
|
||||
path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path)
|
||||
);
|
||||
});
|
||||
add("site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", "));
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"sem-paginas-orfas-no-sitemap",
|
||||
orphanCandidates.length === 0,
|
||||
orphanCandidates.join(", "),
|
||||
);
|
||||
|
||||
// links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado)
|
||||
const ghLinks = new Set();
|
||||
for (const pg of pages.filter(Boolean))
|
||||
for (const a of pg.p.anchors) if (/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")) ghLinks.add(a.href.replace(/\/$/, ""));
|
||||
for (const a of pg.p.anchors)
|
||||
if (
|
||||
/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")
|
||||
)
|
||||
ghLinks.add(a.href.replace(/\/$/, ""));
|
||||
const ghBroken = [];
|
||||
for (const url of ghLinks) {
|
||||
const r = await get(url, UA, { method: "HEAD" }).catch(() => null);
|
||||
if (!r || r.res.status === 404) ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
|
||||
if (!r || r.res.status === 404)
|
||||
ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
|
||||
}
|
||||
add("site", "técnico", "links-github-acessiveis", ghBroken.length === 0, ghBroken.length ? `404 para visitantes: ${ghBroken.join(", ")}` : `${ghLinks.size} repositórios`);
|
||||
add(
|
||||
"site",
|
||||
"técnico",
|
||||
"links-github-acessiveis",
|
||||
ghBroken.length === 0,
|
||||
ghBroken.length
|
||||
? `404 para visitantes: ${ghBroken.join(", ")}`
|
||||
: `${ghLinks.size} repositórios`,
|
||||
);
|
||||
|
||||
const groups = {};
|
||||
for (const c of checks) {
|
||||
const g = (groups[c.group] ??= { ok: 0, total: 0 });
|
||||
groups[c.group] ??= { ok: 0, total: 0 };
|
||||
const g = groups[c.group];
|
||||
g.total++;
|
||||
if (c.ok) g.ok++;
|
||||
}
|
||||
const out = { base: BASE, at: new Date().toISOString(), urls: urls.length, groups, checks };
|
||||
const out = {
|
||||
base: BASE,
|
||||
at: new Date().toISOString(),
|
||||
urls: urls.length,
|
||||
groups,
|
||||
checks,
|
||||
};
|
||||
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
|
||||
|
||||
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
|
||||
for (const [g, v] of Object.entries(groups)) console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
|
||||
for (const [g, v] of Object.entries(groups))
|
||||
console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
|
||||
const failing = checks.filter((c) => !c.ok);
|
||||
console.log(`\nFalhas (${failing.length}):`);
|
||||
const byId = {};
|
||||
for (const f of failing) (byId[f.id] ??= []).push(f);
|
||||
for (const [id, list] of Object.entries(byId)) console.log(` - ${id} (${list.length}): ${list.slice(0, 4).map((f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`).join(" | ")}`);
|
||||
for (const f of failing) {
|
||||
byId[f.id] ??= [];
|
||||
byId[f.id].push(f);
|
||||
}
|
||||
for (const [id, list] of Object.entries(byId))
|
||||
console.log(
|
||||
` - ${id} (${list.length}): ${list
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`,
|
||||
)
|
||||
.join(" | ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -13,23 +13,32 @@ const key = (c) => `${c.scope}::${c.id}`;
|
||||
const prev = new Map(before.checks.map((c) => [key(c), c]));
|
||||
const next = new Map(after.checks.map((c) => [key(c), c]));
|
||||
|
||||
console.log(`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`);
|
||||
console.log(
|
||||
`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`,
|
||||
);
|
||||
console.log("grupo antes depois");
|
||||
for (const g of new Set([...Object.keys(before.groups), ...Object.keys(after.groups)])) {
|
||||
for (const g of new Set([
|
||||
...Object.keys(before.groups),
|
||||
...Object.keys(after.groups),
|
||||
])) {
|
||||
const a = before.groups[g] ?? { ok: 0, total: 0 };
|
||||
const b = after.groups[g] ?? { ok: 0, total: 0 };
|
||||
console.log(`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`);
|
||||
console.log(
|
||||
`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`,
|
||||
);
|
||||
}
|
||||
const fixed = [];
|
||||
const regressed = [];
|
||||
for (const [k, c] of next) {
|
||||
const p = prev.get(k);
|
||||
if (p && !p.ok && c.ok) fixed.push(c);
|
||||
if (p && p.ok && !c.ok) regressed.push(c);
|
||||
if (!p && !c.ok) regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
|
||||
if (p?.ok && !c.ok) regressed.push(c);
|
||||
if (!p && !c.ok)
|
||||
regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
|
||||
}
|
||||
console.log(`\nCorrigidos (${fixed.length}):`);
|
||||
for (const c of fixed) console.log(` + ${c.scope} ${c.id}`);
|
||||
console.log(`\nPiorou (${regressed.length}):`);
|
||||
for (const c of regressed) console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
|
||||
for (const c of regressed)
|
||||
console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
|
||||
process.exit(regressed.length ? 2 : 0);
|
||||
|
After Width: | Height: | Size: 73 KiB |