/* Shared UI: icons, reveal, lightbox, cursor, sidebar, photo items */ const { useState, useEffect, useRef, useCallback, createContext, useContext } = React; /* ---------------- Icons ---------------- */ const Icon = { tg: (p) => , vk: (p) => , ig: (p) => , arrow: (p) => , close: (p) => , expand:(p) => , }; /* ---------------- Lightbox context ---------------- */ const LBContext = createContext({ open: () => {} }); window.LBContext = LBContext; function LightboxProvider({ children }) { const [items, setItems] = useState([]); const [idx, setIdx] = useState(-1); const open = useCallback((list, i) => { setItems(list); setIdx(i); }, []); const close = useCallback(() => setIdx(-1), []); const go = useCallback((d) => setIdx((p) => (p + d + items.length) % items.length), [items.length]); useEffect(() => { const onKey = (e) => { if (idx < 0) return; if (e.key === "Escape") close(); if (e.key === "ArrowRight") go(1); if (e.key === "ArrowLeft") go(-1); }; window.addEventListener("keydown", onKey); document.body.style.overflow = idx >= 0 ? "hidden" : ""; return () => window.removeEventListener("keydown", onKey); }, [idx, go, close]); const cur = items[idx]; return ( {children}
= 0 ? " open" : "")} onClick={close}> {cur && <>
{String(idx + 1).padStart(2, "0")} / {String(items.length).padStart(2, "0")}
{cur.cap e.stopPropagation()} key={cur.src} /> {(cur.cap || cur.meta) &&
{cur.cap &&
{cur.cap}
} {cur.meta &&
{cur.meta}
}
} }
); } /* ---------------- Reveal ---------------- */ function useInView(opts) { const ref = useRef(null); const [seen, setSeen] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setSeen(true); io.disconnect(); } }, { threshold: 0.12, ...opts }); io.observe(el); return () => io.disconnect(); }, []); return [ref, seen]; } function Reveal({ children, className = "", as = "div", delay = 0, img = false, style = {} }) { const [ref, seen] = useInView(); const As = as; return {children}; } /* ---------------- Photo item ---------------- */ function Photo({ item, list, index, num, className = "", aspect, style = {}, noInfo = false }) { const { open } = useContext(LBContext); return (
open(list, index)} style={{ width:"100%", height:"100%" }}> {item.cap {num != null && {String(num).padStart(2, "0")}} {Icon.expand({})} {!noInfo && (item.cap || item.meta) &&
{item.cap && {item.cap}} {item.meta && {item.meta}}
}
); } /* role · city — the separating middot disappears when the line wraps */ function RoleCity() { const S = window.SITE; const ref = useRef(null); const [wrapped, setWrapped] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const segs = el.querySelectorAll(".rc-seg"); const check = () => { if (segs.length < 2) return; setWrapped(Math.abs(segs[0].offsetTop - segs[1].offsetTop) > 1); }; check(); const ro = new ResizeObserver(check); ro.observe(el); if (document.fonts && document.fonts.ready) document.fonts.ready.then(check); return () => ro.disconnect(); }, []); return ( {S.role} · {S.city} ); } /* ---------------- Sidebar ---------------- */ function Sidebar({ route, navigate }) { const S = window.SITE; const portfolio = S.nav.filter((n) => n.group === "Портфолио"); const flat = S.nav.filter((n) => !n.group); const [open, setOpen] = useState(false); useEffect(() => { document.body.style.overflow = open ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [open]); // close the drawer whenever the route changes useEffect(() => { setOpen(false); }, [route]); const go = (id) => { setOpen(false); navigate(id); }; const link = (n, sub) => { const g = sub ? S.galleries[n.id] : null; return ( ); }; return ( <> {/* mobile-only top bar */}
go("home")}> {S.name}
setOpen(false)}>
); } /* ---------------- Custom cursor ---------------- */ function Cursor() { const dot = useRef(null), ring = useRef(null), label = useRef(null), wrap = useRef(null); useEffect(() => { if (window.matchMedia("(pointer:coarse)").matches) return; let rx = 0, ry = 0, x = 0, y = 0, raf; const move = (e) => { x = e.clientX; y = e.clientY; if (dot.current) dot.current.style.transform = `translate(${x}px,${y}px) translate(-50%,-50%)`; if (label.current) label.current.style.transform = `translate(${x}px,${y + 4}px) translate(-50%,-50%)`; const t = e.target.closest("[data-cursor]"); wrap.current.classList.toggle("hover", !!t); if (label.current) label.current.textContent = t && t.dataset.cursorLabel ? t.dataset.cursorLabel : ""; }; const loop = () => { rx += (x - rx) * 0.18; ry += (y - ry) * 0.18; if (ring.current) ring.current.style.transform = `translate(${rx}px,${ry}px) translate(-50%,-50%)`; raf = requestAnimationFrame(loop); }; window.addEventListener("mousemove", move); loop(); return () => { window.removeEventListener("mousemove", move); cancelAnimationFrame(raf); }; }, []); return
; } Object.assign(window, { Icon, LightboxProvider, Reveal, useInView, Photo, RoleCity, Sidebar, Cursor });