260 lines
8.0 KiB
TypeScript
260 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import type { CSSProperties } from "react";
|
|
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 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<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(() => {
|
|
const updateEngine = () => {
|
|
setEngine(notebookProjects.length > 0 && canUseNotebook3D(window));
|
|
};
|
|
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 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 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 (
|
|
<section
|
|
aria-label="Projetos em produção no notebook"
|
|
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="Projetos em produção no notebook"
|
|
className="notebook-story notebook-story-runway"
|
|
data-notebook-engine="3d"
|
|
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
|
|
data-testid="notebook-story"
|
|
id="story"
|
|
ref={sectionRef}
|
|
style={
|
|
{
|
|
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<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}
|
|
onStageError={handleStageError}
|
|
progress={progressRef}
|
|
projects={notebookProjects}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|