Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
258 lines
8.4 KiB
TypeScript
258 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
|
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
|
|
import { ProjectLightbox } from "./ProjectLightbox";
|
|
import { pickActiveCard } from "./showcase-math";
|
|
import "./showcase.css";
|
|
|
|
export interface ShowcaseItem {
|
|
config: ShowcaseConfig;
|
|
project: PortfolioProjectView;
|
|
}
|
|
|
|
interface ShowcaseApi {
|
|
register(id: string, element: HTMLElement): () => void;
|
|
setHover(id: string, hovered: boolean): void;
|
|
open(id: string): void;
|
|
/** Pausa (ou retoma) os vídeos que tocam sozinhos nos cards: quem não quer movimento na página tem como desligar. */
|
|
toggleAutoplay(): void;
|
|
items: ShowcaseItem[];
|
|
}
|
|
|
|
interface ActiveState {
|
|
activeId: string | null;
|
|
userPaused: boolean;
|
|
}
|
|
|
|
const ApiContext = createContext<ShowcaseApi | null>(null);
|
|
const ActiveContext = createContext<ActiveState>({
|
|
activeId: null,
|
|
userPaused: false,
|
|
});
|
|
|
|
/** Só aparece como card com vídeo o projeto que existe na lista e tem vídeo gravado. */
|
|
export function buildShowcaseItems(
|
|
projects: PortfolioProjectView[],
|
|
): ShowcaseItem[] {
|
|
const items: ShowcaseItem[] = [];
|
|
for (const config of SHOWCASE) {
|
|
const project = projects.find((candidate) => candidate.id === config.id);
|
|
if (project) items.push({ config, project });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
|
|
export function useShowcaseItems() {
|
|
return useContext(ApiContext)?.items ?? [];
|
|
}
|
|
|
|
/** Abre a janela ampliada de um projeto (nada acontece fora do provedor). */
|
|
export function useShowcaseOpen() {
|
|
const api = useContext(ApiContext);
|
|
return useCallback((id: string) => api?.open(id), [api]);
|
|
}
|
|
|
|
/**
|
|
* Um vídeo por vez nos cards: toca o card sob o mouse (ou com foco); sem mouse sobre nenhum, o mais perto do
|
|
* centro da tela. Também guarda qual card está ampliado e mostra a janela ampliada.
|
|
*/
|
|
export function ShowcaseProvider({
|
|
projects,
|
|
children,
|
|
}: {
|
|
projects: PortfolioProjectView[];
|
|
children: ReactNode;
|
|
}) {
|
|
const reducedMotion = useReducedMotion();
|
|
const items = useMemo(() => buildShowcaseItems(projects), [projects]);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
const [userPaused, setUserPaused] = useState(false);
|
|
|
|
const cards = useRef(
|
|
new Map<string, { el: HTMLElement; visible: boolean }>(),
|
|
);
|
|
const hovered = useRef<string | null>(null);
|
|
const paused = useRef(false);
|
|
const queued = useRef(false);
|
|
const frame = useRef(0);
|
|
const observer = useRef<IntersectionObserver | null>(null);
|
|
|
|
const evaluate = useCallback(() => {
|
|
queued.current = false;
|
|
if (paused.current || document.hidden) {
|
|
setActiveId(null);
|
|
return;
|
|
}
|
|
const middle = window.innerHeight / 2;
|
|
const candidates = [...cards.current].map(([id, { el, visible }]) => {
|
|
// só quem está à vista entra na disputa: os outros nem têm o layout lido a cada quadro de rolagem
|
|
if (!visible) return { id, visible, distance: Number.POSITIVE_INFINITY };
|
|
const rect = el.getBoundingClientRect();
|
|
return {
|
|
id,
|
|
visible,
|
|
distance: Math.abs(rect.top + rect.height / 2 - middle),
|
|
};
|
|
});
|
|
setActiveId(pickActiveCard(candidates, hovered.current));
|
|
}, []);
|
|
|
|
// sem cards registrados (a home mostra só a vitrine animada) não há o que decidir a cada quadro de rolagem;
|
|
// `force` vale para o que acabou de mudar a lista (um card saiu), que ainda precisa ser reavaliado
|
|
const schedule = useCallback(
|
|
(force = false) => {
|
|
if (queued.current || (!force && cards.current.size === 0)) return;
|
|
queued.current = true;
|
|
frame.current = requestAnimationFrame(evaluate);
|
|
},
|
|
[evaluate],
|
|
);
|
|
|
|
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
|
|
useEffect(() => {
|
|
paused.current = reducedMotion || openId !== null || userPaused;
|
|
schedule(true);
|
|
}, [schedule, openId, reducedMotion, userPaused]);
|
|
|
|
useEffect(() => {
|
|
const onChange = () => schedule();
|
|
window.addEventListener("scroll", onChange, { passive: true });
|
|
window.addEventListener("resize", onChange);
|
|
document.addEventListener("visibilitychange", onChange);
|
|
return () => {
|
|
window.removeEventListener("scroll", onChange);
|
|
window.removeEventListener("resize", onChange);
|
|
document.removeEventListener("visibilitychange", onChange);
|
|
cancelAnimationFrame(frame.current);
|
|
queued.current = false;
|
|
observer.current?.disconnect();
|
|
observer.current = null;
|
|
};
|
|
}, [schedule]);
|
|
|
|
const api = useMemo<ShowcaseApi>(
|
|
() => ({
|
|
items,
|
|
register(id, element) {
|
|
if (!observer.current && typeof IntersectionObserver !== "undefined") {
|
|
observer.current = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
for (const card of cards.current.values()) {
|
|
// um card só vale como "à vista" com pelo menos um terço dele na tela
|
|
if (card.el === entry.target)
|
|
card.visible = entry.intersectionRatio >= 0.33;
|
|
}
|
|
}
|
|
schedule();
|
|
},
|
|
{ threshold: [0, 0.33, 0.66, 1] },
|
|
);
|
|
}
|
|
cards.current.set(id, { el: element, visible: false });
|
|
observer.current?.observe(element);
|
|
return () => {
|
|
observer.current?.unobserve(element);
|
|
cards.current.delete(id);
|
|
if (hovered.current === id) hovered.current = null;
|
|
schedule(true);
|
|
};
|
|
},
|
|
setHover(id, isHovered) {
|
|
if (isHovered) hovered.current = id;
|
|
else if (hovered.current === id) hovered.current = null;
|
|
schedule();
|
|
},
|
|
open(id) {
|
|
setOpenId(id);
|
|
},
|
|
toggleAutoplay() {
|
|
setUserPaused((paused) => !paused);
|
|
},
|
|
}),
|
|
[items, schedule],
|
|
);
|
|
|
|
const activeState = useMemo(
|
|
() => ({ activeId, userPaused }),
|
|
[activeId, userPaused],
|
|
);
|
|
|
|
const openIndex = openId
|
|
? items.findIndex((item) => item.config.id === openId)
|
|
: -1;
|
|
|
|
return (
|
|
<ApiContext.Provider value={api}>
|
|
<ActiveContext.Provider value={activeState}>
|
|
{children}
|
|
{openIndex >= 0 && (
|
|
<ProjectLightbox
|
|
items={items}
|
|
index={openIndex}
|
|
onClose={() => setOpenId(null)}
|
|
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
|
/>
|
|
)}
|
|
</ActiveContext.Provider>
|
|
</ApiContext.Provider>
|
|
);
|
|
}
|
|
|
|
/** Liga um card à vitrine: devolve a ref da moldura, se ele é o card que toca agora e como ampliá-lo. */
|
|
export function useShowcaseCard(id: string) {
|
|
const api = useContext(ApiContext);
|
|
const { activeId, userPaused } = useContext(ActiveContext);
|
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const element = frameRef.current;
|
|
if (!api || !element) return;
|
|
const unregister = api.register(id, element);
|
|
const card = element.closest<HTMLElement>(".ds-card") ?? element;
|
|
const enter = (event: PointerEvent) => {
|
|
// no toque não existe "passar o mouse": só o mouse e a caneta mandam na vitrine
|
|
if (event.pointerType !== "touch") api.setHover(id, true);
|
|
};
|
|
const leave = () => api.setHover(id, false);
|
|
const focusIn = () => api.setHover(id, true);
|
|
const focusOut = (event: FocusEvent) => {
|
|
if (!card.contains(event.relatedTarget as Node | null))
|
|
api.setHover(id, false);
|
|
};
|
|
card.addEventListener("pointerenter", enter);
|
|
card.addEventListener("pointerleave", leave);
|
|
card.addEventListener("focusin", focusIn);
|
|
card.addEventListener("focusout", focusOut);
|
|
return () => {
|
|
unregister();
|
|
card.removeEventListener("pointerenter", enter);
|
|
card.removeEventListener("pointerleave", leave);
|
|
card.removeEventListener("focusin", focusIn);
|
|
card.removeEventListener("focusout", focusOut);
|
|
};
|
|
}, [api, id]);
|
|
|
|
return {
|
|
frameRef,
|
|
active: activeId === id,
|
|
userPaused,
|
|
toggleAutoplay: useCallback(() => api?.toggleAutoplay(), [api]),
|
|
open: useCallback(() => api?.open(id), [api, id]),
|
|
};
|
|
}
|