"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 } from "./notebookTimeline"; const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), { ssr: false, }); const BROWSE_START = 0.44; const BROWSE_END = 0.78; type CapabilityWindow = Pick; export type NotebookStoryProps = { projects: PortfolioProjectView[]; }; 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 getNotebookRunwayVh(tabCount: number): number { const count = Math.max( 1, Number.isFinite(tabCount) ? Math.floor(tabCount) : 1, ); return 360 + count * 40; } 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(null); const progressRef = useRef(0); 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(() => { const updateEngine = () => { const nextEngine = notebookProjects.length > 0 && canUseNotebook3D(window); if (!nextEngine) setSectionIntersects(false); setEngine(nextEngine); }; updateEngine(); if (typeof window.matchMedia !== "function") return undefined; const mediaQueries = [ window.matchMedia("(prefers-reduced-motion: reduce)"), window.matchMedia("(pointer: coarse)"), window.matchMedia("(hover: hover)"), ]; window.addEventListener("resize", updateEngine, { passive: true }); mediaQueries.forEach((mediaQuery) => { mediaQuery.addEventListener("change", updateEngine); }); return () => { window.removeEventListener("resize", updateEngine); mediaQueries.forEach((mediaQuery) => { mediaQuery.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 || !("IntersectionObserver" in window)) { 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 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 handleStageError = useCallback(() => { setEngine(false); }, []); 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 (
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")}
); }