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