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.
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { type CSSProperties, useId, useRef } 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";
|
|
|
|
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}
|
|
sizes={device === "mobile" ? "(max-width: 767px) 70vw" : "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();
|
|
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">
|
|
{items.map((item) => (
|
|
<ListItem
|
|
key={item.config.id}
|
|
item={item}
|
|
device={device}
|
|
onExpand={device ? () => open(item.config.id) : undefined}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|