1/** 2 * Media Atelier â Shared JavaScript 3 * Common functionality for all pages: mobile menu, scroll reveal, icons, navbar scroll, PJAX router. 4 */ 5 6// --- PJAX ROUTER ROUTING MAP & HELPERS --- 7const activeMappings = { 8 "index.html": "index.html", 9 "ueber-uns.html": "ueber-uns.html", 10 "leistungen.html": "leistungen.html", 11 "projekte.html": "projekte.html", 12 "tonstudio-mainz.html": "leistungen.html", 13 "postproduktion-mainz.html": "leistungen.html", 14 "hoerbuchproduktion.html": "leistungen.html", 15 "kontakt.html": "", 16 "impressum.html": "", 17 "datenschutz.html": "" 18}; 19 20// Global scroll state tracker shared between navigateSPA and the scroll observer 21let lastScrollTop = 0; 22 23// --- PJAX PREFETCHING CACHE --- 24const prefetchCache = new Map(); 25 26function prefetchPage(href) { 27 const resolvedUrl = new URL(href, window.location.href).href; 28 if (prefetchCache.has(resolvedUrl)) return; 29 30 const fetchPromise = fetch(resolvedUrl) 31 .then(response => { 32 if (!response.ok) throw new Error('Network response was not ok'); 33 return response.text(); 34 }) 35 .catch(err => { 36 prefetchCache.delete(resolvedUrl); 37 console.error('Failed to prefetch page:', resolvedUrl, err); 38 }); 39 40 prefetchCache.set(resolvedUrl, fetchPromise); 41} 42 43function getFilename(urlStr) { 44 if (!urlStr) return "";
45 let cleanUrl = urlStr.split('?')[0].split('#')[0]; 46 let filename = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1); 47 return filename || "index.html"; 48} 49 50function updateActiveNavLinks(href) { 51 const filename = getFilename(href); 52 const target = activeMappings[filename]; 53 54 // Highlight desktop nav links 55 const desktopLinks = document.querySelectorAll('header nav a'); 56 desktopLinks.forEach(link => { 57 const linkHref = getFilename(link.getAttribute('href')); 58 if (target && linkHref === target) { 59 link.classList.add('active'); 60 } else { 61 link.classList.remove('active'); 62 } 63 }); 64 65 // Highlight mobile nav links 66 const mobileLinks = document.querySelectorAll('#mobile-menu a'); 67 mobileLinks.forEach(link => { 68 const linkHref = getFilename(link.getAttribute('href')); 69 if (target && linkHref === target) { 70 link.classList.remove('text-white'); 71 link.classList.add('text-brand-yellow'); 72 } else { 73 if (linkHref !== 'kontakt.html') { 74 link.classList.remove('text-brand-yellow'); 75 link.classList.add('text-white'); 76 } 77 } 78 }); 79} 80 81function executePageScripts(doc) { 82 // 1. Clean up previous dynamic scripts to prevent duplicate executions/memory build up 83 document.querySelectorAll('.dynamic-script').forEach(el => el.remove()); 84 85 // 2. Fetch all script tags 86 const scripts = doc.querySelectorAll('script'); 87 88 // 3. Temporarily hook into addEventListener to capture DOMContentLoaded registrations 89 const originalAddEventListener = document.addEventListener; 90 const tempListeners = []; 91 document.addEventListener = function (type, listener, options) { 92 if (type === 'DOMContentLoaded') { 93 tempListeners.push(listener); 94 } else { 95 originalAddEventListener.call(document, type, listener, options); 96 } 97 }; 98 99 const originalWindowAddEventListener = window.addEventListener; 100 const tempWindowListeners = []; 101 window.addEventListener = function (type, listener, options) { 102 if (type === 'DOMContentLoaded') { 103 tempWindowListeners.push(listener); 104 } else { 105 originalWindowAddEventListener.call(window, type, listener, options); 106 } 107 }; 108 109 scripts.forEach(script => { 110 const src = script.getAttribute('src'); 111 112 // Skip globally loaded layout files 113 if (src && ( 114 src.includes('shared.js') || 115 src.includes('lucide') || 116 src.includes('data.js') || 117 src.includes('tailwind') 118 )) { 119 return; 120 } 121 122 const newScript = document.createElement('script'); 123 newScript.className = 'dynamic-script'; 124 125 if (src) { 126 newScript.src = src; 127 newScript.async = false; 128 } else { 129 newScript.textContent = script.textContent; 130 } 131 132 document.body.appendChild(newScript); 133 }); 134 135 // Restore original event listeners 136 document.addEventListener = originalAddEventListener; 137 window.addEventListener = originalWindowAddEventListener; 138 139 // Immediately execute captured DOMContentLoaded callbacks in order 140 tempListeners.forEach(fn => { 141 try { fn(); } catch (e) { console.error('Error in deferred DOMContentLoaded listener:', e); } 142 }); 143 tempWindowListeners.forEach(fn => { 144 try { fn(); } catch (e) { console.error('Error in deferred window DOMContentLoaded listener:', e); } 145 }); 146} 147 148/** 149 * Premium Parallelized SPA Router 150 * Concurrently triggers fade-out transition and fetch request, then swaps content smoothly 151 */ 152function navigateSPA(href, pushToHistory = true) { 153 const currentMain = document.querySelector('main'); 154 if (!currentMain) { 155 window.location.href = href; 156 return; 157 } 158 159 // 1. Trigger the main opacity fade-out immediately 160 currentMain.classList.add('main-fade-out'); 161 162 // 2. Fetch the page HTML concurrently (check prefetch cache first) 163 const resolvedUrl = new URL(href, window.location.href).href; 164 let fetchPromise = prefetchCache.get(resolvedUrl); 165 if (!fetchPromise) { 166 fetchPromise = fetch(resolvedUrl) 167 .then(response => { 168 if (!response.ok) throw new Error('Network response was not ok'); 169 return response.text(); 170 }); 171 } 172 173 // 3. Create a timer matching the CSS main fade-out duration (150ms) 174 const fadeOutPromise = new Promise(resolve => setTimeout(resolve, 150)); 175 176 // 4. Swap content only when BOTH the fade-out completes and fetch returns 177 Promise.all([fetchPromise, fadeOutPromise]) 178 .then(([htmlText]) => { 179 if (!htmlText) return; 180 181 const parser = new DOMParser(); 182 const doc = parser.parseFromString(htmlText, 'text/html'); 183 184 const newMain = doc.querySelector('main'); 185 if (newMain && currentMain) { 186 // Pre-set matching classes and attributes 187 currentMain.className = newMain.className; 188 if (newMain.hasAttribute('style')) { 189 currentMain.setAttribute('style', newMain.getAttribute('style')); 190 } else { 191 currentMain.removeAttribute('style'); 192 } 193 194 // Ensure it remains faded out before innerHTML swap 195 currentMain.classList.add('main-fade-out'); 196 197 // Swap the innerHTML content 198 currentMain.innerHTML = newMain.innerHTML; 199 } else { 200 window.location.href = href; 201 return; 202 } 203 204 document.title = doc.title; 205 updateActiveNavLinks(href); 206 207 if (pushToHistory) { 208 history.pushState(null, '', href); 209 } 210 211 // Reset intelligent header visibility & lastScrollTop state 212 const headerEl = document.querySelector('header'); 213 if (headerEl) { 214 headerEl.classList.remove('header-hidden'); 215 headerEl.classList.remove('header-scrolled'); 216 } 217 lastScrollTop = 0; // Reset scroll calculations 218 219 // Adjust scroll position before fading in 220 const hashIndex = href.indexOf('#'); 221 if (hashIndex !== -1) { 222 const hash = href.substring(hashIndex); 223 try { 224 const target = document.querySelector(hash); 225 if (target) { 226 target.scrollIntoView(); 227 } else { 228 window.scrollTo(0, 0); 229 } 230 } catch (err) { 231 window.scrollTo(0, 0); 232 } 233 } else { 234 window.scrollTo(0, 0); 235 } 236 237 // Close mobile menu if open 238 const mobileMenu = document.getElementById('mobile-menu'); 239 if (mobileMenu) { 240 mobileMenu.classList.remove('menu-active'); 241 } 242 243 // Execute dynamic page scripts 244 executePageScripts(doc); 245 246 // Re-observe scroll reveal elements in the new content 247 if (window.globalScrollObserver) { 248 document.querySelectorAll('.reveal-on-scroll').forEach(el => { 249 window.globalScrollObserver.observe(el); 250 }); 251 } 252 253 // Re-render Lucide icons strictly scoped to the new main content (prevents document global scans) 254 if (typeof lucide !== 'undefined') { 255 lucide.createIcons(); 256 } 257 258 // Trigger smooth fade-in on the next frame 259 requestAnimationFrame(() => { 260 currentMain.classList.remove('main-fade-out'); 261 }); 262 }) 263 .catch(err => { 264 console.error('PJAX load error, redirecting natively:', err); 265 window.location.href = href; 266 }); 267} 268 269document.addEventListener('DOMContentLoaded', function () { 270 // --- INITIAL PAGE LOAD FADE-IN --- 271 const mainEl = document.querySelector('main'); 272 if (mainEl) { 273 mainEl.style.opacity = '0'; 274 mainEl.style.transform = 'translate3d(0, 10px, 0)';
275 mainEl.style.transition = 'none'; 276 277 requestAnimationFrame(() => { 278 mainEl.style.transition = 'opacity 0.4s cubic-bezier(0.25, 1, 0.5, 1), transform 0.4s cubic-bezier(0.25, 1, 0.5, 1)'; 279 mainEl.style.opacity = '1'; 280 mainEl.style.transform = 'translate3d(0, 0, 0)'; 281 282 // Clean up styles after transition to allow future SPA router page transitions 283 setTimeout(() => { 284 mainEl.style.transform = ''; 285 mainEl.style.transition = ''; 286 }, 450); 287 }); 288 } 289 290 // --- MOBILE MENU --- 291 const menuBtn = document.getElementById('mobile-menu-btn'); 292 const menu = document.getElementById('mobile-menu'); 293 if (menuBtn && menu) { 294 // Remove static 'hidden' class to allow CSS transition styles to take control 295 menu.classList.remove('hidden'); 296 297 menuBtn.addEventListener('click', (e) => { 298 e.stopPropagation(); // Prevent document click handler from immediately closing it 299 menu.classList.toggle('menu-active'); 300 }); 301 302 // Tap outside to close 303 document.addEventListener('click', (e) => { 304 if (menu.classList.contains('menu-active')) { 305 if (!menu.contains(e.target) && !menuBtn.contains(e.target)) { 306 menu.classList.remove('menu-active'); 307 } 308 } 309 }); 310 } 311 312 // --- SCROLL REVEAL --- 313 const observerOptions = { root: null, rootMargin: '0px', threshold: 0.1 }; 314 window.globalScrollObserver = new IntersectionObserver((entries) => { 315 entries.forEach(entry => { 316 if (entry.isIntersecting) { 317 entry.target.classList.add('reveal-visible'); 318 window.globalScrollObserver.unobserve(entry.target); 319 } 320 }); 321 }, observerOptions); 322 323 document.querySelectorAll('.reveal-on-scroll').forEach(el => window.globalScrollObserver.observe(el)); 324 325 // --- INTELLIGENT NAVBAR SCROLL --- 326 const headerEl = document.querySelector('header'); 327 const scrollThreshold = 80; 328 const minDelta = 6; // Avoid jitter from micro-scrolls 329 330 window.addEventListener('scroll', function () { 331 if (!headerEl) return; 332 let scrollTop = window.pageYOffset || document.documentElement.scrollTop; 333 334 // Don't hide header if mobile menu is currently open 335 const mobileMenuEl = document.getElementById('mobile-menu'); 336 if (mobileMenuEl && mobileMenuEl.classList.contains('menu-active')) { 337 headerEl.classList.remove('header-hidden'); 338 return; 339 } 340 341 // Apply glassmorphism / darker background when scrolled past 20px 342 if (scrollTop > 20) { 343 headerEl.classList.add('header-scrolled'); 344 } else { 345 headerEl.classList.remove('header-scrolled'); 346 } 347 348 // Calculate scroll delta & direction 349 const scrollDelta = scrollTop - lastScrollTop; 350 351 if (scrollTop <= 15) { 352 // Always display header at top of page 353 headerEl.classList.remove('header-hidden'); 354 } else if (Math.abs(scrollDelta) > minDelta) { 355 if (scrollDelta > 0 && scrollTop > scrollThreshold) { 356 // Scrolling down -> hide navbar smoothly 357 headerEl.classList.add('header-hidden'); 358 } else if (scrollDelta < 0) { 359 // Scrolling up -> reveal navbar smoothly 360 headerEl.classList.remove('header-hidden'); 361 } 362 } 363 364 lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; 365 }, { passive: true }); 366 367 // --- LUCIDE ICONS --- 368 if (typeof lucide !== 'undefined') { 369 lucide.createIcons(); 370 } 371 372 // --- AUDIO PLAYER LOGIC --- 373 let currentAudio = null; 374 document.addEventListener('click', function (e) { 375 const btn = e.target.closest('.play-btn'); 376 if (!btn) return; 377 const url = btn.dataset.url; 378 if (!url) return; 379 380 if (currentAudio && currentAudio.src.includes(url) && !currentAudio.paused) { 381 currentAudio.pause(); 382 btn.innerHTML = `<i data-lucide="play" class="w-4 h-4 fill-current"></i>`; 383 lucide.createIcons(); 384 } else { 385 if (currentAudio) { 386 currentAudio.pause(); 387 document.querySelectorAll('.play-btn').forEach(b => { 388 b.innerHTML = `<i data-lucide="play" class="w-4 h-4 fill-current"></i>`; 389 lucide.createIcons(); 390 }); 391 } 392 currentAudio = new Audio(url); 393 currentAudio.play(); 394 btn.innerHTML = `<i data-lucide="pause" class="w-4 h-4 fill-current"></i>`; 395 lucide.createIcons(); 396 currentAudio.onended = () => { 397 btn.innerHTML = `<i data-lucide="play" class="w-4 h-4 fill-current"></i>`; 398 lucide.createIcons(); 399 }; 400 } 401 });
402 403 // --- PAGE TRANSITIONS (PJAX INITIALIZATION) --- 404 405 // Handle back/forward navigation cleanly via PJAX 406 window.addEventListener('popstate', function () { 407 navigateSPA(window.location.href, false); 408 }); 409 410 // Intercept clicks on local links for PJAX SPA transitions (main-only fade) 411 document.addEventListener('click', function (e) { 412 const link = e.target.closest('a'); 413 if (!link) return; 414 415 const href = link.getAttribute('href'); 416 const target = link.getAttribute('target'); 417 418 // Skip external links, anchors, mailto/tel, and new tabs 419 if (!href || 420 href.startsWith('mailto:') || 421 href.startsWith('tel:') || 422 target === '_blank' || 423 link.hasAttribute('download') || 424 e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { 425 return; 426 } 427 428 // Skip same-page anchors 429 if (href.startsWith('#')) { 430 return; 431 } 432 433 // Verify if it is a local page 434 const isLocal = !href.includes('://') || href.includes(window.location.host); 435 if (isLocal) { 436 e.preventDefault(); 437 navigateSPA(href); 438 } 439 }); 440 441 // Prefetch links on mouse hover 442 document.addEventListener('mouseover', function (e) { 443 const link = e.target.closest('a'); 444 if (!link) return; 445 446 const href = link.getAttribute('href'); 447 const target = link.getAttribute('target'); 448 449 if (!href || 450 href.startsWith('mailto:') || 451 href.startsWith('tel:') || 452 href.startsWith('#') || 453 target === '_blank' || 454 link.hasAttribute('download')) { 455 return; 456 } 457 458 const isLocal = !href.includes('://') || href.includes(window.location.host); 459 if (isLocal) { 460 prefetchPage(href); 461 } 462 }); 463 464 // Prefetch links on mobile touch start (lowers click latency on mobile) 465 document.addEventListener('touchstart', function (e) { 466 const link = e.target.closest('a'); 467 if (!link) return; 468 469 const href = link.getAttribute('href'); 470 const target = link.getAttribute('target'); 471 472 if (!href || 473 href.startsWith('mailto:') || 474 href.startsWith('tel:') || 475 href.startsWith('#') || 476 target === '_blank' || 477 link.hasAttribute('download')) { 478 return; 479 } 480 481 const isLocal = !href.includes('://') || href.includes(window.location.host); 482 if (isLocal) { 483 prefetchPage(href); 484 } 485 }, { passive: true }); 486 487 // --- MEDIA SESSION API FOR MOBILE CONTROL CENTER --- 488 document.addEventListener('play', function (e) { 489 const audio = e.target; 490 if (!(audio instanceof HTMLAudioElement)) return; 491 492 let title = "Hörprobe"; 493 let artist = "Media Atelier Mainz"; 494 495 // Find parent player elements to extract semantic title/artist 496 const player = audio.closest('.compact-player'); 497 if (player) { 498 const titleEl = player.querySelector('.cp-title'); 499 if (titleEl) { 500 title = titleEl.textContent.replace("De-Hall", "").replace("Denoise", "").trim(); 501 artist = "Audio-Restauration Beispiel"; 502 } 503 } else { 504 // Find if there is a play button referencing this audio 505 // The file name will match the audio src filename 506 const urlParts = audio.src.split('/'); 507 const filename = urlParts[urlParts.length - 1]; 508 509 const playBtn = document.querySelector(`.play-btn[data-url*="${filename}"]`); 510 if (playBtn) { 511 const speakerCard = playBtn.closest('.speaker-card') || playBtn.closest('.card-body-alt'); 512 if (speakerCard) { 513 const nameEl = speakerCard.querySelector('h3'); 514 if (nameEl) { 515 artist = nameEl.textContent.trim(); 516 // Find label or name of the sample next to the button 517 const labelEl = playBtn.previousElementSibling; 518 if (labelEl) { 519 title = `${labelEl.textContent.trim()} (Sprecher-Demo)`; 520 } else { 521 title = "Sprecher-Demo"; 522 } 523 } 524 } 525 } 526 } 527 528 if ('mediaSession' in navigator) {
529 navigator.mediaSession.metadata = new MediaMetadata({ 530 title: title, 531 artist: artist, 532 album: 'Media Atelier Mainz', 533 artwork: [ 534 { src: 'Bilder/MA/m_hellgrau_rgb_small_wabe.webp', sizes: '256x256', type: 'image/png' } 535 ] 536 }); 537 538 navigator.mediaSession.setActionHandler('play', () => { 539 audio.play(); 540 }); 541 navigator.mediaSession.setActionHandler('pause', () => { 542 audio.pause(); 543 }); 544 } 545 }, true); // Use capture phase because play events do not bubble 546});
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.