Files
JetCore/jetc/qualityiron-site/assets/images/spa.js
T
Brad ArantandClaude Sonnet 5 b8f7793773 Add global music player, invoicing/payment system, account subpages, mail account admin, and the qualityiron.vegas Jet 2.0 port
Jet interpreter (Tag.cpp, __call.cpp, __imap*.cpp/h, __smtpsend.cpp/h,
__imapemptyfolder.cpp/h - new): assorted parser/tag fixes found across
this work - a stray literal-tag-in-comment and backslash-quote parser
corruption class, a dangling-pointer fix, a __call.cpp fd leak, IMAP
folder-management tags, multi-account/per-host IMAP support, and a
smtpsend Message-ID fix.

barant.com site (jetc/barant-site/):
- A site-wide persistent music player (header band, playlist/queue,
  stereo FFT EQ, session-persisted state) fed by new library/
  ianslibrary/music song-list subpages.
- /account split into info/phones/invoices subpages; a full invoicing
  and Square payment system (create/edit/copy/pay/email invoice);
  admin invoice management and mail-account management subpages.
- /services split into overview/hosting/siptrunking/documents
  subpages, retiring the old standalone pages.
- SIPPHONE.tag: a Jet 2.0 WebRTC softphone tag.
- main.css/spa.js/email.js: the SPA loading indicator, the music
  player band styling, and assorted fixes accumulated alongside this
  work.

qualityiron-site/: the qualityiron.vegas job/contractor/customer/
document management app, ported from its original Jet 1.4 codebase to
Jet 2.0 as barant.coms APIServers second multi-tenant site.

wcwc/wcwc.jet: the WCWC site Jet source (wcwc.jtc build output not
included).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 17:42:01 +00:00

347 lines
16 KiB
JavaScript

