/* --------------------------------------------------------------
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
. */
document.addEventListener( 'click', function( event ){
const toggle = event.target.closest( '.help-toggle' );
if ( !toggle ) return;
const panel = document.getElementById( toggle.dataset.help );
if ( !panel ) return;
panel.hidden = !panel.hidden;
toggle.classList.toggle( 'is-open', !panel.hidden );
toggle.setAttribute( 'aria-expanded', panel.hidden ? 'false' : 'true' );
} );
// Some explanations matter too much to wait for a click. Shown once
// per browser, then available behind the question mark like the rest.
function showHelpOnce( key, id ){
if ( localStorage.getItem( 'help_' + key ) ) return;
const panel = document.getElementById( id );
if ( !panel ) return;
panel.hidden = false;
localStorage.setItem( 'help_' + key, '1' );
// Keep the question mark honest: it is open, so it should look open
const toggle = qs( '.help-toggle[data-help="' + id + '"]' );
if ( toggle ){
toggle.classList.add( 'is-open' );
toggle.setAttribute( 'aria-expanded', 'true' );
}
}
// One delegated handler covers every chip group on the page.
// data-single="1" makes the group behave like a radio set.
document.addEventListener( 'click', function( event ){
const chip = event.target.closest( '.chip-toggle' );
if ( !chip ) return;
const group = chip.closest( '.chip-group' );
if ( !group ) return;
if ( group.dataset.single === '1' ){
qsa( '.chip-toggle', group ).forEach( function( other ){
other.classList.toggle( 'selected', other === chip );
} );
} else {
chip.classList.toggle( 'selected' );
}
} );
// Buttons that submit while a request is in flight cause duplicate rows
function withBusy( button, work ){
if ( !button ) return work();
const label = button.textContent;
button.disabled = true;
return Promise.resolve()
.then( work )
.finally( function(){
button.disabled = false;
button.textContent = label;
} );
}
/* ---------------------------------------------------------------
Passkeys (WebAuthn)
--------------------------------------------------------------- */
function passkeySupported(){
return !!window.PublicKeyCredential;
}
function bufToB64url( buffer ){
const bytes = new Uint8Array( buffer );
let binary = '';
for ( let i = 0; i < bytes.length; i++ ){
binary += String.fromCharCode( bytes[ i ] );
}
return window.btoa( binary ).replace( /\+/g, '-' ).replace( /\//g, '_' ).replace( /=+$/, '' );
}
function b64urlToBuf( text ){
const padding = '='.repeat( ( 4 - ( text.length % 4 ) ) % 4 );
const raw = window.atob( ( text + padding ).replace( /-/g, '+' ).replace( /_/g, '/' ) );
const bytes = new Uint8Array( raw.length );
for ( let i = 0; i < raw.length; i++ ){
bytes[ i ] = raw.charCodeAt( i );
}
return bytes.buffer;
}
// Create a passkey for the signed-in user. Resolves when stored.
async function passkeyCreate(){
const options = await api( '/users/passkey/register-options', 'POST' );
const credential = await navigator.credentials.create( { publicKey: {
challenge: b64urlToBuf( options.challenge ),
rp: { id: options.rp_id, name: options.rp_name },
user: {
id: b64urlToBuf( options.user_id ),
name: options.user_name,
displayName: options.user_display || options.user_name
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256 (Windows Hello)
],
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
attestation: 'none'
} } );
return api( '/users/passkey/register', 'POST', {
'client_data': bufToB64url( credential.response.clientDataJSON ),
'attestation': bufToB64url( credential.response.attestationObject ),
'label': ( navigator.platform || 'device' ).toLowerCase().replace( /[^a-z0-9]+/g, '-' ).substring( 0, 30 )
} );
}
// Sign in with a passkey. Resolves with { uid, redirect }.
async function passkeySignIn(){
const options = await api( '/users/passkey/login-options', 'POST', null, { noBounce: true } );
const assertion = await navigator.credentials.get( { publicKey: {
challenge: b64urlToBuf( options.challenge ),
rpId: options.rp_id,
userVerification: 'preferred'
} } );
return api( '/users/passkey/login', 'POST', {
'credential_id': bufToB64url( assertion.rawId ),
'client_data': bufToB64url( assertion.response.clientDataJSON ),
'authenticator_data': bufToB64url( assertion.response.authenticatorData ),
'signature': bufToB64url( assertion.response.signature ),
'user_handle': ( assertion.response.userHandle ? bufToB64url( assertion.response.userHandle ) : '' )
}, { noBounce: true } );
}
/* ---------------------------------------------------------------
Web push
--------------------------------------------------------------- */
function pushSupported(){
return ( 'serviceWorker' in navigator ) && ( 'PushManager' in window ) && vapid_public !== '';
}
// The applicationServerKey wants raw bytes, the config holds base64url
function urlBase64ToUint8Array( base64url ){
const padding = '='.repeat( ( 4 - ( base64url.length % 4 ) ) % 4 );
const base64 = ( base64url + padding ).replace( /-/g, '+' ).replace( /_/g, '/' );
const raw = window.atob( base64 );
const output = new Uint8Array( raw.length );
for ( let i = 0; i < raw.length; i++ ){
output[ i ] = raw.charCodeAt( i );
}
return output;
}
async function pushSubscribe(){
const permission = await Notification.requestPermission();
if ( permission !== 'granted' ){
throw new Error( 'denied' );
}
const registration = await navigator.serviceWorker.register( '/sw.js' );
await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe( {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array( vapid_public )
} );
const json = subscription.toJSON();
const data = await api( '/push', 'POST', {
'input_push-platform': 'web',
'input_push-token': subscription.endpoint,
'input_push-endpoint': subscription.endpoint,
'push_keys': ( json.keys || {} )
} );
localStorage.setItem( 'push_ptid', data.ptid );
return data.ptid;
}
async function pushUnsubscribe(){
const registration = await navigator.serviceWorker.getRegistration( '/sw.js' );
const subscription = registration && await registration.pushManager.getSubscription();
if ( subscription ){
await subscription.unsubscribe();
}
const ptid = localStorage.getItem( 'push_ptid' );
if ( ptid ){
await api( '/push/' + ptid, 'DELETE', null, { silent: true } ).catch( function(){} );
localStorage.removeItem( 'push_ptid' );
}
}
async function pushIsActive(){
if ( !pushSupported() || Notification.permission !== 'granted' ) return false;
const registration = await navigator.serviceWorker.getRegistration( '/sw.js' );
if ( !registration ) return false;
return !!( await registration.pushManager.getSubscription() );
}
/* ---------------------------------------------------------------
Boot
--------------------------------------------------------------- */
document.addEventListener( 'DOMContentLoaded', function(){
const carried = getCookie( 'showMessage' );
if ( carried ){
showMessage( carried, 'info' );
setCookie( 'showMessage', '', -1, '/' );
}
// Always registered, not only when push is enabled: the service
// worker is what makes the site installable and gives the offline
// page. Push subscription stays a separate, user-initiated step.
if ( 'serviceWorker' in navigator ){
navigator.serviceWorker.register( '/sw.js' ).catch( function(){} );
}
} );