Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import {
|
|
type CSSProperties,
|
|
type RefObject,
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
|
import { PhoneFrame } from "./PhoneFrame";
|
|
import { ProjectActions } from "./ProjectActions";
|
|
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
|
import { useNearViewport } from "./useShowcaseMedia";
|
|
|
|
/**
|
|
* Qual cartão do carrossel está à frente: o índice do cartão cujo início está mais perto do começo da faixa (no fim da
|
|
* rolagem, o último, que talvez não alcance o começo). Só lê a posição da rolagem; sem rolagem horizontal fica em 0.
|
|
*/
|
|
export function carouselIndex(list: HTMLElement) {
|
|
const cards = Array.from(list.children) as HTMLElement[];
|
|
if (cards.length < 2 || list.scrollWidth <= list.clientWidth + 1) return 0;
|
|
if (list.scrollLeft + list.clientWidth >= list.scrollWidth - 2)
|
|
return cards.length - 1;
|
|
const stride =
|
|
cards[1].getBoundingClientRect().left -
|
|
cards[0].getBoundingClientRect().left;
|
|
if (stride <= 0) return 0;
|
|
return Math.min(
|
|
cards.length - 1,
|
|
Math.max(0, Math.round(list.scrollLeft / stride)),
|
|
);
|
|
}
|
|
|
|
/** Acompanha a rolagem horizontal da faixa e devolve o índice do cartão à frente (em um quadro de animação por vez). */
|
|
function useCarouselIndex(
|
|
ref: RefObject<HTMLElement | null>,
|
|
enabled: boolean,
|
|
) {
|
|
const [index, setIndex] = useState(0);
|
|
useEffect(() => {
|
|
const list = ref.current;
|
|
if (!enabled || !list) return;
|
|
let frame = 0;
|
|
const read = () => {
|
|
frame = 0;
|
|
setIndex(carouselIndex(list));
|
|
};
|
|
const onScroll = () => {
|
|
if (!frame) frame = requestAnimationFrame(read);
|
|
};
|
|
read();
|
|
list.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("resize", onScroll);
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
list.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("resize", onScroll);
|
|
};
|
|
}, [ref, enabled]);
|
|
return index;
|
|
}
|
|
|
|
function ListItem({
|
|
item,
|
|
device,
|
|
onExpand,
|
|
}: {
|
|
item: ShowcaseItem;
|
|
/** `null` no servidor e até montar: o item mostra só o texto e os links. */
|
|
device: Device | null;
|
|
onExpand?: () => void;
|
|
}) {
|
|
const { config, project } = item;
|
|
const ref = useRef<HTMLLIElement>(null);
|
|
const near = useNearViewport(ref);
|
|
const assets = device ? getShowcaseAssets(config.id, device) : null;
|
|
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela
|
|
const poster =
|
|
assets && near ? (
|
|
<Image
|
|
src={assets.poster}
|
|
alt={`Captura de tela de ${project.title}`}
|
|
width={assets.width}
|
|
height={assets.height}
|
|
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
|
|
sizes={device === "mobile" ? "160px" : "50vw"}
|
|
/>
|
|
) : null;
|
|
|
|
return (
|
|
<li
|
|
ref={ref}
|
|
className="ds-reel-item"
|
|
data-device={device ?? undefined}
|
|
style={{ "--accent": config.accent } as CSSProperties}
|
|
>
|
|
{device && (
|
|
<div className="ds-reel-item-media">
|
|
{device === "mobile" ? (
|
|
<PhoneFrame className="ds-reel-item-phone">{poster}</PhoneFrame>
|
|
) : (
|
|
<BrowserFrame url={displayUrl(project)}>
|
|
<div className="ds-reel-item-screen">{poster}</div>
|
|
</BrowserFrame>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="ds-reel-caption">
|
|
<p className="ds-kicker">
|
|
{project.category} · {project.status}
|
|
</p>
|
|
<h3 className="ds-reel-title">{project.title}</h3>
|
|
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
|
<ProjectActions item={item} onExpand={onExpand} />
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Projetos em lista simples: sem rolagem presa, sem vídeo que acompanha a rolagem. No celular (abaixo de 768 px)
|
|
* é um carrossel por toque (rolagem horizontal com encaixe, o próximo projeto aparece pela borda); em telas
|
|
* maiores, uma grade. Serve também a quem pede menos movimento e a janelas baixas demais para a vitrine fixa.
|
|
*
|
|
* É o que o servidor entrega (texto e links, sem mídia): sem JavaScript os projetos continuam todos ali, e
|
|
* `pending` esconde a lista por CSS só onde a vitrine fixa vai assumir o lugar (largura e altura de desktop).
|
|
* Pôster e "Ampliar vídeo" entram depois que o navegador monta; vídeo, só dentro da janela ampliada.
|
|
*/
|
|
export function ProjectList({
|
|
items,
|
|
device,
|
|
pending = false,
|
|
}: {
|
|
items: ShowcaseItem[];
|
|
device: Device | null;
|
|
pending?: boolean;
|
|
}) {
|
|
const open = useShowcaseOpen();
|
|
const headingId = useId();
|
|
const listRef = useRef<HTMLUListElement>(null);
|
|
// a contagem só existe no celular (o carrossel por toque); a grade das telas maiores não rola para o lado
|
|
const carousel = device === "mobile";
|
|
const current = useCarouselIndex(listRef, carousel);
|
|
return (
|
|
<section
|
|
className="ds-reel-list"
|
|
aria-labelledby={headingId}
|
|
data-pending={pending ? "" : undefined}
|
|
>
|
|
<h2 id={headingId} className="sr-only">
|
|
Projetos de clientes
|
|
</h2>
|
|
<ul className="ds-reel-items" ref={listRef}>
|
|
{items.map((item) => (
|
|
<ListItem
|
|
key={item.config.id}
|
|
item={item}
|
|
device={device}
|
|
onExpand={device ? () => open(item.config.id) : undefined}
|
|
/>
|
|
))}
|
|
</ul>
|
|
{carousel && items.length > 1 && (
|
|
// quem lê com leitor de tela já tem a lista (com o número de itens): a contagem é só visual
|
|
<p className="ds-reel-count" aria-hidden="true">
|
|
<span className="ds-reel-dots">
|
|
{items.map((item, index) => (
|
|
<i
|
|
key={item.config.id}
|
|
data-on={index === current ? "" : undefined}
|
|
/>
|
|
))}
|
|
</span>
|
|
{current + 1}/{items.length}
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|