1// Theme Management System 2const ThemeManager = { 3 init() { 4 this.toggle = document.getElementById('themeToggle'); 5 this.root = document.documentElement; 6 7 // Check for system preference 8 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 9 const savedTheme = localStorage.getItem('theme'); 10 const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light'); 11 12 this.setTheme(initialTheme); 13 14 // Add event listeners 15 this.toggle.addEventListener('click', () => this.toggleTheme()); 16 17 // Keyboard accessibility 18 this.toggle.addEventListener('keydown', (e) => { 19 if (e.key === 'Enter' || e.key === ' ') { 20 e.preventDefault(); 21 this.toggleTheme(); 22 } 23 }); 24 25 // Listen for system theme changes 26 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { 27 if (!localStorage.getItem('theme')) { 28 this.setTheme(e.matches ? 'dark' : 'light'); 29 } 30 }); 31 32 }, 33 34 setTheme(theme) { 35 this.root.setAttribute('data-theme', theme); 36 localStorage.setItem('theme', theme); 37 38 // Update particles for theme 39 this.updateParticles(theme); 40 41 // Dispatch custom event 42 window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } })); 43 }, 44 45 toggleTheme() { 46 const currentTheme = this.root.getAttribute('data-theme') || 'dark'; 47 const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; 48 49 // Create ripple effect 50 this.createRipple(); 51 52 this.setTheme(newTheme); 53 }, 54 55 createRipple() { 56 const ripple = document.createElement('div'); 57 ripple.className = 'theme-ripple'; 58 59 const rect = this.toggle.getBoundingClientRect(); 60 const size = Math.max(window.innerWidth, window.innerHeight) * 2; 61 62 ripple.style.width = ripple.style.height = size + 'px'; 63 ripple.style.left = (rect.left + rect.width / 2 - size / 2) + 'px'; 64 ripple.style.top = (rect.top + rect.height / 2 - size / 2) + 'px'; 65 ripple.style.position = 'fixed'; 66 ripple.style.zIndex = '999'; 67 68 document.body.appendChild(ripple); 69 70 setTimeout(() => ripple.remove(), 600); 71 }, 72 73 updateParticles(theme) { 74 // Particles use CSS variables, so they update automatically on theme change. 75 // We just ensure a smooth transition for the colour swap. 76 const particles = document.querySelectorAll('.particle'); 77 particles.forEach(particle => { 78 particle.style.transition = 'all 0.3s ease'; 79 }); 80 }, 81 82}; 83 84// Particle System â sole ambient effect 85// Uses a DocumentFragment to batch-insert all particles in a single DOM write. 86function createParticles() { 87 // Respect user preference: skip particles entirely when reduced motion is on 88 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; 89 90 const container = document.getElementById('particlesContainer'); 91 if (!container) return; 92 93 const particleCount = window.innerWidth > 768 ? 35 : 15; 94 const fragment = document.createDocumentFragment(); 95 96 for (let i = 0; i < particleCount; i++) { 97 const particle = document.createElement('div'); 98 particle.className = 'particle'; 99 particle.style.left = Math.random() * 100 + '%'; 100 particle.style.animationDelay = Math.random() * 22 + 's'; 101 particle.style.animationDuration = (22 + Math.random() * 14) + 's'; 102 fragment.appendChild(particle); 103 } 104 105 container.appendChild(fragment); 106} 107 108// Ripple effect on CTA button click 109function initCtaRipple() { 110 const button = document.getElementById('ctaButton'); 111 if (!button) return; 112 113 button.addEventListener('click', function(e) { 114 const ripple = document.createElement('span'); 115 const rect = this.getBoundingClientRect(); 116 const size = Math.max(rect.width, rect.height); 117 const x = e.clientX - rect.left - size / 2; 118 const y = e.clientY - rect.top - size / 2; 119 120 ripple.style.width = ripple.style.height = size + 'px'; 121 ripple.style.left = x + 'px'; 122 ripple.style.top = y + 'px'; 123 ripple.style.position = 'absolute'; 124 ripple.style.borderRadius = '50%'; 125 ripple.style.background = 'rgba(255, 255, 255, 0.6)'; 126 ripple.style.transform = 'scale(0)'; 127 ripple.style.animation = 'rippleEffect 0.6s ease-out'; 128 ripple.style.pointerEvents = 'none'; 129 130 this.appendChild(ripple); 131 setTimeout(() => ripple.remove(), 600); 132 }); 133} 134 135// Intersection Observer for scroll animations 136const observerOptions = { 137 threshold: 0.1, 138 rootMargin: '0px 0px -50px 0px' 139}; 140 141const observer = new IntersectionObserver((entries) => {
142 entries.forEach((entry, index) => { 143 if (entry.isIntersecting) { 144 setTimeout(() => { 145 entry.target.classList.add('visible'); 146 }, index * 100); 147 } 148 }); 149}, observerOptions); 150 151// Parallax Scrolling Effect 152function initParallax() { 153 window.addEventListener('scroll', () => { 154 const scrolled = window.pageYOffset; 155 const parallaxElements = document.querySelectorAll('.parallax-layer'); 156 157 parallaxElements.forEach((element, index) => { 158 const speed = (index + 1) * 0.5; 159 element.style.transform = `translateY(${scrolled * speed}px)`; 160 }); 161 }); 162} 163 164 165 166// Sticky CTA Header 167function initStickyCta() { 168 const stickyCta = document.getElementById('stickyCta'); 169 const hero = document.querySelector('.hero'); 170 171 if (!stickyCta || !hero) return; 172 173 let heroHeight = hero.offsetHeight; 174 let isVisible = false; 175 176 function checkScroll() { 177 const scrollY = window.pageYOffset || document.documentElement.scrollTop; 178 const shouldShow = scrollY > heroHeight * 0.7; 179 180 if (shouldShow !== isVisible) { 181 isVisible = shouldShow; 182 stickyCta.classList.toggle('visible', shouldShow); 183 } 184 } 185 186 // Recalculate hero height on resize 187 window.addEventListener('resize', () => { 188 heroHeight = hero.offsetHeight; 189 }); 190 191 // Throttle scroll events for performance 192 let ticking = false; 193 window.addEventListener('scroll', () => { 194 if (!ticking) { 195 window.requestAnimationFrame(() => { 196 checkScroll(); 197 ticking = false; 198 }); 199 ticking = true; 200 } 201 }); 202 203 // Initial check 204 checkScroll(); 205} 206 207// ========================================== 208// Video Modal 209// ========================================== 210function openVideoModal(embedUrl) { 211 const modal = document.getElementById('videoModal'); 212 const iframe = document.getElementById('videoIframe'); 213 214 // Use the original youtube.com domain â youtube-nocookie.com can trigger Error 153 215 // when the video owner hasn't explicitly allowed it. 216 const separator = embedUrl.includes('?') ? '&' : '?'; 217 iframe.src = embedUrl + separator + 'autoplay=1&rel=0'; 218 modal.classList.add('active'); 219 document.body.style.overflow = 'hidden'; 220} 221 222function closeVideoModal(event) { 223 // Only close if clicking the backdrop or the close button, not the video itself 224 if (event && event.target.closest('.video-modal-wrapper')) return; 225 const modal = document.getElementById('videoModal'); 226 const iframe = document.getElementById('videoIframe'); 227 modal.classList.remove('active'); 228 iframe.src = ''; // Stop video playback 229 document.body.style.overflow = ''; 230} 231 232// Close modal on Escape key 233document.addEventListener('keydown', (e) => { 234 if (e.key === 'Escape') { 235 const modal = document.getElementById('videoModal'); 236 if (modal && modal.classList.contains('active')) { 237 closeVideoModal(e); 238 } 239 } 240}); 241 242// Initialize all effects 243document.addEventListener('DOMContentLoaded', () => { 244 // Initialize theme manager 245 ThemeManager.init(); 246 247 createParticles(); 248 initCtaRipple(); 249 initParallax(); 250 initStickyCta(); 251 252 // Observe fade-in elements 253 document.querySelectorAll('.fade-in').forEach(el => observer.observe(el)); 254}); 255 256// Cookie consent is provided by /consent/consent.js (window.cookieConsent). 257// The banner, preferences modal, and [data-consent-open] footer wiring all 258// live in that shared module â do not re-declare the API here. 259 260// Recreate particles on resize (debounced to avoid thrashing) 261let resizeTimer; 262window.addEventListener('resize', () => { 263 clearTimeout(resizeTimer); 264 resizeTimer = setTimeout(() => { 265 const container = document.getElementById('particlesContainer'); 266 if (container) { 267 container.innerHTML = ''; 268 createParticles(); 269 } 270 }, 400); 271});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.