feat(showcase): vitrine animada presa na rolagem, com capitulos e celular sincronizado
Abaixo dos cards, uma cena sticky em que o site de cada projeto rola dentro de um notebook (com um celular ao lado) conforme a pagina rola. Cada projeto tem um trecho com pausa no comeco e no fim; capitulos nomeiam o que esta na tela, levam ao ponto do video e mantem notebook e celular no mesmo trecho do site. So o projeto em cena e os vizinhos baixam video (nada com economizar dados ligado, e o notebook escondido no celular nao baixa). Um bloco da mesma altura segura o lugar antes de montar, para saltos como #contact cairem certo. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
a1a1e91135
commit
540fe9f87a
3 files changed
+1066
-112
No files matched your search
+124
-112
@@ -8,6 +8,8 @@ import { DrawLine } from "./animations/DrawLine";
|
|||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
import { ProjectCover } from "./ProjectCover";
|
import { ProjectCover } from "./ProjectCover";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
|
import { ProjectReel } from "./showcase/ProjectReel";
|
||||||
|
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
|
||||||
|
|
||||||
export default function Projects({
|
export default function Projects({
|
||||||
projects,
|
projects,
|
||||||
@@ -36,129 +38,139 @@ export default function Projects({
|
|||||||
dark
|
dark
|
||||||
disableChildrenAnimation
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<div className="ds-grid-fluid">
|
<ShowcaseProvider projects={projects}>
|
||||||
{projects.map((project) => {
|
<div className="ds-grid-fluid">
|
||||||
const caseStudy = caseStudies.find(
|
{projects.map((project) => {
|
||||||
(entry) => entry.id === project.id,
|
const caseStudy = caseStudies.find(
|
||||||
);
|
(entry) => entry.id === project.id,
|
||||||
return (
|
);
|
||||||
<motion.article
|
return (
|
||||||
key={project.id}
|
<motion.article
|
||||||
className="ds-card h-full"
|
key={project.id}
|
||||||
initial="hidden"
|
className="ds-card h-full"
|
||||||
whileInView="visible"
|
initial="hidden"
|
||||||
viewport={{ once: true, amount: "some" }}
|
whileInView="visible"
|
||||||
variants={cardVariants}
|
viewport={{ once: true, amount: "some" }}
|
||||||
whileHover={
|
variants={cardVariants}
|
||||||
shouldReduceMotion
|
whileHover={
|
||||||
? undefined
|
shouldReduceMotion
|
||||||
: {
|
? undefined
|
||||||
y: -4,
|
: {
|
||||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
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 }}
|
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">
|
<ProjectCover project={project} />
|
||||||
<div className="max-w-[560px]">
|
<div className="flex flex-wrap items-start justify-between gap-sp-6">
|
||||||
<div className="ds-kicker">{project.category}</div>
|
<div className="max-w-[560px]">
|
||||||
<DrawLine delay={0.05} />
|
<div className="ds-kicker">{project.category}</div>
|
||||||
<h3 className="ds-card-title">{project.title}</h3>
|
<DrawLine delay={0.05} />
|
||||||
{project.subtitle && (
|
<h3 className="ds-card-title">{project.title}</h3>
|
||||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
{project.subtitle && (
|
||||||
{project.subtitle}
|
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
|
||||||
</p>
|
{project.subtitle}
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<MagneticButton
|
||||||
|
as="span"
|
||||||
|
className="ds-tag"
|
||||||
|
strength={0.15}
|
||||||
|
wrapperClassName="shrink-0 max-sm:ml-auto"
|
||||||
|
>
|
||||||
|
{project.status}
|
||||||
|
</MagneticButton>
|
||||||
</div>
|
</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">
|
<p className="ds-copy">
|
||||||
{caseStudy?.summary ?? project.description}
|
{caseStudy?.summary ?? project.description}
|
||||||
</p>
|
</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">
|
|
||||||
{caseStudy && (
|
{caseStudy && (
|
||||||
<MagneticButton
|
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||||
as="a"
|
<p className="ds-kicker">O desafio</p>
|
||||||
href={`/cases/${caseStudy.id}`}
|
<p className="ds-copy">{caseStudy.impact}</p>
|
||||||
className="ds-link ds-link-accent"
|
</div>
|
||||||
strength={0.2}
|
|
||||||
>
|
|
||||||
Entender a solução
|
|
||||||
</MagneticButton>
|
|
||||||
)}
|
)}
|
||||||
{project.githubUrl && (
|
{caseStudy?.role && (
|
||||||
<MagneticButton
|
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
|
||||||
as="a"
|
<p className="ds-kicker">Minha participação</p>
|
||||||
href={project.githubUrl}
|
<p className="ds-copy">{caseStudy.role}</p>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noreferrer"
|
|
||||||
className="ds-link ds-link-muted"
|
|
||||||
strength={0.25}
|
|
||||||
>
|
|
||||||
Ver codigo
|
|
||||||
</MagneticButton>
|
|
||||||
)}
|
)}
|
||||||
{project.demoUrl &&
|
|
||||||
(project.demoAvailability === "available" ||
|
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||||
project.demoAvailability === "unknown") && (
|
{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
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
href={project.demoUrl}
|
href={`/cases/${caseStudy.id}`}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="ds-link ds-link-accent"
|
className="ds-link ds-link-accent"
|
||||||
strength={0.25}
|
strength={0.2}
|
||||||
>
|
>
|
||||||
Abrir projeto
|
Entender a solução
|
||||||
{project.demoAvailability === "unknown" && (
|
|
||||||
<span className="sr-only">
|
|
||||||
{" "}
|
|
||||||
Disponibilidade não verificada
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
)}
|
)}
|
||||||
{project.demoAvailability === "unavailable" && (
|
{project.githubUrl && (
|
||||||
<span className="ds-link ds-link-accent" aria-disabled="true">
|
<MagneticButton
|
||||||
Demo indisponível na última verificação
|
as="a"
|
||||||
</span>
|
href={project.githubUrl}
|
||||||
)}
|
target="_blank"
|
||||||
</div>
|
rel="noreferrer"
|
||||||
</motion.article>
|
className="ds-link ds-link-muted"
|
||||||
);
|
strength={0.25}
|
||||||
})}
|
>
|
||||||
</div>
|
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">
|
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user