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>
This commit is contained in:
1 parent
905fbafa5a
commit
b8f7793773
406 files changed
+15803
-587
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,184 @@
|
||||
// Site-wide file transfer manager for qualityiron.vegas - loaded once
|
||||
// via mainpagetemplate.jet (outside #content) so the floating panel and
|
||||
// any in-progress transfer survive spa.js's own #content-only swaps.
|
||||
// Adapted from barant.com's own documents.js (same proven mechanism),
|
||||
// just pointed at qualityiron's own /jobdocs/* routes instead of
|
||||
// barant.com's /documents/*.
|
||||
(function() {
|
||||
var panel = document.getElementById('filemgr-panel');
|
||||
if (!panel)
|
||||
return;
|
||||
|
||||
var toggle = document.getElementById('filemgr-toggle');
|
||||
var badge = document.getElementById('filemgr-badge');
|
||||
var list = document.getElementById('filemgr-list');
|
||||
var clearBtn = document.getElementById('filemgr-clear');
|
||||
|
||||
var transfers = {};
|
||||
var nextId = 1;
|
||||
|
||||
function escapeHtml(s) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = s == null ? '' : String(s);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function updateBadge() {
|
||||
var activeCount = 0;
|
||||
for (var k in transfers) {
|
||||
if (transfers[k].status === 'active')
|
||||
activeCount++;
|
||||
}
|
||||
badge.textContent = activeCount > 0 ? String(activeCount) : '';
|
||||
}
|
||||
|
||||
function removeEmptyPlaceholder() {
|
||||
var empty = document.getElementById('filemgr-empty');
|
||||
if (empty)
|
||||
empty.remove();
|
||||
}
|
||||
|
||||
function showEmptyPlaceholderIfNeeded() {
|
||||
if (Object.keys(transfers).length === 0 && !document.getElementById('filemgr-empty'))
|
||||
list.innerHTML = '<div id="filemgr-empty">No transfers yet.</div>';
|
||||
}
|
||||
|
||||
var UPLOAD_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V6M5 12l7-7 7 7"/></svg>';
|
||||
var DOWNLOAD_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v13M5 12l7 7 7-7"/></svg>';
|
||||
|
||||
function addTransfer(name, direction) {
|
||||
var id = 'xfer' + (nextId++);
|
||||
removeEmptyPlaceholder();
|
||||
|
||||
var el = document.createElement('div');
|
||||
el.className = 'filemgr-item';
|
||||
el.innerHTML =
|
||||
'<div class="filemgr-item-row">' +
|
||||
'<span class="filemgr-item-icon">' + (direction === 'upload' ? UPLOAD_ICON : DOWNLOAD_ICON) + '</span>' +
|
||||
'<span class="filemgr-item-name">' + escapeHtml(name) + '</span>' +
|
||||
'<span class="filemgr-item-status">0%</span>' +
|
||||
'</div>' +
|
||||
'<div class="filemgr-progress-track"><div class="filemgr-progress-fill"></div></div>';
|
||||
list.insertBefore(el, list.firstChild);
|
||||
|
||||
transfers[id] = { el: el, status: 'active' };
|
||||
panel.classList.add('filemgr-expanded');
|
||||
updateBadge();
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateProgress(id, percent) {
|
||||
var t = transfers[id];
|
||||
if (!t)
|
||||
return;
|
||||
t.el.querySelector('.filemgr-progress-fill').style.width = percent + '%';
|
||||
t.el.querySelector('.filemgr-item-status').textContent = percent + '%';
|
||||
}
|
||||
|
||||
function markDone(id, statusText) {
|
||||
var t = transfers[id];
|
||||
if (!t)
|
||||
return;
|
||||
t.status = 'done';
|
||||
t.el.classList.add('filemgr-done');
|
||||
t.el.querySelector('.filemgr-progress-fill').style.width = '100%';
|
||||
t.el.querySelector('.filemgr-item-status').textContent = statusText || 'Done';
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
function markFailed(id, statusText) {
|
||||
var t = transfers[id];
|
||||
if (!t)
|
||||
return;
|
||||
t.status = 'failed';
|
||||
t.el.classList.add('filemgr-failed');
|
||||
t.el.querySelector('.filemgr-item-status').textContent = statusText || 'Failed';
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
// Uploads a FileList/array of File objects, each as its own
|
||||
// concurrent XHR request, tracked here as separate panel entries.
|
||||
// job_id/subtype_id come from data attributes on the dropzone element
|
||||
// itself (set by jobdocs.page per the job/category being uploaded
|
||||
// into) rather than being hardcoded here, since this same script is
|
||||
// shared across every job's documents page.
|
||||
window.fileManagerUpload = function(files, jobId, subtypeId) {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
(function(file) {
|
||||
var id = addTransfer(file.name, 'upload');
|
||||
var xhr = new XMLHttpRequest();
|
||||
transfers[id].xhr = xhr;
|
||||
xhr.open('POST', '/jobdocs/api/upload');
|
||||
xhr.upload.addEventListener('progress', function(e) {
|
||||
if (e.lengthComputable)
|
||||
updateProgress(id, Math.round((e.loaded / e.total) * 100));
|
||||
});
|
||||
xhr.addEventListener('load', function() {
|
||||
var res = null;
|
||||
try { res = JSON.parse(xhr.responseText); } catch (e) {}
|
||||
if (xhr.status === 200 && res && res.ok) {
|
||||
markDone(id);
|
||||
document.dispatchEvent(new CustomEvent('jobdocs:changed'));
|
||||
} else {
|
||||
markFailed(id, (res && res.error) || 'Failed');
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', function() { markFailed(id); });
|
||||
var fd = new FormData();
|
||||
fd.append('name', file.name);
|
||||
fd.append('job_id', jobId);
|
||||
fd.append('subtype_id', subtypeId);
|
||||
fd.append('mimetype', file.type || 'application/octet-stream');
|
||||
fd.append('file_size', file.size);
|
||||
fd.append('filedata', file);
|
||||
xhr.send(fd);
|
||||
})(files[i]);
|
||||
}
|
||||
};
|
||||
|
||||
// Downloads a document by id via XHR (responseType 'blob') so it gets
|
||||
// real tracked progress, then hands the bytes to the browser's normal
|
||||
// save flow via a Blob URL + synthetic <a download> click.
|
||||
window.fileManagerDownload = function(id, name) {
|
||||
var xferId = addTransfer(name, 'download');
|
||||
var xhr = new XMLHttpRequest();
|
||||
transfers[xferId].xhr = xhr;
|
||||
xhr.open('GET', '/jobdocs/download?id=' + encodeURIComponent(id));
|
||||
xhr.responseType = 'blob';
|
||||
xhr.addEventListener('progress', function(e) {
|
||||
if (e.lengthComputable)
|
||||
updateProgress(xferId, Math.round((e.loaded / e.total) * 100));
|
||||
});
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status !== 200) {
|
||||
markFailed(xferId);
|
||||
return;
|
||||
}
|
||||
var blobUrl = URL.createObjectURL(xhr.response);
|
||||
var a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function() { URL.revokeObjectURL(blobUrl); }, 30000);
|
||||
markDone(xferId);
|
||||
});
|
||||
xhr.addEventListener('error', function() { markFailed(xferId); });
|
||||
xhr.send();
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', function() {
|
||||
panel.classList.toggle('filemgr-expanded');
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', function() {
|
||||
for (var k in transfers) {
|
||||
if (transfers[k].status !== 'active') {
|
||||
transfers[k].el.remove();
|
||||
delete transfers[k];
|
||||
}
|
||||
}
|
||||
showEmptyPlaceholderIfNeeded();
|
||||
});
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,83 @@
|
||||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; background: #eef2f5; color: #223; }
|
||||
.pageclass { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
.heading { background-color: #9AB; padding: 4px 10px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.menubar { background-color: #456; }
|
||||
table.menubar td { color: #def; font-size: 13px; padding: 6px 10px; }
|
||||
.baritem a, .baritemselected a { color: #def; text-decoration: none; font-weight: bold; }
|
||||
.baritemselected { background-color: #234; }
|
||||
.panel { background: #fff; }
|
||||
#content { padding: 10px; min-height: 60vh; transition: opacity 0.18s ease; opacity: 1; }
|
||||
#content.spa-fading { opacity: 0; }
|
||||
table { border-collapse: collapse; }
|
||||
table td, table th { padding: 4px 8px; }
|
||||
h1, h2 { color: #234; }
|
||||
.button, button, input[type=button] {
|
||||
background: #345; color: #def; border: none; border-radius: 4px;
|
||||
padding: 6px 14px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.button:hover, button:hover { background: #456; }
|
||||
.footer { text-align: center; padding: 10px; color: #789; font-size: 12px; }
|
||||
a { color: #234; }
|
||||
|
||||
.card { background: #f4f6f8; border: 1pt solid #ccc; border-radius: 6px; padding: 10px; margin-bottom: 10px; }
|
||||
.card table th { text-align: left; color: #567; width: 140px; }
|
||||
.sider { background: #fff; padding: 8px; margin-bottom: 8px; box-sizing: border-box; }
|
||||
.note_job { color: #567; padding-top: 6px !important; }
|
||||
|
||||
#filemgr-panel { position: fixed; bottom: 20px; right: 20px; z-index: 9000; font-size: 9pt; }
|
||||
#filemgr-toggle {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative; cursor: pointer;
|
||||
border: 1px solid #345; background: #234; color: #def;
|
||||
box-shadow: 0 0 0.6em rgba(35, 52, 68, 0.35);
|
||||
transition: box-shadow 0.2s ease, background 0.2s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
#filemgr-toggle:hover { background: #345; }
|
||||
#filemgr-toggle svg { width: 20px; height: 20px; }
|
||||
#filemgr-badge {
|
||||
position: absolute; top: -4px; right: -4px;
|
||||
min-width: 16px; height: 16px; padding: 0 3px; border-radius: 8px;
|
||||
background: #def; color: #234; font-size: 8pt; font-weight: bold;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#filemgr-badge:empty { display: none; }
|
||||
#filemgr-body {
|
||||
display: none; width: 320px; max-height: 400px; margin-bottom: 10px; margin-left: auto;
|
||||
border-radius: 8px; border: 1px solid #ccc; background: #fff;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3); flex-direction: column;
|
||||
}
|
||||
#filemgr-panel.filemgr-expanded #filemgr-body { display: flex; }
|
||||
#filemgr-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-bottom: 1pt solid #eee; color: #234; font-weight: 600;
|
||||
}
|
||||
#filemgr-clear { font-weight: normal; font-size: 8pt; color: #789; cursor: pointer; }
|
||||
#filemgr-clear:hover { color: #234; }
|
||||
#filemgr-list { overflow-y: auto; padding: 6px; }
|
||||
#filemgr-empty { color: #789; padding: 10px; text-align: center; }
|
||||
.filemgr-item { padding: 8px; border-radius: 6px; margin-bottom: 4px; background: #f4f6f8; }
|
||||
.filemgr-item-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.filemgr-item-icon { flex-shrink: 0; width: 14px; height: 14px; color: #345; }
|
||||
.filemgr-item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.filemgr-item-status { flex-shrink: 0; font-size: 8pt; color: #789; }
|
||||
.filemgr-item.filemgr-failed .filemgr-item-status { color: #c66; }
|
||||
.filemgr-item.filemgr-done .filemgr-item-status { color: #2a2; }
|
||||
.filemgr-progress-track { height: 4px; border-radius: 2px; background: #e5e5e5; overflow: hidden; }
|
||||
.filemgr-progress-fill { height: 100%; width: 0%; background: #345; transition: width 0.15s ease; }
|
||||
.filemgr-item.filemgr-done .filemgr-progress-fill { background: #2a2; }
|
||||
.filemgr-item.filemgr-failed .filemgr-progress-fill { background: #c66; }
|
||||
|
||||
#jobdocs-dropzone {
|
||||
border: 2px dashed #99a; border-radius: 8px; padding: 20px; text-align: center;
|
||||
color: #789; margin-top: 10px; cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
#jobdocs-dropzone:hover, #jobdocs-dropzone.jobdocs-dropzone-active { border-color: #345; background: #eef2f5; }
|
||||
#jobdocs-list { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
#jobdocs-list th, #jobdocs-list td { padding: 5px 8px; border-bottom: 1pt solid #eee; text-align: left; }
|
||||
#jobdocs-list th { color: #789; font-weight: normal; font-size: 9pt; }
|
||||
#jobdocs-list td a, #jobdocs-list td .jobdocs-action { color: #345; cursor: pointer; text-decoration: none; }
|
||||
#jobdocs-list td a:hover, #jobdocs-list td .jobdocs-action:hover { text-decoration: underline; }
|
||||
#jobdocs-empty { color: #789; padding: 8px 0; }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,346 @@
|
||||
(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.
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
Reference in new issue
Block a user