feat(story): celular CC0 com site vivo na tela, ReelDirector scrub em todas as seções e filmstrip pinado de prints reais
This commit is contained in:
1 parent
8be2ba572d
commit
ecadb7c540
20 files changed
+637
-83
No files matched your search
+16
@@ -0,0 +1,16 @@
|
||||
# Credits / Third-party assets
|
||||
|
||||
## 3D model — “Phone”
|
||||
- Author: **Quaternius** (https://quaternius.com)
|
||||
- Source: https://poly.pizza/m/k2kgBepoMU
|
||||
- License: **CC0 1.0 Universal (Public Domain)**
|
||||
- Used in: `app/components/story/StoryStage3D.tsx` (hero scrollytelling reel),
|
||||
file at `public/models/phone-quaternius.glb`.
|
||||
- Modifications: material overrides (body/frame metallic dark; the model's
|
||||
`Black` surface renders a live CanvasTexture of the portfolio site). CC0 does
|
||||
not require attribution; credit given anyway.
|
||||
|
||||
## Project screenshots
|
||||
- `public/assets/prints/*.png` are captures taken with headless Chrome from
|
||||
production deployments owned by the site author (evandro.dev.br), used as
|
||||
on-screen content inside the 3D phone and section animations.
|
||||
@@ -91,7 +91,7 @@ export default function About() {
|
||||
description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric">
|
||||
<div className="mt-sp-4 ds-metric" data-reel="left">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
|
||||
Garuva, SC | Remote worldwide
|
||||
</div>
|
||||
@@ -120,7 +120,9 @@ export default function About() {
|
||||
<div className="ds-kicker">{step.label}</div>
|
||||
<DrawLine delay={0.03} />
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy">{step.content}</p>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
{step.content}
|
||||
</p>
|
||||
</motion.article>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -141,13 +143,13 @@ export default function About() {
|
||||
<div className="ds-kicker">Technical profile</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
|
||||
<p className="ds-copy">
|
||||
<p className="ds-copy" data-reel="top">
|
||||
Sou engenheiro da computação e desenvolvedor de software full-stack,
|
||||
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
|
||||
separadas: hardware, servicos, operacao e interface. Meu trabalho e
|
||||
dar forma clara ao que costuma nascer fragmentado.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4">
|
||||
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
|
||||
{PROFILE_POINTS.map((point) => (
|
||||
<div
|
||||
key={point.title}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function CompanyLogos() {
|
||||
// Animated infinite scroll
|
||||
return (
|
||||
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
|
||||
<div className="py-sp-8">
|
||||
<div className="py-sp-8" data-reel="zoom" data-reel-distance="40">
|
||||
<motion.div
|
||||
className="flex gap-sp-12 items-center"
|
||||
animate={{
|
||||
|
||||
@@ -90,19 +90,29 @@ export default function Contact() {
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div className="ds-kicker">Availability</div>
|
||||
<div className="ds-kicker" data-reel="left">
|
||||
Availability
|
||||
</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3>
|
||||
<p className="ds-copy">
|
||||
<h3 className="ds-card-title" data-reel="top">
|
||||
Resposta inicial em ate 24h
|
||||
</h3>
|
||||
<p className="ds-copy" data-reel="right">
|
||||
Ideal para discovery tecnico, revisao de arquitetura, rescue de
|
||||
projeto, performance ou construcao de produto com ownership real.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
|
||||
<div
|
||||
className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]"
|
||||
data-reel-stagger="bottom"
|
||||
>
|
||||
<div>WhatsApp: {whatsAppDisplay}</div>
|
||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||
</div>
|
||||
<div className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]">
|
||||
<div
|
||||
className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]"
|
||||
data-reel="fade"
|
||||
>
|
||||
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
|
||||
conversa tecnica objetiva.
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function Experience({
|
||||
<div className="max-w-[720px]">
|
||||
<div className="ds-kicker">{exp.period}</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title">{exp.title}</h3>
|
||||
<h3 className="ds-card-title" data-reel="left">{exp.title}</h3>
|
||||
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
|
||||
{exp.company} • {exp.type}
|
||||
</p>
|
||||
@@ -87,7 +87,7 @@ export default function Experience({
|
||||
<div className="ds-metric">{exp.location}</div>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">{exp.description}</p>
|
||||
<p className="ds-copy" data-reel="right">{exp.description}</p>
|
||||
|
||||
<ul className="ds-list">
|
||||
{exp.achievements?.map((achievement) => (
|
||||
@@ -95,7 +95,7 @@ export default function Experience({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{exp.technologies?.slice(0, 6).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
|
||||
@@ -43,7 +43,10 @@ export default function LighthouseMetrics() {
|
||||
dark
|
||||
disableChildrenAnimation={false}
|
||||
>
|
||||
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
data-reel="fade"
|
||||
>
|
||||
{METRICS.map((metric, i) => (
|
||||
<motion.article
|
||||
key={metric.label}
|
||||
@@ -96,7 +99,7 @@ export default function LighthouseMetrics() {
|
||||
</div>
|
||||
|
||||
{/* Content Details */}
|
||||
<div className="flex-1">
|
||||
<div className="flex-1" data-reel-stagger="bottom">
|
||||
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
|
||||
{metric.label}
|
||||
</h3>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function Projects({
|
||||
|
||||
<p className="ds-copy">{project.description}</p>
|
||||
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
|
||||
{project.technologies.slice(0, 5).map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
@@ -101,7 +101,7 @@ export default function Projects({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
|
||||
{project.githubUrl && (
|
||||
<MagneticButton
|
||||
as="a"
|
||||
@@ -131,7 +131,7 @@ export default function Projects({
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
|
||||
@@ -69,13 +69,15 @@ export default function Skills({
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<h3 className="ds-card-title !mt-0">{category.category}</h3>
|
||||
<h3 className="ds-card-title !mt-0" data-reel="left">
|
||||
{category.category}
|
||||
</h3>
|
||||
<DrawLine delay={0.05} />
|
||||
<p className="ds-copy !mt-sp-4">
|
||||
<p className="ds-copy !mt-sp-4" data-reel="right">
|
||||
Ferramentas que uso para construir com clareza tecnica e ritmo de
|
||||
entrega.
|
||||
</p>
|
||||
<ul className="ds-tags">
|
||||
<ul className="ds-tags" data-reel-stagger="fade">
|
||||
{category.skills.map((skill) => (
|
||||
<li key={skill} className="ds-tag">
|
||||
{skill}
|
||||
|
||||
@@ -128,11 +128,11 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
|
||||
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel="right">
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
|
||||
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]" data-reel-stagger="fade">
|
||||
{tier.deliverables.map((deliverable) => (
|
||||
<li key={deliverable} className="pl-sp-1">
|
||||
{deliverable}
|
||||
@@ -202,10 +202,10 @@ export function WorkModalities() {
|
||||
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-[760px]">
|
||||
<div className="ds-kicker">Páginas de serviço</div>
|
||||
<h3 className="ds-card-title !mt-sp-3">
|
||||
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
|
||||
Especialidades organizadas por intenção de busca
|
||||
</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
|
||||
Se você chegou buscando uma stack, um tipo de consultoria ou um
|
||||
contexto técnico específico, aqui está o caminho mais direto para
|
||||
a página certa.
|
||||
@@ -221,7 +221,7 @@ export function WorkModalities() {
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3" data-reel-stagger="left">
|
||||
{serviceHubLinks.map((service) => (
|
||||
<Link
|
||||
key={service.href}
|
||||
|
||||
@@ -10,6 +10,8 @@ import Hero from "../Hero";
|
||||
import Projects from "../Projects";
|
||||
import { SectionLoading } from "../SectionLoading";
|
||||
import ScrollStory from "../story/ScrollStory";
|
||||
import ReelDirector from "../story/ReelDirector";
|
||||
import PrintReel from "../story/PrintReel";
|
||||
import CinematicSection from "./CinematicSection";
|
||||
|
||||
const About = dynamic(() => import("../About"), {
|
||||
@@ -52,6 +54,7 @@ export default function CinematicHome({
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
<main className="ds-main">
|
||||
<ReelDirector />
|
||||
<Hero />
|
||||
|
||||
{/* Marquee divider after hero */}
|
||||
@@ -69,6 +72,9 @@ export default function CinematicHome({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Palco 2: filmstrip horizontal pinado com prints reais dos deployments */}
|
||||
<PrintReel />
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<About />
|
||||
</CinematicSection>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { DrawLine } from "../animations/DrawLine";
|
||||
import HorizontalScroll from "../cinematic/HorizontalScroll";
|
||||
import { Section } from "../Section";
|
||||
|
||||
/**
|
||||
* Palco extra estilo product-page: filmstrip horizontal PINNADO com prints
|
||||
* reais capturados dos deployments em produção (public/assets/prints).
|
||||
* O scroll vertical empurra a faixa na horizontal (scrub), cada card entra
|
||||
* com rotateY/scale ligados ao mesmo trilho — sensação de carrossel de vídeo.
|
||||
*/
|
||||
|
||||
const PRINTS = [
|
||||
{
|
||||
src: "/assets/prints/print-nutriscan.png",
|
||||
name: "NutriScan",
|
||||
desc: "PWA de dados nutricionais sobre Open Food Facts — busca, Nutri-Score e NOVA.",
|
||||
url: "https://nutriscan.evandro.dev.br",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
src: "/assets/prints/print-scplus.png",
|
||||
name: "SCPLUS",
|
||||
desc: "Educação e tecnologia sustentável em Garuva-SC; interface institucional viva.",
|
||||
url: "https://scplus.evandro.dev.br",
|
||||
accent: false,
|
||||
},
|
||||
{
|
||||
src: "/assets/prints/print-sev.png",
|
||||
name: "SEV Moda Íntima",
|
||||
desc: "E-commerce em produção com checkout e catálogo — Next.js ponta a ponta.",
|
||||
url: "https://sevmodaintima.com.br",
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
src: "/assets/prints/print-asservicos.png",
|
||||
name: "AS Serviços",
|
||||
desc: "Presença digital SSR para operação de serviços, pronta para crescer.",
|
||||
url: "https://asservicos.com",
|
||||
accent: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function PrintReel() {
|
||||
return (
|
||||
<Section
|
||||
id="print-reel"
|
||||
eyebrow="No ar, rodando"
|
||||
title="Prints ao vivo dos sistemas em produção"
|
||||
description="Capturas reais dos deployments — role e a faixa corre na horizontal como um reel."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="flex h-[100dvh] items-center">
|
||||
<HorizontalScroll className="w-full">
|
||||
{PRINTS.map((print) => (
|
||||
<a
|
||||
key={print.name}
|
||||
data-scroll-item
|
||||
href={print.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex w-[min(78vw,380px)] shrink-0 flex-col"
|
||||
>
|
||||
{/* moldura de navegador */}
|
||||
<div className="overflow-hidden rounded-t-xl border border-b-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.05)] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[rgba(255,255,255,.18)]" />
|
||||
<span
|
||||
className={`h-2.5 w-2.5 rounded-full ${print.accent ? "bg-[var(--slash)]" : "bg-[rgba(255,255,255,.18)]"}`}
|
||||
/>
|
||||
<span className="ml-2 truncate font-mono text-[10px] text-[rgba(244,242,237,.5)]">
|
||||
{new URL(print.url).hostname}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-[420px] overflow-hidden border border-[rgba(255,255,255,.12)] bg-black">
|
||||
{/* o print real, deslizando de leve dentro da moldura (Ken Burns) */}
|
||||
<img
|
||||
src={print.src}
|
||||
alt={`Captura do projeto ${print.name} em produção`}
|
||||
loading="lazy"
|
||||
className="absolute inset-x-0 top-0 w-full origin-top scale-[1.04] object-cover object-top transition-transform duration-700 ease-out group-hover:-translate-y-6 group-hover:scale-100"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_60%,rgba(10,10,10,.85)_100%)]" />
|
||||
</div>
|
||||
<div className="border border-t-0 border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.03)] p-sp-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-display text-[18px] font-extrabold tracking-[-0.02em] text-[var(--paper)]">
|
||||
{print.name}
|
||||
</h3>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
ao vivo ↗
|
||||
</span>
|
||||
</div>
|
||||
<DrawLine delay={0.1} />
|
||||
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.66)]">
|
||||
{print.desc}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</HorizontalScroll>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* ReelDirector — motor de animação "vídeo-ao-rolar" para a página inteira.
|
||||
*
|
||||
* Contrato (declarativo, via atributos data-*):
|
||||
* [data-reel="left|right|top|bottom|zoom|fade"]
|
||||
* elemento entra em scrub contínuo: rolar p/ baixo avança, p/ cima REBOBINA.
|
||||
* [data-reel-stagger="left|right|top|bottom|zoom|fade"]
|
||||
* container: os FILHOS DIRETOS entram em cascata no mesmo trilho.
|
||||
* data-reel-start="top 88%" data-reel-end="top 26%" (opcional: janela do scrub)
|
||||
* data-reel-distance="64" (opcional: px de deslocamento)
|
||||
*
|
||||
* Nunca one-shot — tudo ligado ao progresso do scroll, como o reel do hero.
|
||||
* prefers-reduced-motion: nada é inicializado; conteúdo fica no fluxo natural.
|
||||
*/
|
||||
|
||||
type Direction = "left" | "right" | "top" | "bottom" | "zoom" | "fade";
|
||||
|
||||
const DISTANCE = 64;
|
||||
|
||||
function fromVars(dir: Direction, dist: number) {
|
||||
const base = { opacity: 0, x: 0, y: 0, scale: 1, filter: "blur(0px)" };
|
||||
if (dir === "left") return { ...base, x: -dist };
|
||||
if (dir === "right") return { ...base, x: dist };
|
||||
if (dir === "top") return { ...base, y: -dist * 0.75 };
|
||||
if (dir === "bottom") return { ...base, y: dist };
|
||||
if (dir === "zoom") return { ...base, scale: 1.28, filter: "blur(6px)" };
|
||||
return { ...base, scale: 1.02 };
|
||||
}
|
||||
|
||||
const TO_VARS = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
ease: "none",
|
||||
};
|
||||
|
||||
export default function ReelDirector() {
|
||||
const alive = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
||||
return undefined;
|
||||
|
||||
let teardown = () => {};
|
||||
|
||||
const boot = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (!alive.current) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scan = () => {
|
||||
for (const el of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel]:not([data-reel-init])",
|
||||
)) {
|
||||
el.dataset.reelInit = "1";
|
||||
const dir = (el.dataset.reel || "bottom") as Direction;
|
||||
const dist = Number(el.dataset.reelDistance || DISTANCE);
|
||||
gsap.fromTo(el, fromVars(dir, dist), {
|
||||
...TO_VARS,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: el.dataset.reelStart || "top 88%",
|
||||
end: el.dataset.reelEnd || "top 26%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const container of document.querySelectorAll<HTMLElement>(
|
||||
"[data-reel-stagger]:not([data-reel-init])",
|
||||
)) {
|
||||
container.dataset.reelInit = "1";
|
||||
const dir = (container.dataset.reelStagger || "bottom") as Direction;
|
||||
const dist = Number(container.dataset.reelDistance || DISTANCE);
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
if (children.length === 0) continue;
|
||||
gsap.fromTo(
|
||||
children,
|
||||
fromVars(dir, dist),
|
||||
{
|
||||
...TO_VARS,
|
||||
stagger: { each: 0.09 },
|
||||
scrollTrigger: {
|
||||
trigger: container,
|
||||
start: container.dataset.reelStart || "top 85%",
|
||||
end: container.dataset.reelEnd || "top 25%",
|
||||
scrub: 0.6,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
scan();
|
||||
// seções chegam via next/dynamic depois do boot — observa nós novos
|
||||
const observer = new MutationObserver(() => scan());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
teardown = () => {
|
||||
observer.disconnect();
|
||||
ctx.revert();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
void boot();
|
||||
return () => {
|
||||
alive.current = false;
|
||||
teardown();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -65,10 +65,10 @@ describe("ScrollStory", () => {
|
||||
});
|
||||
|
||||
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
|
||||
mockMatchMedia(false);
|
||||
const { container } = render(<ScrollStory {...props} />);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector("[data-story]")).toBeInTheDocument(),
|
||||
await waitFor(
|
||||
() => expect(container.querySelector("[data-story]")).toBeInTheDocument(),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
|
||||
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
|
||||
@@ -76,5 +76,5 @@ describe("ScrollStory", () => {
|
||||
// count-up é alimentado pela timeline: markup carrega o alvo real
|
||||
const num = container.querySelector("[data-story-num]");
|
||||
expect(num?.getAttribute("data-story-num-to")).toBe("5");
|
||||
});
|
||||
}, 20000);
|
||||
});
|
||||
@@ -1,72 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Environment,
|
||||
Lightformer,
|
||||
RoundedBox,
|
||||
Sparkles,
|
||||
useGLTF,
|
||||
} from "@react-three/drei";
|
||||
import {
|
||||
Component,
|
||||
Suspense,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import { createPhoneScreen, type ScreenPainter } from "./phoneScreenTexture";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
/**
|
||||
* Palco WebGL do scrollytelling: um "dispositivo" 3D procedural que gira,
|
||||
* inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem
|
||||
* re-render do React por frame). O lerp interno suaviza o scrub em touch.
|
||||
* Composição estilo product-page: dispositivo no terço direito, texto à esquerda.
|
||||
* Palco WebGL do scrollytelling.
|
||||
* Modelo 3D do aparelho: "Phone" by Quaternius — CC0 (Public Domain), via
|
||||
* poly.pizza (https://poly.pizza/m/k2kgBepoMU). Creditado em CREDITS.md.
|
||||
* A tela recebe um CanvasTexture com o mini-site do portfólio (prints reais dos
|
||||
* deployments) rolando conforme o progresso do scroll — o celular está "ligado".
|
||||
*/
|
||||
function DeviceRig({ progress }: { progress: ProgressRef }) {
|
||||
|
||||
function motionRig(g: THREE.Object3D, p: number) {
|
||||
// turntable contínuo: uma volta + extra, com inclinação de cena a cena
|
||||
g.rotation.y = -0.7 + p * Math.PI * 2.2;
|
||||
g.rotation.x = 0.08 + Math.sin(p * Math.PI) * 0.22;
|
||||
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.05;
|
||||
g.position.x = 1.4;
|
||||
g.position.y = Math.sin(p * Math.PI * 2) * 0.2;
|
||||
g.scale.setScalar(0.8 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.16);
|
||||
}
|
||||
|
||||
function ScreenPhone({ progress }: { progress: ProgressRef }) {
|
||||
// CC0: Quaternius "Phone" — poly.pizza/m/k2kgBepoMU
|
||||
const { scene } = useGLTF("/models/phone-quaternius.glb");
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const halo = useRef<THREE.Mesh>(null);
|
||||
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
|
||||
const glow = useRef<THREE.PointLight>(null);
|
||||
const smooth = useRef(0);
|
||||
const [painter] = useState<ScreenPainter>(() => createPhoneScreen());
|
||||
const texture = useMemo(() => {
|
||||
const t = new THREE.CanvasTexture(painter.canvas);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.anisotropy = 4;
|
||||
return t;
|
||||
}, [painter]);
|
||||
|
||||
useFrame((_, dt) => {
|
||||
// materiais: corpo metálico; primitiva "Black" = tela com o site vivo
|
||||
const materials = useMemo(() => {
|
||||
const body = new THREE.MeshStandardMaterial({
|
||||
color: "#232429",
|
||||
metalness: 0.85,
|
||||
roughness: 0.38,
|
||||
});
|
||||
const frame = new THREE.MeshStandardMaterial({
|
||||
color: "#3a3b42",
|
||||
metalness: 0.9,
|
||||
roughness: 0.3,
|
||||
});
|
||||
const screen = new THREE.MeshStandardMaterial({
|
||||
color: "#000000",
|
||||
map: texture,
|
||||
emissive: "#ffffff",
|
||||
emissiveMap: texture,
|
||||
emissiveIntensity: 1.15,
|
||||
roughness: 0.4,
|
||||
metalness: 0,
|
||||
});
|
||||
return { body, frame, screen };
|
||||
}, [texture]);
|
||||
|
||||
useFrame((state, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
const g = rig.current;
|
||||
if (!g) return;
|
||||
|
||||
// tomada contínua: ~360° + um extra, como um turntable pré-renderizado
|
||||
g.rotation.y = -0.7 + p * Math.PI * 2.2;
|
||||
g.rotation.x = 0.1 + Math.sin(p * Math.PI) * 0.28;
|
||||
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.06;
|
||||
g.position.x = 2.1;
|
||||
g.position.y = Math.sin(p * Math.PI * 2) * 0.22;
|
||||
// respiro de escala durante o reel (zoom de câmera sutil)
|
||||
g.scale.setScalar(0.62 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.1);
|
||||
|
||||
if (rig.current) motionRig(rig.current, p);
|
||||
if (halo.current) {
|
||||
halo.current.rotation.z += dt * 0.25;
|
||||
const m = halo.current.material as THREE.MeshBasicMaterial;
|
||||
m.opacity = 0.1 + p * 0.08;
|
||||
}
|
||||
if (screenMat.current) {
|
||||
// tela "liga" cena a cena: 4 pulsos ao longo do reel
|
||||
screenMat.current.emissiveIntensity =
|
||||
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
|
||||
}
|
||||
if (glow.current) {
|
||||
glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4;
|
||||
// pinta o mini-site ~4fps quando há mudança real (quantizada no painter)
|
||||
const t = state.clock.elapsedTime * 1000;
|
||||
if (painter.ready() && painter.paint(p, t)) {
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
const cloned = useMemo(() => {
|
||||
const root = scene.clone(true);
|
||||
root.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return;
|
||||
const name = (obj.material as THREE.Material & { name?: string })?.name ?? "";
|
||||
if (name === "Black") {
|
||||
obj.material = materials.screen;
|
||||
// o GLB não tem TEXCOORD — UV plano na face (geo X/Z local, topo = +Z)
|
||||
const geo = obj.geometry;
|
||||
geo.computeBoundingBox();
|
||||
const bb = geo.boundingBox!;
|
||||
const sx = bb.max.x - bb.min.x || 1;
|
||||
const sz = bb.max.z - bb.min.z || 1;
|
||||
const pos = geo.attributes.position as THREE.BufferAttribute;
|
||||
const uv = new Float32Array(pos.count * 2);
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
uv[i * 2] = (pos.getX(i) - bb.min.x) / sx;
|
||||
uv[i * 2 + 1] = (pos.getZ(i) - bb.min.z) / sz;
|
||||
}
|
||||
geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
|
||||
} else {
|
||||
obj.material = name === "Grey" ? materials.frame : materials.body;
|
||||
}
|
||||
obj.frustumCulled = false;
|
||||
});
|
||||
// a primitiva Black aponta +Z mundo após a rotação -90°X do node — já dá de
|
||||
// frente para a câmera em p=0; nenhum flip extra.
|
||||
// o modelo mede ~0.9 unidades de altura; enquadrar no stage (~2.5u)
|
||||
root.scale.setScalar(2.75);
|
||||
return root;
|
||||
}, [scene, materials]);
|
||||
|
||||
return (
|
||||
<group ref={rig}>
|
||||
<primitive object={cloned} />
|
||||
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.2, -0.3]}>
|
||||
<torusGeometry args={[1.9, 0.014, 10, 128]} />
|
||||
<meshBasicMaterial color="#ccff00" transparent opacity={0.16} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight position={[0, 0, 1.9]} color="#ccff00" intensity={0.6} distance={5} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback procedural (GLTF falhou / SwiftShader): rig de RoundedBox. */
|
||||
function ProceduralPhone({ progress }: { progress: ProgressRef }) {
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
|
||||
const smooth = useRef(0);
|
||||
useFrame((_, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
if (rig.current) motionRig(rig.current, p);
|
||||
if (screenMat.current)
|
||||
screenMat.current.emissiveIntensity =
|
||||
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
|
||||
});
|
||||
return (
|
||||
<group ref={rig}>
|
||||
{/* corpo do dispositivo */}
|
||||
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
|
||||
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
|
||||
</RoundedBox>
|
||||
{/* borda luminosa (bezel slash) */}
|
||||
<RoundedBox args={[1.7, 3.4, 0.1]} radius={0.17} smoothness={4} position={[0, 0, -0.01]}>
|
||||
<meshStandardMaterial
|
||||
color="#0a0a0a"
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.22}
|
||||
metalness={0.6}
|
||||
roughness={0.5}
|
||||
/>
|
||||
</RoundedBox>
|
||||
{/* tela emissiva */}
|
||||
<mesh position={[0, 0, 0.098]}>
|
||||
<planeGeometry args={[1.42, 3.06]} />
|
||||
<meshStandardMaterial
|
||||
@@ -77,26 +165,34 @@ function DeviceRig({ progress }: { progress: ProgressRef }) {
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.92}
|
||||
roughness={0.3}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
{/* halo orbital */}
|
||||
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.1, -0.3]}>
|
||||
<torusGeometry args={[1.9, 0.014, 10, 128]} />
|
||||
<meshBasicMaterial color="#ccff00" transparent opacity={0.2} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight ref={glow} position={[0, 0, 1.6]} color="#ccff00" intensity={2.4} distance={6} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Boundary funcional: se o GLTF estourar, mantém o rig procedural no lugar.
|
||||
class PhoneBoundary extends Component<
|
||||
{ progress: ProgressRef },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
render(): ReactNode {
|
||||
if (this.state.failed)
|
||||
return <ProceduralPhone progress={this.props.progress} />;
|
||||
return <ScreenPhone progress={this.props.progress} />;
|
||||
}
|
||||
}
|
||||
|
||||
function CameraRig({ progress }: { progress: ProgressRef }) {
|
||||
const smooth = useRef(0);
|
||||
useFrame(({ camera }, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
// leve steadicam: aproxima no meio da história
|
||||
// steadicam sutil: aproxima no meio da história
|
||||
camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
|
||||
camera.position.y = -0.3 + p * 0.6;
|
||||
camera.lookAt(0, 0, 0);
|
||||
@@ -111,9 +207,12 @@ export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
|
||||
camera={{ position: [0, 0, 7.8], fov: 30 }}
|
||||
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
||||
>
|
||||
<ambientLight intensity={0.32} />
|
||||
<directionalLight position={[4, 6, 5]} intensity={1.1} />
|
||||
<DeviceRig progress={progress} />
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[4, 6, 5]} intensity={1.4} />
|
||||
<directionalLight position={[-5, -2, 6]} intensity={0.6} color="#cfd2da" />
|
||||
<Suspense fallback={<ProceduralPhone progress={progress} />}>
|
||||
<PhoneBoundary progress={progress} />
|
||||
</Suspense>
|
||||
<CameraRig progress={progress} />
|
||||
<Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" />
|
||||
<Environment resolution={64} frames={1}>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Gera a textura "site vivo" da tela do celular: um mini-portfolio desenhado em
|
||||
// canvas 2D, com os prints reais dos projetos em produção rolando verticalmente
|
||||
// conforme o progresso do reel — como se o site estivesse aberto no aparelho.
|
||||
// Fotos dos projetos: capturadas ao vivo dos deployments (headless Chrome).
|
||||
// CC0 model: "Phone" by Quaternius (https://quaternius.com) via poly.pizza.
|
||||
|
||||
type Shot = { img: HTMLImageElement; name: string; tag: string };
|
||||
|
||||
const SHOTS: Array<{ src: string; name: string; tag: string }> = [
|
||||
{ src: "/assets/prints/print-nutriscan.png", name: "NutriScan", tag: "Open Food Facts · PWA" },
|
||||
{ src: "/assets/prints/print-scplus.png", name: "SCPLUS", tag: "Educação + IoT · Garuva-SC" },
|
||||
{ src: "/assets/prints/print-sev.png", name: "SEV Moda Íntima", tag: "E-commerce · Next.js" },
|
||||
{ src: "/assets/prints/print-asservicos.png", name: "AS Serviços", tag: "Institucional · SSR" },
|
||||
];
|
||||
|
||||
export type ScreenPainter = {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** desenha o frame para o progresso p∈[0,1] do reel; retorna true se mudou */
|
||||
paint: (p: number, t: number) => boolean;
|
||||
ready: () => boolean;
|
||||
};
|
||||
|
||||
const W = 512;
|
||||
const H = 1024;
|
||||
const INK = "#0a0a0a";
|
||||
const PAPER = "#f4f2ed";
|
||||
const SLASH = "#ccff00";
|
||||
|
||||
export function createPhoneScreen(): ScreenPainter {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const images: Shot[] = [];
|
||||
let lastKey = "";
|
||||
|
||||
for (const s of SHOTS) {
|
||||
const img = new Image();
|
||||
img.decoding = "async";
|
||||
img.onload = () => images.push({ img, name: s.name, tag: s.tag });
|
||||
img.src = s.src;
|
||||
}
|
||||
|
||||
function roundRect(x: number, y: number, w: number, h: number, r: number) {
|
||||
if (!ctx) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function wrap(text: string, x: number, y: number, maxW: number, lh: number) {
|
||||
if (!ctx) return;
|
||||
const words = text.split(" ");
|
||||
let line = "";
|
||||
let yy = y;
|
||||
for (const word of words) {
|
||||
const candidate = line ? `${line} ${word}` : word;
|
||||
if (ctx.measureText(candidate).width > maxW) {
|
||||
ctx.fillText(line, x, yy);
|
||||
line = word;
|
||||
yy += lh;
|
||||
} else line = candidate;
|
||||
}
|
||||
if (line) ctx.fillText(line, x, yy);
|
||||
}
|
||||
|
||||
function drawHero(offset: number, t: number) {
|
||||
if (!ctx) return;
|
||||
if (offset > H || offset < -H) return;
|
||||
ctx.save();
|
||||
ctx.translate(0, offset);
|
||||
const pulse = 0.6 + 0.4 * Math.sin(t / 600);
|
||||
ctx.fillStyle = SLASH;
|
||||
ctx.globalAlpha = 0.14 + pulse * 0.06;
|
||||
ctx.fillRect(0, 60, W, 2);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = "rgba(244,242,237,.55)";
|
||||
ctx.font = "600 20px ui-monospace, monospace";
|
||||
ctx.fillText("PORTFÓLIO · AO VIVO", 36, 150);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "800 72px system-ui, sans-serif";
|
||||
ctx.fillText("Software", 36, 260);
|
||||
ctx.fillText("que", 36, 340);
|
||||
ctx.fillStyle = SLASH;
|
||||
ctx.fillText("opera.", 36, 420);
|
||||
ctx.fillStyle = "rgba(244,242,237,.66)";
|
||||
ctx.font = "400 28px system-ui, sans-serif";
|
||||
wrap("Engenharia de sistemas que precisam funcionar de verdade.", 36, 500, W - 72, 40);
|
||||
ctx.fillStyle = SLASH;
|
||||
roundRect(36, 700, 250, 72, 36);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = "700 26px ui-monospace, monospace";
|
||||
ctx.fillText("VER PROJETOS ↓", 60, 744);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawProject(shot: Shot, offset: number, i: number, t: number) {
|
||||
if (!ctx) return;
|
||||
ctx.save();
|
||||
ctx.translate(0, offset);
|
||||
ctx.fillStyle = "rgba(244,242,237,.5)";
|
||||
ctx.font = "600 18px ui-monospace, monospace";
|
||||
ctx.fillText(`EM PRODUÇÃO · 0${i + 1}`, 36, 110);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "800 46px system-ui, sans-serif";
|
||||
ctx.fillText(shot.name, 36, 168);
|
||||
ctx.fillStyle = "rgba(204,255,0,.85)";
|
||||
ctx.font = "400 22px system-ui, sans-serif";
|
||||
ctx.fillText(shot.tag, 36, 204);
|
||||
|
||||
// o print real, recortado, com leve auto-scroll dentro do card
|
||||
const cardX = 36;
|
||||
const cardY = 236;
|
||||
const cardW = W - 72;
|
||||
const cardH = 620;
|
||||
ctx.save();
|
||||
roundRect(cardX, cardY, cardW, cardH, 28);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.fillRect(cardX, cardY, cardW, cardH);
|
||||
const scale = cardW / shot.img.width;
|
||||
const drift =
|
||||
(Math.sin(t / 1400 + i * 2.1) * 0.5 + 0.5) *
|
||||
Math.max(0, shot.img.height * scale - cardH);
|
||||
ctx.drawImage(shot.img, cardX, cardY - drift, cardW, shot.img.height * scale);
|
||||
const grd = ctx.createLinearGradient(cardX, cardY, cardX, cardY + cardH);
|
||||
grd.addColorStop(0, "rgba(255,255,255,.08)");
|
||||
grd.addColorStop(0.12, "rgba(255,255,255,0)");
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fillRect(cardX, cardY, cardW, cardH);
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = "rgba(255,255,255,.12)";
|
||||
ctx.lineWidth = 2;
|
||||
roundRect(cardX, cardY, cardW, cardH, 28);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function paint(p: number, t: number) {
|
||||
if (!ctx || images.length === 0) return false;
|
||||
const key = `${p.toFixed(4)}|${Math.floor(t / 250)}|${images.length}`;
|
||||
if (key === lastKey) return false;
|
||||
lastKey = key;
|
||||
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// status bar do "aparelho"
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "600 22px ui-monospace, monospace";
|
||||
ctx.fillText("21:37", 28, 38);
|
||||
ctx.fillStyle = SLASH;
|
||||
ctx.fillRect(W - 96, 22, 44, 14);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.font = "600 16px ui-monospace, monospace";
|
||||
ctx.fillText("E:/", W - 40, 36);
|
||||
|
||||
// o mini-site rolando: hero + página por projeto
|
||||
const pageH = H - 60;
|
||||
const y = p * images.length * pageH;
|
||||
drawHero(-y, t);
|
||||
images.forEach((shot, i) => {
|
||||
const top = (i + 1) * pageH - y;
|
||||
if (top > H || top < -pageH) return;
|
||||
drawProject(shot, top, i, t);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
canvas,
|
||||
paint,
|
||||
ready: () => images.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const PHONE_SCREEN_W = W;
|
||||
export const PHONE_SCREEN_H = H;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
Binary file not shown.
Reference in new issue
Block a user