154 lines
5.6 KiB
JavaScript
154 lines
5.6 KiB
JavaScript
(() => {
|
|
"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: '<span class="prompt">$</span> docker ps --format "table {{.Names}}\\t{{.Status}}"', d: 22 },
|
|
{ t: "\n", d: 0 },
|
|
{ t: '<span class="dim">NAMES STATUS</span>\n', d: 14 },
|
|
{ t: "gitea <span class=\"ok\">Up 14 months</span>\n", d: 14 },
|
|
{ t: "matrix <span class=\"ok\">Up 14 months</span>\n", d: 14 },
|
|
{ t: "sharkey <span class=\"ok\">Up 14 months</span>\n", d: 14 },
|
|
{ t: "copyparty <span class=\"ok\">Up 14 months</span>\n", d: 14 },
|
|
{ t: "grafana <span class=\"ok\">Up 14 months</span>\n", d: 14 },
|
|
{ t: "4get <span class=\"ok\">Up 14 months</span>\n", d: 14 },
|
|
{ t: '<span class="prompt">$</span> uptime', d: 22 },
|
|
{ t: '\n <span class="ok">all systems nominal</span>', d: 14 },
|
|
{ t: '<span class="prompt">$</span> ', d: 22 },
|
|
];
|
|
|
|
// токенизируем: HTML-теги — атомарные, текст — посимвольно
|
|
const tokenize = (str) => str.match(/<[^>]+>|./gs) || [];
|
|
|
|
let lineIndex = 0;
|
|
let tokens = [];
|
|
let pos = 0;
|
|
let html = "";
|
|
|
|
const render = () => {
|
|
typed.innerHTML = html + '<span class="cursor"></span>';
|
|
};
|
|
|
|
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();
|
|
}
|
|
}
|
|
})();
|