/* ================================================================= App — rich loader, route-aware nav, hash router + page transitions ================================================================= */ const { useState: uS, useEffect: uE, useRef: uR } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "topDirection": "index", "language": "jp", "accent": "#f3f2ee", "grain": true, "cursor": true, "transitions": true }/*EDITMODE-END*/; /* ---------- custom cursor ---------- */ function Cursor({ enabled }) { const dot = uR(null), ring = uR(null); uE(() => { if (!enabled) return; if (window.matchMedia('(hover: none)').matches) return; document.body.classList.add('cur-custom'); let mx = innerWidth / 2, my = innerHeight / 2, rx = mx, ry = my, raf; const move = (e) => { mx = e.clientX; my = e.clientY; if (dot.current) dot.current.style.transform = `translate(${mx}px,${my}px) translate(-50%,-50%)`; }; const loop = () => { rx += (mx - rx) * 0.18; ry += (my - ry) * 0.18; if (ring.current) ring.current.style.transform = `translate(${rx}px,${ry}px) translate(-50%,-50%)`; raf = requestAnimationFrame(loop); }; const over = (e) => { if (e.target.closest('[data-hot], a, button')) document.body.classList.add('cur-hot'); }; const out = (e) => { if (e.target.closest('[data-hot], a, button')) document.body.classList.remove('cur-hot'); }; window.addEventListener('mousemove', move); document.addEventListener('mouseover', over); document.addEventListener('mouseout', out); loop(); return () => { window.removeEventListener('mousemove', move); document.removeEventListener('mouseover', over); document.removeEventListener('mouseout', out); cancelAnimationFrame(raf); document.body.classList.remove('cur-hot'); document.body.classList.remove('cur-custom'); }; }, [enabled]); if (!enabled) return null; return (<>
); } /* ---------- rich loader: counts EVERY integer 0 → 100 (imperative, cheap) ---------- */ function Loader({ data, onDone }) { const [exit, setExit] = uS(false); const numRef = uR(null), fillRef = uR(null); const charRefs = uR([]), mfRefs = uR([]); const reduce = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const name = data.meta.nameEn; const chars = name.split(''); const manifest = [ { label: 'FILM & MOTION', count: data.works.film.length, at: 28 }, { label: 'APPLICATIONS', count: data.works.app.length, at: 56 }, { label: 'WEB TOOLS', count: data.works.web.length, at: 82 }, ]; uE(() => { const paint = (v) => { if (numRef.current) numRef.current.textContent = String(v).padStart(3, '0'); if (fillRef.current) fillRef.current.style.transform = `scaleX(${v / 100})`; const revealed = Math.round((v / 100) * chars.length); charRefs.current.forEach((el, i) => { if (el) el.classList.toggle('on', i < revealed); }); mfRefs.current.forEach((el, i) => { if (el) el.classList.toggle('on', v >= manifest[i].at); if (el) { const m = el.querySelector('.ld-mf-mark'); if (m) m.textContent = v >= manifest[i].at ? '●' : '○'; } }); }; if (reduce) { paint(100); setExit(true); const d = setTimeout(onDone, 500); return () => clearTimeout(d); } // wall-clock driven: completes in real time even if timers are throttled, // and steps through every integer at full speed when visible. const DUR = 2400; const start = Date.now(); let last = -1, done = false; const id = setInterval(() => { const p = Math.min(1, (Date.now() - start) / DUR); const v = Math.min(100, Math.floor(Math.pow(p, 0.92) * 100)); if (v !== last) { paint(v); last = v; } if (p >= 1 && !done) { done = true; clearInterval(id); paint(100); setTimeout(() => setExit(true), 360); setTimeout(onDone, 1280); } }, 16); paint(0); return () => { clearInterval(id); }; }, []); return (
SAKUYA MIZUNO — PORTFOLIO {data.meta.nameJp}
{chars.map((ch, i) => ( (charRefs.current[i] = el)} className="ld-ch"> {ch === ' ' ? '\u00A0' : ch} ))}
    {manifest.map((m, i) => (
  • (mfRefs.current[i] = el)} className="ld-mf"> {m.label} {String(m.count).padStart(2, '0')}
  • ))}
000 %
); } /* ---------- theme (light / dark) ---------- */ const readStoredTheme = () => { try { return localStorage.getItem('sm-theme'); } catch (e) { return null; } }; const systemTheme = () => (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); /* ---------- top nav (route-aware) ---------- */ function Nav({ data, lang, setLang, route, theme, toggleTheme }) { const [solid, setSolid] = uS(false); uE(() => { const onScroll = () => setSolid(window.scrollY > 80); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, []); // home keeps the nav transparent until you scroll; inner pages are always solid const home = route.view === 'home'; const isWorks = route.view === 'category' || route.view === 'work'; const onCat = (id) => (route.cat === id); return ( ); } /* ---------- transition curtain ---------- */ function Curtain({ phase, label }) { return ( ); } /* ---------- root ---------- */ function App() { const data = window.PORTFOLIO; const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [intro, setIntro] = uS(true); const lang = t.language === 'en' ? 'en' : 'jp'; const setLang = (l) => setTweak('language', l); const route = useRoute(); const [display, setDisplay] = uS(route); const [phase, setPhase] = uS('idle'); // idle | cover | reveal const [curtainLabel, setCurtainLabel] = uS(''); /* ---- theme: device default, manual choice persisted ---- */ const [theme, setTheme] = uS(() => readStoredTheme() || systemTheme()); uE(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); uE(() => { const mq = window.matchMedia('(prefers-color-scheme: light)'); const onSys = () => { if (!readStoredTheme()) setTheme(systemTheme()); }; mq.addEventListener('change', onSys); return () => mq.removeEventListener('change', onSys); }, []); const toggleTheme = () => setTheme((p) => { const n = p === 'dark' ? 'light' : 'dark'; try { localStorage.setItem('sm-theme', n); } catch (e) {} return n; }); /* accent var */ uE(() => { document.documentElement.style.setProperty('--accent', t.accent); document.documentElement.style.setProperty('--accent-dim', t.accent === '#f3f2ee' ? 'rgba(243,242,238,0.5)' : t.accent); }, [t.accent]); /* SPA link handler — intercepts internal clicks */ uE(() => { const handle = (e) => { const a = e.target.closest('a[href]'); if (!a || a.target === '_blank') return; const href = a.getAttribute('href'); if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('mailto:') || href.startsWith('tel:')) return; e.preventDefault(); if (href !== location.pathname) { history.pushState(null, '', href); window.dispatchEvent(new PopStateEvent('popstate', { state: null })); } }; document.addEventListener('click', handle); return () => document.removeEventListener('click', handle); }, []); /* route change → curtain wipe → swap content */ uE(() => { if (route.key === display.key) return; if (!t.transitions) { setDisplay(route); window.scrollTo(0, 0); return; } setCurtainLabel(routeLabel(route, lang)); setPhase('cover'); const t1 = setTimeout(() => { setDisplay(route); window.scrollTo(0, 0); setPhase('reveal'); }, 560); const t2 = setTimeout(() => setPhase('idle'), 1180); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [route.key]); /* page view tracking */ uE(() => { if (!intro) window.smTrack?.(display.key); }, [display.key, intro]); /* global reveal observer — re-arms on each page swap + lang change */ uE(() => { if (intro) return; const io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -6% 0px' }); const id = setTimeout(() => { document.querySelectorAll('.reveal:not(.in)').forEach((el) => io.observe(el)); }, 80); return () => { clearTimeout(id); io.disconnect(); }; }, [intro, display.key, lang, t.topDirection]); return (
{t.grain &&
}
{intro && setIntro(false)} />}
setTweak('topDirection', v)} />

index=静かな索引 · reel=動くマーキー · vault=ターミナル目録

setTweak('language', v)} /> setTweak('accent', v)} />

既定は無彩色(モノクロ)。色は最小限のハイライトにのみ反映。

setTweak('transitions', v)} /> setTweak('grain', v)} /> setTweak('cursor', v)} />
); } ReactDOM.createRoot(document.getElementById('root')).render();