Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
697 lines
24 KiB
TypeScript
697 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import {
|
|
type CSSProperties,
|
|
type RefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
ViewTransition,
|
|
} from "react";
|
|
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
|
|
import { isWebKit } from "@/lib/device";
|
|
import {
|
|
chapterIndexAtScroll,
|
|
chapterScrollFraction,
|
|
getScrubSource,
|
|
getShowcaseAssets,
|
|
projectKicker,
|
|
videoFractionAtScroll,
|
|
} from "@/lib/project-showcase";
|
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
|
import { PhoneFrame } from "./PhoneFrame";
|
|
import { ProjectActions } from "./ProjectActions";
|
|
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
|
import {
|
|
CAPTION_OUT_MS,
|
|
easeToward,
|
|
localAtProgress,
|
|
locateInReel,
|
|
reelHolds,
|
|
reelOffset,
|
|
videoProgress,
|
|
} from "./showcase-math";
|
|
import { useRovingTabindex } from "./useRovingTabindex";
|
|
import { useSaveData } from "./useShowcaseMedia";
|
|
import "./pinned-reel.css";
|
|
|
|
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
|
let headerCache = { width: -1, height: 60 };
|
|
const headerHeight = () => {
|
|
if (headerCache.width !== window.innerWidth)
|
|
headerCache = {
|
|
width: window.innerWidth,
|
|
height:
|
|
Number.parseFloat(
|
|
getComputedStyle(document.documentElement).getPropertyValue(
|
|
"--site-header-height",
|
|
),
|
|
) || 60,
|
|
};
|
|
return headerCache.height;
|
|
};
|
|
|
|
/**
|
|
* Larguras (CSS) em que os pôsteres aparecem, para o next/image pedir o arquivo do tamanho certo (um WebP/AVIF de
|
|
* ~1080 px no notebook e de ~256 px no celular) e não o original de 1440 px: o notebook mede ~58vw (até ~1100 px nas
|
|
* telas largas; 90vw abaixo de 1024 px, com a vitrine em coluna) e o celular da moldura, 22% dele.
|
|
*/
|
|
const LAPTOP_POSTER_SIZES =
|
|
"(min-width: 1900px) 1100px, (min-width: 1024px) 58vw, 90vw";
|
|
const PHONE_POSTER_SIZES =
|
|
"(min-width: 1900px) 240px, (min-width: 1024px) 13vw, 20vw";
|
|
|
|
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
|
|
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
|
if (!video || video.readyState < 1 || !video.duration) return;
|
|
const target = Math.min(
|
|
video.duration - 0.001,
|
|
Math.max(0, fraction * video.duration),
|
|
);
|
|
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
|
|
video.currentTime = target;
|
|
}
|
|
|
|
/**
|
|
* O WebKit (Safari, iPhone, iPad) só desenha o primeiro quadro de um vídeo que já tocou uma vez. Só ele toca e pausa:
|
|
* nos outros navegadores tocar faz o arquivo inteiro ser baixado (os 4 vídeos eram 3,2 MB por visita, contra 63 KB
|
|
* quando só as buscas da rolagem pedem os trechos que o visitante vê).
|
|
*/
|
|
function prime(video: HTMLVideoElement) {
|
|
if (video.dataset.primed || !isWebKit()) return;
|
|
video.dataset.primed = "true";
|
|
video
|
|
.play()
|
|
.then(() => video.pause())
|
|
.catch(() => {});
|
|
}
|
|
|
|
let stopScroll = () => {};
|
|
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
|
|
function scrollToSmoothly(top: number) {
|
|
stopScroll();
|
|
const from = window.scrollY;
|
|
const distance = top - from;
|
|
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
|
|
const started = performance.now();
|
|
let id = 0;
|
|
const stop = () => {
|
|
cancelAnimationFrame(id);
|
|
window.removeEventListener("wheel", stop);
|
|
window.removeEventListener("touchstart", stop);
|
|
};
|
|
stopScroll = stop;
|
|
window.addEventListener("wheel", stop, { passive: true });
|
|
window.addEventListener("touchstart", stop, { passive: true });
|
|
const step = (now: number) => {
|
|
const t = Math.min(1, (now - started) / duration);
|
|
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
|
|
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
|
|
if (t < 1) id = requestAnimationFrame(step);
|
|
else stop();
|
|
};
|
|
id = requestAnimationFrame(step);
|
|
}
|
|
|
|
const layerState = (index: number, active: number) => {
|
|
if (index === active) return "active";
|
|
return index < active ? "before" : "after";
|
|
};
|
|
|
|
/** Estado do traço de capítulo: ainda por vir, o de agora ou já visto. */
|
|
const tickState = (tick: number, current: number) => {
|
|
if (tick > current) return "todo";
|
|
return tick === current ? "now" : "done";
|
|
};
|
|
|
|
/**
|
|
* Progresso da rolagem (0 a 1, sem as pausas) de cada projeto quando o projeto `index` está na fração `local` do trecho
|
|
* dele. É da rolagem, não do vídeo: os capítulos têm rolagem igual (`videoFractionAtScroll` leva ao ponto do vídeo).
|
|
*/
|
|
function targetsAt(count: number, index: number, local: number) {
|
|
const { head, tail } = reelHolds(index, count);
|
|
return Array.from({ length: count }, (_, i) => {
|
|
if (i < index) return 1;
|
|
return i === index ? videoProgress(local, head, tail) : 0;
|
|
});
|
|
}
|
|
|
|
/** Onde a rolagem está na vitrine: projeto em cena, fração dele e se ela está a ~1 tela da janela. Só lê o DOM. */
|
|
function readReel(container: HTMLElement, pin: HTMLElement, weights: number[]) {
|
|
const rect = container.getBoundingClientRect();
|
|
const scrollable = rect.height - pin.offsetHeight;
|
|
const progress =
|
|
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
|
|
const { index, local } = locateInReel(weights, progress);
|
|
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
|
|
const near =
|
|
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
|
return { index, local, near };
|
|
}
|
|
|
|
/**
|
|
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
|
|
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
|
|
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; o celular é só um
|
|
* pôster da mesma tela (o vídeo de celular é do celular: aqui ele nem é baixado).
|
|
*
|
|
* Os aparelhos de cada projeto são uma camada (as camadas trocam por fade cruzado); a legenda (nome, trecho,
|
|
* contador e botões) é UM bloco só, fora das camadas, que sai e entra sem nunca existir em dobro.
|
|
*
|
|
* Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela
|
|
* da janela; com "economizar dados" ninguém baixa vídeo.
|
|
*/
|
|
export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
|
const openProject = useShowcaseOpen();
|
|
const [active, setActive] = useState(0);
|
|
const [chapter, setChapter] = useState(0);
|
|
const [near, setNear] = useState(false);
|
|
const [settled, setSettled] = useState(0);
|
|
const saveData = useSaveData();
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const pinRef = useRef<HTMLDivElement>(null);
|
|
const indexRef = useRef<HTMLOListElement>(null);
|
|
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
|
const bars = useRef<Array<HTMLElement | null>>([]);
|
|
const live = useRef({
|
|
target: [] as number[],
|
|
smooth: [] as number[],
|
|
active: 0,
|
|
chapter: 0,
|
|
raf: 0,
|
|
last: 0,
|
|
near: false,
|
|
});
|
|
|
|
const weights = useMemo(
|
|
() => items.map((item) => item.config.weight),
|
|
[items],
|
|
);
|
|
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
|
|
|
// só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); e só depois que a rolagem parou ali (passar
|
|
// voando por um projeto no clique do índice não pode baixar todos os do caminho)
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setSettled(active), 300);
|
|
return () => clearTimeout(timer);
|
|
}, [active]);
|
|
|
|
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
|
|
useEffect(() => {
|
|
const list = indexRef.current;
|
|
const button = list?.children[active]?.querySelector("button");
|
|
if (
|
|
!list ||
|
|
!button ||
|
|
list.scrollWidth <= list.clientWidth ||
|
|
typeof list.scrollTo !== "function"
|
|
)
|
|
return;
|
|
list.scrollTo({
|
|
left: Math.max(0, button.offsetLeft - 16),
|
|
behavior: "smooth",
|
|
});
|
|
}, [active]);
|
|
|
|
const isAttached = useCallback(
|
|
(index: number) => near && !saveData && index === settled,
|
|
[near, saveData, settled],
|
|
);
|
|
// o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto
|
|
const showsPoster = (index: number) => near && Math.abs(index - active) <= 1;
|
|
|
|
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
|
useEffect(() => {
|
|
items.forEach((_, index) => {
|
|
if (isAttached(index)) return;
|
|
const video = desktopVideos.current[index];
|
|
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
|
video.load();
|
|
});
|
|
}, [items, isAttached]);
|
|
|
|
const frame = useCallback(
|
|
(now: number) => {
|
|
const state = live.current;
|
|
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
|
state.last = now;
|
|
let moving = false;
|
|
items.forEach((_, index) => {
|
|
if (Math.abs(index - state.active) > 1) return;
|
|
const target = state.target[index] ?? 0;
|
|
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
|
state.smooth[index] = next;
|
|
if (Math.abs(next - target) > 0.0004) moving = true;
|
|
seekVideo(
|
|
desktopVideos.current[index],
|
|
videoFractionAtScroll(items[index].config, next),
|
|
);
|
|
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
|
});
|
|
const current = items[state.active];
|
|
if (current) {
|
|
const index = chapterIndexAtScroll(
|
|
current.config,
|
|
state.smooth[state.active] ?? 0,
|
|
);
|
|
if (index !== state.chapter) {
|
|
state.chapter = index;
|
|
setChapter(index);
|
|
}
|
|
}
|
|
state.raf = moving ? requestAnimationFrame(frame) : 0;
|
|
},
|
|
[items],
|
|
);
|
|
|
|
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
|
const syncVideo = useCallback(
|
|
(index: number) => {
|
|
const state = live.current;
|
|
seekVideo(
|
|
desktopVideos.current[index],
|
|
videoFractionAtScroll(
|
|
items[index].config,
|
|
state.smooth[index] ?? state.target[index] ?? 0,
|
|
),
|
|
);
|
|
},
|
|
[items],
|
|
);
|
|
|
|
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
|
|
const changeActive = useCallback(
|
|
(index: number) => {
|
|
const state = live.current;
|
|
state.active = index;
|
|
state.chapter = chapterIndexAtScroll(
|
|
items[index].config,
|
|
state.smooth[index] ?? 0,
|
|
);
|
|
setActive(index);
|
|
setChapter(state.chapter);
|
|
},
|
|
[items],
|
|
);
|
|
|
|
const measure = useCallback(() => {
|
|
const container = containerRef.current;
|
|
const pin = pinRef.current;
|
|
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
|
|
if (!container?.offsetHeight || !pin) return;
|
|
const state = live.current;
|
|
const { index, local, near: isNear } = readReel(container, pin, weights);
|
|
state.target = targetsAt(items.length, index, local);
|
|
if (isNear !== state.near) {
|
|
state.near = isNear;
|
|
setNear(isNear);
|
|
}
|
|
if (index !== state.active) changeActive(index);
|
|
if (state.near && !state.raf) {
|
|
state.last = 0;
|
|
state.raf = requestAnimationFrame(frame);
|
|
}
|
|
}, [changeActive, frame, items.length, weights]);
|
|
|
|
useEffect(() => {
|
|
let pending = 0;
|
|
const schedule = () => {
|
|
if (!pending)
|
|
pending = requestAnimationFrame(() => {
|
|
pending = 0;
|
|
measure();
|
|
});
|
|
};
|
|
schedule();
|
|
window.addEventListener("scroll", schedule, { passive: true });
|
|
window.addEventListener("resize", schedule);
|
|
const state = live.current;
|
|
return () => {
|
|
window.removeEventListener("scroll", schedule);
|
|
window.removeEventListener("resize", schedule);
|
|
cancelAnimationFrame(pending);
|
|
cancelAnimationFrame(state.raf);
|
|
state.raf = 0;
|
|
};
|
|
}, [measure]);
|
|
|
|
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
|
const scrollToReel = (index: number, local: number) => {
|
|
const container = containerRef.current;
|
|
const pin = pinRef.current;
|
|
if (!container || !pin) return;
|
|
const scrollable = container.offsetHeight - pin.offsetHeight;
|
|
const top = container.getBoundingClientRect().top + window.scrollY;
|
|
// 2px além: garante que a conta caia dentro do trecho
|
|
scrollToSmoothly(
|
|
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
|
|
);
|
|
};
|
|
|
|
const goTo = (index: number) => scrollToReel(index, 0);
|
|
const goToChapter = (index: number, chapterIndex: number) => {
|
|
const { head, tail } = reelHolds(index, items.length);
|
|
const progress = chapterScrollFraction(items[index].config, chapterIndex);
|
|
scrollToReel(index, localAtProgress(progress, head, tail));
|
|
};
|
|
|
|
const current = items[active];
|
|
|
|
return (
|
|
<section
|
|
className="ds-reel"
|
|
aria-label="Vitrine animada dos projetos"
|
|
style={{ "--reel-sum": weightSum } as CSSProperties}
|
|
ref={containerRef}
|
|
>
|
|
<h2 className="sr-only">Projetos de clientes</h2>
|
|
<div className="ds-reel-pin" ref={pinRef}>
|
|
<div className="ds-reel-grid">
|
|
<ReelIndex
|
|
items={items}
|
|
active={active}
|
|
chapter={chapter}
|
|
listRef={indexRef}
|
|
bars={bars}
|
|
onProject={goTo}
|
|
onChapter={goToChapter}
|
|
/>
|
|
|
|
<div className="ds-reel-stage">
|
|
<div className="ds-reel-set">
|
|
<div className="ds-reel-devices">
|
|
{items.map((item, index) => (
|
|
<ReelLayer
|
|
key={item.config.id}
|
|
item={item}
|
|
state={layerState(index, active)}
|
|
poster={showsPoster(index)}
|
|
attached={isAttached(index)}
|
|
videoRef={(element) => {
|
|
desktopVideos.current[index] = element;
|
|
}}
|
|
onVideoLoaded={() => syncVideo(index)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<ReelCaption
|
|
items={items}
|
|
active={active}
|
|
chapter={chapter}
|
|
onExpand={openProject}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p className="sr-only" aria-live="polite">
|
|
{current
|
|
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. É UMA parada
|
|
* no Tab (roving tabindex): as setas e Home/End movem o foco entre os projetos e os trechos, Enter e espaço
|
|
* escolhem. Sem isto eram um Tab por projeto e por trecho (7) antes de chegar aos botões da legenda.
|
|
*/
|
|
function ReelIndex({
|
|
items,
|
|
active,
|
|
chapter,
|
|
listRef,
|
|
bars,
|
|
onProject,
|
|
onChapter,
|
|
}: {
|
|
items: ShowcaseItem[];
|
|
active: number;
|
|
chapter: number;
|
|
listRef: RefObject<HTMLOListElement | null>;
|
|
bars: RefObject<Array<HTMLElement | null>>;
|
|
onProject: (index: number) => void;
|
|
onChapter: (index: number, chapter: number) => void;
|
|
}) {
|
|
const { groupProps, itemProps } = useRovingTabindex(
|
|
`p:${active}`,
|
|
// os trechos são sempre os do projeto em cena: os de um projeto que já saiu de cena deixam de ser a parada
|
|
(key) => key.startsWith("p:") || key.startsWith(`c:${active}:`),
|
|
);
|
|
return (
|
|
<nav
|
|
className="ds-reel-index"
|
|
aria-label="Projetos da vitrine"
|
|
{...groupProps}
|
|
>
|
|
<p className="ds-reel-hint">
|
|
<span aria-hidden="true" className="ds-reel-hint-line" />
|
|
Em movimento
|
|
<span className="ds-reel-hint-sub">Role para avançar</span>
|
|
<span className="sr-only">
|
|
Use as setas para escolher o projeto e o trecho.
|
|
</span>
|
|
</p>
|
|
<ol ref={listRef}>
|
|
{items.map((item, index) => (
|
|
<li key={item.config.id}>
|
|
<button
|
|
type="button"
|
|
className="ds-reel-pick"
|
|
aria-current={index === active ? "true" : undefined}
|
|
aria-label={item.project.title}
|
|
onClick={() => onProject(index)}
|
|
{...itemProps(`p:${index}`)}
|
|
>
|
|
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
|
|
<span
|
|
className="ds-reel-num"
|
|
aria-hidden="true"
|
|
data-n={String(index + 1).padStart(2, "0")}
|
|
/>
|
|
<span className="ds-reel-name">{item.project.title}</span>
|
|
<i
|
|
className="ds-reel-bar"
|
|
aria-hidden="true"
|
|
ref={(element) => {
|
|
bars.current[index] = element;
|
|
}}
|
|
/>
|
|
</button>
|
|
{index === active && (
|
|
<ol
|
|
className="ds-reel-chapters"
|
|
aria-label={`Trechos de ${item.project.title}`}
|
|
>
|
|
{item.config.chapters.map((entry, i) => (
|
|
<li key={entry.label}>
|
|
<button
|
|
type="button"
|
|
className="ds-reel-chapter-pick"
|
|
aria-current={i === chapter ? "true" : undefined}
|
|
onClick={() => onChapter(index, i)}
|
|
{...itemProps(`c:${index}:${i}`)}
|
|
>
|
|
<span className="ds-reel-chapter-num" aria-hidden="true">
|
|
{String(i + 1).padStart(2, "0")}
|
|
</span>
|
|
{entry.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Os aparelhos de um projeto: notebook com o vídeo que acompanha a rolagem e celular só com o pôster. Camadas
|
|
* empilhadas na mesma célula; quem não está em cena fica transparente, inerte e escondido dos leitores de tela.
|
|
*/
|
|
function ReelLayer({
|
|
item,
|
|
state,
|
|
poster,
|
|
attached,
|
|
videoRef,
|
|
onVideoLoaded,
|
|
}: {
|
|
item: ShowcaseItem;
|
|
state: "active" | "before" | "after";
|
|
/** O pôster já pode ser pedido (a vitrine está perto e este projeto é o da vez ou um vizinho). */
|
|
poster: boolean;
|
|
/** O vídeo de rolagem já pode ser baixado. */
|
|
attached: boolean;
|
|
videoRef: (element: HTMLVideoElement | null) => void;
|
|
onVideoLoaded: () => void;
|
|
}) {
|
|
const { config, project } = item;
|
|
const isActive = state === "active";
|
|
const desktop = getShowcaseAssets(config.id, "desktop");
|
|
const phone = getShowcaseAssets(config.id, "mobile");
|
|
return (
|
|
<div
|
|
className="ds-reel-layer"
|
|
data-state={state}
|
|
style={{ "--accent": config.accent } as CSSProperties}
|
|
aria-hidden={isActive ? undefined : true}
|
|
inert={!isActive}
|
|
>
|
|
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
|
|
<div className="ds-reel-screen">
|
|
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
|
|
{poster && (
|
|
<ActiveCover id={config.id} active={isActive}>
|
|
<Image
|
|
src={desktop.poster}
|
|
alt=""
|
|
width={desktop.width}
|
|
height={desktop.height}
|
|
sizes={LAPTOP_POSTER_SIZES}
|
|
loading="eager"
|
|
fetchPriority="low"
|
|
/>
|
|
</ActiveCover>
|
|
)}
|
|
<video
|
|
ref={videoRef}
|
|
src={attached ? getScrubSource(config.id) : undefined}
|
|
muted
|
|
playsInline
|
|
preload="metadata"
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
disablePictureInPicture
|
|
disableRemotePlayback
|
|
onLoadedMetadata={onVideoLoaded}
|
|
onLoadedData={(event) => prime(event.currentTarget)}
|
|
/>
|
|
</div>
|
|
</BrowserFrame>
|
|
<PhoneFrame className="ds-reel-phone">
|
|
{poster && (
|
|
<Image
|
|
src={phone.poster}
|
|
alt=""
|
|
width={phone.width}
|
|
height={phone.height}
|
|
sizes={PHONE_POSTER_SIZES}
|
|
loading="eager"
|
|
fetchPriority="low"
|
|
/>
|
|
)}
|
|
</PhoneFrame>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Só o projeto em cena leva o nome da capa (`case-<id>`): em "Ver case", o pôster do notebook viaja até o topo do
|
|
* case. É o pôster, e não o vídeo por cima dele, porque é a mesma imagem do topo do case (o vídeo está no meio da
|
|
* rolagem do site). O nome não troca de lugar numa atualização: o React só esquece um nome quando o
|
|
* <ViewTransition> sai da árvore, e um nome trocado no lugar ficava preso ao projeto antigo (aviso de nome
|
|
* repetido ao abrir a capa dele depois). Por isso o projeto que entra em cena ganha um <ViewTransition> novo,
|
|
* e só a imagem remonta (o vídeo fica).
|
|
*/
|
|
function ActiveCover({
|
|
id,
|
|
active,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
active: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
if (!active) return children;
|
|
return (
|
|
<ViewTransition {...coverTransitionProps(id)}>{children}</ViewTransition>
|
|
);
|
|
}
|
|
|
|
const pad = (value: number) => String(value).padStart(2, "0");
|
|
|
|
/**
|
|
* Legenda do projeto em cena: um bloco só, fora das camadas. Na troca de projeto a legenda atual some em
|
|
* CAPTION_OUT_MS e só então o conteúdo muda e ela volta (fade, sem deslocamento): nunca há título, contador ou
|
|
* botões em dobro. Dentro do mesmo projeto, o trecho troca no lugar.
|
|
*/
|
|
function ReelCaption({
|
|
items,
|
|
active,
|
|
chapter,
|
|
onExpand,
|
|
}: {
|
|
items: ShowcaseItem[];
|
|
active: number;
|
|
chapter: number;
|
|
onExpand: (id: string) => void;
|
|
}) {
|
|
const [shown, setShown] = useState({ index: active, chapter });
|
|
const leaving = shown.index !== active;
|
|
// o que vale quando a saída termina: o trecho continua mudando durante os 120 ms, e isso não reinicia a espera
|
|
const latest = useRef({ active, chapter });
|
|
useEffect(() => {
|
|
latest.current = { active, chapter };
|
|
});
|
|
|
|
// dentro do mesmo projeto o trecho troca no lugar
|
|
useEffect(() => {
|
|
if (shown.index === active && shown.chapter !== chapter)
|
|
setShown({ index: active, chapter });
|
|
}, [active, chapter, shown]);
|
|
|
|
// projeto novo: a legenda sai e, passado CAPTION_OUT_MS, o conteúdo muda
|
|
useEffect(() => {
|
|
if (shown.index === active) return;
|
|
const timer = setTimeout(
|
|
() =>
|
|
setShown({
|
|
index: latest.current.active,
|
|
chapter: latest.current.chapter,
|
|
}),
|
|
CAPTION_OUT_MS,
|
|
);
|
|
return () => clearTimeout(timer);
|
|
}, [active, shown.index]);
|
|
|
|
const item = items[shown.index];
|
|
const { config, project } = item;
|
|
return (
|
|
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
|
|
<p className="ds-kicker">
|
|
{projectKicker(project.category, project.status)}
|
|
</p>
|
|
<h3 className="ds-reel-title">{project.title}</h3>
|
|
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
|
<div className="ds-reel-chapter">
|
|
<span className="ds-reel-chapter-count">
|
|
{pad(shown.chapter + 1)}/{pad(config.chapters.length)}
|
|
</span>
|
|
<strong>{config.chapters[shown.chapter].label}</strong>
|
|
<ol className="ds-reel-ticks" aria-hidden="true">
|
|
{config.chapters.map((entry, tick) => (
|
|
<li key={entry.label} data-state={tickState(tick, shown.chapter)} />
|
|
))}
|
|
</ol>
|
|
</div>
|
|
<ProjectActions
|
|
item={item}
|
|
onExpand={() => onExpand(config.id)}
|
|
grouped
|
|
/>
|
|
</div>
|
|
);
|
|
}
|