/** * GirafPingvin — Liquid Glass Controller * * Handles: * • Sliding pill indicator on nav hover / active state * • DK / EN language toggle with localStorage persistence * • Dark / Light mode toggle with system preference * • Mobile hamburger menu */ (function () { 'use strict'; /* ------------------------------------------------------------------ DOM refs ------------------------------------------------------------------ */ var body = document.body; var langBtns = document.querySelectorAll('.nav-lang__btn'); var indicator = document.querySelector('.nav-indicator'); var navLinksList = document.querySelector('.nav-links'); var navLinks = document.querySelectorAll('.nav-links a, .nav-mega__trigger'); var hamburger = document.querySelector('.nav-hamburger'); var navCenter = document.querySelector('.nav-center'); /* ------------------------------------------------------------------ Sliding pill indicator ------------------------------------------------------------------ */ /** Move indicator to cover a given link element */ function moveIndicator(link) { if (!indicator || !navLinksList || !link) return; var listRect = navLinksList.getBoundingClientRect(); var linkRect = link.getBoundingClientRect(); var offsetLeft = linkRect.left - listRect.left; indicator.style.width = linkRect.width + 'px'; indicator.style.transform = 'translateX(' + offsetLeft + 'px)'; indicator.classList.add('visible'); } /** Hide indicator */ function hideIndicator() { if (!indicator) return; var mega = document.querySelector('.nav-mega'); var megaIsOpen = mega && mega.getAttribute('data-open') === 'true'; if (megaIsOpen) { var trigger = mega.querySelector('.nav-mega__trigger'); if (trigger) { moveIndicator(trigger); return; } } var active = navLinksList.querySelector('a.active'); if (active) { moveIndicator(active); } else { indicator.classList.remove('visible'); } } // Hover events on each link navLinks.forEach(function (link) { link.addEventListener('mouseenter', function () { // Lock indicator on trigger while megamenu is open var mega = document.querySelector('.nav-mega'); if (mega && mega.getAttribute('data-open') === 'true' && link !== mega.querySelector('.nav-mega__trigger')) { return; } moveIndicator(link); }); }); // When mouse leaves the entire nav-links list, snap back if (navLinksList) { navLinksList.addEventListener('mouseleave', hideIndicator); } // Position indicator on the active link at load function initIndicator() { var active = navLinksList && navLinksList.querySelector('a.active'); if (active) { requestAnimationFrame(function () { moveIndicator(active); }); } } // Reposition on resize var resizeTimer; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { var active = navLinksList && navLinksList.querySelector('a.active'); if (active) moveIndicator(active); var activeLang = document.querySelector('.nav-lang__btn.active'); if (activeLang) moveLangIndicator(activeLang); }, 100); }); /* ------------------------------------------------------------------ Language toggle (visual indicator only — navigation is via links) ------------------------------------------------------------------ */ var langIndicator = document.querySelector('.nav-lang__indicator'); var navControlsEl = document.querySelector('.nav-controls'); function moveLangIndicator(btn) { if (!langIndicator || !navControlsEl || !btn) return; var parentRect = navControlsEl.getBoundingClientRect(); var btnRect = btn.getBoundingClientRect(); var offsetLeft = btnRect.left - parentRect.left - 3; langIndicator.style.width = btnRect.width + 'px'; langIndicator.style.transform = 'translateX(' + offsetLeft + 'px)'; var lang = btn.dataset.lang; var isNorde = btn.classList.contains('nav-lang__norde'); if (isNorde) { langIndicator.dataset.pos = 'right'; } else { langIndicator.dataset.pos = (lang === 'da') ? 'left' : 'center'; } } /* Hover effects — slide indicator to hovered flag, snap back on leave */ langBtns.forEach(function (btn) { btn.addEventListener('mouseenter', function () { moveLangIndicator(btn); }); btn.addEventListener('mouseleave', function () { var activeLang = document.querySelector('.nav-lang__btn.active'); if (activeLang) moveLangIndicator(activeLang); }); }); /* Nørdezone robot button — slide indicator on hover, restore on leave */ var nordeBtn = document.querySelector('.nav-lang__norde'); if (nordeBtn) { nordeBtn.addEventListener('mouseenter', function () { moveLangIndicator(nordeBtn); }); nordeBtn.addEventListener('mouseleave', function () { var activeLang = document.querySelector('.nav-lang__btn.active'); if (activeLang) moveLangIndicator(activeLang); }); } /* ------------------------------------------------------------------ Dark / Light mode toggle ------------------------------------------------------------------ */ var modeToggle = document.querySelector('.nav-mode-toggle'); var prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); function getEffectiveMode() { var saved = null; try { saved = localStorage.getItem('gp_mode'); } catch (e) { /* noop */ } if (saved === 'dark' || saved === 'light') return saved; return prefersDark.matches ? 'dark' : 'light'; } function applyMode(mode, animate) { var isDark = mode === 'dark'; if (animate) { document.documentElement.classList.add('mode-transition'); } body.dataset.mode = isDark ? 'dark' : ''; document.documentElement.dataset.mode = body.dataset.mode; if (modeToggle) { modeToggle.classList.toggle('is-dark', isDark); } if (animate) { setTimeout(function () { document.documentElement.classList.remove('mode-transition'); }, 1000); } } if (modeToggle) { modeToggle.addEventListener('click', function () { var current = getEffectiveMode(); var next = current === 'dark' ? 'light' : 'dark'; try { localStorage.setItem('gp_mode', next); } catch (e) { /* noop */ } document.cookie = 'gp_mode=' + next + ';path=/;max-age=31536000;SameSite=Lax' + (location.protocol==='https:' ? ';Secure' : ''); applyMode(next, true); }); } prefersDark.addEventListener('change', function () { var saved = null; try { saved = localStorage.getItem('gp_mode'); } catch (e) { /* noop */ } if (!saved) applyMode(prefersDark.matches ? 'dark' : 'light', true); }); /* ------------------------------------------------------------------ Mobile hamburger ------------------------------------------------------------------ */ if (hamburger && navCenter) { hamburger.addEventListener('click', function () { navCenter.classList.toggle('open'); var expanded = navCenter.classList.contains('open'); hamburger.setAttribute('aria-expanded', String(expanded)); }); } /* ------------------------------------------------------------------ NørdeGiraf Megamenu toggle ------------------------------------------------------------------ */ var megaTrigger = document.querySelector('.nav-mega__trigger'); var megaParent = document.querySelector('.nav-mega'); if (megaTrigger && megaParent) { megaTrigger.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); var isOpen = megaParent.getAttribute('data-open') === 'true'; megaParent.setAttribute('data-open', isOpen ? 'false' : 'true'); megaTrigger.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); if (!isOpen) { moveIndicator(megaTrigger); } else { hideIndicator(); } }); // Close when clicking outside document.addEventListener('click', function (e) { if (!megaParent.contains(e.target)) { megaParent.setAttribute('data-open', 'false'); megaTrigger.setAttribute('aria-expanded', 'false'); hideIndicator(); } }); // Close on Escape key document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && megaParent.getAttribute('data-open') === 'true') { megaParent.setAttribute('data-open', 'false'); megaTrigger.setAttribute('aria-expanded', 'false'); hideIndicator(); megaTrigger.focus(); } }); } /* ------------------------------------------------------------------ Init ------------------------------------------------------------------ */ (function init() { // Apply dark/light mode applyMode(getEffectiveMode()); // Init nav indicator initIndicator(); // Init lang indicator (position on server-set active lang) requestAnimationFrame(function () { var activeLang = document.querySelector('.nav-lang__btn.active'); if (activeLang) moveLangIndicator(activeLang); }); })(); /* ------------------------------------------------------------------ Story Timeline — infinite scroll + fullscreen viewer ------------------------------------------------------------------ */ (function () { var track = document.getElementById('storyTrack'); var sentinel = document.getElementById('storySentinel'); var hint = document.getElementById('storyHint'); var viewer = document.getElementById('storyViewer'); var viewerMedia = viewer ? viewer.querySelector('.story-viewer__media') : null; var viewerProgress = document.getElementById('storyViewerProgress'); var viewerDate = document.getElementById('storyViewerDate'); if (!track) return; var storyData = []; var storyIdx = 0; var storyTimer = null; var STORY_DUR = 5000; var loading = false; var hasMore = true; // Collect initial stories from DOM function collectFromDOM() { storyData = []; track.querySelectorAll('.story-ring').forEach(function (btn, i) { btn.dataset.index = i; storyData.push({ type: btn.dataset.type || 'image', src: btn.dataset.src || '', id: parseInt(btn.dataset.id, 10) || 0, date: btn.closest('.story-timeline__label + .story-ring') ? '' : '' }); }); } collectFromDOM(); // Hide hint after first scroll if (track && hint) { var hintDismissed = false; track.addEventListener('scroll', function () { if (!hintDismissed && track.scrollLeft > 60) { hintDismissed = true; hint.classList.add('is-hidden'); } }, { passive: true }); } // ── Infinite scroll: load more stories ───────────────────── function getOldestId() { for (var i = storyData.length - 1; i >= 0; i--) { if (storyData[i].id > 0) return storyData[i].id; } return 0; } function timeLabel(dateStr) { var d = new Date(dateStr); var now = new Date(); var diffMs = now - d; var diffDays = Math.floor(diffMs / 86400000); if (diffDays === 0) return 'I dag'; if (diffDays === 1) return 'I går'; if (diffDays < 7) return 'Denne uge'; if (diffDays < 14) return 'Sidste uge'; var months = ['','Jan','Feb','Mar','Apr','Maj','Jun','Jul','Aug','Sep','Okt','Nov','Dec']; return months[d.getMonth() + 1] + ' ' + d.getFullYear(); } function formatDate(dateStr) { var d = new Date(dateStr); var day = d.getDate(); var months = ['jan','feb','mar','apr','maj','jun','jul','aug','sep','okt','nov','dec']; return day + '. ' + months[d.getMonth()] + ' ' + d.getFullYear(); } function loadMoreStories() { if (loading || !hasMore) return; loading = true; var oldestId = getOldestId(); var url = '/api/stories.php?before_id=' + oldestId + '&limit=20'; fetch(url) .then(function (r) { return r.json(); }) .then(function (data) { if (!data.stories || !data.stories.length) { hasMore = false; if (sentinel) sentinel.classList.add('is-done'); return; } hasMore = data.hasMore; if (!hasMore && sentinel) sentinel.classList.add('is-done'); var lastLabel = ''; // Find last label in track var labels = track.querySelectorAll('.story-timeline__label span'); if (labels.length) lastLabel = labels[labels.length - 1].textContent; var frag = document.createDocumentFragment(); data.stories.forEach(function (s, si) { var label = timeLabel(s.createDate); // Insert time label if new period if (label !== lastLabel) { lastLabel = label; var labelDiv = document.createElement('div'); labelDiv.className = 'story-timeline__label'; labelDiv.setAttribute('aria-hidden', 'true'); var labelSpan = document.createElement('span'); labelSpan.textContent = label; labelDiv.appendChild(labelSpan); frag.appendChild(labelDiv); } var btn = document.createElement('button'); btn.className = 'story-ring story-ring--new'; btn.setAttribute('role', 'listitem'); btn.dataset.type = s.mediaType || 'image'; btn.dataset.src = s.src || ''; btn.dataset.id = s.id; btn.setAttribute('aria-label', 'Story'); btn.style.animationDelay = (si * 0.05) + 's'; btn.innerHTML = '' + ''; btn.addEventListener('click', function () { collectFromDOM(); var idx = parseInt(btn.dataset.index, 10) || 0; openStory(idx); }); frag.appendChild(btn); }); // Insert before sentinel track.insertBefore(frag, sentinel); collectFromDOM(); loading = false; }) .catch(function () { loading = false; }); } // Observe sentinel for infinite scroll if (sentinel && 'IntersectionObserver' in window) { var scrollObs = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) loadMoreStories(); }); }, { root: track, rootMargin: '0px 300px 0px 0px', threshold: 0 }); scrollObs.observe(sentinel); } // ── Story Viewer ─────────────────────────────────────────── function rebuildProgressBars() { if (!viewerProgress) return; viewerProgress.innerHTML = ''; for (var i = 0; i < storyData.length; i++) { var bar = document.createElement('div'); bar.className = 'story-viewer__bar'; bar.dataset.index = i; bar.innerHTML = ''; viewerProgress.appendChild(bar); } } function openStory(idx) { if (!viewer || !storyData.length) return; // Clamp visible progress bars for performance (show nearby ±15) rebuildProgressBars(); storyIdx = idx; viewer.classList.add('active'); viewer.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; showStory(); } function closeStory() { if (!viewer) return; viewer.classList.remove('active'); viewer.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; clearTimeout(storyTimer); if (viewerMedia) viewerMedia.innerHTML = ''; } function showStory() { if (storyIdx < 0 || storyIdx >= storyData.length) { closeStory(); return; } clearTimeout(storyTimer); var s = storyData[storyIdx]; // Update progress bars (only show range around current) var bars = viewerProgress ? viewerProgress.querySelectorAll('.story-viewer__bar') : []; var rangeStart = Math.max(0, storyIdx - 15); var rangeEnd = Math.min(storyData.length - 1, storyIdx + 15); bars.forEach(function (b, i) { b.classList.remove('active', 'watched'); b.querySelector('span').style.width = ''; // Hide bars outside visible range if (i < rangeStart || i > rangeEnd) { b.style.display = 'none'; } else { b.style.display = ''; if (i < storyIdx) b.classList.add('watched'); } }); requestAnimationFrame(function () { if (bars[storyIdx]) bars[storyIdx].classList.add('active'); }); // Show date if (viewerDate) { // Find corresponding DOM button var ringBtn = track.querySelector('.story-ring[data-index="' + storyIdx + '"]'); if (ringBtn) { // Walk back to find nearest label var prev = ringBtn.previousElementSibling; var labelText = ''; while (prev) { if (prev.classList && prev.classList.contains('story-timeline__label')) { labelText = prev.textContent.trim(); break; } prev = prev.previousElementSibling; } viewerDate.textContent = labelText || ''; } } // Render media if (viewerMedia) { if (s.type === 'video') { viewerMedia.innerHTML = ''; var vid = viewerMedia.querySelector('video'); vid.addEventListener('ended', function () { nextStory(); }); storyTimer = setTimeout(function () { nextStory(); }, 30000); } else { viewerMedia.innerHTML = ''; storyTimer = setTimeout(function () { nextStory(); }, STORY_DUR); } } // If near end, try loading more if (storyIdx > storyData.length - 5 && hasMore) { loadMoreStories(); } } function nextStory() { if (storyIdx < storyData.length - 1) { storyIdx++; showStory(); } else if (hasMore) { // Try loading more, then advance loadMoreStories(); setTimeout(function () { if (storyIdx < storyData.length - 1) { storyIdx++; showStory(); } else { closeStory(); } }, 1500); } else { closeStory(); } } function prevStory() { if (storyIdx > 0) { storyIdx--; showStory(); } else { showStory(); } } function escHtml(str) { var div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; } // Bind initial story ring clicks track.querySelectorAll('.story-ring').forEach(function (btn) { btn.addEventListener('click', function () { collectFromDOM(); openStory(parseInt(btn.dataset.index, 10) || 0); }); }); // Viewer nav buttons if (viewer) { var closeBtn = viewer.querySelector('.story-viewer__close'); var prevBtn = viewer.querySelector('.story-viewer__nav--prev'); var nextBtn = viewer.querySelector('.story-viewer__nav--next'); if (closeBtn) closeBtn.addEventListener('click', closeStory); if (prevBtn) prevBtn.addEventListener('click', prevStory); if (nextBtn) nextBtn.addEventListener('click', nextStory); document.addEventListener('keydown', function (e) { if (!viewer.classList.contains('active')) return; if (e.key === 'Escape') closeStory(); if (e.key === 'ArrowRight') nextStory(); if (e.key === 'ArrowLeft') prevStory(); }); } })(); /* ------------------------------------------------------------------ Animated stat counters (count up on scroll into view) ------------------------------------------------------------------ */ var statValues = document.querySelectorAll('.stat-dash__num[data-count], .stat-dash__total-num[data-count]'); if (statValues.length && 'IntersectionObserver' in window) { var counted = false; var observer = new IntersectionObserver(function (entries) { if (counted) return; entries.forEach(function (entry) { if (entry.isIntersecting) { counted = true; animateCounters(); observer.disconnect(); } }); }, { threshold: 0.3 }); statValues.forEach(function (el) { observer.observe(el); }); } function animateCounters() { statValues.forEach(function (el) { var target = parseInt(el.dataset.count, 10) || 0; if (target === 0) return; var start = 0; var duration = 1800; var startTime = null; function step(ts) { if (!startTime) startTime = ts; var progress = Math.min((ts - startTime) / duration, 1); // Ease out cubic var ease = 1 - Math.pow(1 - progress, 3); var current = Math.round(start + (target - start) * ease); el.textContent = formatNum(current); if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); }); } function formatNum(n) { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.'); } /* ------------------------------------------------------------------ Image Lightbox — .girafmodal-popup ------------------------------------------------------------------ */ (function initLightbox() { var lightbox = document.getElementById('lightbox'); if (!lightbox) return; var img = lightbox.querySelector('.lightbox__img'); var counter = lightbox.querySelector('.lightbox__counter'); var btnPrev = lightbox.querySelector('.lightbox__nav--prev'); var btnNext = lightbox.querySelector('.lightbox__nav--next'); var gallery = []; var idx = 0; function show(i) { idx = i; img.classList.remove('lightbox__img--loaded'); img.src = gallery[i].src; img.alt = gallery[i].alt; counter.textContent = (i + 1) + ' / ' + gallery.length; btnPrev.style.display = gallery.length > 1 ? '' : 'none'; btnNext.style.display = gallery.length > 1 ? '' : 'none'; } img.addEventListener('load', function () { img.classList.add('lightbox__img--loaded'); }); function open(galleryLinks, startIndex) { gallery = galleryLinks.map(function (a) { var thumb = a.querySelector('img'); return { src: a.getAttribute('href'), alt: thumb ? thumb.alt : '' }; }); idx = startIndex || 0; show(idx); lightbox.classList.add('lightbox--open'); lightbox.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; } function close() { lightbox.classList.remove('lightbox--open'); lightbox.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; img.src = ''; } function prev() { if (gallery.length > 1) show((idx - 1 + gallery.length) % gallery.length); } function next() { if (gallery.length > 1) show((idx + 1) % gallery.length); } // Delegate click on .girafmodal-popup links document.addEventListener('click', function (e) { var link = e.target.closest('.girafmodal-popup'); if (!link) return; e.preventDefault(); // Build gallery from sibling links in the same figure/row var container = link.closest('figure') || link.closest('.row') || link.parentElement; var links = Array.prototype.slice.call(container.querySelectorAll('.girafmodal-popup')); if (!links.length) links = [link]; var startIdx = links.indexOf(link); if (startIdx < 0) startIdx = 0; open(links, startIdx); }); // Close lightbox.querySelector('.lightbox__backdrop').addEventListener('click', close); lightbox.querySelector('.lightbox__close').addEventListener('click', close); // Nav btnPrev.addEventListener('click', prev); btnNext.addEventListener('click', next); // Keyboard document.addEventListener('keydown', function (e) { if (!lightbox.classList.contains('lightbox--open')) return; if (e.key === 'Escape') close(); if (e.key === 'ArrowLeft') prev(); if (e.key === 'ArrowRight') next(); }); // Swipe support var touchX = 0; lightbox.addEventListener('touchstart', function (e) { touchX = e.changedTouches[0].clientX; }, { passive: true }); lightbox.addEventListener('touchend', function (e) { var dx = e.changedTouches[0].clientX - touchX; if (Math.abs(dx) > 50) { dx > 0 ? prev() : next(); } }, { passive: true }); })(); })();