refactor: substituir celular e filmstrip por notebook
This commit is contained in:
1 parent
d768dfa791
commit
1c1a6d5c6d
14 files changed
+851
-1182
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import * as THREE from "three";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import NotebookStage3D, {
|
||||
NOTEBOOK_MODEL_URL,
|
||||
type NotebookStage3DProps,
|
||||
} from "./NotebookStage3D";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: ({
|
||||
children,
|
||||
frameloop,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
frameloop: string;
|
||||
}) => (
|
||||
<div data-frameloop={frameloop} data-testid="mock-canvas">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
useFrame: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@react-three/drei", () => ({
|
||||
Html: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="mock-html">{children}</div>
|
||||
),
|
||||
useGLTF: jest.fn(() => ({ scene: new THREE.Group() })),
|
||||
}));
|
||||
|
||||
const mockedUseGLTF = jest.mocked(useGLTF);
|
||||
const projects = selectNotebookProjects(portfolio.projects);
|
||||
const props: NotebookStage3DProps = {
|
||||
activeIndex: 0,
|
||||
isVisible: true,
|
||||
onSelectTab: jest.fn(),
|
||||
progress: { current: 0.56 },
|
||||
projects,
|
||||
};
|
||||
|
||||
describe("NotebookStage3D", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
mockedUseGLTF.mockImplementation((() => ({
|
||||
scene: new THREE.Group(),
|
||||
})) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a single stage using the local model and browser surface", () => {
|
||||
render(<NotebookStage3D {...props} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-canvas")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("notebook-browser")).toBeInTheDocument();
|
||||
expect(mockedUseGLTF).toHaveBeenCalledWith(NOTEBOOK_MODEL_URL);
|
||||
});
|
||||
|
||||
it("pauses the renderer when the stage is outside the viewport", () => {
|
||||
render(<NotebookStage3D {...props} isVisible={false} />);
|
||||
|
||||
expect(screen.getByTestId("mock-canvas")).toHaveAttribute(
|
||||
"data-frameloop",
|
||||
"never",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns usable browser content when the GLB boundary fails", () => {
|
||||
const error = new Error("GLB failed");
|
||||
mockedUseGLTF.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
render(<NotebookStage3D {...props} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /abrir projeto/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import { Html, useGLTF } from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
Component,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import NotebookBrowser from "./NotebookBrowser";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import type { NotebookProject } from "./notebookProjects";
|
||||
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
|
||||
|
||||
export const NOTEBOOK_MODEL_URL = "/models/laptop-kenney.glb";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
export type NotebookStage3DProps = {
|
||||
progress: ProgressRef;
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
type NotebookSceneProps = Omit<NotebookStage3DProps, "isVisible">;
|
||||
|
||||
type MaterialMap = {
|
||||
body: THREE.MeshStandardMaterial;
|
||||
frame: THREE.MeshStandardMaterial;
|
||||
screen: THREE.MeshStandardMaterial;
|
||||
};
|
||||
|
||||
const MODEL_SCALE = 3.5;
|
||||
const OPEN_LID_ANGLE = 112;
|
||||
|
||||
function createMaterials(): MaterialMap {
|
||||
return {
|
||||
body: new THREE.MeshStandardMaterial({
|
||||
color: "#102020",
|
||||
metalness: 0.78,
|
||||
roughness: 0.3,
|
||||
transparent: true,
|
||||
}),
|
||||
frame: new THREE.MeshStandardMaterial({
|
||||
color: "#294345",
|
||||
metalness: 0.64,
|
||||
roughness: 0.38,
|
||||
transparent: true,
|
||||
}),
|
||||
screen: new THREE.MeshStandardMaterial({
|
||||
color: "#a8b8b8",
|
||||
metalness: 0.05,
|
||||
roughness: 0.72,
|
||||
transparent: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneNotebookScene(scene: THREE.Object3D): {
|
||||
root: THREE.Object3D;
|
||||
materials: MaterialMap;
|
||||
} {
|
||||
const root = scene.clone(true);
|
||||
const materials = createMaterials();
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
|
||||
object.geometry = object.geometry.clone();
|
||||
const sourceMaterial = Array.isArray(object.material)
|
||||
? object.material[0]
|
||||
: object.material;
|
||||
const materialName = sourceMaterial?.name ?? "";
|
||||
object.material =
|
||||
materialName === "metal"
|
||||
? materials.screen
|
||||
: materialName === "metalMedium"
|
||||
? materials.frame
|
||||
: materials.body;
|
||||
object.frustumCulled = false;
|
||||
});
|
||||
|
||||
return { root, materials };
|
||||
}
|
||||
|
||||
function updateMaterialOpacity(materials: MaterialMap, opacity: number) {
|
||||
materials.body.opacity = opacity;
|
||||
materials.frame.opacity = opacity;
|
||||
materials.screen.opacity = opacity;
|
||||
}
|
||||
|
||||
function disposeNotebookScene(root: THREE.Object3D, materials: MaterialMap) {
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh)) return;
|
||||
object.geometry.dispose();
|
||||
});
|
||||
materials.body.dispose();
|
||||
materials.frame.dispose();
|
||||
materials.screen.dispose();
|
||||
}
|
||||
|
||||
function LoadingNotebook() {
|
||||
return (
|
||||
<group position={[0, -0.3, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[2.4, 0.16, 1.5]} />
|
||||
<meshStandardMaterial
|
||||
color="#102020"
|
||||
metalness={0.7}
|
||||
roughness={0.34}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenSurface({
|
||||
frame,
|
||||
projects,
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
}: {
|
||||
frame: NotebookFrame;
|
||||
projects: NotebookProject[];
|
||||
activeIndex: number;
|
||||
onSelectTab: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
distanceFactor={4.6}
|
||||
position={[-0.132, 0.087, 0.081]}
|
||||
transform
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="notebook-screen-surface"
|
||||
style={{ opacity: frame.screenOpacity }}
|
||||
>
|
||||
<NotebookBrowser
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookScene({
|
||||
activeIndex,
|
||||
onSelectTab,
|
||||
progress,
|
||||
projects,
|
||||
}: NotebookSceneProps) {
|
||||
const { scene } = useGLTF(NOTEBOOK_MODEL_URL);
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const lidRig = useRef<THREE.Group>(null);
|
||||
const smoothProgress = useRef(progress.current);
|
||||
const elapsedForDom = useRef(0);
|
||||
const [frame, setFrame] = useState(() =>
|
||||
getNotebookFrame(progress.current, projects.length),
|
||||
);
|
||||
const cloned = useMemo(() => cloneNotebookScene(scene), [scene]);
|
||||
const lid = cloned.root.getObjectByName("Laptop_Lid");
|
||||
const base = cloned.root.getObjectByName("Laptop_Base");
|
||||
|
||||
useFrame((_, delta) => {
|
||||
smoothProgress.current +=
|
||||
(progress.current - smoothProgress.current) * Math.min(1, delta * 8);
|
||||
const nextFrame = getNotebookFrame(smoothProgress.current, projects.length);
|
||||
|
||||
if (rig.current) {
|
||||
rig.current.position.set(0.46, -0.34, 0);
|
||||
rig.current.scale.setScalar(MODEL_SCALE);
|
||||
}
|
||||
if (lidRig.current) {
|
||||
lidRig.current.rotation.x = THREE.MathUtils.degToRad(
|
||||
nextFrame.lidAngle - OPEN_LID_ANGLE,
|
||||
);
|
||||
}
|
||||
updateMaterialOpacity(cloned.materials, nextFrame.deviceOpacity);
|
||||
|
||||
elapsedForDom.current += delta;
|
||||
if (elapsedForDom.current >= 1 / 24) {
|
||||
elapsedForDom.current = 0;
|
||||
setFrame(nextFrame);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => () => disposeNotebookScene(cloned.root, cloned.materials),
|
||||
[cloned],
|
||||
);
|
||||
|
||||
return (
|
||||
<group ref={rig}>
|
||||
<ambientLight intensity={0.66} />
|
||||
<directionalLight color="#f4f2ed" intensity={1.8} position={[3, 5, 4]} />
|
||||
<directionalLight
|
||||
color="#ccff00"
|
||||
intensity={0.45}
|
||||
position={[-4, 1, 2]}
|
||||
/>
|
||||
{base && lid ? (
|
||||
<>
|
||||
<primitive object={base} />
|
||||
<group ref={lidRig}>
|
||||
<primitive object={lid} />
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<primitive object={cloned.root} />
|
||||
<group ref={lidRig}>
|
||||
<ScreenSurface
|
||||
activeIndex={activeIndex}
|
||||
frame={frame}
|
||||
onSelectTab={onSelectTab}
|
||||
projects={projects}
|
||||
/>
|
||||
</group>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
class NotebookStageBoundary extends Component<
|
||||
NotebookStage3DProps,
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<NotebookFallback
|
||||
initialIndex={this.props.activeIndex}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ fov: 30, position: [0, 0, 6.8] }}
|
||||
className="notebook-story-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={this.props.isVisible ? "always" : "never"}
|
||||
gl={{
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<LoadingNotebook />}>
|
||||
<NotebookScene
|
||||
activeIndex={this.props.activeIndex}
|
||||
onSelectTab={this.props.onSelectTab}
|
||||
progress={this.props.progress}
|
||||
projects={this.props.projects}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotebookStage3D(props: NotebookStage3DProps) {
|
||||
return (
|
||||
<div className="notebook-story-stage" data-testid="notebook-stage">
|
||||
<NotebookStageBoundary {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { portfolio } from "@/lib/portfolio-data";
|
||||
import NotebookStory, {
|
||||
canUseNotebook3D,
|
||||
getNotebookSeekProgress,
|
||||
} from "./NotebookStory";
|
||||
|
||||
jest.mock("next/dynamic", () => () => {
|
||||
return function DynamicNotebookStage() {
|
||||
return <div data-testid="notebook-stage" />;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })),
|
||||
context: jest.fn((callback: () => void) => {
|
||||
callback();
|
||||
return { revert: jest.fn() };
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
const projects = portfolio.projects;
|
||||
|
||||
function mockMatchMedia({
|
||||
reduced = false,
|
||||
coarse = false,
|
||||
hover = true,
|
||||
}: {
|
||||
reduced?: boolean;
|
||||
coarse?: boolean;
|
||||
hover?: boolean;
|
||||
} = {}) {
|
||||
const matcher = jest.isMockFunction(window.matchMedia)
|
||||
? window.matchMedia
|
||||
: jest.spyOn(window, "matchMedia");
|
||||
matcher.mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === "(prefers-reduced-motion: reduce)" && reduced) ||
|
||||
(query === "(pointer: coarse)" && coarse) ||
|
||||
(query === "(hover: hover)" && hover),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("NotebookStory", () => {
|
||||
beforeEach(() => {
|
||||
mockMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1280,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the static fallback when reduced motion is enabled", async () => {
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not initialize the 3D stage on coarse pointers", async () => {
|
||||
mockMatchMedia({ coarse: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
it("keeps the static fallback when no mapped projects are available", async () => {
|
||||
mockMatchMedia();
|
||||
|
||||
render(<NotebookStory projects={[]} />);
|
||||
|
||||
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires desktop hover input without reduced motion", () => {
|
||||
const matchMedia = (query: string) =>
|
||||
({
|
||||
matches: query === "(hover: hover)",
|
||||
}) as MediaQueryList;
|
||||
|
||||
expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true);
|
||||
expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false);
|
||||
expect(
|
||||
canUseNotebook3D({
|
||||
innerWidth: 1280,
|
||||
matchMedia: (query) =>
|
||||
({
|
||||
matches:
|
||||
query === "(hover: hover)" ||
|
||||
query === "(prefers-reduced-motion: reduce)",
|
||||
}) as MediaQueryList,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes only mapped projects into the fallback browser", () => {
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /Twins Lab/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps tab seeks inside the browse window without a second scroll engine", () => {
|
||||
const first = getNotebookSeekProgress(0, 4);
|
||||
const middle = getNotebookSeekProgress(2, 4);
|
||||
const last = getNotebookSeekProgress(3, 4);
|
||||
|
||||
expect(first).toBeGreaterThanOrEqual(0.44);
|
||||
expect(first).toBeLessThan(0.78);
|
||||
expect(middle).toBeGreaterThan(first);
|
||||
expect(last).toBeLessThan(0.78);
|
||||
expect(getNotebookSeekProgress(99, 4)).toBeLessThan(0.78);
|
||||
});
|
||||
|
||||
it("does not register a global scroll listener", () => {
|
||||
const addEventListener = jest.spyOn(window, "addEventListener");
|
||||
mockMatchMedia({ reduced: true });
|
||||
|
||||
render(<NotebookStory projects={projects} />);
|
||||
|
||||
expect(
|
||||
addEventListener.mock.calls.some(([type]) => type === "scroll"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import NotebookFallback from "./NotebookFallback";
|
||||
import { selectNotebookProjects } from "./notebookProjects";
|
||||
import { getNotebookFrame } from "./notebookTimeline";
|
||||
|
||||
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const BROWSE_START = 0.44;
|
||||
const BROWSE_END = 0.78;
|
||||
|
||||
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
|
||||
|
||||
export type NotebookStoryProps = {
|
||||
projects: PortfolioProject[];
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
if (typeof view.matchMedia !== "function") return false;
|
||||
return (
|
||||
view.innerWidth >= 1025 &&
|
||||
!view.matchMedia("(prefers-reduced-motion: reduce)").matches &&
|
||||
!view.matchMedia("(pointer: coarse)").matches &&
|
||||
view.matchMedia("(hover: hover)").matches
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotebookSeekProgress(
|
||||
index: number,
|
||||
tabCount: number,
|
||||
): number {
|
||||
const count = Math.max(0, Math.floor(tabCount));
|
||||
if (count < 1) return BROWSE_START;
|
||||
const safeIndex = Math.min(Math.max(Math.floor(index), 0), count - 1);
|
||||
const slot = (BROWSE_END - BROWSE_START) / count;
|
||||
return BROWSE_START + slot * (safeIndex + 0.5);
|
||||
}
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length < 1) return -1;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
const notebookProjects = useMemo(
|
||||
() => selectNotebookProjects(projects),
|
||||
[projects],
|
||||
);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
|
||||
useEffect(() => {
|
||||
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
|
||||
}, [notebookProjects.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!engine || !section || !("IntersectionObserver" in window)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsVisible(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
observer.observe(section);
|
||||
return () => observer.disconnect();
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engine) return undefined;
|
||||
const sectionElement = sectionRef.current;
|
||||
if (!sectionElement) return undefined;
|
||||
const sectionRoot: HTMLElement = sectionElement;
|
||||
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
async function setupTimeline() {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
const context = gsap.context(() => {
|
||||
const driver = { value: 0 };
|
||||
const timeline = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
scrollTrigger: {
|
||||
trigger: sectionRoot,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.85,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
progressRef.current = self.progress;
|
||||
const nextIndex = getNotebookFrame(
|
||||
self.progress,
|
||||
notebookProjects.length,
|
||||
).activeTabIndex;
|
||||
if (nextIndex >= 0 && nextIndex !== activeIndexRef.current) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
setActiveIndex(nextIndex);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
timeline.to(driver, { duration: 1, value: 1 });
|
||||
}, sectionRoot);
|
||||
cleanup = () => context.revert();
|
||||
}
|
||||
|
||||
void setupTimeline();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [engine, notebookProjects.length]);
|
||||
|
||||
const seekToTab = useCallback(
|
||||
(index: number) => {
|
||||
const section = sectionRef.current;
|
||||
if (!section || notebookProjects.length < 1) return;
|
||||
|
||||
const rect = section.getBoundingClientRect();
|
||||
const sectionTop = (window.scrollY || window.pageYOffset) + rect.top;
|
||||
const runwayDistance = Math.max(
|
||||
section.offsetHeight - window.innerHeight,
|
||||
0,
|
||||
);
|
||||
const target =
|
||||
sectionTop +
|
||||
runwayDistance *
|
||||
getNotebookSeekProgress(index, notebookProjects.length);
|
||||
window.scrollTo({ top: target, behavior: "smooth" });
|
||||
},
|
||||
[notebookProjects.length],
|
||||
);
|
||||
|
||||
const handleSelectTab = useCallback(
|
||||
(index: number) => {
|
||||
const safeIndex = clampIndex(index, notebookProjects.length);
|
||||
if (safeIndex < 0) return;
|
||||
activeIndexRef.current = safeIndex;
|
||||
setActiveIndex(safeIndex);
|
||||
seekToTab(safeIndex);
|
||||
},
|
||||
[notebookProjects.length, seekToTab],
|
||||
);
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção"
|
||||
className="notebook-story notebook-story-static"
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
>
|
||||
<div className="notebook-story-static-content">
|
||||
<div className="notebook-story-copy">
|
||||
<span className="notebook-story-kicker">O notebook</span>
|
||||
<h2>Software que opera.</h2>
|
||||
<p>
|
||||
Uma seleção navegável de sistemas reais, com suas interfaces e
|
||||
tecnologias em produção.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookFallback projects={notebookProjects} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Apresentação em scroll contínuo"
|
||||
className="notebook-story notebook-story-runway"
|
||||
data-notebook-engine="3d"
|
||||
data-testid="notebook-story"
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
>
|
||||
<div className="notebook-story-sticky">
|
||||
<div className="notebook-story-grid">
|
||||
<div className="notebook-story-copy">
|
||||
<span className="notebook-story-kicker">O notebook</span>
|
||||
<h2>Software que opera.</h2>
|
||||
<p>
|
||||
Role para abrir a engenharia por trás de cada entrega. O scroll é
|
||||
o controle de play e rebobinar.
|
||||
</p>
|
||||
<span className="notebook-story-count" aria-live="polite">
|
||||
{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "}
|
||||
{String(notebookProjects.length).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<NotebookStage3D
|
||||
activeIndex={activeIndex}
|
||||
isVisible={isVisible}
|
||||
onSelectTab={handleSelectTab}
|
||||
progress={progressRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll.
|
||||
* Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada
|
||||
* no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa
|
||||
* da direita para a esquerda; quando a faixa termina, o sticky solta e o
|
||||
* scroll continua para baixo normalmente. Role para cima = rebobina.
|
||||
*
|
||||
* Sem JS / reduced motion: grid estático (fallback), como no ScrollStory.
|
||||
*/
|
||||
|
||||
const PRINTS = [
|
||||
{
|
||||
src: "/assets/prints/print-nutriscan.png",
|
||||
name: "NutriScan",
|
||||
desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
|
||||
url: "https://nutriscan.evandro.dev.br",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
src: "/assets/prints/print-scplus.png",
|
||||
name: "SCPLUS",
|
||||
desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
|
||||
url: "https://scplus.evandro.dev.br",
|
||||
accent: false,
|
||||
},
|
||||
{
|
||||
src: "/assets/prints/print-sev.png",
|
||||
name: "SEV Moda Íntima",
|
||||
desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
|
||||
url: "https://sevmodaintima.com.br",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
src: "/assets/prints/print-asservicos.png",
|
||||
name: "AS Serviços",
|
||||
desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
|
||||
url: "https://asservicos.com",
|
||||
accent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const CARD_W = "min(80vw, 430px)";
|
||||
|
||||
export default function PrintReel() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const [engine, setEngine] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||
return undefined;
|
||||
setEngine(true);
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engine) return undefined;
|
||||
const section = sectionRef.current;
|
||||
if (!section) return undefined;
|
||||
|
||||
let cleanup = () => {};
|
||||
let cancelled = false;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const track = section.querySelector<HTMLElement>("[data-strip-track]");
|
||||
const idxEl = section.querySelector<HTMLElement>("[data-strip-index]");
|
||||
const bar = section.querySelector<HTMLElement>("[data-strip-bar]");
|
||||
if (!track) return;
|
||||
|
||||
// distância = largura da faixa menos a viewport
|
||||
const distance = () =>
|
||||
Math.max(0, track.scrollWidth - window.innerWidth);
|
||||
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.85,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
tl.to(track, { x: () => -distance(), duration: 1 });
|
||||
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
|
||||
if (idxEl) {
|
||||
tl.eventCallback("onUpdate", () => {
|
||||
const p = (tl.scrollTrigger?.progress ?? 0) * PRINTS.length;
|
||||
idxEl.textContent = String(
|
||||
Math.min(PRINTS.length, Math.floor(p) + 1),
|
||||
).padStart(2, "0");
|
||||
});
|
||||
}
|
||||
|
||||
// cada card "acende" ao entrar e esmaece ao passar (scrub, reversível)
|
||||
const cards = Array.from(
|
||||
section.querySelectorAll<HTMLElement>("[data-strip-card]"),
|
||||
);
|
||||
const span = 1 / cards.length;
|
||||
cards.forEach((card, i) => {
|
||||
tl.fromTo(
|
||||
card,
|
||||
{ opacity: 0.15, scale: 0.92, rotateY: -9 },
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateY: 0,
|
||||
transformPerspective: 1100,
|
||||
duration: span * 0.5,
|
||||
},
|
||||
Math.max(0, i * span - span * 0.12),
|
||||
);
|
||||
if (i > 0) {
|
||||
tl.to(
|
||||
card,
|
||||
{ opacity: 0.35, scale: 0.94, duration: span * 0.4 },
|
||||
(i - 1) * span + span * 0.55,
|
||||
);
|
||||
}
|
||||
});
|
||||
}, section);
|
||||
|
||||
cleanup = () => ctx.revert();
|
||||
};
|
||||
|
||||
void setup();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [engine]);
|
||||
|
||||
// ---- Fallback estático (sem JS / reduced motion) ----
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
id="print-reel"
|
||||
className="border-t border-[rgba(255,255,255,.06)] bg-[var(--ink)] py-16 text-[var(--paper)]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1240px] px-6">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
No ar, rodando
|
||||
</div>
|
||||
<h2 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold tracking-[-0.02em]">
|
||||
Prints ao vivo dos sistemas em produção
|
||||
</h2>
|
||||
<div className="mt-8 grid gap-6 sm:grid-cols-2">
|
||||
{PRINTS.map((print) => (
|
||||
<a
|
||||
key={print.name}
|
||||
href={print.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.03)]"
|
||||
>
|
||||
<img
|
||||
src={print.src}
|
||||
alt={`Captura do projeto ${print.name} em produção`}
|
||||
className="h-72 w-full object-cover object-top"
|
||||
/>
|
||||
<div className="p-5">
|
||||
<div className="font-display text-[18px] font-extrabold">
|
||||
{print.name}
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
|
||||
{print.desc}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Modo reel: runway + sticky ----
|
||||
return (
|
||||
<section
|
||||
id="print-reel"
|
||||
ref={sectionRef}
|
||||
data-strip
|
||||
className="relative h-[280vh] bg-[var(--ink)]"
|
||||
aria-label="Galeria horizontal dos projetos em produção"
|
||||
>
|
||||
<div className="sticky top-0 flex h-[100dvh] w-full flex-col overflow-hidden">
|
||||
{/* cabeçalho do palco */}
|
||||
<div className="mx-auto flex w-full max-w-[1240px] shrink-0 flex-wrap items-end justify-between gap-4 px-6 pt-sp-16 lg:px-16">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--slash)]">
|
||||
No ar, rodando
|
||||
</div>
|
||||
<h2 className="mt-2 font-display text-[clamp(1.6rem,3.4vw,2.6rem)] font-extrabold uppercase leading-[1] tracking-[-0.03em] text-[var(--paper)]">
|
||||
Prints ao vivo da produção
|
||||
</h2>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
|
||||
← role para percorrer →
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* faixa */}
|
||||
<div className="flex min-h-0 flex-1 items-center">
|
||||
<div
|
||||
data-strip-track
|
||||
className="flex w-max items-stretch gap-10 pl-[max(1.5rem,calc((100vw-1240px)/2+2rem))] pr-[max(1.5rem,calc((100vw-1240px)/2+2rem))] will-change-transform"
|
||||
>
|
||||
{PRINTS.map((print, i) => (
|
||||
<a
|
||||
key={print.name}
|
||||
data-strip-card
|
||||
href={print.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex shrink-0 flex-col will-change-transform"
|
||||
style={{ width: CARD_W }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
|
||||
/>
|
||||
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
|
||||
{print.url.replace("https://", "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-[min(50vh,380px)] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
|
||||
<img
|
||||
src={print.src}
|
||||
alt={`Captura do projeto ${print.name} em produção`}
|
||||
loading={i < 2 ? "eager" : "lazy"}
|
||||
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.03] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-5"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_62%,rgba(10,10,10,.85)_100%)]" />
|
||||
</div>
|
||||
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-display text-[17px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
|
||||
{print.name}
|
||||
</h3>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
ao vivo ↗
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[12px] leading-6 text-[rgba(244,242,237,.62)]">
|
||||
{print.desc}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUD */}
|
||||
<div className="mx-auto mb-6 flex w-full max-w-[1240px] shrink-0 items-center gap-4 px-6 lg:px-16">
|
||||
<span
|
||||
data-strip-index
|
||||
className="font-mono text-[10px] tracking-[0.14em] text-[var(--slash)]"
|
||||
>
|
||||
01
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tracking-[0.14em] text-[rgba(244,242,237,.45)]">
|
||||
/ 0{PRINTS.length}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-[rgba(255,255,255,.12)]">
|
||||
<div
|
||||
data-strip-bar
|
||||
className="h-full w-full origin-left bg-[var(--slash)]"
|
||||
style={{ transform: "scaleX(0)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import ScrollStory from "./ScrollStory";
|
||||
|
||||
// gsap é carregado via import() dinâmico dentro do effect; o contrato testado
|
||||
// aqui é markup do reel (cenas/HUD/alvos), não a timeline — que é verificada
|
||||
// no browser via probe CDP. Mock leve mantém a suíte em jsdom.
|
||||
jest.mock("gsap", () => {
|
||||
const chain = { set: jest.fn(), fromTo: jest.fn(), to: jest.fn() };
|
||||
Object.assign(chain, {
|
||||
set: jest.fn(() => chain),
|
||||
fromTo: jest.fn(() => chain),
|
||||
to: jest.fn(() => chain),
|
||||
});
|
||||
return {
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
set: jest.fn(),
|
||||
timeline: jest.fn(() => chain),
|
||||
ticker: { add: jest.fn(), remove: jest.fn(), lagSmoothing: jest.fn() },
|
||||
utils: { toArray: jest.fn(() => []) },
|
||||
context: jest.fn((fn: () => void) => {
|
||||
fn();
|
||||
return { revert: jest.fn() };
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
|
||||
const props = { projectsCount: 5, experiencesCount: 6, skillsCount: 18 };
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ScrollStory", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
it("renderiza fallback empilhado com conteúdo real com reduced motion", () => {
|
||||
mockMatchMedia(true);
|
||||
const { container } = render(<ScrollStory {...props} />);
|
||||
// sem palco scrub: capítulos legíveis no fluxo, números reais do portfólio
|
||||
expect(container.querySelector("[data-story]")).not.toBeInTheDocument();
|
||||
expect(container).toHaveTextContent("01 — Visão geral");
|
||||
expect(container).toHaveTextContent("04 — Próximo ciclo");
|
||||
expect(container).toHaveTextContent("5");
|
||||
expect(screen.getAllByText(/sistemas ativos no portfólio/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
|
||||
const { container } = render(<ScrollStory {...props} />);
|
||||
await waitFor(
|
||||
() => expect(container.querySelector("[data-story]")).toBeInTheDocument(),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
|
||||
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
|
||||
expect(container.querySelector("[data-story-bar]")).toBeInTheDocument();
|
||||
// count-up é alimentado pela timeline: markup carrega o alvo real
|
||||
const num = container.querySelector("[data-story-num]");
|
||||
expect(num?.getAttribute("data-story-num-to")).toBe("5");
|
||||
}, 20000);
|
||||
});
|
||||
@@ -1,377 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
|
||||
const StoryStage3D = dynamic(() => import("./StoryStage3D"), { ssr: false });
|
||||
|
||||
type Chapter = {
|
||||
kicker: string;
|
||||
lines: string[];
|
||||
body?: string;
|
||||
stats?: Array<{ value: number; suffix: string; label: string }>;
|
||||
cta?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reel scrollytelling da home: palco sticky de 100dvh dirigido por uma única
|
||||
* timeline GSAP com scrub — o scroll é o controle de play/rebobinar.
|
||||
* Sem JS ou com prefers-reduced-motion: capítulos renderizam empilhados,
|
||||
* inteiros e legíveis (mesma regra de fail-soft do hero).
|
||||
*/
|
||||
export default function ScrollStory({
|
||||
projectsCount,
|
||||
experiencesCount,
|
||||
skillsCount,
|
||||
}: {
|
||||
projectsCount: number;
|
||||
experiencesCount: number;
|
||||
skillsCount: number;
|
||||
}) {
|
||||
const chapters: Chapter[] = [
|
||||
{
|
||||
kicker: "01 — Visão geral",
|
||||
lines: ["Software que opera.", "Não só roda."],
|
||||
body: "Role para rebobinar a engenharia por trás de cada sistema — a mesma página, agora em tomada contínua.",
|
||||
},
|
||||
{
|
||||
kicker: "02 — Arquitetura",
|
||||
lines: ["Da borda", "ao painel."],
|
||||
body: "Dispositivo, integração, API e interface girando juntos: contratos claros entre camada física e produto.",
|
||||
},
|
||||
{
|
||||
kicker: "03 — Em produção",
|
||||
lines: ["Entrega com", "critério real."],
|
||||
stats: [
|
||||
{ value: projectsCount, suffix: "", label: "sistemas ativos no portfólio" },
|
||||
{ value: experiencesCount, suffix: "", label: "ciclos de produto entregues" },
|
||||
{ value: skillsCount, suffix: "", label: "tecnologias em operação" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kicker: "04 — Próximo ciclo",
|
||||
lines: ["Vamos girar o", "seu próximo sistema."],
|
||||
cta: true,
|
||||
},
|
||||
];
|
||||
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [webgl, setWebgl] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) return undefined;
|
||||
setEngine(true);
|
||||
setWebgl(
|
||||
!window.matchMedia("(pointer: coarse)").matches && window.innerWidth >= 768,
|
||||
);
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engine) return undefined;
|
||||
const el = sectionRef.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
let cleanup = () => {};
|
||||
let cancelled = false;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scenes = gsap.utils.toArray<HTMLElement>("[data-story-scene]");
|
||||
const bar = el.querySelector("[data-story-bar]");
|
||||
const indexEl = el.querySelector<HTMLElement>("[data-story-index]");
|
||||
const hint = el.querySelector("[data-story-hint]");
|
||||
|
||||
gsap.set(scenes.slice(1), { autoAlpha: 0 });
|
||||
gsap.set(scenes, { yPercent: 0 });
|
||||
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.85,
|
||||
onUpdate: (self) => {
|
||||
progressRef.current = self.progress;
|
||||
if (indexEl) {
|
||||
const i = Math.min(
|
||||
scenes.length,
|
||||
Math.floor(self.progress * scenes.length) + 1,
|
||||
);
|
||||
indexEl.textContent = String(i).padStart(2, "0");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const span = 1 / scenes.length;
|
||||
scenes.forEach((scene, i) => {
|
||||
const at = i * span;
|
||||
const inner = scene.querySelectorAll("[data-story-line]");
|
||||
const kicker = scene.querySelector("[data-story-kicker]");
|
||||
const body = scene.querySelectorAll("[data-story-body]");
|
||||
const stats = scene.querySelectorAll("[data-story-stat]");
|
||||
|
||||
tl.fromTo(
|
||||
inner,
|
||||
{ yPercent: 118 },
|
||||
{ yPercent: 0, duration: span * 0.34, stagger: span * 0.05 },
|
||||
at + span * 0.02,
|
||||
);
|
||||
if (kicker)
|
||||
tl.fromTo(
|
||||
kicker,
|
||||
{ autoAlpha: 0, y: 10 },
|
||||
{ autoAlpha: 1, y: 0, duration: span * 0.16 },
|
||||
at,
|
||||
);
|
||||
if (body.length)
|
||||
tl.fromTo(
|
||||
body,
|
||||
{ autoAlpha: 0, y: 22 },
|
||||
{ autoAlpha: 1, y: 0, duration: span * 0.22, stagger: span * 0.04 },
|
||||
at + span * 0.18,
|
||||
);
|
||||
if (stats.length)
|
||||
tl.fromTo(
|
||||
stats,
|
||||
{ autoAlpha: 0, y: 26, filter: "blur(6px)" },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration: span * 0.24,
|
||||
stagger: span * 0.05,
|
||||
},
|
||||
at + span * 0.2,
|
||||
);
|
||||
// entrada da cena + saída — janelas sobrepostas p/ crossfade contínuo
|
||||
tl.fromTo(
|
||||
scene,
|
||||
{ autoAlpha: 0 },
|
||||
{ autoAlpha: 1, duration: span * 0.18 },
|
||||
Math.max(0, at - span * 0.08),
|
||||
);
|
||||
if (i < scenes.length - 1)
|
||||
tl.to(scene, { autoAlpha: 0, yPercent: -5, duration: span * 0.18 }, at + span * 0.8);
|
||||
|
||||
if (stats.length) {
|
||||
stats.forEach((statEl) => {
|
||||
const numEl = statEl.querySelector<HTMLElement>("[data-story-num]");
|
||||
if (!numEl) return;
|
||||
const target = Number(numEl.dataset.storyNumTo ?? "0");
|
||||
const counter = { v: 0 };
|
||||
tl.to(
|
||||
counter,
|
||||
{
|
||||
v: target,
|
||||
duration: span * 0.3,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
numEl.textContent = String(Math.round(counter.v));
|
||||
},
|
||||
},
|
||||
at + span * 0.22,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
|
||||
if (hint) tl.to(hint, { autoAlpha: 0, duration: span * 0.1 }, span * 0.12);
|
||||
}, el);
|
||||
|
||||
cleanup = () => ctx.revert();
|
||||
};
|
||||
|
||||
void setup();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [engine]);
|
||||
|
||||
// ---- Fallback estático: capítulos empilhados, conteúdo inteiro ----
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
id="story"
|
||||
className="border-b border-[rgba(255,255,255,.06)] bg-[var(--ink)] text-[var(--paper)]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1240px] px-6 py-16">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
O reel
|
||||
</div>
|
||||
{chapters.map((ch) => (
|
||||
<article key={ch.kicker} className="mt-10 border-l border-[rgba(255,255,255,.1)] pl-6">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
|
||||
{ch.kicker}
|
||||
</div>
|
||||
<h3 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-[1.05] tracking-[-0.02em]">
|
||||
{ch.lines.join(" ")}
|
||||
</h3>
|
||||
{ch.body && (
|
||||
<p className="mt-3 max-w-[58ch] text-[14px] leading-7 text-[rgba(244,242,237,.72)]">
|
||||
{ch.body}
|
||||
</p>
|
||||
)}
|
||||
{ch.stats && (
|
||||
<ul className="mt-4 flex flex-wrap gap-6">
|
||||
{ch.stats.map((s) => (
|
||||
<li key={s.label}>
|
||||
<div className="font-display text-[2rem] font-extrabold text-[var(--slash)]">
|
||||
{s.value}
|
||||
{s.suffix}
|
||||
</div>
|
||||
<div className="text-[12px] text-[rgba(244,242,237,.6)]">{s.label}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ch.cta && (
|
||||
<a
|
||||
href={buildWhatsAppUrl(
|
||||
"Olá! Vi o reel do portfólio e quero conversar sobre um sistema.",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-5 inline-flex items-center justify-center bg-[var(--slash)] px-6 py-3 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)]"
|
||||
>
|
||||
Conversar agora
|
||||
</a>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Modo reel: palco sticky com cenas em crossfade ----
|
||||
return (
|
||||
<section
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
data-story
|
||||
className="relative h-[460vh] bg-[var(--ink)]"
|
||||
aria-label="Apresentação em scroll contínuo"
|
||||
>
|
||||
<div className="sticky top-0 h-[100dvh] w-full overflow-hidden">
|
||||
{/* fundo: gradiente radial + brilho do WebGL */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_55%_at_62%_45%,rgba(204,255,0,0.07),transparent_65%)]" />
|
||||
{webgl && (
|
||||
<div className="absolute inset-0">
|
||||
<StoryStage3D progress={progressRef} />
|
||||
</div>
|
||||
)}
|
||||
{/* legibilidade do texto sobre a cena */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,rgba(10,10,10,0.88)_0%,rgba(10,10,10,0.55)_42%,transparent_70%)]" />
|
||||
|
||||
{/* cenas */}
|
||||
<div className="absolute inset-0">
|
||||
{chapters.map((ch, i) => (
|
||||
<div
|
||||
key={ch.kicker}
|
||||
data-story-scene
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{ zIndex: 10 - i }}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1240px] px-6 lg:px-16">
|
||||
<div className="max-w-[720px]">
|
||||
<div
|
||||
data-story-kicker
|
||||
className="font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--slash)]"
|
||||
>
|
||||
{ch.kicker}
|
||||
</div>
|
||||
<h2 className="mt-4 font-display text-[clamp(2.4rem,7vw,5.2rem)] font-extrabold uppercase leading-[0.98] tracking-[-0.03em] text-[var(--paper)]">
|
||||
{ch.lines.map((line) => (
|
||||
<span key={line} className="block overflow-hidden pb-[0.06em]">
|
||||
<span data-story-line className="block will-change-transform">
|
||||
{line}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</h2>
|
||||
{ch.body && (
|
||||
<p
|
||||
data-story-body
|
||||
className="mt-6 max-w-[54ch] text-[clamp(14px,1.4vw,17px)] leading-8 text-[rgba(244,242,237,.78)]"
|
||||
>
|
||||
{ch.body}
|
||||
</p>
|
||||
)}
|
||||
{ch.stats && (
|
||||
<ul className="mt-8 flex flex-wrap gap-x-12 gap-y-6">
|
||||
{ch.stats.map((s) => (
|
||||
<li key={s.label} data-story-stat className="min-w-[132px]">
|
||||
<div className="font-display text-[clamp(2.2rem,4.5vw,3.4rem)] font-extrabold leading-none text-[var(--slash)]">
|
||||
<span data-story-num data-story-num-to={s.value}>
|
||||
0
|
||||
</span>
|
||||
{s.suffix}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.62)]">
|
||||
{s.label}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ch.cta && (
|
||||
<a
|
||||
data-story-body
|
||||
href={buildWhatsAppUrl(
|
||||
"Olá! Vi o reel do portfólio e quero conversar sobre um sistema.",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pointer-events-auto mt-8 inline-flex items-center justify-center bg-[var(--slash)] px-8 py-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white"
|
||||
>
|
||||
Conversar agora
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* HUD: índice de capítulo + barra de progresso do reel */}
|
||||
<div className="pointer-events-none absolute bottom-6 left-6 z-20 lg:left-16">
|
||||
<div className="flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)]">
|
||||
<span data-story-index className="text-[var(--slash)]">
|
||||
01
|
||||
</span>
|
||||
<span>/ 0{chapters.length}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-px w-40 bg-[rgba(255,255,255,.14)]">
|
||||
<div
|
||||
data-story-bar
|
||||
className="h-full w-full origin-left bg-[var(--slash)]"
|
||||
style={{ transform: "scaleX(0)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-story-hint
|
||||
className="pointer-events-none absolute right-6 top-1/2 z-20 -translate-y-1/2 font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.5)] lg:right-16"
|
||||
>
|
||||
↕ scroll = play / rewind
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
Environment,
|
||||
Lightformer,
|
||||
RoundedBox,
|
||||
Sparkles,
|
||||
useGLTF,
|
||||
} from "@react-three/drei";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import { createPhoneScreen, type ScreenPainter } from "./phoneScreenTexture";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
/**
|
||||
* Palco WebGL do scrollytelling.
|
||||
* Modelo 3D do aparelho: "Phone" by Quaternius — CC0 (Public Domain), via
|
||||
* poly.pizza (https://poly.pizza/m/k2kgBepoMU). Creditado em CREDITS.md.
|
||||
* A tela recebe um CanvasTexture com o mini-site do portfólio (prints reais dos
|
||||
* deployments) rolando conforme o progresso do scroll — o celular está "ligado".
|
||||
*/
|
||||
|
||||
function motionRig(g: THREE.Object3D, p: number) {
|
||||
// turntable contínuo: uma volta + extra, com inclinação de cena a cena
|
||||
g.rotation.y = -0.7 + p * Math.PI * 2.2;
|
||||
g.rotation.x = 0.08 + Math.sin(p * Math.PI) * 0.22;
|
||||
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.05;
|
||||
g.position.x = 1.4;
|
||||
g.position.y = Math.sin(p * Math.PI * 2) * 0.2;
|
||||
g.scale.setScalar(0.8 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.16);
|
||||
}
|
||||
|
||||
function ScreenPhone({ progress }: { progress: ProgressRef }) {
|
||||
// CC0: Quaternius "Phone" — poly.pizza/m/k2kgBepoMU
|
||||
const { scene } = useGLTF("/models/phone-quaternius.glb");
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const halo = useRef<THREE.Mesh>(null);
|
||||
const smooth = useRef(0);
|
||||
const [painter] = useState<ScreenPainter>(() => createPhoneScreen());
|
||||
const texture = useMemo(() => {
|
||||
const t = new THREE.CanvasTexture(painter.canvas);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.anisotropy = 4;
|
||||
return t;
|
||||
}, [painter]);
|
||||
|
||||
// materiais: corpo metálico; primitiva "Black" = tela com o site vivo
|
||||
const materials = useMemo(() => {
|
||||
const body = new THREE.MeshStandardMaterial({
|
||||
color: "#232429",
|
||||
metalness: 0.85,
|
||||
roughness: 0.38,
|
||||
});
|
||||
const frame = new THREE.MeshStandardMaterial({
|
||||
color: "#3a3b42",
|
||||
metalness: 0.9,
|
||||
roughness: 0.3,
|
||||
});
|
||||
const screen = new THREE.MeshStandardMaterial({
|
||||
color: "#000000",
|
||||
map: texture,
|
||||
emissive: "#ffffff",
|
||||
emissiveMap: texture,
|
||||
emissiveIntensity: 1.15,
|
||||
roughness: 0.4,
|
||||
metalness: 0,
|
||||
});
|
||||
return { body, frame, screen };
|
||||
}, [texture]);
|
||||
|
||||
useFrame((state, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
if (rig.current) motionRig(rig.current, p);
|
||||
if (halo.current) {
|
||||
halo.current.rotation.z += dt * 0.25;
|
||||
const m = halo.current.material as THREE.MeshBasicMaterial;
|
||||
m.opacity = 0.1 + p * 0.08;
|
||||
}
|
||||
// pinta o mini-site ~4fps quando há mudança real (quantizada no painter)
|
||||
const t = state.clock.elapsedTime * 1000;
|
||||
if (painter.ready() && painter.paint(p, t)) {
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
const cloned = useMemo(() => {
|
||||
const root = scene.clone(true);
|
||||
root.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return;
|
||||
const name = (obj.material as THREE.Material & { name?: string })?.name ?? "";
|
||||
if (name === "Black") {
|
||||
obj.material = materials.screen;
|
||||
// o GLB não tem TEXCOORD — UV plano na face (geo X/Z local, topo = +Z)
|
||||
const geo = obj.geometry;
|
||||
geo.computeBoundingBox();
|
||||
const bb = geo.boundingBox!;
|
||||
const sx = bb.max.x - bb.min.x || 1;
|
||||
const sz = bb.max.z - bb.min.z || 1;
|
||||
const pos = geo.attributes.position as THREE.BufferAttribute;
|
||||
const uv = new Float32Array(pos.count * 2);
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
uv[i * 2] = (pos.getX(i) - bb.min.x) / sx;
|
||||
uv[i * 2 + 1] = (pos.getZ(i) - bb.min.z) / sz;
|
||||
}
|
||||
geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
|
||||
} else {
|
||||
obj.material = name === "Grey" ? materials.frame : materials.body;
|
||||
}
|
||||
obj.frustumCulled = false;
|
||||
});
|
||||
// a primitiva Black aponta +Z mundo após a rotação -90°X do node — já dá de
|
||||
// frente para a câmera em p=0; nenhum flip extra.
|
||||
// o modelo mede ~0.9 unidades de altura; enquadrar no stage (~2.5u)
|
||||
root.scale.setScalar(2.75);
|
||||
return root;
|
||||
}, [scene, materials]);
|
||||
|
||||
return (
|
||||
<group ref={rig}>
|
||||
<primitive object={cloned} />
|
||||
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.2, -0.3]}>
|
||||
<torusGeometry args={[1.9, 0.014, 10, 128]} />
|
||||
<meshBasicMaterial color="#ccff00" transparent opacity={0.16} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0, 1.9]} color="#ccff00" intensity={0.6} distance={5} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback procedural (GLTF falhou / SwiftShader): rig de RoundedBox. */
|
||||
function ProceduralPhone({ progress }: { progress: ProgressRef }) {
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
|
||||
const smooth = useRef(0);
|
||||
useFrame((_, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
if (rig.current) motionRig(rig.current, p);
|
||||
if (screenMat.current)
|
||||
screenMat.current.emissiveIntensity =
|
||||
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
|
||||
});
|
||||
return (
|
||||
<group ref={rig}>
|
||||
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
|
||||
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
|
||||
</RoundedBox>
|
||||
<mesh position={[0, 0, 0.098]}>
|
||||
<planeGeometry args={[1.42, 3.06]} />
|
||||
<meshStandardMaterial
|
||||
ref={screenMat}
|
||||
color="#050505"
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.3}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.92}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Boundary funcional: se o GLTF estourar, mantém o rig procedural no lugar.
|
||||
class PhoneBoundary extends Component<
|
||||
{ progress: ProgressRef },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
render(): ReactNode {
|
||||
if (this.state.failed)
|
||||
return <ProceduralPhone progress={this.props.progress} />;
|
||||
return <ScreenPhone progress={this.props.progress} />;
|
||||
}
|
||||
}
|
||||
|
||||
function CameraRig({ progress }: { progress: ProgressRef }) {
|
||||
const smooth = useRef(0);
|
||||
useFrame(({ camera }, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
// steadicam sutil: aproxima no meio da história
|
||||
camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
|
||||
camera.position.y = -0.3 + p * 0.6;
|
||||
camera.lookAt(0, 0, 0);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, 1.75]}
|
||||
camera={{ position: [0, 0, 7.8], fov: 30 }}
|
||||
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
||||
>
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[4, 6, 5]} intensity={1.4} />
|
||||
<directionalLight position={[-5, -2, 6]} intensity={0.6} color="#cfd2da" />
|
||||
<Suspense fallback={<ProceduralPhone progress={progress} />}>
|
||||
<PhoneBoundary progress={progress} />
|
||||
</Suspense>
|
||||
<CameraRig progress={progress} />
|
||||
<Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" />
|
||||
<Environment resolution={64} frames={1}>
|
||||
<Lightformer intensity={1.6} position={[0, 4, 4]} scale={[8, 3, 1]} color="#f4f2ed" />
|
||||
<Lightformer intensity={1.1} position={[-5, -1, 3]} scale={[3, 6, 1]} color="#ccff00" />
|
||||
<Lightformer intensity={0.8} position={[5, 0, -4]} scale={[3, 6, 1]} color="#8f8fff" />
|
||||
</Environment>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// Gera a textura "site vivo" da tela do celular: um mini-portfolio desenhado em
|
||||
// canvas 2D, com os prints reais dos projetos em produção rolando verticalmente
|
||||
// conforme o progresso do reel — como se o site estivesse aberto no aparelho.
|
||||
// Fotos dos projetos: capturadas ao vivo dos deployments (headless Chrome).
|
||||
// CC0 model: "Phone" by Quaternius (https://quaternius.com) via poly.pizza.
|
||||
|
||||
type Shot = { img: HTMLImageElement; name: string; tag: string };
|
||||
|
||||
const SHOTS: Array<{ src: string; name: string; tag: string }> = [
|
||||
{ src: "/assets/prints/print-nutriscan.png", name: "NutriScan", tag: "Open Food Facts · PWA" },
|
||||
{ src: "/assets/prints/print-scplus.png", name: "SCPLUS", tag: "Educação + IoT · Garuva-SC" },
|
||||
{ src: "/assets/prints/print-sev.png", name: "SEV Moda Íntima", tag: "E-commerce · Next.js" },
|
||||
{ src: "/assets/prints/print-asservicos.png", name: "AS Serviços", tag: "Institucional · SSR" },
|
||||
];
|
||||
|
||||
export type ScreenPainter = {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** desenha o frame para o progresso p∈[0,1] do reel; retorna true se mudou */
|
||||
paint: (p: number, t: number) => boolean;
|
||||
ready: () => boolean;
|
||||
};
|
||||
|
||||
const W = 512;
|
||||
const H = 1024;
|
||||
const INK = "#0a0a0a";
|
||||
const PAPER = "#f4f2ed";
|
||||
const SLASH = "#ccff00";
|
||||
|
||||
export function createPhoneScreen(): ScreenPainter {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const images: Shot[] = [];
|
||||
let lastKey = "";
|
||||
|
||||
for (const s of SHOTS) {
|
||||
const img = new Image();
|
||||
img.decoding = "async";
|
||||
img.onload = () => images.push({ img, name: s.name, tag: s.tag });
|
||||
img.src = s.src;
|
||||
}
|
||||
|
||||
function roundRect(x: number, y: number, w: number, h: number, r: number) {
|
||||
if (!ctx) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function wrap(text: string, x: number, y: number, maxW: number, lh: number) {
|
||||
if (!ctx) return;
|
||||
const words = text.split(" ");
|
||||
let line = "";
|
||||
let yy = y;
|
||||
for (const word of words) {
|
||||
const candidate = line ? `${line} ${word}` : word;
|
||||
if (ctx.measureText(candidate).width > maxW) {
|
||||
ctx.fillText(line, x, yy);
|
||||
line = word;
|
||||
yy += lh;
|
||||
} else line = candidate;
|
||||
}
|
||||
if (line) ctx.fillText(line, x, yy);
|
||||
}
|
||||
|
||||
function drawHero(offset: number, t: number) {
|
||||
if (!ctx) return;
|
||||
if (offset > H || offset < -H) return;
|
||||
ctx.save();
|
||||
ctx.translate(0, offset);
|
||||
const pulse = 0.6 + 0.4 * Math.sin(t / 600);
|
||||
ctx.fillStyle = SLASH;
|
||||
ctx.globalAlpha = 0.14 + pulse * 0.06;
|
||||
ctx.fillRect(0, 60, W, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = "rgba(244,242,237,.55)";
|
||||
ctx.font = "600 20px ui-monospace, monospace";
|
||||
ctx.fillText("PORTFÓLIO · AO VIVO", 36, 150);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "800 72px system-ui, sans-serif";
|
||||
ctx.fillText("Software", 36, 260);
|
||||
ctx.fillText("que", 36, 340);
|
||||
ctx.fillStyle = SLASH;
|
||||
ctx.fillText("opera.", 36, 420);
|
||||
ctx.fillStyle = "rgba(244,242,237,.66)";
|
||||
ctx.font = "400 28px system-ui, sans-serif";
|
||||
wrap("Engenharia de sistemas que precisam funcionar de verdade.", 36, 500, W - 72, 40);
|
||||
ctx.fillStyle = SLASH;
|
||||
roundRect(36, 700, 250, 72, 36);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = "700 26px ui-monospace, monospace";
|
||||
ctx.fillText("VER PROJETOS ↓", 60, 744);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawProject(shot: Shot, offset: number, i: number, t: number) {
|
||||
if (!ctx) return;
|
||||
ctx.save();
|
||||
ctx.translate(0, offset);
|
||||
ctx.fillStyle = "rgba(244,242,237,.5)";
|
||||
ctx.font = "600 18px ui-monospace, monospace";
|
||||
ctx.fillText(`EM PRODUÇÃO · 0${i + 1}`, 36, 110);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "800 46px system-ui, sans-serif";
|
||||
ctx.fillText(shot.name, 36, 168);
|
||||
ctx.fillStyle = "rgba(204,255,0,.85)";
|
||||
ctx.font = "400 22px system-ui, sans-serif";
|
||||
ctx.fillText(shot.tag, 36, 204);
|
||||
|
||||
// o print real, recortado, com leve auto-scroll dentro do card
|
||||
const cardX = 36;
|
||||
const cardY = 236;
|
||||
const cardW = W - 72;
|
||||
const cardH = 620;
|
||||
ctx.save();
|
||||
roundRect(cardX, cardY, cardW, cardH, 28);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.fillRect(cardX, cardY, cardW, cardH);
|
||||
const scale = cardW / shot.img.width;
|
||||
const drift =
|
||||
(Math.sin(t / 1400 + i * 2.1) * 0.5 + 0.5) *
|
||||
Math.max(0, shot.img.height * scale - cardH);
|
||||
ctx.drawImage(shot.img, cardX, cardY - drift, cardW, shot.img.height * scale);
|
||||
const grd = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH);
|
||||
grd.addColorStop(0, "rgba(255,255,255,.08)");
|
||||
grd.addColorStop(0.12, "rgba(255,255,255,0)");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(cardX, cardY, cardW, cardH);
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "rgba(255,255,255,.12)";
|
||||
ctx.lineWidth = 2;
|
||||
roundRect(cardX, cardY, cardW, cardH, 28);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function paint(p: number, t: number) {
|
||||
if (!ctx || images.length === 0) return false;
|
||||
const key = `${p.toFixed(4)}|${Math.floor(t / 250)}|${images.length}`;
|
||||
if (key === lastKey) return false;
|
||||
lastKey = key;
|
||||
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// status bar do "aparelho"
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "600 22px ui-monospace, monospace";
|
||||
ctx.fillText("21:37", 28, 38);
|
||||
ctx.fillStyle = SLASH;
|
||||
ctx.fillRect(W - 96, 22, 44, 14);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "600 16px ui-monospace, monospace";
|
||||
ctx.fillText("E:/", W - 40, 36);
|
||||
|
||||
// o mini-site rolando: hero + página por projeto
|
||||
const pageH = H - 60;
|
||||
const y = p * images.length * pageH;
|
||||
drawHero(-y, t);
|
||||
images.forEach((shot, i) => {
|
||||
const top = (i + 1) * pageH - y;
|
||||
if (top > H || top < -pageH) return;
|
||||
drawProject(shot, top, i, t);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
canvas,
|
||||
paint,
|
||||
ready: () => images.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const PHONE_SCREEN_W = W;
|
||||
export const PHONE_SCREEN_H = H;
|
||||
Reference in new issue
Block a user