CI / verify (push) Failing after 5m9s
Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
303 lines
9.6 KiB
TypeScript
303 lines
9.6 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, "innerWidth" | "matchMedia">;
|
|
|
|
export type NotebookStoryProps = {
|
|
projects: PortfolioProjectView[];
|
|
onLayoutReady?: () => void;
|
|
};
|
|
|
|
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 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 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) 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],
|
|
);
|
|
|
|
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`,
|
|
} 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`,
|
|
} 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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|