/* =================================================================
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 (
{Array.from({ length: 7 }).map((_, i) => )}
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')}
))}
);
}
/* ---------- 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 (
{Array.from({ length: 7 }).map((_, i) => )}
{label}
);
}
/* ---------- 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();