feat: harden portfolio and add SEO foundations
This commit is contained in:
1 parent
b079afdd73
commit
62bcfbd8fa
121 files changed
+7769
-10469
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import FilmGrain from "./FilmGrain";
|
||||
import ScrollVelocityText from "./ScrollVelocityText";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
|
||||
function mockReducedMotion(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("prefers-reduced-motion") ? matches : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("static cinematic effects", () => {
|
||||
beforeEach(() => mockReducedMotion(false));
|
||||
|
||||
it("renders grain and every divider without a canvas", () => {
|
||||
const grain = render(<FilmGrain />);
|
||||
expect(grain.container.firstElementChild).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
grain.unmount();
|
||||
|
||||
for (const variant of ["gradient", "pulse", "waveform"] as const) {
|
||||
const divider = render(<SectionDivider variant={variant} />);
|
||||
expect(divider.container.firstElementChild).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
expect(divider.container.querySelector("canvas")).not.toBeInTheDocument();
|
||||
expect(divider.container.firstElementChild).not.toHaveAttribute(
|
||||
"tabindex",
|
||||
);
|
||||
divider.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a persistent pause control for an automatic marquee", () => {
|
||||
render(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
|
||||
|
||||
const pause = screen.getByRole("button", { name: "Pausar animação" });
|
||||
const track = document.getElementById(
|
||||
pause.getAttribute("aria-controls") ?? "",
|
||||
);
|
||||
expect(pause).toHaveAttribute("aria-pressed", "false");
|
||||
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
|
||||
expect(
|
||||
screen.getByText("SOFTWARE ENGINEERING", {
|
||||
selector: '[aria-hidden="false"]',
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2);
|
||||
expect(
|
||||
screen
|
||||
.getAllByText("SOFTWARE ENGINEERING")
|
||||
.slice(1)
|
||||
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(pause);
|
||||
|
||||
const resume = screen.getByRole("button", { name: "Retomar animação" });
|
||||
expect(resume).toHaveAttribute("aria-pressed", "true");
|
||||
expect(track).toHaveStyle({ animationPlayState: "paused" });
|
||||
|
||||
fireEvent.click(resume);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pausar animação" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
|
||||
});
|
||||
|
||||
it("starts static under reduced motion without a redundant control", () => {
|
||||
mockReducedMotion(true);
|
||||
render(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /animação/i }),
|
||||
).not.toBeInTheDocument();
|
||||
const copies = screen.getAllByText("VAMOS CONVERSAR");
|
||||
expect(copies).toHaveLength(2);
|
||||
expect(copies[0]).toBeVisible();
|
||||
expect(copies[0]).toHaveAttribute("aria-hidden", "false");
|
||||
expect(copies[0].closest("[style]")).toHaveStyle({
|
||||
animationPlayState: "paused",
|
||||
});
|
||||
expect(
|
||||
copies
|
||||
.slice(1)
|
||||
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import CinematicHome from "./CinematicHome";
|
||||
|
||||
jest.mock("next/dynamic", () => {
|
||||
@@ -19,41 +18,44 @@ jest.mock("next/dynamic", () => {
|
||||
return () => {
|
||||
const label = labels[index++] ?? "DynamicSection";
|
||||
return function DynamicMock() {
|
||||
return <div data-testid={`dynamic-${label}`}>{label}</div>;
|
||||
return <section data-testid={`home-section-${label}`}>{label}</section>;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../Hero", () => () => <div data-testid="hero" />);
|
||||
jest.mock("../Projects", () => () => <div data-testid="projects" />);
|
||||
jest.mock("../story/ReelDirector", () => () => (
|
||||
<div data-testid="reel-director" />
|
||||
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />);
|
||||
jest.mock("../Projects", () => () => (
|
||||
<section data-testid="home-section-Projects" />
|
||||
));
|
||||
jest.mock("../story/NotebookStory", () => () => (
|
||||
<section data-testid="notebook-story" />
|
||||
<section data-testid="home-section-NotebookStory" />
|
||||
));
|
||||
jest.mock(
|
||||
"./CinematicSection",
|
||||
() =>
|
||||
({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
);
|
||||
|
||||
describe("CinematicHome", () => {
|
||||
it("uses one notebook story and keeps About after it", () => {
|
||||
render(
|
||||
it("renders each primary section directly in the published order", () => {
|
||||
const { container } = render(
|
||||
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />,
|
||||
);
|
||||
const main = container.querySelector("main");
|
||||
const sectionNames = [
|
||||
"Hero",
|
||||
"NotebookStory",
|
||||
"About",
|
||||
"Skills",
|
||||
"Experience",
|
||||
"Projects",
|
||||
"LighthouseMetrics",
|
||||
"WorkModalities",
|
||||
"Contact",
|
||||
];
|
||||
const sectionIds = sectionNames.map((name) => `home-section-${name}`);
|
||||
const directSections = Array.from(main?.children ?? []).filter((element) =>
|
||||
sectionIds.includes(element.getAttribute("data-testid") ?? ""),
|
||||
);
|
||||
|
||||
const story = screen.getByTestId("notebook-story");
|
||||
const about = screen.getByTestId("dynamic-About");
|
||||
|
||||
expect(story).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("print-reel")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("scroll-story")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("home-section-NotebookStory")).toHaveLength(1);
|
||||
expect(
|
||||
Boolean(
|
||||
story.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
).toBe(true);
|
||||
directSections.map((section) => section.getAttribute("data-testid")),
|
||||
).toEqual(sectionIds);
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,13 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
PortfolioProject,
|
||||
PortfolioProjectView,
|
||||
PortfolioSkillCategory,
|
||||
} from "@/lib/portfolio-data";
|
||||
import Hero from "../Hero";
|
||||
import Projects from "../Projects";
|
||||
import { SectionLoading } from "../SectionLoading";
|
||||
import NotebookStory from "../story/NotebookStory";
|
||||
import ReelDirector from "../story/ReelDirector";
|
||||
import CinematicSection from "./CinematicSection";
|
||||
|
||||
const About = dynamic(() => import("../About"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
@@ -46,67 +44,52 @@ export default function CinematicHome({
|
||||
experiences,
|
||||
skillCategories,
|
||||
}: {
|
||||
projects: PortfolioProject[];
|
||||
projects: PortfolioProjectView[];
|
||||
experiences: PortfolioExperience[];
|
||||
skillCategories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
<main className="ds-main">
|
||||
<ReelDirector />
|
||||
<Hero />
|
||||
|
||||
{/* Marquee divider after hero */}
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
|
||||
</div>
|
||||
|
||||
<NotebookStory projects={projects} />
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<About />
|
||||
</CinematicSection>
|
||||
<About />
|
||||
|
||||
<SectionDivider variant="waveform" />
|
||||
|
||||
<CinematicSection effect="clip-reveal">
|
||||
<Skills categories={skillCategories} />
|
||||
</CinematicSection>
|
||||
<Skills categories={skillCategories} />
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
|
||||
<ScrollVelocityText
|
||||
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
|
||||
baseVelocity={1}
|
||||
direction="right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CinematicSection effect="fade-scale">
|
||||
<Experience experiences={experiences} />
|
||||
</CinematicSection>
|
||||
<Experience experiences={experiences} />
|
||||
|
||||
<SectionDivider variant="gradient" />
|
||||
|
||||
<CinematicSection effect="rotate-in">
|
||||
<Projects projects={projects} />
|
||||
</CinematicSection>
|
||||
<Projects projects={projects} />
|
||||
|
||||
<CinematicSection effect="clip-reveal">
|
||||
<LighthouseMetrics />
|
||||
</CinematicSection>
|
||||
<LighthouseMetrics />
|
||||
|
||||
<SectionDivider variant="pulse" />
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<WorkModalities />
|
||||
</CinematicSection>
|
||||
<WorkModalities />
|
||||
|
||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
|
||||
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
|
||||
</div>
|
||||
|
||||
<CinematicSection effect="fade-scale">
|
||||
<Contact />
|
||||
</CinematicSection>
|
||||
<Contact />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import PageIntro from "./PageIntro";
|
||||
|
||||
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||
ssr: false,
|
||||
@@ -16,10 +14,8 @@ export default function CinematicOverlays() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageIntro />
|
||||
{isHome ? (
|
||||
<>
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
</>
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function CinematicSection({
|
||||
children,
|
||||
className = "",
|
||||
effect = "fade-scale",
|
||||
id,
|
||||
}: CinematicSectionProps) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sectionRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Check reduced motion preference
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
switch (effect) {
|
||||
case "fade-scale": {
|
||||
gsap.set(el, {
|
||||
opacity: 0,
|
||||
scale: 0.92,
|
||||
transformOrigin: "center center",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 85%",
|
||||
end: "top 20%",
|
||||
scrub: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "clip-reveal": {
|
||||
gsap.set(el, {
|
||||
clipPath: "inset(15% 5% 15% 5%)",
|
||||
opacity: 0.3,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
end: "top 15%",
|
||||
scrub: 1,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
clipPath: "inset(0% 0% 0% 0%)",
|
||||
opacity: 1,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "parallax-depth": {
|
||||
gsap.set(el, {
|
||||
y: 80,
|
||||
opacity: 0,
|
||||
rotateX: 4,
|
||||
transformPerspective: 1200,
|
||||
transformOrigin: "center bottom",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 90%",
|
||||
end: "top 30%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
rotateX: 0,
|
||||
ease: "power2.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "rotate-in": {
|
||||
gsap.set(el, {
|
||||
opacity: 0,
|
||||
rotateY: -5,
|
||||
x: -60,
|
||||
transformPerspective: 1000,
|
||||
transformOrigin: "left center",
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
end: "top 25%",
|
||||
scrub: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(el, {
|
||||
opacity: 1,
|
||||
rotateY: 0,
|
||||
x: 0,
|
||||
ease: "power3.out",
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, [effect]);
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className={className} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
tag?: "h1" | "h2" | "h3" | "p" | "span";
|
||||
delay?: number;
|
||||
splitBy?: "chars" | "words" | "lines";
|
||||
}
|
||||
|
||||
export default function CinematicText({
|
||||
text,
|
||||
className = "",
|
||||
tag: Tag = "h2",
|
||||
delay = 0,
|
||||
splitBy = "chars",
|
||||
}: CinematicTextProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const spans = el.querySelectorAll("[data-char]");
|
||||
if (spans.length === 0) return;
|
||||
|
||||
gsap.set(spans, {
|
||||
opacity: 0,
|
||||
y: 60,
|
||||
rotateX: -90,
|
||||
transformOrigin: "center bottom",
|
||||
transformPerspective: 600,
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 80%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(spans, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
duration: 0.8,
|
||||
ease: "back.out(1.2)",
|
||||
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
|
||||
delay,
|
||||
});
|
||||
|
||||
cleanup = () => {
|
||||
tl.scrollTrigger?.kill();
|
||||
tl.kill();
|
||||
};
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, [text, delay, splitBy]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (splitBy === "chars") {
|
||||
return text.split("").map((char, i) => (
|
||||
<span
|
||||
key={`${char}-${i}`}
|
||||
data-char
|
||||
className="inline-block will-change-transform"
|
||||
style={{ whiteSpace: char === " " ? "pre" : undefined }}
|
||||
>
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
if (splitBy === "words") {
|
||||
return text.split(" ").map((word, i) => (
|
||||
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
|
||||
<span data-char className="inline-block will-change-transform">
|
||||
{word}
|
||||
</span>
|
||||
{i < text.split(" ").length - 1 && "\u00A0"}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
// lines
|
||||
return text.split("\n").map((line, i) => (
|
||||
<span key={`${line}-${i}`} className="block overflow-hidden">
|
||||
<span data-char className="block will-change-transform">
|
||||
{line}
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="overflow-hidden">
|
||||
<Tag className={className}>{renderContent()}</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import createGlobe from "cobe";
|
||||
import { ControlRoomGlobe } from "./ControlRoomGlobe";
|
||||
|
||||
const mockDestroy = jest.fn();
|
||||
|
||||
jest.mock("cobe", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ destroy: mockDestroy })),
|
||||
}));
|
||||
|
||||
jest.mock("motion/react", () => ({
|
||||
useMotionValue: (initial: number) => ({
|
||||
get: () => initial,
|
||||
set: jest.fn(),
|
||||
}),
|
||||
useSpring: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined;
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
let measuredWidth = 400;
|
||||
|
||||
function setDocumentHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: hidden,
|
||||
});
|
||||
}
|
||||
|
||||
function setGlobeIntersects(isIntersecting: boolean) {
|
||||
const entry = { isIntersecting } as IntersectionObserverEntry;
|
||||
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
|
||||
}
|
||||
|
||||
function renderCallback() {
|
||||
expect(createGlobe).toHaveBeenCalledTimes(1);
|
||||
const call = jest.mocked(createGlobe).mock.calls[0];
|
||||
if (!call) throw new Error("Globe was not initialized");
|
||||
return call[1].onRender;
|
||||
}
|
||||
|
||||
describe("ControlRoomGlobe lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setDocumentHidden(false);
|
||||
intersectionCallback = undefined;
|
||||
resizeCallback = undefined;
|
||||
measuredWidth = 400;
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
|
||||
.mockImplementation(() => measuredWidth);
|
||||
jest
|
||||
.spyOn(HTMLCanvasElement.prototype, "getContext")
|
||||
.mockReturnValue({} as unknown as GPUCanvasContext);
|
||||
|
||||
class MockIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = jest.fn();
|
||||
observe = jest.fn();
|
||||
takeRecords = () => [];
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.IntersectionObserver =
|
||||
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
disconnect = jest.fn();
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
global.ResizeObserver =
|
||||
MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("pauses for a hidden tab without losing the intersection flag", async () => {
|
||||
render(<ControlRoomGlobe />);
|
||||
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
|
||||
const renderFrame = renderCallback();
|
||||
const state = { phi: 0, theta: 0, width: 0, height: 0 };
|
||||
|
||||
setGlobeIntersects(true);
|
||||
renderFrame(state);
|
||||
expect(state.phi).not.toBe(0);
|
||||
|
||||
setDocumentHidden(true);
|
||||
const pausedPhi = state.phi;
|
||||
renderFrame(state);
|
||||
expect(state.phi).toBe(pausedPhi);
|
||||
|
||||
setDocumentHidden(false);
|
||||
renderFrame(state);
|
||||
expect(state.phi).not.toBe(pausedPhi);
|
||||
|
||||
setGlobeIntersects(false);
|
||||
const offscreenPhi = state.phi;
|
||||
setDocumentHidden(true);
|
||||
setDocumentHidden(false);
|
||||
renderFrame(state);
|
||||
expect(state.phi).toBe(offscreenPhi);
|
||||
});
|
||||
|
||||
it("keeps one Cobe instance while ResizeObserver updates render dimensions", async () => {
|
||||
render(<ControlRoomGlobe />);
|
||||
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
|
||||
|
||||
measuredWidth = 520;
|
||||
const entry = {
|
||||
contentRect: { width: measuredWidth },
|
||||
} as ResizeObserverEntry;
|
||||
act(() => resizeCallback?.([entry], {} as ResizeObserver));
|
||||
setGlobeIntersects(true);
|
||||
const state = { phi: 0, theta: 0, width: 0, height: 0 };
|
||||
renderCallback()(state);
|
||||
|
||||
expect(createGlobe).toHaveBeenCalledTimes(1);
|
||||
expect(state.width).toBe(1040);
|
||||
expect(state.height).toBe(1040);
|
||||
});
|
||||
|
||||
it("keeps the reduced-motion globe static", async () => {
|
||||
render(<ControlRoomGlobe autoRotate={false} />);
|
||||
await waitFor(() => expect(createGlobe).toHaveBeenCalled());
|
||||
setGlobeIntersects(true);
|
||||
const state = { phi: 0, theta: 0, width: 0, height: 0 };
|
||||
|
||||
renderCallback()(state);
|
||||
|
||||
expect(state).toEqual({ phi: 0, theta: 0, width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ControlRoomGlobe({
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
}) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const widthRef = useRef(0);
|
||||
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
|
||||
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
|
||||
const thetaPhaseRef = useRef(0);
|
||||
@@ -55,7 +55,7 @@ export function ControlRoomGlobe({
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerYOrigin = useRef<number | null>(null);
|
||||
const isVisibleRef = useRef(true);
|
||||
const intersectsRef = useRef(false);
|
||||
|
||||
const r = useMotionValue(0);
|
||||
const rs = useSpring(r, {
|
||||
@@ -88,57 +88,44 @@ export function ControlRoomGlobe({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
setWidth(canvasRef.current.offsetWidth);
|
||||
}
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
|
||||
const dpr = config.devicePixelRatio;
|
||||
const updateWidth = (entry?: ResizeObserverEntry) => {
|
||||
const nextWidth =
|
||||
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
|
||||
if (nextWidth > 0) widthRef.current = nextWidth;
|
||||
};
|
||||
updateWidth();
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
|
||||
resizeObserver.observe(canvas);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisibleRef.current = entry.isIntersecting;
|
||||
intersectsRef.current = entry.isIntersecting;
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
intersectionObserver.observe(canvas);
|
||||
|
||||
if (canvasRef.current) {
|
||||
observer.observe(canvasRef.current);
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
isVisibleRef.current = !document.hidden;
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
|
||||
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
|
||||
let globe: ReturnType<typeof createGlobe> | null = null;
|
||||
let globe: { destroy(): void } | null = null;
|
||||
try {
|
||||
const supportsWebGL =
|
||||
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
if (supportsWebGL) {
|
||||
const initialRenderWidth = widthRef.current * dpr;
|
||||
globe = createGlobe(canvas, {
|
||||
...config,
|
||||
width: width * 2,
|
||||
height: width * 2,
|
||||
width: initialRenderWidth,
|
||||
height: initialRenderWidth,
|
||||
onRender: (state) => {
|
||||
if (!isVisibleRef.current || document.hidden) return;
|
||||
if (autoRotate && !pointerInteracting.current) {
|
||||
if (!autoRotate || !intersectsRef.current || document.hidden)
|
||||
return;
|
||||
if (!pointerInteracting.current) {
|
||||
phiRef.current += rotationSpeed;
|
||||
thetaPhaseRef.current += rotationSpeed * 0.9;
|
||||
}
|
||||
@@ -148,8 +135,9 @@ export function ControlRoomGlobe({
|
||||
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
||||
Math.sin(thetaPhaseRef.current) * 0.35 +
|
||||
thetaDragRef.current;
|
||||
state.width = width * 2;
|
||||
state.height = width * 2;
|
||||
const renderWidth = widthRef.current * dpr;
|
||||
state.width = renderWidth;
|
||||
state.height = renderWidth;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -161,28 +149,24 @@ export function ControlRoomGlobe({
|
||||
// Sem globo: fallback visível (gradiente do card permanece), página viva.
|
||||
canvas.style.opacity = "1";
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const revealTimer = window.setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(revealTimer);
|
||||
globe.destroy();
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [rs, config, autoRotate, rotationSpeed, width]);
|
||||
}, [rs, config, autoRotate, rotationSpeed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function CustomCursor() {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [isClicking, setIsClicking] = useState(false);
|
||||
const [hoverText, setHoverText] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const cursorX = useMotionValue(0);
|
||||
const cursorY = useMotionValue(0);
|
||||
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
cursorX.set(e.clientX);
|
||||
cursorY.set(e.clientY);
|
||||
if (!isVisible) setIsVisible(true);
|
||||
});
|
||||
},
|
||||
[cursorX, cursorY, isVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only show custom cursor on desktop with fine pointer
|
||||
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
|
||||
if (!hasFinePointer) return;
|
||||
|
||||
const handleDown = () => setIsClicking(true);
|
||||
const handleUp = () => setIsClicking(false);
|
||||
const handleLeave = () => setIsVisible(false);
|
||||
const handleEnter = () => setIsVisible(true);
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mousedown", handleDown);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
document.addEventListener("mouseleave", handleLeave);
|
||||
document.addEventListener("mouseenter", handleEnter);
|
||||
|
||||
// Detect hoverable elements
|
||||
const observer = new MutationObserver(() => {
|
||||
attachHoverListeners();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
attachHoverListeners();
|
||||
|
||||
function attachHoverListeners() {
|
||||
const hoverables = document.querySelectorAll(
|
||||
'a, button, [role="button"], input, textarea, select, [data-cursor]',
|
||||
);
|
||||
for (const el of hoverables) {
|
||||
el.addEventListener("mouseenter", handleElementEnter as EventListener);
|
||||
el.addEventListener("mouseleave", handleElementLeave as EventListener);
|
||||
}
|
||||
}
|
||||
|
||||
function handleElementEnter(e: Event) {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
setIsHovering(true);
|
||||
const cursorData = el.getAttribute("data-cursor");
|
||||
if (cursorData) setHoverText(cursorData);
|
||||
}
|
||||
|
||||
function handleElementLeave() {
|
||||
setIsHovering(false);
|
||||
setHoverText("");
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mousedown", handleDown);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.removeEventListener("mouseleave", handleLeave);
|
||||
document.removeEventListener("mouseenter", handleEnter);
|
||||
observer.disconnect();
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [handleMouseMove]);
|
||||
|
||||
// Don't render on touch devices
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!window.matchMedia("(pointer: fine)").matches
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
@media (pointer: fine) {
|
||||
* { cursor: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Dot cursor */}
|
||||
<motion.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
|
||||
style={{
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
}}
|
||||
animate={{
|
||||
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-[#ccff00]"
|
||||
style={{ width: 8, height: 8 }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Ring cursor */}
|
||||
<motion.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
|
||||
style={{
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
}}
|
||||
animate={{
|
||||
width: isHovering ? 80 : 40,
|
||||
height: isHovering ? 80 : 40,
|
||||
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
|
||||
borderWidth: isHovering ? 2 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
|
||||
style={{
|
||||
borderStyle: "solid",
|
||||
borderColor: "#ccff00",
|
||||
borderWidth: "inherit",
|
||||
}}
|
||||
>
|
||||
{hoverText && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
|
||||
>
|
||||
{hoverText}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function FilmGrain() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (reducedMotion || coarsePointer) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animId: number;
|
||||
let lastFrame = 0;
|
||||
const fps = 24; // Cinematic film fps
|
||||
const frameInterval = 1000 / fps;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth / 2;
|
||||
canvas.height = window.innerHeight / 2;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const render = (timestamp: number) => {
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
const delta = timestamp - lastFrame;
|
||||
if (delta < frameInterval) return;
|
||||
lastFrame = timestamp - (delta % frameInterval);
|
||||
|
||||
const { width, height } = canvas;
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const noise = Math.random() * 20;
|
||||
data[i] = noise;
|
||||
data[i + 1] = noise;
|
||||
data[i + 2] = noise;
|
||||
data[i + 3] = 12; // Very subtle
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
);
|
||||
return <div aria-hidden="true" className="film-grain" />;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Float,
|
||||
MeshDistortMaterial,
|
||||
MeshTransmissionMaterial,
|
||||
} from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
function GlassSphere({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
|
||||
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
|
||||
});
|
||||
|
||||
return (
|
||||
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||
<mesh ref={meshRef} position={position} scale={scale}>
|
||||
<sphereGeometry args={[1, 32, 32]} />
|
||||
<MeshDistortMaterial
|
||||
color="#ccff00"
|
||||
transparent
|
||||
opacity={0.08}
|
||||
distort={0.4}
|
||||
speed={2}
|
||||
wireframe
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
);
|
||||
}
|
||||
|
||||
function WireframeRing({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!meshRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
meshRef.current.rotation.x = t * 0.1;
|
||||
meshRef.current.rotation.y = t * 0.15;
|
||||
});
|
||||
|
||||
return (
|
||||
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
|
||||
<mesh ref={meshRef} position={position} scale={scale}>
|
||||
<torusGeometry args={[1, 0.02, 16, 100]} />
|
||||
<meshStandardMaterial
|
||||
color="#ccff00"
|
||||
transparent
|
||||
opacity={0.15}
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
);
|
||||
}
|
||||
|
||||
function DataStream({ count = 50 }: { count?: number }) {
|
||||
const pointsRef = useRef<THREE.Points>(null);
|
||||
|
||||
const positions = useMemo(() => {
|
||||
const pos = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
// Create a spiral/helix pattern
|
||||
const angle = (i / count) * Math.PI * 8;
|
||||
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
|
||||
pos[i3] = Math.cos(angle) * radius;
|
||||
pos[i3 + 1] = (i / count - 0.5) * 12;
|
||||
pos[i3 + 2] = Math.sin(angle) * radius;
|
||||
}
|
||||
return pos;
|
||||
}, [count]);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!pointsRef.current) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
pointsRef.current.rotation.y = t * 0.05;
|
||||
|
||||
const posAttr = pointsRef.current.geometry.getAttribute(
|
||||
"position",
|
||||
) as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
arr[i3 + 1] += 0.02;
|
||||
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={pointsRef}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions, 3]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
color="#ccff00"
|
||||
size={0.04}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
sizeAttenuation
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
interface FloatingElementsProps {
|
||||
variant?: "sphere" | "ring" | "data" | "combined";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FloatingElements({
|
||||
variant = "combined",
|
||||
className = "",
|
||||
}: FloatingElementsProps) {
|
||||
return (
|
||||
<div className={`pointer-events-none absolute inset-0 ${className}`}>
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 6], fov: 50 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{ background: "transparent" }}
|
||||
>
|
||||
<ambientLight intensity={0.2} />
|
||||
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
|
||||
|
||||
{(variant === "sphere" || variant === "combined") && (
|
||||
<>
|
||||
<GlassSphere position={[3, 1, -2]} scale={0.8} />
|
||||
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(variant === "ring" || variant === "combined") && (
|
||||
<>
|
||||
<WireframeRing position={[0, 0, -2]} scale={1.5} />
|
||||
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(variant === "data" || variant === "combined") && (
|
||||
<DataStream count={60} />
|
||||
)}
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,6 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
type HeroSceneVariant =
|
||||
| "command"
|
||||
| "launch"
|
||||
| "rescue"
|
||||
| "signal"
|
||||
| "diagnostic"
|
||||
| "portrait";
|
||||
|
||||
function GridFloor() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
@@ -104,13 +96,10 @@ function CameraRig() {
|
||||
}
|
||||
|
||||
export default function HeroScene({
|
||||
variant,
|
||||
isVisible = true,
|
||||
}: {
|
||||
variant?: HeroSceneVariant;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
void variant;
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
export default function HomeButton() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!isHome && (
|
||||
<motion.div
|
||||
className="fixed left-6 top-6 z-[9000]"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||
>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/"
|
||||
strength={0.3}
|
||||
aria-label="Voltar para o início"
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.72)] px-4 py-2.5 text-[11px] font-mono uppercase tracking-[0.1em] text-[rgba(244,242,237,0.76)] backdrop-blur-[16px] transition-all duration-300 hover:border-[rgba(204,255,0,0.5)] hover:text-[var(--slash)]"
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Arrow left icon */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="relative z-10 -translate-x-0 transition-transform duration-300 group-hover:-translate-x-0.5"
|
||||
>
|
||||
<path d="M7.5 1.5 L2.5 6 L7.5 10.5" />
|
||||
</svg>
|
||||
|
||||
<span className="relative z-10">Home</span>
|
||||
|
||||
{/* Animated neon dot */}
|
||||
<motion.span
|
||||
className="relative z-10 ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--slash)]"
|
||||
animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface HorizontalScrollProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function HorizontalScroll({
|
||||
children,
|
||||
className = "",
|
||||
}: HorizontalScrollProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!container || !track) return;
|
||||
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
// Disable on mobile for better UX
|
||||
if (window.innerWidth < 768) return;
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const totalWidth = track.scrollWidth;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const tween = gsap.to(track, {
|
||||
x: -(totalWidth - viewportWidth),
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
end: () => `+=${totalWidth - viewportWidth}`,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Animate individual items as they come into view
|
||||
const items = track.querySelectorAll("[data-scroll-item]");
|
||||
for (const item of items) {
|
||||
gsap.fromTo(
|
||||
item,
|
||||
{
|
||||
opacity: 0.3,
|
||||
scale: 0.9,
|
||||
rotateY: -8,
|
||||
transformPerspective: 1000,
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateY: 0,
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
containerAnimation: tween,
|
||||
start: "left 80%",
|
||||
end: "left 30%",
|
||||
scrub: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
ScrollTrigger.getAll().forEach((st) => {
|
||||
if (
|
||||
st.trigger === container ||
|
||||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
|
||||
tween
|
||||
) {
|
||||
st.kill();
|
||||
}
|
||||
});
|
||||
tween.kill();
|
||||
};
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex items-stretch gap-sp-6 will-change-transform"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
|
||||
target?: string;
|
||||
rel?: string;
|
||||
download?: boolean | string;
|
||||
"data-cursor"?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
wrapperClassName?: string;
|
||||
"aria-label"?: string;
|
||||
@@ -29,7 +28,6 @@ export default function MagneticButton({
|
||||
target,
|
||||
rel,
|
||||
download,
|
||||
"data-cursor": dataCursor,
|
||||
type,
|
||||
wrapperClassName = "",
|
||||
"aria-label": ariaLabel,
|
||||
@@ -84,7 +82,6 @@ export default function MagneticButton({
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
@@ -102,7 +99,6 @@ export default function MagneticButton({
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import PageIntro from "./PageIntro";
|
||||
|
||||
jest.mock("react-dom", () => ({
|
||||
...jest.requireActual("react-dom"),
|
||||
createPortal: (children: ReactNode) => children,
|
||||
}));
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_target, tag: string) =>
|
||||
({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement(tag, props, children),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
AnimatePresence: ({ children }: { children: ReactNode }) => children,
|
||||
motion,
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
describe("PageIntro", () => {
|
||||
it("renders the compact signal loading state without a fake percentage", () => {
|
||||
render(<PageIntro />);
|
||||
|
||||
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
|
||||
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
expect(screen.queryByText("000")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays visible long enough to be perceived on a fast page load", () => {
|
||||
jest.useFakeTimers();
|
||||
render(<PageIntro />);
|
||||
|
||||
act(() => jest.advanceTimersByTime(900));
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
|
||||
act(() => jest.runAllTimers());
|
||||
expect(
|
||||
screen.queryByText("Preparando o portfólio"),
|
||||
).not.toBeInTheDocument();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
|
||||
|
||||
export default function PageIntro() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const timeout = window.setTimeout(
|
||||
() => setVisible(false),
|
||||
reduceMotion ? 150 : 1350,
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [reduceMotion]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
initial={{ opacity: 1 }}
|
||||
exit={
|
||||
reduceMotion
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
clipPath: "inset(0 0 100% 0)",
|
||||
transition: { duration: 0.65, ease: EXIT_EASE },
|
||||
}
|
||||
}
|
||||
aria-live="polite"
|
||||
aria-label="Preparando o portfólio"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
|
||||
|
||||
<div className="relative m-auto w-[min(88vw,680px)]">
|
||||
<motion.p
|
||||
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
SINAL DE ENTRADA
|
||||
</motion.p>
|
||||
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
|
||||
<motion.p
|
||||
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.48,
|
||||
delay: 0.06,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="mt-sp-8 flex items-center gap-sp-4"
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.28, delay: 0.18 }}
|
||||
>
|
||||
<span className="relative block h-2 w-2" aria-hidden="true">
|
||||
<span className="absolute inset-0 bg-[var(--slash)]" />
|
||||
{!reduceMotion && (
|
||||
<motion.span
|
||||
className="absolute inset-0 bg-[var(--slash)]"
|
||||
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
|
||||
Preparando o portfólio
|
||||
</p>
|
||||
<span
|
||||
className="h-px flex-1 bg-[rgba(244,242,237,.22)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
|
||||
<span>Engenharia de software</span>
|
||||
<span>Garuva · Joinville · Curitiba</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,100 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useMotionValue,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
baseVelocity?: number;
|
||||
direction: "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function wrap(min: number, max: number, v: number) {
|
||||
const rangeSize = max - min;
|
||||
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
|
||||
}
|
||||
const GROUPS = [0, 1] as const;
|
||||
|
||||
export default function ScrollVelocityText({
|
||||
text,
|
||||
baseVelocity = 2,
|
||||
direction,
|
||||
className = "",
|
||||
}: ScrollVelocityTextProps) {
|
||||
const baseX = useMotionValue(0);
|
||||
const { scrollY } = useScroll();
|
||||
const scrollVelocity = useMotionValue(0);
|
||||
const smoothVelocity = useSpring(scrollVelocity, {
|
||||
damping: 50,
|
||||
stiffness: 300,
|
||||
});
|
||||
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
|
||||
clamp: false,
|
||||
});
|
||||
|
||||
const [repetitions, setRepetitions] = useState(4);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trackId = useId();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePreference = (event: MediaQueryListEvent) => {
|
||||
setShouldReduceMotion(event.matches);
|
||||
};
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
media.addEventListener("change", updatePreference);
|
||||
return () => media.removeEventListener("change", updatePreference);
|
||||
}, []);
|
||||
|
||||
// Track scroll velocity
|
||||
const prevScrollY = useRef(0);
|
||||
useEffect(() => {
|
||||
return scrollY.on("change", (latest) => {
|
||||
const delta = latest - prevScrollY.current;
|
||||
prevScrollY.current = latest;
|
||||
scrollVelocity.set(Math.abs(delta));
|
||||
});
|
||||
}, [scrollY, scrollVelocity]);
|
||||
|
||||
const directionFactor = useRef(1);
|
||||
|
||||
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
|
||||
|
||||
useAnimationFrame((_, delta) => {
|
||||
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
|
||||
|
||||
const vf = velocityFactor.get();
|
||||
if (vf < 0) {
|
||||
directionFactor.current = -1;
|
||||
} else if (vf > 0) {
|
||||
directionFactor.current = 1;
|
||||
}
|
||||
|
||||
moveBy += directionFactor.current * moveBy * vf;
|
||||
baseX.set(baseX.get() + moveBy);
|
||||
});
|
||||
const animationPaused = shouldReduceMotion || isPaused;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`overflow-hidden whitespace-nowrap ${className}`}
|
||||
>
|
||||
<motion.div className="inline-flex" style={{ x }}>
|
||||
{[...Array(repetitions)].map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
|
||||
>
|
||||
{text}
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">
|
||||
•
|
||||
<div className={`scroll-velocity-text ${className}`.trim()}>
|
||||
<div className="scroll-velocity-viewport">
|
||||
<div
|
||||
id={trackId}
|
||||
className={`scroll-velocity-track ${
|
||||
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
|
||||
}`}
|
||||
style={{
|
||||
animationPlayState: animationPaused ? "paused" : undefined,
|
||||
}}
|
||||
>
|
||||
{GROUPS.map((group) => (
|
||||
<span
|
||||
key={group}
|
||||
className="scroll-velocity-group"
|
||||
aria-hidden={group === 0 ? undefined : "true"}
|
||||
>
|
||||
<span className="scroll-velocity-item">
|
||||
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
|
||||
<span aria-hidden="true" className="scroll-velocity-dot">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shouldReduceMotion ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-velocity-control"
|
||||
aria-controls={trackId}
|
||||
aria-pressed={isPaused}
|
||||
onClick={() => setIsPaused((paused) => !paused)}
|
||||
>
|
||||
{isPaused ? "Retomar animação" : "Pausar animação"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SectionDividerProps {
|
||||
variant?: "gradient" | "pulse" | "waveform";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const;
|
||||
|
||||
export default function SectionDivider({
|
||||
variant = "gradient",
|
||||
className = "",
|
||||
}: SectionDividerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "waveform") return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animId: number;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = 60;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const render = () => {
|
||||
animId = requestAnimationFrame(render);
|
||||
const { width, height } = canvas;
|
||||
const t = performance.now() * 0.001;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw multiple waveforms
|
||||
for (let wave = 0; wave < 3; wave++) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = `rgba(204, 255, 0, ${0.1 - wave * 0.03})`;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const frequency = 0.005 + wave * 0.002;
|
||||
const amplitude = 8 + wave * 4;
|
||||
const speed = t * (0.5 + wave * 0.2);
|
||||
const y =
|
||||
height / 2 +
|
||||
Math.sin(x * frequency + speed) * amplitude +
|
||||
Math.sin(x * frequency * 2.3 + speed * 1.5) * (amplitude * 0.4);
|
||||
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
animId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [variant]);
|
||||
const rootClassName =
|
||||
`section-divider section-divider-${variant} ${className}`.trim();
|
||||
|
||||
if (variant === "gradient") {
|
||||
return (
|
||||
<div className={`relative h-px w-full overflow-hidden ${className}`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-30" />
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-60"
|
||||
style={{
|
||||
animation: "shimmerDivider 3s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pulse") {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-8 ${className}`}
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-1 w-1 rounded-full bg-[var(--slash)]"
|
||||
style={{
|
||||
animation: `pulseDot 2s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
{PULSE_DOTS.map((dot) => (
|
||||
<span key={dot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// waveform
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full opacity-60"
|
||||
style={{ height: 60 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div aria-hidden="true" className={rootClassName}>
|
||||
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
|
||||
<title>Divisor decorativo</title>
|
||||
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
|
||||
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
|
||||
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user