// ============ SHARED HELPERS + CURSOR + NAV + BOOT ============
const { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } = React;
// ---- Language context ----
const LangCtx = createContext({ lang: 'es', setLang: () => {}, t: window.CONTENT.es });
function LangProvider({ children }) {
const [lang, setLangState] = useState(() => {
try { return localStorage.getItem('lang') || 'es'; } catch { return 'es'; }
});
const setLang = (v) => { setLangState(v); try { localStorage.setItem('lang', v); } catch {} };
const t = window.CONTENT[lang];
return {children};
}
function useLang() { return useContext(LangCtx); }
// ---- reveal-on-scroll ----
function useReveal() {
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');
el.querySelectorAll('.fade-up').forEach(n => n.classList.add('in'));
io.disconnect();
}
});
}, { threshold: 0.12 });
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}
// ---- reduced motion ----
function useReducedMotion() {
const [r, setR] = useState(() => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const h = () => setR(mq.matches);
mq.addEventListener?.('change', h);
return () => mq.removeEventListener?.('change', h);
}, []);
return r;
}
// ---- Custom cursor ----
function CustomCursor() {
const dotRef = useRef(null);
const ringRef = useRef(null);
const reduce = useReducedMotion();
useEffect(() => {
if (reduce) return;
if (matchMedia('(pointer:coarse)').matches) return;
let mx = window.innerWidth / 2, my = window.innerHeight / 2;
let rx = mx, ry = my;
let raf;
const onMove = (e) => {
mx = e.clientX; my = e.clientY;
if (dotRef.current) {
dotRef.current.style.left = mx + 'px';
dotRef.current.style.top = my + 'px';
}
};
const loop = () => {
rx += (mx - rx) * 0.18;
ry += (my - ry) * 0.18;
if (ringRef.current) {
ringRef.current.style.left = rx + 'px';
ringRef.current.style.top = ry + 'px';
}
raf = requestAnimationFrame(loop);
};
const onOver = (e) => {
const r = ringRef.current; if (!r) return;
const target = e.target;
if (target.closest('a, button, .os-icon, .chip, .sq, [data-hover]')) r.classList.add('hover');
else r.classList.remove('hover');
if (target.matches('p, h1, h2, h3, h4, li, span, input, textarea')) r.classList.add('text');
else r.classList.remove('text');
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseover', onOver);
raf = requestAnimationFrame(loop);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseover', onOver);
cancelAnimationFrame(raf);
};
}, [reduce]);
if (reduce) return null;
return (
<>
>
);
}
// ---- Magnetic button ----
function useMagnetic(strength = 12) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el || matchMedia('(pointer:coarse)').matches) return;
const onMove = (e) => {
const r = el.getBoundingClientRect();
const x = e.clientX - (r.left + r.width / 2);
const y = e.clientY - (r.top + r.height / 2);
const dist = Math.hypot(x, y);
const max = 80;
if (dist < max) {
const factor = (1 - dist / max) * strength;
el.style.transform = `translate(${x / max * strength}px, ${y / max * strength}px)`;
} else {
el.style.transform = '';
}
};
const onLeave = () => { el.style.transform = ''; };
const parent = el.parentElement;
parent?.addEventListener('mousemove', onMove);
parent?.addEventListener('mouseleave', onLeave);
return () => {
parent?.removeEventListener('mousemove', onMove);
parent?.removeEventListener('mouseleave', onLeave);
};
}, [strength]);
return ref;
}
// ---- spotlight effect (mouse position CSS vars) ----
function useSpotlight() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onMove = (e) => {
const r = el.getBoundingClientRect();
el.style.setProperty('--mx', `${e.clientX - r.left}px`);
el.style.setProperty('--my', `${e.clientY - r.top}px`);
};
el.addEventListener('mousemove', onMove);
return () => el.removeEventListener('mousemove', onMove);
}, []);
return ref;
}
// ---- Nav ----
function Nav() {
const { lang, setLang, t } = useLang();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const h = () => setScrolled(window.scrollY > 30);
window.addEventListener('scroll', h, { passive: true });
return () => window.removeEventListener('scroll', h);
}, []);
const links = [
{ id: 'about', label: t.nav.about },
{ id: 'os', label: t.nav.os },
{ id: 'projects', label: t.nav.projects },
{ id: 'aura', label: t.nav.aura },
{ id: 'cafe', label: t.nav.cafe },
{ id: 'contact', label: t.nav.contact },
];
return (
<>
>
);
}
// ---- Boot screen ----
function BootScreen() {
const [lines, setLines] = useState([]);
const [gone, setGone] = useState(false);
const reduce = useReducedMotion();
useEffect(() => {
if (reduce) { setGone(true); return; }
const seq = [
"> booting angelo.os",
"> loading modules...",
"> mounting filesystem [ok]",
"> warm-up render pipeline...",
"> ready"
];
let i = 0;
const tick = () => {
if (i >= seq.length) {
setTimeout(() => setGone(true), 280);
return;
}
setLines(L => [...L, seq[i]]); i++;
setTimeout(tick, 220);
};
tick();
}, [reduce]);
return (
{lines.map((l, idx) => {l}
)}
);
}
// ---- Typing rotator ----
function TypingRotator({ items, interval = 3800 }) {
const reduce = useReducedMotion();
const [idx, setIdx] = useState(0);
const [text, setText] = useState(items[0]);
const [phase, setPhase] = useState('hold');
useEffect(() => {
if (reduce) { setText(items[idx]); return; }
let to;
if (phase === 'hold') {
to = setTimeout(() => setPhase('out'), interval);
} else if (phase === 'out') {
const target = items[idx];
let len = target.length;
const erase = () => {
len -= 1;
setText(target.slice(0, Math.max(0, len)));
if (len <= 0) { setPhase('in'); setIdx((idx + 1) % items.length); }
else to = setTimeout(erase, 18);
};
to = setTimeout(erase, 30);
} else if (phase === 'in') {
const target = items[idx];
let len = 0;
const type = () => {
len += 1;
setText(target.slice(0, len));
if (len >= target.length) { setPhase('hold'); }
else to = setTimeout(type, 32);
};
to = setTimeout(type, 40);
}
return () => clearTimeout(to);
}, [phase, idx, items, interval, reduce]);
return (
{text}
);
}
// expose
Object.assign(window, {
LangProvider, useLang, useReveal, useReducedMotion,
CustomCursor, useMagnetic, useSpotlight, Nav, BootScreen, TypingRotator
});