- __mysql.cpp: <sql> never checked mysql_real_query's own return code, so a genuine query failure was indistinguishable from "ran fine, zero rows" - <try>/<catch> around a failing <sql> never actually caught anything anywhere. Now throws on a real failure. - spa.js: dynamically re-injected scripts now run in true sequential order (wait for each external script's load event before running the next), fixing checkout's Square card field never building on the first client-side navigation to /checkout (only after a refresh) - async=false alone only orders multiple external scripts relative to each other, it doesn't delay a plain inline sibling. - /vst split into a real overview + /vst/modular1 subpage with a left menu, reusing opensource.page's existing subpage-tier pattern (opensource.menu/main.css's submenuitem-active highlighting, and several other subpage-tier retirements, were already finished earlier this session but never committed - included here too). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
16 KiB
JavaScript
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.
|
|
}
|
|
});
|
|
})();
|
|
})();
|