(() => { "use strict"; /* ---------- мобильное меню ---------- */ const toggle = document.getElementById("navToggle"); const links = document.getElementById("navLinks"); if (toggle && links) { toggle.addEventListener("click", () => { const open = links.classList.toggle("open"); toggle.classList.toggle("open", open); toggle.setAttribute("aria-expanded", String(open)); }); links.querySelectorAll("a").forEach((a) => a.addEventListener("click", () => { links.classList.remove("open"); toggle.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); }) ); } /* ---------- тень хедера при скролле ---------- */ const header = document.querySelector(".site-header"); const onScroll = () => header && header.classList.toggle("scrolled", window.scrollY > 8); onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); /* ---------- появление блоков ---------- */ const reveal = document.querySelectorAll("[data-reveal]"); if ("IntersectionObserver" in window) { const io = new IntersectionObserver( (entries) => { entries.forEach((entry, i) => { if (entry.isIntersecting) { const el = entry.target; setTimeout(() => el.classList.add("visible"), i * 70); io.unobserve(el); } }); }, { threshold: 0.12, rootMargin: "0px 0px -40px 0px" } ); reveal.forEach((el) => io.observe(el)); } else { reveal.forEach((el) => el.classList.add("visible")); } /* ---------- счётчики ---------- */ const counters = document.querySelectorAll("[data-count]"); const animateCount = (el) => { const target = parseInt(el.dataset.count, 10); const duration = 1400; const start = performance.now(); const step = (now) => { const p = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - p, 3); el.textContent = Math.round(target * eased).toString(); if (p < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); }; if ("IntersectionObserver" in window) { const co = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { animateCount(entry.target); co.unobserve(entry.target); } }); }, { threshold: 0.5 } ); counters.forEach((el) => co.observe(el)); } else { counters.forEach((el) => (el.textContent = el.dataset.count)); } /* ---------- печатающийся терминал ---------- */ const typed = document.getElementById("typed"); if (typed) { const lines = [ { t: '$ docker ps --format "table {{.Names}}\\t{{.Status}}"', d: 22 }, { t: "\n", d: 0 }, { t: 'NAMES STATUS\n', d: 14 }, { t: "gitea Up 14 months\n", d: 14 }, { t: "matrix Up 14 months\n", d: 14 }, { t: "sharkey Up 14 months\n", d: 14 }, { t: "copyparty Up 14 months\n", d: 14 }, { t: "grafana Up 14 months\n", d: 14 }, { t: "4get Up 14 months\n", d: 14 }, { t: '$ uptime', d: 22 }, { t: '\n all systems nominal', d: 14 }, { t: '$ ', d: 22 }, ]; // токенизируем: HTML-теги — атомарные, текст — посимвольно const tokenize = (str) => str.match(/<[^>]+>|./gs) || []; let lineIndex = 0; let tokens = []; let pos = 0; let html = ""; const render = () => { typed.innerHTML = html + ''; }; const typeNext = () => { if (lineIndex >= lines.length) { render(); return; } if (pos === 0) tokens = tokenize(lines[lineIndex].t); if (pos < tokens.length) { html += tokens[pos++]; render(); const step = tokens[pos - 1].startsWith("<") ? 0 : lines[lineIndex].d + Math.random() * 35; setTimeout(typeNext, step); } else { lineIndex++; pos = 0; setTimeout(typeNext, 190); } }; const startTyping = () => setTimeout(typeNext, 500); if ("IntersectionObserver" in window) { const to = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { startTyping(); to.disconnect(); } }); }, { threshold: 0.3 } ); to.observe(typed); } else { startTyping(); } } })();