// root_url and root_data_url are injected by html-header.blk // MAIN document.addEventListener('DOMContentLoaded', function() { let savedMessage = get_cookie("showMessage"); if ( savedMessage ){ showMessage( unescape( savedMessage ), "info" ); set_cookie( "showMessage", "", -1, "/" ); } initMobileNav(); }); // Mobile nav (hamburger). Desktop CSS hides the toggle, so this is effectively a no-op // on wide screens. Closes on Escape, on outside click, and after picking a nav link. function initMobileNav(){ var header = document.getElementById('header'); var toggle = document.getElementById('nav-toggle'); var nav = document.getElementById('header-nav'); if ( !header || !toggle || !nav ) return; function setOpen( open ){ header.classList.toggle( 'is-open', open ); toggle.setAttribute( 'aria-expanded', open ? 'true' : 'false' ); } toggle.addEventListener( 'click', function( e ){ e.stopPropagation(); setOpen( !header.classList.contains('is-open') ); }); // Close when a link is picked (so reopening feels right when staying on the same SPA-ish page) nav.addEventListener( 'click', function( e ){ if ( e.target.closest('a') ) setOpen( false ); }); // Click outside the header closes the menu document.addEventListener( 'click', function( e ){ if ( header.classList.contains('is-open') && !header.contains( e.target ) ) setOpen( false ); }); // Escape closes the menu document.addEventListener( 'keydown', function( e ){ if ( e.key === 'Escape' && header.classList.contains('is-open') ) setOpen( false ); }); } function set_cookie( c_name, value, exdays, path ){ let expires = new Date(); expires.setTime( expires.getTime() + ( exdays * 86400000 ) ); let c_value = encodeURIComponent( value ); if ( exdays !== null ){ c_value = c_value + "; expires=" + expires.toUTCString(); } if ( path !== null ){ c_value = c_value + "; path=" + path; } document.cookie = c_name + "=" + c_value; } function get_cookie( c_name ){ let parts = document.cookie.split( ";" ); for ( let i = 0; i < parts.length; i++ ){ let eq = parts[i].indexOf( "=" ); if ( eq < 0 ) continue; let name = parts[i].substr( 0, eq ).replace( /^\s+|\s+$/g, "" ); if ( name === c_name ){ return decodeURIComponent( parts[i].substr( eq + 1 ) ); } } return undefined; } // ------------------------------------------------------------ // Slide animations (vanilla replacement for jQuery slideDown/slideUp). // Animate height from/to the element's natural height with overflow hidden, // then clean up the inline styles so layout returns to normal afterwards. // ------------------------------------------------------------ function slideDown( el, duration, done ){ if ( !el ) return; el.style.removeProperty('display'); let display = getComputedStyle( el ).display; if ( display === 'none' ) display = 'block'; el.style.display = display; const target = el.scrollHeight; el.style.overflow = 'hidden'; el.style.height = '0px'; el.style.transition = 'height ' + duration + 'ms ease'; el.offsetHeight; // force reflow so the transition runs el.style.height = target + 'px'; window.setTimeout(function(){ el.style.removeProperty('height'); el.style.removeProperty('overflow'); el.style.removeProperty('transition'); if ( typeof done === 'function' ) done(); }, duration); } function slideUp( el, duration, done ){ if ( !el ) return; el.style.height = el.scrollHeight + 'px'; el.style.overflow = 'hidden'; el.style.transition = 'height ' + duration + 'ms ease'; el.offsetHeight; // force reflow el.style.height = '0px'; window.setTimeout(function(){ el.style.display = 'none'; el.style.removeProperty('height'); el.style.removeProperty('overflow'); el.style.removeProperty('transition'); if ( typeof done === 'function' ) done(); }, duration); } function showMessage( message, type ){ let messageBar = document.getElementById('message-bar'); if ( !messageBar ) return; let holdTime = Math.max( 2000, message.length * ( 500 / ( type == "alert" ? 2 : 6 ) ) ); messageBar.className = 'message-bar ' + type; messageBar.innerHTML = message; slideDown( messageBar, 500, function(){ setTimeout( function(){ slideUp( messageBar, 500 ); }, holdTime ); }); } // ------------------------------------------------------------ // In-site confirmation dialog - a styled replacement for the browser's native // confirm(). Builds a matching the site chrome and runs onConfirm only // when the user accepts. Message/title are set via textContent (no HTML injection). // opts: { title?, message, confirmLabel?, cancelLabel?, danger? (bool) } // Falls back to native confirm() if isn't supported. // ------------------------------------------------------------ function showConfirm( opts, onConfirm ){ opts = opts || {}; var message = opts.message || 'Er du sikker?'; if ( typeof document.createElement( 'dialog' ).showModal !== 'function' ){ if ( window.confirm( message ) && typeof onConfirm === 'function' ) onConfirm(); return; } var dlg = document.createElement( 'dialog' ); dlg.className = 'confirm-dialog'; var body = document.createElement( 'div' ); body.className = 'confirm-body'; if ( opts.title ){ var h = document.createElement( 'h3' ); h.className = 'confirm-title'; h.textContent = opts.title; body.appendChild( h ); } var p = document.createElement( 'p' ); p.className = 'confirm-message'; p.textContent = message; body.appendChild( p ); var actions = document.createElement( 'div' ); actions.className = 'confirm-actions'; var cancelBtn = document.createElement( 'button' ); cancelBtn.type = 'button'; cancelBtn.className = 'button button_cancel confirm-cancel'; cancelBtn.textContent = opts.cancelLabel || 'Avbryt'; var okBtn = document.createElement( 'button' ); okBtn.type = 'button'; okBtn.className = 'button confirm-ok' + ( opts.danger ? ' confirm-danger' : '' ); okBtn.textContent = opts.confirmLabel || 'OK'; actions.appendChild( cancelBtn ); actions.appendChild( okBtn ); body.appendChild( actions ); dlg.appendChild( body ); document.body.appendChild( dlg ); function close(){ if ( dlg.open ) dlg.close(); dlg.remove(); } cancelBtn.addEventListener( 'click', close ); okBtn.addEventListener( 'click', function(){ close(); if ( typeof onConfirm === 'function' ) onConfirm(); } ); dlg.addEventListener( 'click', function( e ){ if ( e.target === dlg ) close(); } ); // backdrop dlg.addEventListener( 'cancel', function( e ){ e.preventDefault(); close(); } ); // ESC dlg.showModal(); okBtn.focus(); } function logout(){ set_cookie( "showMessage", "Du er logget ut.", 30, "/" ); top.window.location.href = ( session_aid > 0 ? '/admin/login' : '/login' ); } // ------------------------------------------------------------ // AJAX wrapper (vanilla fetch). Mirrors the previous jQuery $.ajax semantics: // - success callback runs only on a 2xx response // - 403 -> logout() // - any other non-2xx -> generic warning (HTTP reason phrase) // - network failure -> "Ingen forbindelse til serveren." // CSRF: a token is attached to every state-changing request (non GET/HEAD/OPTIONS). // Optional errorCallback(data, status) runs on any non-2xx (except 403) or network // failure, so callers can re-enable UI / release guards on failure. // ------------------------------------------------------------ function ajaxCall( url, type, payload, successCallback, errorCallback ){ let method = ( type || 'GET' ).toUpperCase(); let opts = { method: method, credentials: 'same-origin', headers: {} }; if ( method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' ){ opts.headers['Content-Type'] = 'application/json'; let meta = document.querySelector('meta[name=csrf-token]'); let token = meta ? meta.getAttribute('content') : ''; if ( token ) opts.headers['X-CSRF-Token'] = token; if ( payload != null ) opts.body = payload; } fetch( url, opts ) .then(function( res ){ return res.text().then(function( text ){ let data = {}; if ( text ){ try { data = JSON.parse( text ); } catch ( e ) { data = {}; } } return { res: res, data: data }; }); }) .then(function( r ){ if ( r.res.ok ){ successCallback( r.data, r.res.status ); } else if ( r.res.status === 403 ){ logout(); } else { // Prefer the server's (Norwegian) message over the raw HTTP status text. var serverMsg = ( r.data && r.data.status && r.data.status.message ) ? r.data.status.message : ''; showMessage( serverMsg || r.res.statusText || "Det oppstod en uventet feil.", "warning" ); console.error( r.res.status + ' ' + r.res.statusText ); if ( typeof errorCallback === 'function' ) errorCallback( r.data, r.res.status ); } }) .catch(function( error ){ showMessage( "Ingen forbindelse til serveren.", "alert" ); console.error( error ); if ( typeof errorCallback === 'function' ) errorCallback( null, 0 ); }); } /** * Auto-fill a "Sted" (place) input from a "Postnr" input. * Looks up the postnr via /api/v1/geo/postnr/{nr} and fills the city field. * The lookup fills the place whenever the postnr changes - including over a * value that was pre-filled programmatically (e.g. from the user's last * address) - but NEVER over a place the user has typed in themselves. * Unknown postnr (404) is silently ignored. */ function attachPostnrLookup( zipEl, cityEl ){ if ( !zipEl || !cityEl ) return; // Track only *manual* edits: typing in the city field freezes it. // Programmatic prefill (el.value = ...) does not fire 'input', so it stays overridable. cityEl.addEventListener( 'input', function(){ cityEl.dataset.userEdited = '1'; }); var timer = null; var lastNr = ''; function lookup(){ var nr = ( zipEl.value || '' ).replace( /[^0-9]/g, '' ); if ( nr.length !== 4 || nr === lastNr ) return; lastNr = nr; fetch( '/api/v1/geo/postnr/' + nr, { credentials: 'same-origin' } ) .then(function( res ){ return res.ok ? res.json() : null; }) .then(function( json ){ if ( !json || !json.data || !json.data.poststad ) return; // Fill unless the user has manually typed their own place. if ( cityEl.dataset.userEdited !== '1' ){ cityEl.value = json.data.poststad; } }) .catch(function(){ /* offline / unknown postnr: leave city as-is */ }); } zipEl.addEventListener( 'input', function(){ clearTimeout( timer ); timer = setTimeout( lookup, 250 ); }); zipEl.addEventListener( 'blur', lookup ); }