Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
This commit is contained in:
1 parent
e2b20bc699
commit
8b12e51bc5
35 files changed
+2359
-1299
No files matched your search
@@ -6,19 +6,17 @@ 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";
|
||||
import { getNotebookFrame, getNotebookSeekProgress } 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: PortfolioProjectView[];
|
||||
onLayoutReady?: () => void;
|
||||
};
|
||||
|
||||
export function canUseNotebook3D(view: CapabilityWindow): boolean {
|
||||
@@ -36,18 +34,7 @@ export function getNotebookRunwayVh(tabCount: number): number {
|
||||
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);
|
||||
return 220 + count * 85;
|
||||
}
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
@@ -55,13 +42,22 @@ function clampIndex(index: number, length: number): number {
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
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);
|
||||
@@ -69,12 +65,21 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
() => 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 =
|
||||
notebookProjects.length > 0 && canUseNotebook3D(window);
|
||||
!failedRef.current &&
|
||||
notebookProjects.length > 0 &&
|
||||
canUseNotebook3D(window);
|
||||
if (!nextEngine) setSectionIntersects(false);
|
||||
setEngine(nextEngine);
|
||||
setCapabilityResolved(true);
|
||||
};
|
||||
updateEngine();
|
||||
if (typeof window.matchMedia !== "function") return undefined;
|
||||
@@ -108,7 +113,9 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!engine || !section || !("IntersectionObserver" in window)) {
|
||||
if (!engine || !section) return undefined;
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setSectionIntersects(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -139,33 +146,58 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
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: 0.85,
|
||||
scrub: true,
|
||||
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);
|
||||
}
|
||||
},
|
||||
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 = () => context.revert();
|
||||
cleanup = () => {
|
||||
seekRef.current = null;
|
||||
context.revert();
|
||||
};
|
||||
}
|
||||
|
||||
void setupTimeline();
|
||||
void setupTimeline().catch(() => {
|
||||
if (!cancelled) {
|
||||
failedRef.current = true;
|
||||
setEngine(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
@@ -173,47 +205,38 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
}, [engine, notebookProjects.length]);
|
||||
|
||||
const handleStageError = useCallback(() => {
|
||||
failedRef.current = true;
|
||||
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);
|
||||
if (engine) {
|
||||
seekRef.current?.(
|
||||
getNotebookSeekProgress(safeIndex, notebookProjects.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
[notebookProjects.length, seekToTab],
|
||||
[engine, notebookProjects.length],
|
||||
);
|
||||
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
aria-label="Projetos em produção no notebook"
|
||||
className="notebook-story notebook-story-static"
|
||||
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">
|
||||
@@ -224,7 +247,11 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
tecnologias em produção.
|
||||
</p>
|
||||
</div>
|
||||
<NotebookFallback projects={notebookProjects} />
|
||||
<NotebookFallback
|
||||
initialIndex={activeIndex}
|
||||
onSelectTab={handleSelectTab}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -265,6 +292,7 @@ export default function NotebookStory({ projects }: NotebookStoryProps) {
|
||||
onSelectTab={handleSelectTab}
|
||||
onStageError={handleStageError}
|
||||
progress={progressRef}
|
||||
requestRender={requestRenderRef}
|
||||
projects={notebookProjects}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user