/* 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")}

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%" }}>

{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 });