/* ================================================================= Shared primitives + routing helpers Exposed on window for the other babel scripts. ================================================================= */ const { useState, useEffect, useRef } = React; /* localized string helper */ const L = (obj, lang) => (obj && typeof obj === 'object' ? (obj[lang] ?? obj.en) : obj); /* ---- slug + route maps (built once from data) ---- */ const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); const CAT_BY_ROUTE = {}; // 'video' -> 'film' const ROUTE_BY_CAT = {}; // 'film' -> 'video' (function initRoutes() { const data = window.PORTFOLIO; data.categories.forEach((c) => { CAT_BY_ROUTE[c.route] = c.id; ROUTE_BY_CAT[c.id] = c.route; }); Object.keys(data.works).forEach((cat) => data.works[cat].forEach((w) => { w.slug = slugify(w.title); })); })(); const catMeta = (catId) => window.PORTFOLIO.categories.find((c) => c.id === catId); const workBySlug = (catId, slug) => window.PORTFOLIO.works[catId].find((w) => w.slug === slug); const navTo = (path) => { const clean = '/' + String(path).replace(/^\/+/, ''); history.pushState(null, '', clean); window.dispatchEvent(new PopStateEvent('popstate', { state: null })); }; /* ---- reveal-on-scroll hook ---- */ function useReveal(opts = {}) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { el.classList.add('in'); io.unobserve(el); } }); }, { threshold: opts.threshold ?? 0.16, rootMargin: opts.rootMargin ?? '0px 0px -8% 0px' }); io.observe(el); return () => io.disconnect(); }, []); return ref; } function Reveal({ as = 'div', delay = 0, className = '', children, ...rest }) { const ref = useReveal(); const Tag = as; return ( {children} ); } /* =================== โ€” typing language swap ==================== Renders a localized string that RE-TYPES whenever the resolved text changes (i.e. on JP/EN switch). A hidden ghost reserves the final box so multi-line text never reflows mid-type. Usage: | =================================================================== */ const _reduceMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; function T({ t, lang, as = 'span', block = false, className = '', speed = 20 }) { const target = typeof t === 'string' ? t : L(t, lang); const [disp, setDisp] = useState(target); const [typing, setTyping] = useState(false); const first = useRef(true); useEffect(() => { if (first.current) { first.current = false; setDisp(target); return; } if (_reduceMotion() || !target) { setDisp(target); setTyping(false); return; } setTyping(true); let i = 0; const len = target.length; // aim for ~1.6โ€“2.4s of typing regardless of string length const ticks = Math.min(len, 60 + Math.round(len * 0.25)); const per = Math.max(1, len / ticks); const interval = Math.max(speed, Math.round(2000 / ticks)); setDisp(''); const id = setInterval(() => { i += per; if (i >= len) { setDisp(target); setTyping(false); clearInterval(id); } else setDisp(target.slice(0, Math.round(i))); }, interval); return () => clearInterval(id); }, [target]); const Tag = as; return ( {disp} ); } /* =================== Vimeo ==================== */ function VimeoFrame({ id, title }) { const [loaded, setLoaded] = useState(false); return (
{!loaded &&
LOADING ยท VIMEO {id}
}