/* ============================================================ Shared components + scroll hooks ============================================================ */ const { useState, useEffect, useRef, useCallback } = React; /* ---------- Gray placeholder (or real image when src is set) ---------- */ function Placeholder({ tone = "1", label, parallax = false, zoom = false, speed = 0.12, style, className = "", src }) { const ref = useRef(null); useEffect(() => { if (!parallax) return; const el = ref.current; if (!el) return; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) return; let raf = null; const inner = el.querySelector(".ph__inner"); const update = () => { raf = null; const amount = window.__PARALLAX_AMOUNT ?? 1; if (!inner) return; if (amount === 0) { inner.style.transform = ""; return; } const r = el.getBoundingClientRect(); const vh = window.innerHeight; const center = r.top + r.height / 2; const prog = (center - vh / 2) / (vh / 2 + r.height / 2); // -1..1, 0 centered const ty = -prog * speed * 100 * amount; let tr = `translate3d(0, ${ty.toFixed(2)}px, 0)`; if (zoom) { const sc = 1 + Math.min(0.09, Math.abs(prog) * 0.09); tr += ` scale(${sc.toFixed(3)})`; } inner.style.transform = tr; }; const onScroll = () => { if (raf == null) raf = requestAnimationFrame(update); }; update(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); return () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); if (raf) cancelAnimationFrame(raf); }; }, [parallax, zoom, speed]); return (
{src && {label}
{label && !src ? {label} : null}
); } /* ---------- Reveal-on-scroll wrapper ---------- */ function Reveal({ children, as = "div", delay = 0, className = "", style, fromRight = false }) { const ref = useRef(null); const [shown, setShown] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) { setShown(true); return; } let done = false; let raf = null; const check = () => { raf = null; if (done || !ref.current) return; const r = ref.current.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; // in view once its top crosses ~94% of viewport height if (r.top < vh * 0.94 && r.bottom > 0) { done = true; setTimeout(() => setShown(true), delay); window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); } }; const onScroll = () => { if (raf == null) raf = requestAnimationFrame(check); }; // run once synchronously (rAF is throttled in offscreen iframes) check(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); return () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); if (raf) cancelAnimationFrame(raf); }; }, [delay]); const Tag = as; return {children}; } /* ---------- Social icons (line) ---------- */ const ICONS = { instagram: <>, dribbble: <>, linkedin: <>, read: <>, }; function SocialIcon({ name }) { return ( {ICONS[name]} ); } function Socials({ names = ["instagram", "dribbble", "linkedin", "read"] }) { return (
{names.map((n) => ( e.preventDefault()}> ))}
); } /* ---------- Lang toggle (top-right of card) ---------- */ function LangToggle({ lang, setLang }) { return ( ); } /* ---------- Back button (circle arrow) ---------- */ function BackButton({ onClick, label }) { return ( ); } Object.assign(window, { Placeholder, Reveal, Socials, SocialIcon, LangToggle, BackButton });