"use client"; import dynamic from "next/dynamic"; import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PortfolioProjectView } from "@/lib/portfolio-data"; import NotebookFallback from "./NotebookFallback"; import { selectNotebookProjects } from "./notebookProjects"; import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline"; const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), { ssr: false, }); type CapabilityWindow = Pick; export type NotebookStoryProps = { projects: PortfolioProjectView[]; onLayoutReady?: () => void; }; export function canUseNotebook3D(view: CapabilityWindow): boolean { if (typeof view.matchMedia !== "function") return false; return !view.matchMedia("(prefers-reduced-motion: reduce)").matches; } export function getNotebookRunwayVh(tabCount: number): number { const count = Math.max( 1, Number.isFinite(tabCount) ? Math.floor(tabCount) : 1, ); return 220 + count * 85; } 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, onLayoutReady, }: NotebookStoryProps) { const notebookProjects = useMemo( () => selectNotebookProjects(projects), [projects], ); const sectionRef = useRef(null); const progressRef = useRef(0); const requestRenderRef = useRef<(() => void) | null>(null); const seekRef = useRef<((progress: number) => void) | null>(null); const layoutReadyRef = useRef(onLayoutReady); const failedRef = useRef(false); const frameRef = useRef(getNotebookFrame(0, notebookProjects.length)); const [capabilityResolved, setCapabilityResolved] = useState(false); const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1); const [engine, setEngine] = useState(false); const [sectionIntersects, setSectionIntersects] = useState(false); const [isPageVisible, setIsPageVisible] = useState( () => typeof document === "undefined" || !document.hidden, ); const [activeIndex, setActiveIndex] = useState(activeIndexRef.current); useEffect(() => { layoutReadyRef.current = onLayoutReady; }, [onLayoutReady]); useEffect(() => { if (!engine && capabilityResolved) layoutReadyRef.current?.(); }, [engine, capabilityResolved]); useEffect(() => { const updateEngine = () => { const nextEngine = !failedRef.current && notebookProjects.length > 0 && canUseNotebook3D(window); if (!nextEngine) setSectionIntersects(false); setEngine(nextEngine); setCapabilityResolved(true); }; updateEngine(); if (typeof window.matchMedia !== "function") return undefined; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); reducedMotion.addEventListener("change", updateEngine); return () => { reducedMotion.removeEventListener("change", updateEngine); }; }, [notebookProjects.length]); useEffect(() => { const handleVisibilityChange = () => { setIsPageVisible(!document.hidden); }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => document.removeEventListener("visibilitychange", handleVisibilityChange); }, []); useEffect(() => { const section = sectionRef.current; if (!engine || !section) return undefined; if (!("IntersectionObserver" in window)) { setSectionIntersects(true); return undefined; } const observer = new IntersectionObserver( ([entry]) => setSectionIntersects(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 applyProgress = () => { progressRef.current = driver.value; const nextIndex = getNotebookFrame( driver.value, notebookProjects.length, frameRef.current, ).activeTabIndex; if (nextIndex !== activeIndexRef.current) { activeIndexRef.current = nextIndex; setActiveIndex(nextIndex); } requestRenderRef.current?.(); }; const timeline = gsap.timeline({ defaults: { ease: "none" }, onUpdate: applyProgress, scrollTrigger: { trigger: sectionRoot, start: "top top", end: "bottom bottom", scrub: true, invalidateOnRefresh: true, onRefresh: () => layoutReadyRef.current?.(), }, }); timeline.to(driver, { duration: 1, value: 1 }); seekRef.current = (progress) => { const trigger = timeline.scrollTrigger; if (!trigger) return; // Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs. window.scrollTo({ top: trigger.start + (trigger.end - trigger.start) * progress, behavior: "instant", }); ScrollTrigger.update(); timeline.progress(progress); applyProgress(); }; timeline.scrollTrigger?.refresh(); }, sectionRoot); cleanup = () => { seekRef.current = null; context.revert(); }; } void setupTimeline().catch(() => { if (!cancelled) { failedRef.current = true; setEngine(false); } }); return () => { cancelled = true; cleanup(); }; }, [engine, notebookProjects.length]); const handleStageError = useCallback(() => { failedRef.current = true; setEngine(false); }, []); const handleSelectTab = useCallback( (index: number) => { const safeIndex = clampIndex(index, notebookProjects.length); if (safeIndex < 0) return; activeIndexRef.current = safeIndex; setActiveIndex(safeIndex); if (engine) { seekRef.current?.( getNotebookSeekProgress(safeIndex, notebookProjects.length), ); } }, [engine, notebookProjects.length], ); const activeProject = notebookProjects[activeIndex]; if (!engine) { return (
0 ? " notebook-story-pending" : ""}`} data-testid="notebook-story" id="story" ref={sectionRef} style={ { "--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`, "--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`, } as CSSProperties } >
O notebook

Software que opera.

Uma seleção navegável de sistemas reais, com suas interfaces e tecnologias em produção.

); } return (
O notebook

Software que opera.

Role para abrir a engenharia por trás de cada entrega. O scroll é o controle de play e rebobinar.

{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "} {String(notebookProjects.length).padStart(2, "0")}
); }