const { useState, useEffect, useMemo, useRef } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#FFD53E",
"borderColor": "#EFEFEF",
"showAvailability": true,
"cardRadius": 6
} /*EDITMODE-END*/;
// ---------- Site config (loaded from site.config.json) ----------
const SITE_CONFIG_DEFAULTS = {
contact: {
email: "batuich@gmail.com",
telegram: "https://t.me/batuich"
},
social: {
linkedin: "https://www.linkedin.com/in/oleg-tcybikov-1742a273/",
x: "https://x.com/batuich",
instagram: "https://www.instagram.com/madebybatuich/",
dribbble: "https://dribbble.com/batuich",
behance: "https://www.behance.net/batuich"
},
features: { showAvailability: true }
};
const SiteConfigContext = React.createContext(SITE_CONFIG_DEFAULTS);
const useSiteConfig = () => React.useContext(SiteConfigContext);
// ---------- Tooltip ----------
function Tooltip({ children, label, side = "bottom" }) {
const [open, setOpen] = useState(false);
const timer = useRef(null);
const show = () => {
clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(true), 120);
};
const hide = () => {
clearTimeout(timer.current);
setOpen(false);
};
return (
{children}
{label}
);
}
// ---------- Buttons ----------
function PrimaryButton({ href, onClick, children, type = "a" }) {
const cls =
"inline-flex items-center justify-center h-10 px-4 bg-[#111] text-white text-[13px] font-medium rounded-[4px] transition-colors duration-150 hover:bg-black active:bg-[#000]";
if (type === "button")
return (
{children}
);
return (
{children}
);
}
function SecondaryButton({ href, onClick, children, target, rel, type = "a" }) {
const cls =
"inline-flex items-center justify-center h-10 px-4 bg-white text-[#111] text-[13px] font-medium border border-[#EFEFEF] rounded-[4px] transition-colors duration-150 hover:border-[#111]";
if (type === "button")
return (
{children}
);
return (
{children}
);
}
// ---------- Contact actions ----------
function ContactActions({ accent, compact = false }) {
const config = useSiteConfig();
const EMAIL = config.contact.email;
const TG_URL = config.contact.telegram;
const [copied, setCopied] = useState(false);
const [hovered, setHovered] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(EMAIL);
} catch (e) {
// fallback
const ta = document.createElement("textarea");
ta.value = EMAIL;
document.body.appendChild(ta);
ta.select();
try {document.execCommand("copy");} catch {}
ta.remove();
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
);
}
// ---------- Bouncing badge (DVD-screensaver style) ----------
function BouncingBadge({ containerRef }) {
const config = useSiteConfig();
const EMAIL = config.contact.email;
const elRef = useRef(null);
// Use refs (not state) for mutable physics so rAF doesn't trigger re-renders.
const posRef = useRef({ x: 24, y: 24 });
const velRef = useRef({ vx: 0, vy: 0 });
const rotRef = useRef({ angle: 0, omega: 0 }); // degrees, deg/sec
useEffect(() => {
const container = containerRef.current;
const el = elRef.current;
if (!container || !el) return;
const SPEED = 100; // px / second — constant
const ROT_SPEED = 90; // deg / second — constant magnitude
// initial direction: 45° upward (right + up)
const angle = -Math.PI / 4;
velRef.current = {
vx: Math.cos(angle) * SPEED,
vy: Math.sin(angle) * SPEED
};
// initial rotation: clockwise since dx > 0 (matches "next top/bottom hit" rule preemptively)
rotRef.current = { angle: 0, omega: ROT_SPEED };
// Start position: somewhere inside the container
const cw0 = container.clientWidth;
const ch0 = container.clientHeight;
const bw0 = el.offsetWidth;
const bh0 = el.offsetHeight;
posRef.current = {
x: Math.max(0, Math.min(cw0 - bw0, 700)),
y: Math.max(0, Math.min(ch0 - bh0, 200))
};
el.style.transform = `translate3d(${posRef.current.x}px, ${posRef.current.y}px, 0) rotate(0deg)`;
let rafId;
let last = performance.now();
const tick = (now) => {
const dt = Math.min(0.05, (now - last) / 1000); // clamp to avoid jumps on tab focus
last = now;
const cw = container.clientWidth;
const ch = container.clientHeight;
const bw = el.offsetWidth;
const bh = el.offsetHeight;
let { x, y } = posRef.current;
let { vx, vy } = velRef.current;
let { angle: rotAngle, omega } = rotRef.current;
x += vx * dt;
y += vy * dt;
// Horizontal edges — keep current rotation direction (do not change it)
if (x <= 0) {x = 0;vx = Math.abs(vx);} else
if (x + bw >= cw) {x = cw - bw;vx = -Math.abs(vx);}
// Vertical edges — set rotation direction from current dx
if (y <= 0) {
y = 0;
vy = Math.abs(vy);
omega = vx >= 0 ? ROT_SPEED : -ROT_SPEED; // CW if moving right, CCW if left
} else if (y + bh >= ch) {
y = ch - bh;
vy = -Math.abs(vy);
omega = vx >= 0 ? ROT_SPEED : -ROT_SPEED;
}
// Smooth continuous rotation
rotAngle += omega * dt;
posRef.current = { x, y };
velRef.current = { vx, vy };
rotRef.current = { angle: rotAngle, omega };
el.style.transform = `translate3d(${x}px, ${y}px, 0) rotate(${rotAngle}deg)`;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
// On container resize, re-clamp position so badge stays inside
const ro = new ResizeObserver(() => {
const cw = container.clientWidth;
const ch = container.clientHeight;
const bw = el.offsetWidth;
const bh = el.offsetHeight;
posRef.current.x = Math.max(0, Math.min(cw - bw, posRef.current.x));
posRef.current.y = Math.max(0, Math.min(ch - bh, posRef.current.y));
});
ro.observe(container);
return () => {
cancelAnimationFrame(rafId);
ro.disconnect();
};
}, [containerRef]);
return (
);
}
// ---------- Hero ----------
function Hero({ accent, showAvailability }) {
const heroRef = useRef(null);
return (
{showAvailability && }
OLEG TCYBIKOV, A MULTIDISCIPLINARY DESIGNER
{/* Inversion/blend treatment — source colors are light so that
mix-blend-mode: difference renders them DARK over the white page
(normal, readable) and LIGHT over the dark sphere image cards.
- Headline source = pure white -> black on white, white over dark.
- Subheadline source = warm desaturated gray -> reads as neutral
gray on white, shifts toward warm/yellowish light over dark.
Applied to the
wrapper ONLY; siblings (avatar, eyebrow,
buttons, badge, nav) are untouched. No isolation/opacity/filter
sits between this and the sphere, so the blend reaches it. */}
Identity, decks, websites, UI/UX, and more —full design support for startups & growing teams.
);
}
// ---------- Filters ----------
function Filters({ tags, active, counts, totalCount, onToggle, onClear }) {
const allActive = !active;
return (
All
{totalCount}
{tags.map((tag) => {
const isActive = active === tag;
return (
onToggle(tag)}
className={`inline-flex items-center gap-1.5 h-8 px-3 rounded-[3px] border text-[12px] font-medium transition-colors duration-150 ${
isActive ?
"bg-[#111] text-white border-[#111]" :
"bg-white text-[#111] border-[#EFEFEF] hover:border-[#111]"}`
}>
{tag}
{counts[tag] || 0}
);
})}
);
}
// ---------- Works section ----------
function Works() {
const [active, setActive] = useState(null);
const [works, setWorks] = useState([]);
const [loadError, setLoadError] = useState(null);
useEffect(() => {
let cancelled = false;
window.loadWorks().
then((data) => {if (!cancelled) setWorks(data);}).
catch((err) => {if (!cancelled) setLoadError(err.message || String(err));});
return () => {cancelled = true;};
}, []);
// Build the tag list dynamically from cases.json — every unique tag that
// appears on at least one case becomes a filter, with its true count.
const counts = useMemo(() => {
const c = {};
works.forEach((w) => (w.tags || []).forEach((t) => {
c[t] = (c[t] || 0) + 1;
}));
return c;
}, [works]);
const filtered = useMemo(() => {
const list = active ? works.filter((w) => w.tags.includes(active)) : works;
return [...list].sort((a, b) => (b.order || 0) - (a.order || 0));
}, [active, works]);
// Sort by count desc, then alpha for stable order on ties.
const sortedTags = useMemo(() => {
return Object.keys(counts).sort((a, b) => {
const diff = (counts[b] || 0) - (counts[a] || 0);
return diff !== 0 ? diff : a.localeCompare(b);
});
}, [counts]);
// If the active tag disappears from the data (e.g. cases.json edited),
// gracefully fall back to "All".
useEffect(() => {
if (active && !counts[active]) setActive(null);
}, [active, counts]);
const toggle = (tag) => setActive((cur) => cur === tag ? null : tag);
return (
{loadError &&
Failed to load cases.json — {loadError}
}
{filtered.length === 0 &&
No work matches that filter yet. setActive(null)}>Clear filter
}
);
}
// ---------- Footer ----------
function Footer({ accent }) {
const config = useSiteConfig();
const year = new Date().getFullYear();
return (
Have something to launch? Let's make it sharp.
Also on:
{[
{ label: "LinkedIn", href: config.social.linkedin },
{ label: "X", href: config.social.x },
{ label: "Instagram", href: config.social.instagram },
{ label: "Dribbble", href: config.social.dribbble },
{ label: "Behance", href: config.social.behance }].
filter((l) => l.href).
map((link, i, arr) =>
{link.label}
{i < arr.length - 1 && · }
)}
{[
{ label: "Company name", value: "Batuich Design Studio DOO" },
{ label: "Legal form", value: "Društvo sa ograničenom odgovornošću (DOO)" },
{ label: "Founded", value: "2024" },
{ label: "Registration", value: "Budva, Montenegro" },
{ label: "Registration number", value: "278455" },
{ label: "Tax number (VAT)", value: "03716805" }].
map((row) =>
)}
© {year} Batuich Design Studio DOO · All rights reserved
V1.1 — LAST UPDATED MAY 2026
);
}
// ---------- Header ----------
function Header() {
return (
);
}
// ---------- App ----------
function App() {
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [siteConfig, setSiteConfig] = useState(SITE_CONFIG_DEFAULTS);
// Load site.config.json (email, social links, feature flags)
useEffect(() => {
let cancelled = false;
fetch("site.config.json", { cache: "no-cache" }).
then((r) => r.ok ? r.json() : null).
then((cfg) => {
if (cancelled || !cfg) return;
setSiteConfig({
contact: { ...SITE_CONFIG_DEFAULTS.contact, ...(cfg.contact || {}) },
social: { ...SITE_CONFIG_DEFAULTS.social, ...(cfg.social || {}) },
features: { ...SITE_CONFIG_DEFAULTS.features, ...(cfg.features || {}) }
});
}).
catch(() => {/* keep defaults */});
return () => {cancelled = true;};
}, []);
// Live-apply tweakable tokens via CSS variables
useEffect(() => {
const r = document.documentElement;
r.style.setProperty("--accent", tweaks.accent);
r.style.setProperty("--border", tweaks.borderColor);
r.style.setProperty("--radius", `${tweaks.cardRadius}px`);
}, [tweaks]);
const showAvailability = tweaks.showAvailability && siteConfig.features.showAvailability !== false;
return (
setTweak("accent", v)} />
setTweak("accent", v)}
options={[
{ label: "Yellow", value: "#FFD53E" },
{ label: "Lime", value: "#D4FF3E" },
{ label: "Coral", value: "#FF6F3E" },
{ label: "Sky", value: "#3EB8FF" }]
} />
setTweak("cardRadius", v)} />
setTweak("borderColor", v)} />
setTweak("showAvailability", v)} />
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );