"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(null); const ActiveContext = createContext({ 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(null); const [openId, setOpenId] = useState(null); const [userPaused, setUserPaused] = useState(false); const cards = useRef( new Map(), ); const hovered = useRef(null); const paused = useRef(false); const queued = useRef(false); const frame = useRef(0); const observer = useRef(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( () => ({ 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 ( {children} {openIndex >= 0 && ( setOpenId(null)} onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)} /> )} ); } /** 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(null); useEffect(() => { const element = frameRef.current; if (!api || !element) return; const unregister = api.register(id, element); const card = element.closest(".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]), }; }