709 lines
23 KiB
JavaScript
709 lines
23 KiB
JavaScript
/**
|
|
* LemonLink - Interactive Landing Page
|
|
* Modern animations and interactions
|
|
*/
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Initialize all modules
|
|
initNavbar();
|
|
initSmoothScroll();
|
|
initCounters();
|
|
initServiceCards();
|
|
initScrollReveal();
|
|
initMobileMenu();
|
|
initParallax();
|
|
});
|
|
|
|
/**
|
|
* Navbar scroll effect
|
|
*/
|
|
function initNavbar() {
|
|
const navbar = document.querySelector('.navbar');
|
|
let lastScroll = 0;
|
|
|
|
window.addEventListener('scroll', () => {
|
|
const currentScroll = window.pageYOffset;
|
|
|
|
// Add/remove scrolled class
|
|
if (currentScroll > 50) {
|
|
navbar.classList.add('scrolled');
|
|
} else {
|
|
navbar.classList.remove('scrolled');
|
|
}
|
|
|
|
// Hide/show on scroll direction
|
|
if (currentScroll > lastScroll && currentScroll > 100) {
|
|
navbar.style.transform = 'translateY(-100%)';
|
|
} else {
|
|
navbar.style.transform = 'translateY(0)';
|
|
}
|
|
|
|
lastScroll = currentScroll;
|
|
});
|
|
|
|
// Smooth transition for navbar
|
|
navbar.style.transition = 'transform 0.3s ease, background 0.3s ease';
|
|
}
|
|
|
|
/**
|
|
* Smooth scroll for anchor links
|
|
*/
|
|
function initSmoothScroll() {
|
|
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
|
anchor.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const target = document.querySelector(this.getAttribute('href'));
|
|
if (target) {
|
|
target.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animated counters for stats
|
|
*/
|
|
function initCounters() {
|
|
const counters = document.querySelectorAll('.stat-number');
|
|
|
|
const animateCounter = (counter) => {
|
|
const target = parseFloat(counter.getAttribute('data-target'));
|
|
const duration = 2000;
|
|
const startTime = performance.now();
|
|
|
|
const updateCounter = (currentTime) => {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Easing function for smooth animation
|
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
|
const current = target * easeOutQuart;
|
|
|
|
// Format based on target value
|
|
if (target % 1 === 0) {
|
|
counter.textContent = Math.round(current);
|
|
} else {
|
|
counter.textContent = current.toFixed(1);
|
|
}
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(updateCounter);
|
|
} else {
|
|
counter.textContent = target;
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(updateCounter);
|
|
};
|
|
|
|
// Use Intersection Observer to trigger animation
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
animateCounter(entry.target);
|
|
observer.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.5 });
|
|
|
|
counters.forEach(counter => observer.observe(counter));
|
|
}
|
|
|
|
/**
|
|
* Service cards mouse follow glow effect
|
|
*/
|
|
function initServiceCards() {
|
|
const cards = document.querySelectorAll('.service-card');
|
|
|
|
cards.forEach(card => {
|
|
const glow = card.querySelector('.service-glow');
|
|
|
|
card.addEventListener('mousemove', (e) => {
|
|
const rect = card.getBoundingClientRect();
|
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
|
|
|
card.style.setProperty('--mouse-x', `${x}%`);
|
|
card.style.setProperty('--mouse-y', `${y}%`);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Scroll reveal animations
|
|
*/
|
|
function initScrollReveal() {
|
|
const reveals = document.querySelectorAll('.section, .service-card, .infra-card, .project-card, .domain-node');
|
|
|
|
const revealOnScroll = (entries, observer) => {
|
|
entries.forEach((entry, index) => {
|
|
if (entry.isIntersecting) {
|
|
// Add stagger delay for cards
|
|
if (entry.target.classList.contains('service-card') ||
|
|
entry.target.classList.contains('domain-node')) {
|
|
entry.target.style.animationDelay = `${index * 0.1}s`;
|
|
}
|
|
|
|
entry.target.style.opacity = '1';
|
|
entry.target.style.transform = 'translateY(0)';
|
|
observer.unobserve(entry.target);
|
|
}
|
|
});
|
|
};
|
|
|
|
const observer = new IntersectionObserver(revealOnScroll, {
|
|
threshold: 0.1,
|
|
rootMargin: '0px 0px -50px 0px'
|
|
});
|
|
|
|
reveals.forEach(el => {
|
|
el.style.opacity = '0';
|
|
el.style.transform = 'translateY(30px)';
|
|
el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
|
observer.observe(el);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mobile menu toggle
|
|
*/
|
|
function initMobileMenu() {
|
|
const menuBtn = document.querySelector('.mobile-menu-btn');
|
|
const navLinks = document.querySelector('.nav-links');
|
|
|
|
if (!menuBtn) return;
|
|
|
|
menuBtn.addEventListener('click', () => {
|
|
menuBtn.classList.toggle('active');
|
|
navLinks.classList.toggle('active');
|
|
|
|
// Animate hamburger
|
|
const spans = menuBtn.querySelectorAll('span');
|
|
if (menuBtn.classList.contains('active')) {
|
|
spans[0].style.transform = 'rotate(45deg) translate(5px, 5px)';
|
|
spans[1].style.opacity = '0';
|
|
spans[2].style.transform = 'rotate(-45deg) translate(5px, -5px)';
|
|
|
|
// Show mobile menu
|
|
navLinks.style.display = 'flex';
|
|
navLinks.style.flexDirection = 'column';
|
|
navLinks.style.position = 'absolute';
|
|
navLinks.style.top = '100%';
|
|
navLinks.style.left = '0';
|
|
navLinks.style.right = '0';
|
|
navLinks.style.background = 'rgba(10, 10, 15, 0.98)';
|
|
navLinks.style.padding = '2rem';
|
|
navLinks.style.backdropFilter = 'blur(20px)';
|
|
navLinks.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
|
|
} else {
|
|
spans[0].style.transform = 'none';
|
|
spans[1].style.opacity = '1';
|
|
spans[2].style.transform = 'none';
|
|
navLinks.style.display = '';
|
|
}
|
|
});
|
|
|
|
// Close menu on link click
|
|
navLinks.querySelectorAll('a').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
menuBtn.classList.remove('active');
|
|
navLinks.classList.remove('active');
|
|
const spans = menuBtn.querySelectorAll('span');
|
|
spans[0].style.transform = 'none';
|
|
spans[1].style.opacity = '1';
|
|
spans[2].style.transform = 'none';
|
|
navLinks.style.display = '';
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parallax effect for hero section
|
|
*/
|
|
function initParallax() {
|
|
const hero = document.querySelector('.hero');
|
|
const blobs = document.querySelectorAll('.bg-blob');
|
|
|
|
if (!hero) return;
|
|
|
|
// Check for touch device
|
|
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
|
|
if (isTouchDevice) return;
|
|
|
|
let ticking = false;
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!ticking) {
|
|
requestAnimationFrame(() => {
|
|
const x = (e.clientX / window.innerWidth - 0.5) * 2;
|
|
const y = (e.clientY / window.innerHeight - 0.5) * 2;
|
|
|
|
blobs.forEach((blob, index) => {
|
|
const speed = (index + 1) * 20;
|
|
blob.style.transform = `translate(${x * speed}px, ${y * speed}px)`;
|
|
});
|
|
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typing effect for terminal cursor
|
|
*/
|
|
function initTerminalTyping() {
|
|
const terminal = document.querySelector('.terminal-body');
|
|
if (!terminal) return;
|
|
|
|
const commands = [
|
|
{ text: 'docker ps', output: 'CONTAINER ID IMAGE STATUS\nabc123 nginx Up 3 days' },
|
|
{ text: 'kubectl get pods', output: 'NAME READY STATUS\nlemonlink-app-7d9f4 1/1 Running' },
|
|
{ text: 'systemctl status lemonlink', output: '● lemonlink.service - LemonLink Platform\n Active: active (running)' }
|
|
];
|
|
|
|
let currentCommand = 0;
|
|
let isTyping = false;
|
|
|
|
const typeCommand = () => {
|
|
if (isTyping) return;
|
|
isTyping = true;
|
|
|
|
const cmd = commands[currentCommand];
|
|
const lines = terminal.querySelectorAll('.terminal-line');
|
|
const lastLine = lines[lines.length - 1];
|
|
|
|
// Clear cursor from previous line
|
|
const cursor = terminal.querySelector('.cursor');
|
|
if (cursor) cursor.remove();
|
|
|
|
// Create new command line
|
|
const newLine = document.createElement('div');
|
|
newLine.className = 'terminal-line';
|
|
newLine.innerHTML = `
|
|
<span class="prompt">➜</span>
|
|
<span class="path">~</span>
|
|
<span class="command"></span>
|
|
<span class="cursor">|</span>
|
|
`;
|
|
terminal.appendChild(newLine);
|
|
|
|
const commandSpan = newLine.querySelector('.command');
|
|
let charIndex = 0;
|
|
|
|
const typeChar = () => {
|
|
if (charIndex < cmd.text.length) {
|
|
commandSpan.textContent += cmd.text[charIndex];
|
|
charIndex++;
|
|
setTimeout(typeChar, 50 + Math.random() * 50);
|
|
} else {
|
|
// Show output after typing
|
|
setTimeout(() => {
|
|
const outputDiv = document.createElement('div');
|
|
outputDiv.className = 'terminal-output';
|
|
outputDiv.innerHTML = cmd.output.replace(/\n/g, '<br>');
|
|
terminal.appendChild(outputDiv);
|
|
|
|
// Move cursor to new line
|
|
const nextLine = document.createElement('div');
|
|
nextLine.className = 'terminal-line';
|
|
nextLine.innerHTML = `
|
|
<span class="prompt">➜</span>
|
|
<span class="path">~</span>
|
|
<span class="cursor">|</span>
|
|
`;
|
|
terminal.appendChild(nextLine);
|
|
|
|
// Scroll to bottom
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
|
|
isTyping = false;
|
|
currentCommand = (currentCommand + 1) % commands.length;
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
typeChar();
|
|
};
|
|
|
|
// Start typing effect every 8 seconds
|
|
setInterval(typeCommand, 8000);
|
|
}
|
|
|
|
// Initialize terminal typing when contact section is visible - limited to 3 cycles
|
|
const terminalObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
initLimitedTerminalTyping();
|
|
terminalObserver.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.5 });
|
|
|
|
const contactSection = document.querySelector('.contact-section');
|
|
if (contactSection) {
|
|
terminalObserver.observe(contactSection);
|
|
}
|
|
|
|
/**
|
|
* Limited Terminal Typing Effect (3 cycles max)
|
|
*/
|
|
function initLimitedTerminalTyping() {
|
|
const terminal = document.querySelector('.terminal-body');
|
|
if (!terminal) return;
|
|
|
|
const commands = [
|
|
{ text: 'docker ps', output: 'CONTAINER ID IMAGE STATUS\nabc123 nginx Up 3 days\ndef456 nextcloud Up 5 days' },
|
|
{ text: 'uptime', output: 'Load: 1.06, 1.06, 1.14 | RAM: 18% of 96GB' },
|
|
{ text: 'tailscale status', output: '100.x.x.x compute-01 linux -' }
|
|
];
|
|
|
|
let currentCommand = 0;
|
|
let cycleCount = 0;
|
|
const maxCycles = 3;
|
|
let isTyping = false;
|
|
|
|
const typeCommand = () => {
|
|
if (isTyping || cycleCount >= maxCycles) return;
|
|
isTyping = true;
|
|
|
|
const cmd = commands[currentCommand];
|
|
const lines = terminal.querySelectorAll('.terminal-line');
|
|
|
|
// Remove cursor from previous line
|
|
const cursor = terminal.querySelector('.cursor');
|
|
if (cursor) cursor.remove();
|
|
|
|
// Create new command line
|
|
const newLine = document.createElement('div');
|
|
newLine.className = 'terminal-line';
|
|
newLine.innerHTML = `
|
|
<span class="prompt">➜</span>
|
|
<span class="path">~</span>
|
|
<span class="command"></span>
|
|
<span class="cursor">|</span>
|
|
`;
|
|
terminal.appendChild(newLine);
|
|
|
|
const commandSpan = newLine.querySelector('.command');
|
|
let charIndex = 0;
|
|
|
|
const typeChar = () => {
|
|
if (charIndex < cmd.text.length) {
|
|
commandSpan.textContent += cmd.text[charIndex];
|
|
charIndex++;
|
|
setTimeout(typeChar, 50 + Math.random() * 50);
|
|
} else {
|
|
setTimeout(() => {
|
|
const outputDiv = document.createElement('div');
|
|
outputDiv.className = 'terminal-output';
|
|
outputDiv.innerHTML = cmd.output.replace(/\n/g, '<br>');
|
|
terminal.appendChild(outputDiv);
|
|
|
|
const nextLine = document.createElement('div');
|
|
nextLine.className = 'terminal-line';
|
|
nextLine.innerHTML = `
|
|
<span class="prompt">➜</span>
|
|
<span class="path">~</span>
|
|
<span class="cursor">|</span>
|
|
`;
|
|
terminal.appendChild(nextLine);
|
|
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
|
|
isTyping = false;
|
|
currentCommand = (currentCommand + 1) % commands.length;
|
|
|
|
// Count completed cycles
|
|
if (currentCommand === 0) {
|
|
cycleCount++;
|
|
}
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
typeChar();
|
|
};
|
|
|
|
// Start typing effect every 6 seconds, but only for 3 cycles
|
|
const intervalId = setInterval(() => {
|
|
if (cycleCount >= maxCycles) {
|
|
clearInterval(intervalId);
|
|
// Keep cursor blinking at the end
|
|
return;
|
|
}
|
|
typeCommand();
|
|
}, 6000);
|
|
}
|
|
|
|
/**
|
|
* Network connection lines animation
|
|
*/
|
|
function drawConnectionLines() {
|
|
const hub = document.querySelector('.hub-center');
|
|
const nodes = document.querySelectorAll('.domain-node');
|
|
const svg = document.querySelector('.connections-svg');
|
|
|
|
if (!hub || !svg || nodes.length === 0) return;
|
|
|
|
const hubRect = hub.getBoundingClientRect();
|
|
const hubX = hubRect.left + hubRect.width / 2;
|
|
const hubY = hubRect.top + hubRect.height / 2;
|
|
|
|
// Clear existing lines
|
|
svg.innerHTML = svg.querySelector('defs').outerHTML;
|
|
|
|
nodes.forEach(node => {
|
|
const nodeRect = node.getBoundingClientRect();
|
|
const nodeX = nodeRect.left + nodeRect.width / 2;
|
|
const nodeY = nodeRect.top + nodeRect.height / 2;
|
|
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
line.setAttribute('x1', hubX);
|
|
line.setAttribute('y1', hubY);
|
|
line.setAttribute('x2', nodeX);
|
|
line.setAttribute('y2', nodeY);
|
|
line.setAttribute('stroke', 'url(#line-gradient)');
|
|
line.setAttribute('stroke-width', '1');
|
|
line.setAttribute('opacity', '0.3');
|
|
|
|
svg.appendChild(line);
|
|
});
|
|
}
|
|
|
|
// Draw lines on load and resize
|
|
window.addEventListener('load', drawConnectionLines);
|
|
window.addEventListener('resize', drawConnectionLines);
|
|
|
|
/**
|
|
* Easter egg: Konami code
|
|
*/
|
|
let konamiCode = [];
|
|
const konamiSequence = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'];
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
konamiCode.push(e.key);
|
|
konamiCode = konamiCode.slice(-10);
|
|
|
|
if (konamiCode.join(',') === konamiSequence.join(',')) {
|
|
// Activate lemon mode!
|
|
document.body.style.filter = 'hue-rotate(180deg)';
|
|
setTimeout(() => {
|
|
document.body.style.filter = '';
|
|
}, 3000);
|
|
|
|
// Create floating lemons
|
|
for (let i = 0; i < 20; i++) {
|
|
createFloatingLemon();
|
|
}
|
|
}
|
|
});
|
|
|
|
function createFloatingLemon() {
|
|
const lemon = document.createElement('div');
|
|
lemon.textContent = '🍋';
|
|
lemon.style.cssText = `
|
|
position: fixed;
|
|
font-size: 2rem;
|
|
pointer-events: none;
|
|
z-index: 9999;
|
|
left: ${Math.random() * 100}vw;
|
|
top: -50px;
|
|
animation: fall ${3 + Math.random() * 2}s linear forwards;
|
|
`;
|
|
|
|
document.body.appendChild(lemon);
|
|
|
|
setTimeout(() => lemon.remove(), 5000);
|
|
}
|
|
|
|
// Add falling animation
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes fall {
|
|
to {
|
|
transform: translateY(110vh) rotate(${Math.random() * 360}deg);
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
/**
|
|
* Service status check simulation
|
|
*/
|
|
function checkServiceStatus() {
|
|
const statusIndicators = document.querySelectorAll('.service-status');
|
|
|
|
statusIndicators.forEach(indicator => {
|
|
// Random status update for demo purposes
|
|
const isOnline = Math.random() > 0.1; // 90% online
|
|
const dot = indicator.querySelector('.status-dot');
|
|
|
|
if (isOnline) {
|
|
indicator.className = 'service-status online';
|
|
indicator.querySelector('span:last-child').textContent = 'Online';
|
|
dot.style.background = '#22c55e';
|
|
} else {
|
|
indicator.className = 'service-status maintenance';
|
|
indicator.querySelector('span:last-child').textContent = 'Maintenance';
|
|
dot.style.background = '#f59e0b';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Simulate status check every 30 seconds
|
|
setInterval(checkServiceStatus, 30000);
|
|
|
|
/**
|
|
* Prefers reduced motion
|
|
*/
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
// Disable animations
|
|
document.documentElement.style.setProperty('--transition-fast', '0s');
|
|
document.documentElement.style.setProperty('--transition-normal', '0s');
|
|
document.documentElement.style.setProperty('--transition-slow', '0s');
|
|
}
|
|
|
|
|
|
/**
|
|
* Service Status Checker - Real-time health checks
|
|
*/
|
|
function initServiceStatusChecker() {
|
|
const statusElements = document.querySelectorAll('.service-status[data-status-url]');
|
|
|
|
statusElements.forEach(element => {
|
|
const url = element.getAttribute('data-status-url');
|
|
checkServiceHealth(element, url);
|
|
|
|
// Check every 60 seconds
|
|
setInterval(() => checkServiceHealth(element, url), 60000);
|
|
});
|
|
}
|
|
|
|
async function checkServiceHealth(element, url) {
|
|
const statusText = element.querySelector('.status-text');
|
|
const originalText = statusText.textContent;
|
|
|
|
try {
|
|
// Try to fetch with a short timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
|
|
const response = await fetch(url, {
|
|
method: 'HEAD',
|
|
mode: 'no-cors',
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// If we get here, service is likely online
|
|
element.className = 'service-status online';
|
|
statusText.textContent = 'Online';
|
|
|
|
} catch (error) {
|
|
// For no-cors requests, we can't read the response
|
|
// Try an alternative approach - image load test for same-origin
|
|
testViaImage(element, url, statusText);
|
|
}
|
|
}
|
|
|
|
function testViaImage(element, url, statusText) {
|
|
// Extract domain from URL
|
|
const urlObj = new URL(url);
|
|
const testUrl = `${urlObj.protocol}//${urlObj.hostname}/favicon.ico`;
|
|
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
element.className = 'service-status online';
|
|
statusText.textContent = 'Online';
|
|
};
|
|
img.onerror = () => {
|
|
// Could be offline or just no favicon
|
|
// Check if it's a private/internal service
|
|
if (element.closest('.private-service')) {
|
|
element.className = 'service-status online';
|
|
statusText.textContent = 'Restricted';
|
|
} else {
|
|
element.className = 'service-status offline';
|
|
statusText.textContent = 'Offline';
|
|
}
|
|
};
|
|
img.src = testUrl + '?t=' + Date.now();
|
|
|
|
// Timeout after 3 seconds
|
|
setTimeout(() => {
|
|
if (!img.complete) {
|
|
img.src = '';
|
|
if (element.closest('.private-service')) {
|
|
element.className = 'service-status online';
|
|
statusText.textContent = 'Restricted';
|
|
} else {
|
|
element.className = 'service-status maintenance';
|
|
statusText.textContent = 'Unknown';
|
|
}
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
// Initialize status checker
|
|
document.addEventListener('DOMContentLoaded', initServiceStatusChecker);
|
|
|
|
/**
|
|
* Service Info Modals
|
|
*/
|
|
function initServiceModals() {
|
|
const privateServices = document.querySelectorAll('.private-service');
|
|
const modals = document.querySelectorAll('.service-modal');
|
|
const closeButtons = document.querySelectorAll('.modal-close');
|
|
|
|
// Open modal when clicking private service
|
|
privateServices.forEach(service => {
|
|
service.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const serviceName = service.getAttribute('data-service');
|
|
const modal = document.getElementById(`modal-${serviceName}`);
|
|
if (modal) {
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close modal when clicking close button
|
|
closeButtons.forEach(button => {
|
|
button.addEventListener('click', () => {
|
|
const modal = button.closest('.service-modal');
|
|
modal.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
});
|
|
});
|
|
|
|
// Close modal when clicking outside
|
|
modals.forEach(modal => {
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close on Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
modals.forEach(modal => {
|
|
modal.classList.remove('active');
|
|
});
|
|
document.body.style.overflow = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize modals
|
|
document.addEventListener('DOMContentLoaded', initServiceModals);
|