perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -5,17 +5,12 @@ import {
|
||||
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;
|
||||
@@ -23,24 +18,11 @@ export interface ShowcaseItem {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -54,7 +36,7 @@ export function buildShowcaseItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
|
||||
/** Itens da vitrine para quem está dentro do provedor. */
|
||||
export function useShowcaseItems() {
|
||||
return useContext(ApiContext)?.items ?? [];
|
||||
}
|
||||
@@ -66,8 +48,8 @@ export function useShowcaseOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Guarda os projetos da vitrine e qual deles está ampliado, e mostra a janela ampliada. A vitrine animada
|
||||
* (ProjectReel) e a lista do celular (ProjectList) leem a mesma lista de itens.
|
||||
*/
|
||||
export function ShowcaseProvider({
|
||||
projects,
|
||||
@@ -76,121 +58,10 @@ export function ShowcaseProvider({
|
||||
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 api = useMemo<ShowcaseApi>(() => ({ items, open: setOpenId }), [items]);
|
||||
|
||||
const openIndex = openId
|
||||
? items.findIndex((item) => item.config.id === openId)
|
||||
@@ -198,60 +69,15 @@ export function ShowcaseProvider({
|
||||
|
||||
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>
|
||||
{children}
|
||||
{openIndex >= 0 && (
|
||||
<ProjectLightbox
|
||||
items={items}
|
||||
index={openIndex}
|
||||
onClose={() => setOpenId(null)}
|
||||
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||
/>
|
||||
)}
|
||||
</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]),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user