331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
"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<Window, "matchMedia">;
|
|
|
|
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<HTMLElement>(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 (
|
|
<section
|
|
aria-label="Projetos em produção no notebook"
|
|
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 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
|
|
}
|
|
>
|
|
<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
|
|
initialIndex={activeIndex}
|
|
onSelectTab={handleSelectTab}
|
|
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`,
|
|
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
|
|
} 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={sectionIntersects && isPageVisible}
|
|
onSelectTab={handleSelectTab}
|
|
onStageError={handleStageError}
|
|
progress={progressRef}
|
|
requestRender={requestRenderRef}
|
|
projects={notebookProjects}
|
|
/>
|
|
<nav
|
|
className="notebook-story-controls"
|
|
aria-label="Navegar projetos do notebook"
|
|
>
|
|
<div className="notebook-story-project-buttons">
|
|
{notebookProjects.map((project, index) => (
|
|
<button
|
|
key={project.id}
|
|
type="button"
|
|
aria-label={`Mostrar ${project.title}`}
|
|
aria-pressed={index === activeIndex}
|
|
onClick={() => handleSelectTab(index)}
|
|
>
|
|
{String(index + 1).padStart(2, "0")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p aria-live="polite">{activeProject?.title}</p>
|
|
{activeProject?.demoAvailability === "unavailable" ? (
|
|
<span aria-disabled="true">
|
|
Demo indisponível na última verificação
|
|
</span>
|
|
) : activeProject?.demoAvailability !== "none" &&
|
|
activeProject?.demoUrl ? (
|
|
<a
|
|
href={activeProject.demoUrl}
|
|
rel="noopener noreferrer"
|
|
target="_blank"
|
|
>
|
|
Abrir projeto
|
|
{activeProject.demoAvailability === "unknown" && (
|
|
<span className="sr-only">
|
|
{" "}
|
|
— Disponibilidade não verificada
|
|
</span>
|
|
)}
|
|
</a>
|
|
) : null}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|