// Hooks & helpers shared across components const { useState, useEffect, useRef, useCallback, useMemo } = React; // ---------- YouTube oEmbed metadata fetcher ---------- // Cache in-memory + localStorage const ytCache = (() => { const KEY = '__yt_meta_cache_v1'; let mem = {}; try { mem = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) {} return { get: (id) => mem[id], set: (id, data) => { mem[id] = data; try { localStorage.setItem(KEY, JSON.stringify(mem)); } catch (e) {} }, }; })(); async function fetchYTMeta(id) { const cached = ytCache.get(id); if (cached) return cached; try { const res = await fetch( `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${id}&format=json` ); if (!res.ok) throw new Error('oembed failed'); const data = await res.json(); const meta = { title: data.title || `Video ${id}`, author: data.author_name || '', thumb: `https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, thumbFallback: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`, }; ytCache.set(id, meta); return meta; } catch (e) { return { title: `Video ${id}`, author: '', thumb: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`, thumbFallback: `https://i.ytimg.com/vi/${id}/0.jpg`, }; } } window.fetchYTMeta = fetchYTMeta; // ---------- IntersectionObserver-driven reveal ---------- 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) { e.target.classList.add('in'); // also mark any contained section-header for underline anim e.target.querySelectorAll('.section-header').forEach((h) => h.classList.add('in-view')); if (e.target.classList.contains('section-header')) e.target.classList.add('in-view'); io.unobserve(e.target); } }); }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' } ); io.observe(el); return () => io.disconnect(); }, []); return ref; } window.useReveal = useReveal; // ---------- Scroll-triggered nav style ---------- function useScrolled(threshold = 20) { const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > threshold); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, [threshold]); return scrolled; } window.useScrolled = useScrolled; // ---------- Lightbox state (singleton, lifted to App) ---------- function useLightboxState() { const [activeId, setActiveId] = useState(null); const [activeMeta, setActiveMeta] = useState(null); const open = useCallback((id, meta) => { setActiveId(id); setActiveMeta(meta || null); document.body.style.overflow = 'hidden'; }, []); const close = useCallback(() => { setActiveId(null); setActiveMeta(null); document.body.style.overflow = ''; }, []); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') close(); }; if (activeId) window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [activeId, close]); return { activeId, activeMeta, open, close }; } window.useLightboxState = useLightboxState;