(function() {
var content = document.getElementById('content');
var form = document.getElementById('mainform');
// Lives outside #content (a sibling in the persistent shell, next to
// the menu bar/form) - a fragment swap never touches it on its own,
// so a per-page side menu would otherwise stay frozen on whichever
// page's menu happened to be there when the shell first loaded. See
// applyResponse()'s own handling of the #_spa_panelmenu marker below.
var panelMenu = document.getElementById('panelmenu');
// Same story as panelMenu just above, for the top-of-page tab bar and
// the upper-right "Welcome X / Logout" box - both live outside
// #content too, and both depend on server-side login state
// ($[_login_id], see menu.bar's own ACCOUNT/EMAIL/ADMIN tabs and
// login.menu's Welcome/Logout block) that only the server can know.
// Without this, logging in via a SPA form submit left the tab bar
// still showing LOGIN and the header box empty, even though the
// account page's own content came back correctly - only a full page
// reload picked up the new state, since only the non-fragment branch
// ever rendered these two includes at all.
var menuBar = document.getElementById('menubar');
var loginMenu = document.getElementById('loginmenu');
// The form's own initial action="<prefix>/<currentPage>" (server-
// rendered per-request - "/spa/$[PAGE]" during the 8081 test-path
// phase, "/$[PAGE]" once a port is cut over to the real path) tells
// us the route prefix this specific deployment is using, captured
// once here since the form itself lives outside #content and never
// gets re-rendered by a client-side navigation - only the initial
// page load sets it. Used to build the target for every subsequent
// submit (the PAGE hidden field's own value changes per navigation,
// this prefix does not) - keeps this one shared spa.js correct across
// every phase of the rollout without needing a matching edit each
// time a port's routing changes.
var formPrefix = '';
if (form) {
var initialAction = form.getAttribute('action') || '';
var initialPage = (form.elements['PAGE'] && form.elements['PAGE'].value) || '';
// action is "<prefix>/<page>" normally, or "<prefix>/<page>/<subpage>"
// when the page loaded directly on a URL that already has one (e.g.
// a bookmark to /opensource/barantmail) - suffix has to account for
// both or this initial load's own subpage would get mistaken for
// part of the prefix.
var initialSubpage = (form.elements['SUBPAGE'] && form.elements['SUBPAGE'].value) || '';
var initialSuffix = initialPage + (initialSubpage ? '/' + initialSubpage : '');
if (initialSuffix && initialAction.slice(-initialSuffix.length) === initialSuffix)
formPrefix = initialAction.slice(0, initialAction.length - initialSuffix.length - 1);
}
// The nav bar (menu.bar) lives in the persistent shell, outside
// #content - a fetch-and-swap navigation never re-renders it, so
// without this the "selected" tab highlight would just stay frozen
// on whichever page the shell happened to load with. Each tab's own
// <a href="/x"> (the real, un-prefixed target path - see menu.bar's
// own <tab page="x"><a href="/x">...</a></tab> shape) is what's used
// to identify it; toggling classes here keeps it in sync with every
// client-side navigation without needing the server to resend the
// whole menu bar on every fragment response.
function syncActiveTab(targetPath) {
if (formPrefix && targetPath.indexOf(formPrefix) === 0)
targetPath = targetPath.slice(formPrefix.length) || '/';
var tabs = document.querySelectorAll('.baritem, .baritemselected');
for (var i = 0; i < tabs.length; i++) {
// Each tab wraps TWO anchors: the outer one linkbutton generates
// (href="javascript:...", drives the actual click) and a real
// <a href="/x"> nested inside it as the visible link text/label
// (see menu.bar's own <tab page="x"><a href="/x">...</a></tab>
// shape). Both match a[href] - only the second, real-path one
// identifies the page, so the javascript: one has to be skipped
// explicitly rather than just taking the first match.
var links = tabs[i].querySelectorAll('a[href]');
var isActive = false;
for (var j = 0; j < links.length; j++) {
var href = links[j].getAttribute('href');
// Exact match covers a plain page; a subpage URL (e.g.
// /admin/users, /opensource/barantmail) never equals its own
// tab's bare /admin or /opensource href, so the tab lost its
// highlight the moment a subpage was selected. Matching on
// "targetPath starts with href, then either ends or continues
// with a /" keeps the tab highlighted for any of its own
// subpages without also matching an unrelated page whose name
// merely starts with the same characters (e.g. a hypothetical
// /administration would not match /admin).
if (href && href.indexOf('javascript:') !== 0 && href !== '/' &&
(href === targetPath ||
(targetPath.indexOf(href) === 0 &&
(targetPath.charAt(href.length) === '/' || targetPath.charAt(href.length) === '')))) {
isActive = true;
break;
}
}
tabs[i].classList.toggle('baritemselected', isActive);
tabs[i].classList.toggle('baritem', !isActive);
}
}
// Returns false if the response was a full-navigation redirect
// (already handled here, caller does nothing further); true if no
// data-spa-page marker was present at all (caller keeps using the
// URL it already requested); otherwise swaps in the content and
// returns {page, subpage} - the AUTHORITATIVE page/subpage the
// server actually rendered. mainpagetemplate.jet emits
// <div data-spa-page="$[PAGE]" data-spa-subpage="$[SUBPAGE]"> right
// before the fragment body, reflecting whatever a <process> may have
// changed PAGE to via <setpage> while handling the request (e.g. a
// successful login handing back the account page, not the login page
// the form was actually submitted to). Callers must use THIS value
// for history.pushState()/the active-tab highlight, not whatever
// page they originally requested - using the pre-request page here
// was exactly why a successful login left the address bar (and the
// tab highlight) stuck on /login even though the account page's
// content had correctly come back. subpage is the optional second
// URL segment (e.g. /opensource/barantmail) a page can use to pick
// what to render in its own second-tier content area.
function applyResponse(html) {
var redirect = /<div\s+data-spa-redirect="([^"]*)"/i.exec(html);
if (redirect) {
window.location.href = redirect[1];
return false;
}
content.innerHTML = html;
// <script> elements set via innerHTML are inert - the browser
// deliberately never executes them (both inline scripts and
// src="..." ones), only ones present when the HTML parser itself
// builds the DOM (a full page load). Pages whose own content
// depends on a <script> running - checkout's Square card-form
// wiring, email's UI logic - silently did nothing when reached via
// a client-side SPA swap (e.g. clicking "Check Out") instead of a
// full reload; only worked by accident on a fresh /checkout load.
// Re-creating each script element makes the browser actually run
// it. A prior fix set async=false on src="" ones, but that only
// orders MULTIPLE external scripts relative to each other - it
// does nothing to delay a plain inline script sitting right after
// one, which still runs immediately on insertion regardless of a
// sibling's in-flight fetch. checkout.page is exactly that shape
// (an external Square SDK <script src> immediately followed by an
// inline script calling Square.payments(...)): on a client-side
// SPA swap the inline script ran before Square had actually
// finished downloading, throwing "Square is not defined" inside an
// async function with no visible catch, leaving the card container
// permanently empty - reported as "checkout's card field doesn't
// build the first time, only after a refresh" (a real full page
// load's parser-inserted <script src> blocks until loaded before
// the next tag runs, which a dynamically re-created one does not
// do on its own). Fixed by actually waiting for each external
// script's load/error event before moving on to the next one,
// rather than trusting async=false's narrower ordering guarantee -
// reproduced the failure and confirmed the fix against a real
// artificially-delayed script load before deploying.
var scripts = content.querySelectorAll('script');
var scriptList = [];
for (var i = 0; i < scripts.length; i++) scriptList.push(scripts[i]);
(function runScript(index) {
if (index >= scriptList.length) return;
var old = scriptList[index];
var fresh = document.createElement('script');
for (var j = 0; j < old.attributes.length; j++)
fresh.setAttribute(old.attributes[j].name, old.attributes[j].value);
fresh.textContent = old.textContent;
if (fresh.src) {
fresh.onload = fresh.onerror = function() { runScript(index + 1); };
old.parentNode.replaceChild(fresh, old);
} else {
old.parentNode.replaceChild(fresh, old);
runScript(index + 1);
}
})(0);
// #_spa_panelmenu carries the CURRENT page's own side-menu markup
// (mainpagetemplate.jet renders it into every fragment response
// specifically so this can pull it out) - extracted from the
// parsed DOM (not the raw html string) so nested tags inside a
// page's own .menu content can't break a naive string search.
// Removed from #content afterward since it only ever belonged in
// #panelmenu - it was just riding along in the fragment payload to
// get there.
var panelMenuMarker = content.querySelector('#_spa_panelmenu');
if (panelMenuMarker) {
if (panelMenu) panelMenu.innerHTML = panelMenuMarker.innerHTML;
panelMenuMarker.remove();
}
// #_spa_menubar/#_spa_loginmenu carry the CURRENT request's own
// login-state-dependent header markup, the same riding-along
// pattern as #_spa_panelmenu above - see the comment on
// menuBar/loginMenu's declaration for why this is needed at all.
var menuBarMarker = content.querySelector('#_spa_menubar');
if (menuBarMarker) {
if (menuBar) menuBar.innerHTML = menuBarMarker.innerHTML;
menuBarMarker.remove();
}
var loginMenuMarker = content.querySelector('#_spa_loginmenu');
if (loginMenuMarker) {
if (loginMenu) loginMenu.innerHTML = loginMenuMarker.innerHTML;
loginMenuMarker.remove();
}
var pageMarker = /data-spa-page="([^"]*)"/i.exec(html);
if (!pageMarker) return true;
var subpageMarker = /data-spa-subpage="([^"]*)"/i.exec(html);
return { page: pageMarker[1], subpage: subpageMarker ? subpageMarker[1] : '' };
}
// Shared by navigate() and the form submit handler: turns
// applyResponse()'s return value into the URL that should replace
// the address bar. `applied === true` (no marker in the response)
// keeps whatever URL was actually requested; an {page, subpage}
// object builds the authoritative one instead.
function resolvedUrl(applied, requestedUrl) {
if (applied === true) return requestedUrl;
return formPrefix + '/' + applied.page + (applied.subpage ? '/' + applied.subpage : '');
}
function delay(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
// Fade-out/fade-in on every content swap (see the matching #content
// CSS transition/`.spa-fading` rule in main.css). The 180ms delay is
// paired with Promise.all so the fetch and the minimum fade-out time
// run concurrently, not back-to-back - a slow fetch just means the
// content stays faded a little longer, a fast one still shows the
// full fade rather than snapping instantly.
function navigate(url, push) {
var sep = url.indexOf('?') === -1 ? '?' : '&';
content.classList.add('spa-fading');
Promise.all([
fetch(url + sep + '_fragment=1', { credentials: 'same-origin' }).then(function(r) { return r.text(); }),
delay(180)
])
.then(function(results) {
var html = results[0];
var applied = applyResponse(html);
if (applied) {
var finalUrl = resolvedUrl(applied, url);
if (push) history.pushState({ spa: true }, '', finalUrl);
syncActiveTab(finalUrl.split('?')[0]);
}
content.classList.remove('spa-fading');
});
}
document.addEventListener('click', function(e) {
var a = e.target.closest('a');
if (!a) return;
var href = a.getAttribute('href');
if (!href || href.charAt(0) !== '/' || href.indexOf('javascript:') === 0) return;
if (a.target && a.target !== '' && a.target !== '_self') return;
e.preventDefault();
navigate(formPrefix + href, true);
});
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var fd = new FormData(form);
var page = fd.get('PAGE') || '';
var subpage = fd.get('SUBPAGE') || '';
var url = formPrefix + '/' + page + (subpage ? '/' + subpage : '');
content.classList.add('spa-fading');
Promise.all([
fetch(url + '?_fragment=1', {
method: 'POST',
body: fd,
credentials: 'same-origin'
}).then(function(r) { return r.text(); }),
delay(180)
])
.then(function(results) {
var html = results[0];
var applied = applyResponse(html);
if (applied) {
var finalUrl = resolvedUrl(applied, url);
history.pushState({ spa: true }, '', finalUrl);
syncActiveTab(finalUrl.split('?')[0]);
}
content.classList.remove('spa-fading');
});
});
}
window.addEventListener('popstate', function() {
navigate(window.location.pathname + window.location.search, false);
});
// Drag-to-resize for the side menu (#panelmenu) against the main
// content column - the resizer bar (#panelmenu-resizer) lives in the
// persistent shell right alongside panelMenu itself (see its own
// declaration above), never touched by a fragment swap, so this only
// needs to be wired up once here, not re-attached per navigation.
// #content deliberately carries no fixed width of its own (see
// mainpagetemplate.jet) - it's a table-cell with nothing set, so it
// automatically takes up whatever's left in the row, meaning only
// panelMenu's own width ever needs to change by hand.
(function initPanelMenuResize() {
var resizer = document.getElementById('panelmenu-resizer');
if (!resizer || !panelMenu)
return;
var STORAGE_KEY = 'barant_panelmenu_width';
var MIN_WIDTH = 120;
var MAX_WIDTH = 400;
try {
var saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
var savedWidth = parseInt(saved, 10);
if (savedWidth >= MIN_WIDTH && savedWidth <= MAX_WIDTH)
panelMenu.style.width = savedWidth + 'px';
}
} catch (e) {
// Private-browsing/storage-disabled - just keep the default width.
}
var dragging = false;
var startX = 0;
var startWidth = 0;
resizer.addEventListener('mousedown', function(e) {
dragging = true;
startX = e.clientX;
startWidth = panelMenu.getBoundingClientRect().width;
resizer.classList.add('resizing');
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!dragging)
return;
var newWidth = startWidth + (e.clientX - startX);
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
panelMenu.style.width = newWidth + 'px';
});
document.addEventListener('mouseup', function() {
if (!dragging)
return;
dragging = false;
resizer.classList.remove('resizing');
document.body.style.userSelect = '';
try {
localStorage.setItem(STORAGE_KEY, Math.round(panelMenu.getBoundingClientRect().width).toString());
} catch (e) {
// Private-browsing/storage-disabled - the width still applies
// for the rest of this page view, just doesn't persist.
}
});
})();
})();