/* -------------------------------------------------------------- Shared front-end library. Vanilla JS, no framework, no build step. root_url, api_root, page_name, session_uid, session_lang and supported_langs are injected as globals by html/html-header.blk. Every request goes through api(). Do not call fetch() directly - api() centralises the response envelope, the 403 -> sign-in bounce and the error message. -------------------------------------------------------------- */ const MONTHS_SHORT = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; const DAYS_SHORT = [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ]; const params = new URLSearchParams( window.location.search ); /* --------------------------------------------------------------- DOM helpers --------------------------------------------------------------- */ function qs( selector, scope ){ return ( scope || document ).querySelector( selector ); } function qsa( selector, scope ){ return Array.from( ( scope || document ).querySelectorAll( selector ) ); } // Anything interpolated into an HTML string goes through this first. function escapeHtml( value ){ if ( value === null || value === undefined ) return ''; return String( value ) .replace( /&/g, '&' ) .replace( //g, '>' ) .replace( /"/g, '"' ) .replace( /'/g, ''' ); } // Interface string in the reader's language, from the table injected as // `const T` by html-header.blk. Fills {token} placeholders. An unknown // key returns the key itself, which is visible and greppable rather than // silently blank. This is chrome only; user content goes through the // translate endpoint. function t( key, tokens ){ let text = ( typeof T !== 'undefined' && T[ key ] ) ? T[ key ] : key; if ( tokens ){ for ( const name in tokens ){ text = text.split( '{' + name + '}' ).join( tokens[ name ] ); } } return text; } // Inline sprite reference - the sprite itself lives in html-header.blk function icon( name, className ){ return ''; } // Initials on a tinted square, or the photo when there is one. function avatarMarkup( person, className ){ const initials = escapeHtml( person.initials || '?' ); if ( person.avatar_url ){ return '
' + '' + '
'; } return '
' + initials + '
'; } // Fill an existing .avatar element rather than replacing it function setAvatar( element, person ){ if ( !element ) return; element.innerHTML = person.avatar_url ? '' : escapeHtml( person.initials || '?' ); } function verifiedMarkup( person ){ if ( !person.email_verified ) return ''; return '' + icon( 'check' ) + ''; } /* --------------------------------------------------------------- Dates --------------------------------------------------------------- */ // '2026-03-12' -> Date, without the UTC shift a bare Date() would add function parseDate( value ){ if ( !value ) return null; const parts = String( value ).substring( 0, 10 ).split( '-' ); if ( parts.length !== 3 ) return null; return new Date( Number( parts[0] ), Number( parts[1] ) - 1, Number( parts[2] ) ); } function formatDay( value ){ const date = parseDate( value ); if ( !date ) return ''; return MONTHS_SHORT[ date.getMonth() ] + ' ' + date.getDate(); } function formatWeekday( value ){ const date = parseDate( value ); if ( !date ) return ''; return DAYS_SHORT[ date.getDay() ]; } function formatRange( from, to ){ if ( !from || !to ) return ''; if ( from === to ) return formatDay( from ); return formatDay( from ) + ' to ' + formatDay( to ); } function nightsBetween( from, to ){ const a = parseDate( from ); const b = parseDate( to ); if ( !a || !b ) return 0; return Math.max( 0, Math.round( ( b - a ) / 86400000 ) ); } function daysUntil( value ){ const date = parseDate( value ); if ( !date ) return 0; const today = new Date(); today.setHours( 0, 0, 0, 0 ); return Math.round( ( date - today ) / 86400000 ); } // MySQL 'Y-m-d H:i:s' -> 'Tue 19:42', or 'Mar 12' once it is older than a week function formatTimestamp( value ){ if ( !value ) return ''; const iso = String( value ).replace( ' ', 'T' ); const date = new Date( iso ); if ( isNaN( date.getTime() ) ) return ''; const ageDays = ( Date.now() - date.getTime() ) / 86400000; const hhmm = String( date.getHours() ).padStart( 2, '0' ) + ':' + String( date.getMinutes() ).padStart( 2, '0' ); if ( ageDays < 1 ) return hhmm; if ( ageDays < 7 ) return DAYS_SHORT[ date.getDay() ] + ' ' + hhmm; return MONTHS_SHORT[ date.getMonth() ] + ' ' + date.getDate(); } /* --------------------------------------------------------------- Cookies - only used to carry a message across a redirect --------------------------------------------------------------- */ function setCookie( name, value, days, path ){ let cookie = name + '=' + encodeURIComponent( value ); if ( days !== null && days !== undefined ){ const expires = new Date( Date.now() + ( days * 86400000 ) ); cookie += '; expires=' + expires.toUTCString(); } cookie += '; path=' + ( path || '/' ) + '; SameSite=Lax'; document.cookie = cookie; } function getCookie( name ){ const match = document.cookie.split( ';' ).find( function( part ){ return part.trim().indexOf( name + '=' ) === 0; } ); return ( match ? decodeURIComponent( match.trim().substring( name.length + 1 ) ) : '' ); } /* --------------------------------------------------------------- Message bar --------------------------------------------------------------- */ let messageTimer = null; function showMessage( text, type ){ const bar = qs( '#message-bar' ); if ( !bar ) return; bar.className = 'message-bar ' + ( type || 'info' ); bar.textContent = text; // Long or alarming messages stay up longer const hold = Math.max( 2600, text.length * ( type === 'alert' ? 90 : 55 ) ); window.clearTimeout( messageTimer ); window.requestAnimationFrame( function(){ bar.classList.add( 'is-visible' ); } ); messageTimer = window.setTimeout( function(){ bar.classList.remove( 'is-visible' ); }, hold ); } /* --------------------------------------------------------------- Dialogs The message bar is for things that have already happened. This is for the question before. window.confirm() cannot say which trip is about to go, cannot mark an answer as the one that does not come back, and puts the domain name in the title. const ok = await confirmDialog( { title: 'Delete the trip to Gdansk?', text: 'Anyone who connected with you on it keeps the conversation.', confirm: 'Delete trip', danger: true } ); Resolves true only on the confirm button. Escape, the backdrop and Cancel all resolve false, so a stray click is never a yes. --------------------------------------------------------------- */ let dialogResolve = null; function openDialog( options ){ const backdrop = qs( '#dialog-backdrop' ); if ( !backdrop ) return Promise.resolve( false ); // Never leave a caller hanging when a second dialog opens over it closeDialog( false ); qs( '#dialog-title' ).textContent = options.title || ''; qs( '#dialog-text' ).textContent = options.text || ''; const confirmButton = qs( '.cmd_dialog-confirm', backdrop ); const cancelButton = qs( '.cmd_dialog-cancel', backdrop ); confirmButton.textContent = options.confirm || 'Continue'; confirmButton.classList.toggle( 'button_danger', options.danger === true ); cancelButton.textContent = options.cancel || 'Cancel'; cancelButton.hidden = options.single === true; backdrop.hidden = false; document.body.classList.add( 'dialog-open' ); confirmButton.focus(); return new Promise( function( resolve ){ dialogResolve = resolve; } ); } function closeDialog( answer ){ const backdrop = qs( '#dialog-backdrop' ); if ( !backdrop || backdrop.hidden ) return; backdrop.hidden = true; document.body.classList.remove( 'dialog-open' ); const resolve = dialogResolve; dialogResolve = null; if ( resolve ) resolve( answer === true ); } // Ask before doing something the user cannot undo. function confirmDialog( options ){ return openDialog( options ); } // Say something that needs more room than the message bar gives it. function infoDialog( options ){ return openDialog( Object.assign( { single: true, confirm: 'Got it' }, options ) ); } document.addEventListener( 'click', function( event ){ if ( event.target.closest( '.cmd_dialog-confirm' ) ){ closeDialog( true ); return; } // The backdrop itself, not the card sitting on it if ( event.target.closest( '.cmd_dialog-cancel' ) || event.target.id === 'dialog-backdrop' ){ closeDialog( false ); } } ); document.addEventListener( 'keydown', function( event ){ const backdrop = qs( '#dialog-backdrop' ); if ( !backdrop || backdrop.hidden ) return; if ( event.key === 'Escape' ){ closeDialog( false ); return; } // Keep tabbing inside the dialog while it owns the screen if ( event.key === 'Tab' ){ const buttons = qsa( 'button:not([hidden])', backdrop ); if ( !buttons.length ) return; const first = buttons[ 0 ]; const last = buttons[ buttons.length - 1 ]; if ( event.shiftKey && document.activeElement === first ){ event.preventDefault(); last.focus(); } else if ( !event.shiftKey && document.activeElement === last ){ event.preventDefault(); first.focus(); } } } ); function logout( message ){ setCookie( 'showMessage', message || 'You have been signed out.', 1, '/' ); window.location.href = '/login'; } /* --------------------------------------------------------------- API --------------------------------------------------------------- */ /** * api( '/trips', 'POST', payload ) * * Resolves with the `data` object of the response envelope. Rejects on * any non-2xx, after showing the server's message. Options: * silent - do not show the error message bar * noBounce - do not redirect to /login on 403 */ async function api( path, method, payload, options ){ options = options || {}; const init = { method: ( method || 'GET' ).toUpperCase(), credentials: 'same-origin', headers: { 'Accept': 'application/json' } }; if ( payload instanceof FormData ){ init.body = payload; } else if ( payload !== undefined && payload !== null ){ init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify( payload ); } let response; try { response = await fetch( api_root + path, init ); } catch ( error ){ showMessage( 'No connection to the server', 'alert' ); throw error; } let body = null; try { body = await response.json(); } catch ( error ){ body = null; } if ( !response.ok ){ const message = ( body && body.status && body.status.message ) || 'Something went wrong'; if ( response.status === 403 && !options.noBounce ){ logout( 'Please sign in again.' ); } if ( !options.silent ){ showMessage( message, response.status >= 500 ? 'alert' : 'warning' ); } const error = new Error( message ); error.status = response.status; error.body = body; throw error; } return ( body && body.data !== undefined ) ? body.data : {}; } /* --------------------------------------------------------------- Forms --------------------------------------------------------------- */ /** * Field ids double as payload keys, so a form serialises straight into * what the PHP side reads: id="input_trip-city" -> payload['input_trip-city']. */ function serializeForm( form ){ const payload = {}; qsa( 'input, textarea, select', form ).forEach( function( field ){ if ( !field.id ) return; if ( !/^(input|textarea|select)_/.test( field.id ) ) return; if ( field.type === 'file' ) return; if ( field.type === 'checkbox' ){ payload[ field.id ] = field.checked; } else { payload[ field.id ] = field.value; } } ); return payload; } // Chip groups stand in for checkbox sets and radio groups function chipValues( group ){ return qsa( '.chip-toggle.selected', group ).map( function( chip ){ return chip.dataset.value; } ); } function setChipValues( group, values ){ const wanted = ( values || [] ).map( String ); qsa( '.chip-toggle', group ).forEach( function( chip ){ chip.classList.toggle( 'selected', wanted.indexOf( String( chip.dataset.value ) ) !== -1 ); } ); } /* Small explanations, one mechanic everywhere: a question mark that folds out a short paragraph. Markup is followed anywhere by