refactor: substituir celular e filmstrip por notebook
This commit is contained in:
1 parent
d768dfa791
commit
1c1a6d5c6d
14 files changed
+851
-1182
No files matched your search
@@ -0,0 +1,218 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user