Files
portifolio-evandrodevbr/app/components/story/PrintReel.tsx
T

291 lines
11 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
/**
* Palco 2 do estilo product-page: filmstrip horizontal dirigido pelo scroll.
* Arquitetura: runway 280vh + palco `position: sticky` (mesma técnica provada
* no reel do hero — sem GSAP pin, sem wrapper de flex). O scrub move a faixa
* da direita para a esquerda; quando a faixa termina, o sticky solta e o
* scroll continua para baixo normalmente. Role para cima = rebobina.
*
* Sem JS / reduced motion: grid estático (fallback), como no ScrollStory.
*/
const PRINTS = [
{
src: "/assets/prints/print-nutriscan.png",
name: "NutriScan",
desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
url: "https://nutriscan.evandro.dev.br",
accent: true,
},
{
src: "/assets/prints/print-scplus.png",
name: "SCPLUS",
desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
url: "https://scplus.evandro.dev.br",
accent: false,
},
{
src: "/assets/prints/print-sev.png",
name: "SEV Moda Íntima",
desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
url: "https://sevmodaintima.com.br",
accent: true,
},
{
src: "/assets/prints/print-asservicos.png",
name: "AS Serviços",
desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
url: "https://asservicos.com",
accent: false,
},
];
const CARD_W = "min(80vw, 430px)";
export default function PrintReel() {
const sectionRef = useRef<HTMLElement>(null);
const [engine, setEngine] = useState(false);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
return undefined;
setEngine(true);
return undefined;
}, []);
useEffect(() => {
if (!engine) return undefined;
const section = sectionRef.current;
if (!section) return undefined;
let cleanup = () => {};
let cancelled = false;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const track = section.querySelector<HTMLElement>("[data-strip-track]");
const idxEl = section.querySelector<HTMLElement>("[data-strip-index]");
const bar = section.querySelector<HTMLElement>("[data-strip-bar]");
if (!track) return;
// distância = largura da faixa menos a viewport
const distance = () =>
Math.max(0, track.scrollWidth - window.innerWidth);
const tl = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: section,
start: "top top",
end: "bottom bottom",
scrub: 0.85,
invalidateOnRefresh: true,
},
});
tl.to(track, { x: () => -distance(), duration: 1 });
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
if (idxEl) {
tl.eventCallback("onUpdate", () => {
const p = (tl.scrollTrigger?.progress ?? 0) * PRINTS.length;
idxEl.textContent = String(
Math.min(PRINTS.length, Math.floor(p) + 1),
).padStart(2, "0");
});
}
// cada card "acende" ao entrar e esmaece ao passar (scrub, reversível)
const cards = Array.from(
section.querySelectorAll<HTMLElement>("[data-strip-card]"),
);
const span = 1 / cards.length;
cards.forEach((card, i) => {
tl.fromTo(
card,
{ opacity: 0.15, scale: 0.92, rotateY: -9 },
{
opacity: 1,
scale: 1,
rotateY: 0,
transformPerspective: 1100,
duration: span * 0.5,
},
Math.max(0, i * span - span * 0.12),
);
if (i > 0) {
tl.to(
card,
{ opacity: 0.35, scale: 0.94, duration: span * 0.4 },
(i - 1) * span + span * 0.55,
);
}
});
}, section);
cleanup = () => ctx.revert();
};
void setup();
return () => {
cancelled = true;
cleanup();
};
}, [engine]);
// ---- Fallback estático (sem JS / reduced motion) ----
if (!engine) {
return (
<section
id="print-reel"
className="border-t border-[rgba(255,255,255,.06)] bg-[var(--ink)] py-16 text-[var(--paper)]"
>
<div className="mx-auto w-full max-w-[1240px] px-6">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
No ar, rodando
</div>
<h2 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold tracking-[-0.02em]">
Prints ao vivo dos sistemas em produção
</h2>
<div className="mt-8 grid gap-6 sm:grid-cols-2">
{PRINTS.map((print) => (
<a
key={print.name}
href={print.url}
target="_blank"
rel="noopener noreferrer"
className="block border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.03)]"
>
<img
src={print.src}
alt={`Captura do projeto ${print.name} em produção`}
className="h-72 w-full object-cover object-top"
/>
<div className="p-5">
<div className="font-display text-[18px] font-extrabold">
{print.name}
</div>
<p className="mt-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
{print.desc}
</p>
</div>
</a>
))}
</div>
</div>
</section>
);
}
// ---- Modo reel: runway + sticky ----
return (
<section
id="print-reel"
ref={sectionRef}
data-strip
className="relative h-[280vh] bg-[var(--ink)]"
aria-label="Galeria horizontal dos projetos em produção"
>
<div className="sticky top-0 flex h-[100dvh] w-full flex-col overflow-hidden">
{/* cabeçalho do palco */}
<div className="mx-auto flex w-full max-w-[1240px] shrink-0 flex-wrap items-end justify-between gap-4 px-6 pt-sp-16 lg:px-16">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--slash)]">
No ar, rodando
</div>
<h2 className="mt-2 font-display text-[clamp(1.6rem,3.4vw,2.6rem)] font-extrabold uppercase leading-[1] tracking-[-0.03em] text-[var(--paper)]">
Prints ao vivo da produção
</h2>
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
← role para percorrer →
</div>
</div>
{/* faixa */}
<div className="flex min-h-0 flex-1 items-center">
<div
data-strip-track
className="flex w-max items-stretch gap-10 pl-[max(1.5rem,calc((100vw-1240px)/2+2rem))] pr-[max(1.5rem,calc((100vw-1240px)/2+2rem))] will-change-transform"
>
{PRINTS.map((print, i) => (
<a
key={print.name}
data-strip-card
href={print.url}
target="_blank"
rel="noopener noreferrer"
className="group flex shrink-0 flex-col will-change-transform"
style={{ width: CARD_W }}
>
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
<span
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
/>
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
{print.url.replace("https://", "")}
</span>
</div>
</div>
<div className="relative h-[min(50vh,380px)] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
<img
src={print.src}
alt={`Captura do projeto ${print.name} em produção`}
loading={i < 2 ? "eager" : "lazy"}
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.03] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-5"
/>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_62%,rgba(10,10,10,.85)_100%)]" />
</div>
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="font-display text-[17px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
{print.name}
</h3>
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
ao vivo ↗
</span>
</div>
<p className="mt-2 text-[12px] leading-6 text-[rgba(244,242,237,.62)]">
{print.desc}
</p>
</div>
</a>
))}
</div>
</div>
{/* HUD */}
<div className="mx-auto mb-6 flex w-full max-w-[1240px] shrink-0 items-center gap-4 px-6 lg:px-16">
<span
data-strip-index
className="font-mono text-[10px] tracking-[0.14em] text-[var(--slash)]"
>
01
</span>
<span className="font-mono text-[10px] tracking-[0.14em] text-[rgba(244,242,237,.45)]">
/ 0{PRINTS.length}
</span>
<div className="h-px flex-1 bg-[rgba(255,255,255,.12)]">
<div
data-strip-bar
className="h-full w-full origin-left bg-[var(--slash)]"
style={{ transform: "scaleX(0)" }}
/>
</div>
</div>
</div>
</section>
);
}