Files
portifolio-evandrodevbr/app/components/story/NotebookStory.tsx
T

219 lines
6.8 KiB
TypeScript

"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>
);
}