Add IMAP webmail tag, admin/opensource subpage tiers, store/cart, htmleditor, uploads

New __imap*/__smtpsend/__eval Jet tags give templates real IMAP
(list/message/flag/delete/move/search/folders/accounts) and SMTP-send
capability, backing the new /email webmail client (email.jet/
email.menu, assets/images/email.js + newemail.js) and its
mainpagetemplate.jet-based page shell. Most of this tag family's
source was already being built into the live site (Tag.cpp already
registered __imap/__imaplist/__imapmessage/__imapflag/__imapdelete/
__eval) but had never actually been committed; this also registers
and commits the remaining five (__imapmove/__imapfolders/
__imapsearch/__smtpsend/__imapaccounts).

admin.page/admin.menu gain a real subpage tier (admin-email,
admin-newemail, admin-invoices, admin-products, admin-users) replacing
several previously top-level pages, mirrored by a similar
opensource.page subpage tier (opensource-overview/asm1802/barantmail/
jet/serverframework/synthmax) - old top-level pages retired via git
rename (asm1802.page, jet.page, serverframework.page,
admin_invoices.page); barantmail.page is dropped outright, folded into
opensource-barantmail.subpage.

New storefront (store.page, store-createstore.subpage,
barantstore.page) and cart flow (cart.jet, updatecartqty.proc,
removecartitem.proc, editproduct.page/selectproduct.proc/
update_product.proc for inventory maintenance against the barant
database).

New document upload/download manager (documents.jet, documents_api.jet,
documents_upload.proc, documents_delete.proc,
assets/images/documents.js + documents-page.js) with a persistent
floating panel, and a rich-text page editor (htmleditor.jet/.page,
createhtmleditor.proc, savehtmleditor.proc, assets/images/htmleditor.js
+ its icon/logo assets).

SESSIONCTL.tag: login now redirects back to the originally-requested
page instead of always landing on /account ($[start:0] vs $[start]
variable-name mismatch).

main.css/menu.bar/invoices.menu/jet_overview.section/login.menu:
styling and navigation updates for the above; new site assets (fonts,
images, audio) referenced by them.

jetc/build-assets.sh, list_jet_files.sh, lowercase_jet.py,
strip_fragments.py: small dev tooling used while building the above.
This commit is contained in:
Brad Arant committed 2026-09-06 04:14:00 +00:00
1 parent b0cadc3f08
commit c1491b044b
115 files changed
+6174 -137

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,96 @@
(function() {
var dropzone = document.getElementById('documents-dropzone');
var fileInput = document.getElementById('documents-file-input');
var listBody = document.getElementById('documents-list-body');
if (!dropzone || !listBody)
return;
function escapeHtml(s) {
var div = document.createElement('div');
div.textContent = s == null ? '' : String(s);
return div.innerHTML;
}
function post(path, params) {
return fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params || {}).toString()
}).then(function(r) { return r.json(); });
}
function loadList() {
post('/documents/api/list', {}).then(function(res) {
if (!res.ok || !res.documents.length) {
listBody.innerHTML = '<div id="documents-empty">No documents yet - upload your first one above.</div>';
return;
}
var html = '<table id="documents-list"><tr><th>Name</th><th>Uploaded</th><th></th><th></th></tr>';
for (var i = 0; i < res.documents.length; i++) {
var doc = res.documents[i];
html += '<tr>' +
'<td>' + escapeHtml(doc.name) + '</td>' +
'<td>' + escapeHtml(doc.created) + '</td>' +
'<td><span class="documents-action" data-action="download" data-id="' + doc.id + '" data-name="' + escapeHtml(doc.name) + '">Download</span></td>' +
'<td><span class="documents-action" data-action="delete" data-id="' + doc.id + '">Delete</span></td>' +
'</tr>';
}
html += '</table>';
listBody.innerHTML = html;
var actions = listBody.querySelectorAll('.documents-action');
for (var j = 0; j < actions.length; j++) {
actions[j].addEventListener('click', function() {
var id = this.getAttribute('data-id');
if (this.getAttribute('data-action') === 'download') {
window.fileManagerDownload && window.fileManagerDownload(id, this.getAttribute('data-name'));
} else {
post('/documents/api/delete', { id: id }).then(function(res) {
if (res.ok)
loadList();
});
}
});
}
}).catch(function() {
listBody.innerHTML = '<div id="documents-empty">Could not load your documents.</div>';
});
}
function uploadFiles(files) {
if (!files || !files.length)
return;
if (window.fileManagerUpload)
window.fileManagerUpload(files);
}
dropzone.addEventListener('click', function() {
fileInput.click();
});
fileInput.addEventListener('change', function() {
uploadFiles(fileInput.files);
fileInput.value = '';
});
dropzone.addEventListener('dragover', function(e) {
e.preventDefault();
dropzone.classList.add('documents-dropzone-active');
});
dropzone.addEventListener('dragleave', function() {
dropzone.classList.remove('documents-dropzone-active');
});
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.classList.remove('documents-dropzone-active');
uploadFiles(e.dataTransfer.files);
});
// The site-wide file manager (documents.js, loaded once via
// mainpagetemplate.jet) dispatches this after a successful upload -
// listening here (rather than only refreshing right after our own
// uploadFiles() calls) also picks up uploads that were still running
// when this page was navigated to via spa.js.
document.addEventListener('documents:changed', loadList);
loadList();
})();
+178
View File
@@ -0,0 +1,178 @@
// Site-wide file transfer manager - loaded once via mainpagetemplate.jet
// (see its own script tag, outside #content) so the floating panel and
// any in-progress transfer survive spa.js's own #content-only swaps
// when navigating to a different page mid-upload/download.
(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 - the browser's own HTTP connection pooling
// runs several at once, tracked here as separate panel entries with
// independent progress via xhr.upload's own progress event (fetch()
// has no equivalent for the request/upload side, only the response).
window.fileManagerUpload = function(files) {
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', '/documents/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('documents: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('filedata', file);
xhr.send(fd);
})(files[i]);
}
};
// Downloads a document by id via XHR (responseType 'blob') instead
// of a plain <a href> so it gets real tracked progress in the same
// panel as uploads, then hands the fetched 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', '/documents/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: 4.9 KiB

+778
View File
@@ -0,0 +1,778 @@
(function() {
var app = document.getElementById('email-app');
if (!app) return;
// Small inline-SVG icon set for the toolbar/action buttons below -
// stroke="currentColor" so each one just inherits its <button>'s own
// text color (the site's gold theme, see .icon-btn in email.page's
// own <style> block), no separate image assets or color variants to
// keep in sync with the theme.
var ICONS = {
search:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<circle cx="11" cy="11" r="7"/>' +
'<path d="M21 21l-4.35-4.35"/>' +
'</svg>',
compose:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M4 6h13a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/>' +
'<path d="M2.5 7.5 12 14l9.5-6.5"/>' +
'<path d="M18.5 3.5v5M16 6h5"/>' +
'</svg>',
refresh:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M20 11a8 8 0 0 0-14.6-4.4M4 4v5h5"/>' +
'<path d="M4 13a8 8 0 0 0 14.6 4.4M20 20v-5h-5"/>' +
'</svg>',
disconnect:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>' +
'<path d="M16 17l5-5-5-5"/>' +
'<path d="M21 12H9"/>' +
'</svg>',
flag:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M5 3v18"/>' +
'<path d="M5 4h12l-3 4.5L17 13H5"/>' +
'</svg>',
trash:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M4 7h16"/>' +
'<path d="M9 7V4h6v3"/>' +
'<path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"/>' +
'<path d="M10 11v6M14 11v6"/>' +
'</svg>'
};
// Matches the exact, case-sensitive names the mailbox itself reports
// via IMAP LIST (confirmed live: "Inbox", "Spam", "Trash" - only
// INBOX is specially case-insensitive per RFC 3501, the other two
// are not, so this has to be exact) - see email.menu's own folder
// links, which pass these same three names to emailSelectFolder().
var currentFolder = 'Inbox';
var currentUid = null;
// null = showing the normal folder listing; a non-empty string =
// showing search results instead (see performSearch()/clearSearch()
// and loadMessageList()'s own branch on this).
var currentSearchQuery = null;
// The array last handed to renderMessageList() - kept so openMessage()
// can flip a single message's .seen flag locally and recompute the
// toolbar's unread/total count without a round trip.
var currentMessages = [];
// email.menu (the site's own left-side #panelmenu, a static include
// living outside #email-app/#content entirely - see mainpagetemplate
// .jet's own panelmenu wiring) renders its folder links against this
// global rather than anything scoped to this closure, since it has
// no other way to reach into a same-page IMAP client without a full
// page navigation (which the rest of this client deliberately avoids
// for every other interaction too - see the module doc comment on
// post() below... [see loadMessageList/openMessage]). Guarded with
// `window.emailSelectFolder &&` at each call site in email.menu so a
// click before email.js has loaded (or before a mailbox is even
// connected) is a harmless no-op instead of a broken link.
window.emailSelectFolder = function(folder) {
if (!document.getElementById('email-client') || (folder === currentFolder && !currentSearchQuery))
return;
currentFolder = folder;
currentUid = null;
// A folder click always means "show me this folder, plain" - drops
// out of search mode even if the folder didn't actually change,
// which is exactly how a user would expect to back out of a
// search result view.
currentSearchQuery = null;
var searchInput = document.getElementById('email-search-input');
if (searchInput) searchInput.value = '';
syncActiveFolder();
var pane = document.getElementById('email-reading-pane');
if (pane) pane.innerHTML = '<div id="email-empty-state">Select a message to read it.</div>';
loadMessageList();
};
function syncActiveFolder() {
var links = document.querySelectorAll('.email-folder-link');
for (var i = 0; i < links.length; i++)
links[i].classList.toggle('email-folder-selected', links[i].getAttribute('data-folder') === currentFolder);
}
function escapeHtml(s) {
var div = document.createElement('div');
div.textContent = s == null ? '' : String(s);
return div.innerHTML;
}
function post(path, params) {
return fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params || {}).toString()
}).then(function(r) { return r.json(); });
}
// Matches spa.js's own delay() exactly - a separate closure here
// since this file has no dependency on spa.js at all, not worth
// sharing just for this.
function delay(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
function showConnectForm(errorText) {
app.innerHTML =
'<div id="email-connect-form">' +
'<h3>Connect to Mailbox</h3>' +
'<input type="text" id="email-user-input" placeholder="you@barant.com" autocomplete="username">' +
'<input type="password" id="email-password-input" placeholder="Password" autocomplete="current-password">' +
'<button type="button" id="email-connect-btn">Connect</button>' +
'<div id="email-connect-error">' + (errorText ? escapeHtml(errorText) : '') + '</div>' +
'</div>';
wireConnectForm();
}
function showClientView(userEmail) {
app.innerHTML =
'<div id="email-client">' +
'<div id="email-toolbar">' +
'<span id="email-connected-as">' + escapeHtml(userEmail) + '</span>' +
'<span id="email-list-count"></span>' +
'<span id="email-toolbar-actions">' +
'<span id="email-search-box">' +
'<input type="text" id="email-search-input" placeholder="Search mail&hellip;">' +
'<button type="button" id="email-search-btn" class="icon-btn" title="Search">' + ICONS.search + '</button>' +
'</span>' +
'<button type="button" id="email-compose-btn" class="icon-btn" title="New Mail">' + ICONS.compose + '</button>' +
'<button type="button" id="email-refresh-btn" class="icon-btn" title="Refresh">' + ICONS.refresh + '</button>' +
'<button type="button" id="email-disconnect-btn" class="icon-btn" title="Disconnect">' + ICONS.disconnect + '</button>' +
'</span>' +
'</div>' +
'<div id="email-layout">' +
'<div id="email-list"></div>' +
'<div id="email-reading-pane"><div id="email-empty-state">Select a message to read it.</div></div>' +
'</div>' +
'</div>';
wireClientView();
wireAddAccountForm();
syncActiveFolder();
loadMessageList();
loadAccounts();
}
function wireConnectForm() {
var btn = document.getElementById('email-connect-btn');
var userInput = document.getElementById('email-user-input');
var passInput = document.getElementById('email-password-input');
var errorDiv = document.getElementById('email-connect-error');
function submit() {
errorDiv.textContent = '';
btn.disabled = true;
post('/email/connect', { user: userInput.value, password: passInput.value })
.then(function(res) {
btn.disabled = false;
if (res.ok) {
showClientView(userInput.value);
} else {
errorDiv.textContent = res.error || 'Could not connect.';
}
})
.catch(function() {
btn.disabled = false;
errorDiv.textContent = 'Connection request failed.';
});
}
btn.addEventListener('click', submit);
passInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') submit();
});
}
function wireClientView() {
// Navigates via a synthetic <a> click rather than
// window.location.href so spa.js's own document-level click
// listener intercepts it and does its normal fade transition,
// exactly as if a real link had been clicked.
document.getElementById('email-compose-btn').addEventListener('click', function() {
var a = document.createElement('a');
a.href = '/admin/newemail';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
document.getElementById('email-refresh-btn').addEventListener('click', function() {
loadMessageList();
});
document.getElementById('email-disconnect-btn').addEventListener('click', function() {
// Disconnecting only ever drops the ACTIVE account now (see
// /email/disconnect's own doc comment in email.jet) - if another
// already-connected account remains, res.active names it and the
// view just switches there instead of falling back to the
// connect form.
post('/email/disconnect', {}).then(function(res) {
if (res.active) {
resetViewForActiveAccount(res.active);
} else {
currentFolder = 'Inbox';
currentUid = null;
syncActiveFolder();
showConnectForm();
}
});
});
// Matches sender, subject, or body content in whatever folder is
// currently open (see __imap.cpp's searchMessages()) - an empty
// query is treated as "leave search mode" rather than sent to the
// server as a real search, since an empty needle would otherwise
// just match everything anyway (see __IMAP_SEARCH.cpp's own
// containsCI()) - clearer to just fall back to the plain listing.
function runSearch() {
var input = document.getElementById('email-search-input');
var query = input.value.trim();
currentSearchQuery = query === '' ? null : query;
currentUid = null;
document.getElementById('email-reading-pane').innerHTML =
'<div id="email-empty-state">Select a message to read it.</div>';
loadMessageList();
}
document.getElementById('email-search-btn').addEventListener('click', runSearch);
document.getElementById('email-search-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter')
runSearch();
});
}
function updateListCount(messages) {
var countEl = document.getElementById('email-list-count');
if (!countEl)
return;
if (!messages.length) {
countEl.textContent = '';
return;
}
var unread = 0;
for (var i = 0; i < messages.length; i++) {
if (!messages[i].seen)
unread++;
}
countEl.textContent = '(' + unread + '/' + messages.length + ')';
}
function renderMessageList(messages) {
var list = document.getElementById('email-list');
currentMessages = messages;
updateListCount(messages);
if (!messages.length) {
list.innerHTML = '<div id="email-empty-state">No messages.</div>';
return;
}
var html = '';
for (var i = 0; i < messages.length; i++) {
var m = messages[i];
html += '<div class="email-row' + (m.seen ? '' : ' unread') +
(m.uid === currentUid ? ' selected' : '') + '" data-uid="' + m.uid + '">' +
'<div class="email-row-info">' +
'<div class="email-from">' + escapeHtml(m.from) + '</div>' +
'<div class="email-subject">' + escapeHtml(m.subject || '(no subject)') + '</div>' +
'<div class="email-date">' + escapeHtml(m.date) + '</div>' +
'</div>' +
'<button type="button" class="icon-btn email-row-trash-btn" data-uid="' + m.uid + '" title="Move to Trash">' + ICONS.trash + '</button>' +
'</div>';
}
list.innerHTML = html;
var rows = list.querySelectorAll('.email-row');
for (var j = 0; j < rows.length; j++) {
rows[j].addEventListener('click', function() {
openMessage(parseInt(this.getAttribute('data-uid'), 10));
});
}
// Each row's own trash button - stopPropagation so it doesn't also
// trigger the row's own click-to-open handler just above.
var trashBtns = list.querySelectorAll('.email-row-trash-btn');
for (var k = 0; k < trashBtns.length; k++) {
trashBtns[k].addEventListener('click', function(e) {
e.stopPropagation();
trashMessage(parseInt(this.getAttribute('data-uid'), 10));
});
}
// Whatever caused this particular render (initial connect, a
// folder switch, the refresh button with nothing open) left
// currentUid null - open the top message rather than leave the
// reading pane sitting empty. trashMessage() deliberately sets
// currentUid to the row it's already decided to select BEFORE
// reloading the list (see its own comment), specifically so this
// check sees a real, already-correct uid and stays out of the way
// instead of racing it to open something different.
if (currentUid === null)
openMessage(messages[0].uid);
}
// Moves a message into the Trash folder (server-side: COPY there,
// then \Deleted+EXPUNGE from the original folder - see __imap.cpp's
// moveMessage()) rather than permanently removing it, the same
// "delete means trash it" convention the reading pane's own Delete
// button (a real, permanent expunge) deliberately does NOT follow -
// that one stays as-is; this is a separate, softer action reachable
// straight from the list.
// Total time the .email-row-removing CSS animation takes to finish
// (see email.page's own <style> block) - the opacity fade runs with
// no delay for FADE_MS, then the height/padding/margin/border
// collapse (what actually slides the rows below it up) runs for
// COLLAPSE_MS starting COLLAPSE_DELAY_MS in. Kept here as the single
// source of truth for how long trashMessage() waits before swapping
// in the server's real, post-trash list - duplicated as literal
// numbers in the CSS transition properties themselves, since CSS has
// no way to reference a JS constant, so keep those two in sync by
// hand if either ever changes.
var REMOVE_FADE_MS = 200;
var REMOVE_COLLAPSE_DELAY_MS = 200;
var REMOVE_COLLAPSE_MS = 220;
// Plays the dissolve-then-collapse removal animation on one row in
// place, resolving once it's fully finished (safe to call with a
// null row - resolves immediately, for when the trashed uid's row
// isn't actually in the currently-rendered list for some reason).
function animateRowRemoval(row) {
if (!row)
return Promise.resolve();
var height = row.getBoundingClientRect().height;
row.style.height = height + 'px';
// Forces the browser to register the explicit pixel height above
// as a real, already-applied style before the next line changes
// it again - without this the two height changes collapse into a
// single layout pass and no transition plays at all.
row.offsetHeight;
row.classList.add('email-row-removing');
return new Promise(function(resolve) {
setTimeout(resolve, REMOVE_FADE_MS + REMOVE_COLLAPSE_DELAY_MS + REMOVE_COLLAPSE_MS);
});
}
// Only the trashed row itself animates - every other row just stays
// exactly where it is and shifts up as an ordinary side effect of
// the trashed row's own height collapsing to 0 (real document-flow
// reflow, not a separate animation applied to them). The list is
// still reloaded from the server afterward (flags/counts could have
// changed for reasons unrelated to this one trash action), but with
// loadMessageList(true) - skipping ITS OWN whole-list fade, since by
// the time it swaps in fresh markup the trashed row has already
// visually shrunk to nothing and every other row is already sitting
// in its final position; fading the whole list on top of that would
// just be a second, redundant flash.
function trashMessage(uid) {
var rows = document.querySelectorAll('.email-row');
var index = -1;
var trashedRow = null;
for (var i = 0; i < rows.length; i++) {
if (parseInt(rows[i].getAttribute('data-uid'), 10) === uid) {
index = i;
trashedRow = rows[i];
break;
}
}
var nextUid = null;
if (index !== -1) {
if (index + 1 < rows.length)
nextUid = parseInt(rows[index + 1].getAttribute('data-uid'), 10);
else if (index - 1 >= 0)
nextUid = parseInt(rows[index - 1].getAttribute('data-uid'), 10);
}
Promise.all([
animateRowRemoval(trashedRow),
post('/email/trash', { folder: currentFolder, uid: uid })
]).then(function() {
// Set BEFORE the reload, not after - renderMessageList()'s own
// auto-select-first-when-nothing's-selected check runs partway
// through loadMessageList(true) below, and needs to already see
// the real target here (or genuinely null, if the list is about
// to be empty) rather than a stale null that would make it pick
// the top row instead and race this function's own openMessage()
// call just below.
if (currentUid === uid)
currentUid = nextUid;
loadMessageList(true).then(function() {
if (nextUid !== null)
openMessage(nextUid);
else
document.getElementById('email-reading-pane').innerHTML =
'<div id="email-empty-state">Select a message to read it.</div>';
});
});
}
// Same fade-out/fetch-concurrently/fade-in shape as openMessage() and
// spa.js's own navigate() - every list refresh (folder switch,
// refresh button) gets the same smooth transition instead of a flash
// of "Loading..." text followed by an abrupt content swap. Pass
// skipFade=true to skip that whole-list transition and just quietly
// re-render in place - used only right after trashMessage()'s own
// per-row removal animation has already provided the visual
// transition, so this reload doesn't also blink the entire list.
function loadMessageList(skipFade) {
var list = document.getElementById('email-list');
if (!list)
return Promise.resolve();
if (!skipFade)
list.classList.add('email-fading');
// Search results use the identical JSON shape as a normal list
// (see __imap.cpp's shared parseSummaryLines()), so this is the
// only place that needs to know search mode even exists -
// renderMessageList() below has no idea which one produced what
// it's rendering.
var request = currentSearchQuery
? post('/email/search', { folder: currentFolder, query: currentSearchQuery })
: post('/email/list', { folder: currentFolder });
return Promise.all([
request,
skipFade ? true : delay(180)
])
.then(function(results) {
var res = results[0];
if (!res.ok && res.error)
list.innerHTML = '<div id="email-empty-state">' + escapeHtml(res.error) + '</div>';
else
renderMessageList(Array.isArray(res) ? res : []);
// Every place that reloads the list (connect, folder switch,
// refresh, post-trash) is a place the unseen counts next to
// each folder name could plausibly have changed too - one
// hook here covers all of them rather than needing a separate
// call at each individual call site.
loadFolderCounts();
if (!skipFade)
list.classList.remove('email-fading');
})
.catch(function() {
list.innerHTML = '<div id="email-empty-state">Could not load messages.</div>';
if (!skipFade)
list.classList.remove('email-fading');
});
}
// Populates the unread-count badge next to each folder name in
// email.menu's own static markup (a real DOM lookup by
// data-folder-count, not anything rendered by this file - email.menu
// is a separate include living in #panelmenu, outside #content
// entirely). Uses IMAP STATUS server-side (see __imap.cpp's
// folderCounts()), not a real FETCH, so this is cheap enough to call
// on every list reload without it being a heavy extra round trip.
// Silently does nothing on error - a stale/missing count badge is a
// cosmetic problem, not worth surfacing as a user-visible failure.
function loadFolderCounts() {
post('/email/folders', {}).then(function(res) {
if (res.error)
return;
var folders = ['Inbox', 'Spam', 'Trash'];
for (var i = 0; i < folders.length; i++) {
var f = folders[i];
var span = document.querySelector('.email-folder-count[data-folder-count="' + f + '"]');
if (span && res[f])
span.textContent = res[f].messages > 0 ? (res[f].unseen + '/' + res[f].messages) : '';
}
}).catch(function() {});
}
function loadAccounts() {
post('/email/accounts', {}).then(function(res) {
var list = document.getElementById('email-account-list');
if (!list || !res.accounts)
return;
var html = '';
for (var i = 0; i < res.accounts.length; i++) {
var user = res.accounts[i];
html += '<a href="javascript:void(0);" class="email-account-link' +
(user === res.active ? ' email-account-selected' : '') + '" data-user="' +
escapeHtml(user) + '">' + escapeHtml(user) + '</a>';
}
list.innerHTML = html;
var links = list.querySelectorAll('.email-account-link');
for (var j = 0; j < links.length; j++) {
links[j].addEventListener('click', function() {
var user = this.getAttribute('data-user');
if (!this.classList.contains('email-account-selected'))
switchAccount(user);
});
}
}).catch(function() {});
}
// Shared by switchAccount() and the add-account form's own success
// path (which is already connected/active server-side by the time
// /email/connect returns ok - no reason to also round-trip through
// switchaccount right after) - resets the view to a fresh Inbox for
// whichever account just became active, same as a brand new connect.
function resetViewForActiveAccount(user) {
currentFolder = 'Inbox';
currentUid = null;
currentSearchQuery = null;
var searchInput = document.getElementById('email-search-input');
if (searchInput) searchInput.value = '';
var connectedAs = document.getElementById('email-connected-as');
if (connectedAs) connectedAs.textContent = user;
syncActiveFolder();
var pane = document.getElementById('email-reading-pane');
if (pane) pane.innerHTML = '<div id="email-empty-state">Select a message to read it.</div>';
loadMessageList();
loadAccounts();
}
function switchAccount(user) {
post('/email/switchaccount', { user: user }).then(function(res) {
if (res.ok)
resetViewForActiveAccount(user);
}).catch(function() {});
}
window.emailShowAddAccount = function() {
var form = document.getElementById('email-add-account-form');
var link = document.getElementById('email-add-account-link');
if (!form)
return;
form.style.display = 'flex';
if (link) link.style.display = 'none';
var userInput = document.getElementById('email-add-account-user');
if (userInput) userInput.focus();
};
function wireAddAccountForm() {
var btn = document.getElementById('email-add-account-btn');
var userInput = document.getElementById('email-add-account-user');
var passInput = document.getElementById('email-add-account-password');
var errorDiv = document.getElementById('email-add-account-error');
if (!btn)
return;
function submit() {
errorDiv.textContent = '';
btn.disabled = true;
post('/email/connect', { user: userInput.value, password: passInput.value })
.then(function(res) {
btn.disabled = false;
if (!res.ok) {
errorDiv.textContent = res.error || 'Could not connect.';
return;
}
var newUser = userInput.value;
userInput.value = '';
passInput.value = '';
document.getElementById('email-add-account-form').style.display = 'none';
document.getElementById('email-add-account-link').style.display = 'block';
resetViewForActiveAccount(newUser);
})
.catch(function() {
btn.disabled = false;
errorDiv.textContent = 'Connection request failed.';
});
}
btn.addEventListener('click', submit);
passInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') submit();
});
}
function renderMessage(msg) {
var pane = document.getElementById('email-reading-pane');
pane.innerHTML =
'<div class="email-header">' +
'<div><b>From:</b> ' + escapeHtml(msg.from) + '</div>' +
'<div><b>To:</b> ' + escapeHtml(msg.to) + '</div>' +
'<div><b>Subject:</b> ' + escapeHtml(msg.subject || '(no subject)') + '</div>' +
'<div><b>Date:</b> ' + escapeHtml(msg.date) + '</div>' +
'</div>' +
'<div class="email-body-container"></div>' +
'<div class="email-actions">' +
'<button type="button" id="email-flag-btn" class="icon-btn' + (msg.flagged ? ' icon-btn-active' : '') +
'" title="' + (msg.flagged ? 'Unflag' : 'Flag') + '">' + ICONS.flag + '</button>' +
'<button type="button" id="email-delete-btn" class="icon-btn" title="Delete">' + ICONS.trash + '</button>' +
'</div>';
// HTML-formatted mail renders in a sandboxed iframe, never via
// innerHTML into this page directly - the body content is whatever
// a third party sent, and this page runs as the logged-in user
// with real session cookies. `allow-same-origin` alone (no
// `allow-scripts`, no `allow-forms`, no `allow-popups`, no
// `allow-top-navigation`) still blocks every dangerous thing
// untrusted HTML could do - scripts simply do not run without
// allow-scripts, full stop, regardless of same-origin access - it
// ONLY additionally lets THIS PAGE read the iframe's own rendered
// content height back out (contentDocument), which the very first,
// fully-opaque `sandbox=""` version couldn't do at all. That's
// needed to size the iframe to its actual content below, rather
// than a fixed height with its own internal scrollbar sitting
// inside the reading pane's own scrollbar - two nested scrollbars
// for one piece of content, confusing regardless of how tall the
// fixed box happened to be. Building it via createElement + the
// .srcdoc PROPERTY (not a string-concatenated HTML attribute)
// sidesteps any need to hand-escape the content for attribute-
// safety - the DOM API does that correctly on its own. Plain-text
// mail keeps using .textContent, same reasoning.
var bodyContainer = pane.querySelector('.email-body-container');
if (msg.isHtml) {
var frame = document.createElement('iframe');
frame.className = 'email-body-frame';
frame.setAttribute('sandbox', 'allow-same-origin');
frame.setAttribute('scrolling', 'no');
frame.addEventListener('load', function() {
try {
var doc = frame.contentDocument;
if (doc && doc.documentElement)
frame.style.height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight) + 'px';
} catch (e) {
// Left at its CSS fallback height - still shows the content,
// just with its own scrollbar if that fallback runs short.
}
});
frame.srcdoc = msg.body;
bodyContainer.appendChild(frame);
} else {
var textDiv = document.createElement('div');
textDiv.className = 'email-body';
textDiv.textContent = msg.body;
bodyContainer.appendChild(textDiv);
}
document.getElementById('email-flag-btn').addEventListener('click', function() {
post('/email/flag', {
folder: currentFolder,
uid: msg.uid,
flag: '\\Flagged',
set: msg.flagged ? 'no' : 'yes'
}).then(function() {
loadMessageList();
openMessage(msg.uid);
});
});
document.getElementById('email-delete-btn').addEventListener('click', function() {
if (!window.confirm('Delete this message?')) return;
post('/email/delete', { folder: currentFolder, uid: msg.uid }).then(function() {
currentUid = null;
document.getElementById('email-reading-pane').innerHTML =
'<div id="email-empty-state">Select a message to read it.</div>';
loadMessageList();
});
});
}
// Opening a message is the reading pane's own concern - it fetches
// and renders itself via /email/message and nothing else. It used to
// also call loadMessageList() afterward (re-fetching and
// re-rendering the ENTIRE list, every single time a message was
// opened) just to pick up the \Seen flag the server already set as a
// side effect of fetching the body - that's a locally-known update
// (we know which uid was just opened), applied straight to the
// already-rendered row below instead of paying for a second full
// round trip and list re-render on every click.
function openMessage(uid) {
currentUid = uid;
var pane = document.getElementById('email-reading-pane');
var rows = document.querySelectorAll('.email-row');
var wasUnread = false;
for (var i = 0; i < rows.length; i++) {
var isTarget = parseInt(rows[i].getAttribute('data-uid'), 10) === uid;
rows[i].classList.toggle('selected', isTarget);
if (isTarget) {
wasUnread = rows[i].classList.contains('unread');
// Marked read the instant it's selected, not after the fetch
// resolves - fetchMessage() marks it \Seen server-side anyway
// (BODY[] rather than BODY.PEEK[]), so there's nothing
// optimistic about this beyond removing the visual lag.
rows[i].classList.remove('unread');
}
}
if (wasUnread) {
for (var k = 0; k < currentMessages.length; k++) {
if (currentMessages[k].uid === uid)
currentMessages[k].seen = true;
}
updateListCount(currentMessages);
loadFolderCounts();
}
// Same fade-out/fetch-concurrently/fade-in shape as spa.js's own
// navigate() - a minimum-duration fade (via delay()) runs alongside
// the fetch rather than after it, so a fast response still shows
// the full transition instead of a flash, and a slow one doesn't
// wait any longer than the fetch itself already takes.
pane.classList.add('email-fading');
Promise.all([
post('/email/message', { folder: currentFolder, uid: uid }),
delay(180)
])
.then(function(results) {
var res = results[0];
if (!res.ok && res.error) {
pane.innerHTML = '<div id="email-empty-state">' + escapeHtml(res.error) + '</div>';
} else {
renderMessage(res);
}
pane.classList.remove('email-fading');
})
.catch(function() {
pane.innerHTML = '<div id="email-empty-state">Could not load message.</div>';
pane.classList.remove('email-fading');
});
}
// Up/Down moves through the message list and opens whatever it
// lands on - registered once, unconditionally, at load time (same
// always-on-but-internally-guarded shape as spa.js's own document-
// level click listener) rather than added/removed each time the
// client view is (re)shown, so reconnecting never stacks up
// duplicate handlers. Bails out immediately whenever the client
// view isn't actually showing (the connect form has no .email-row
// elements at all) or the keystroke is really meant for a form
// field (a future compose/search input, say) rather than list
// navigation.
document.addEventListener('keydown', function(e) {
var active = document.activeElement;
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA'))
return;
// Delete trashes whichever message is currently open, the same
// action (and the same dissolve-and-slide-up animation) as
// clicking that row's own trash button - see trashMessage().
if (e.key === 'Delete') {
if (!document.getElementById('email-client') || currentUid === null)
return;
e.preventDefault();
trashMessage(currentUid);
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')
return;
var rows = document.querySelectorAll('.email-row');
if (!rows.length)
return;
e.preventDefault();
var index = -1;
for (var i = 0; i < rows.length; i++) {
if (parseInt(rows[i].getAttribute('data-uid'), 10) === currentUid) {
index = i;
break;
}
}
if (e.key === 'ArrowDown')
index = (index === -1) ? 0 : Math.min(index + 1, rows.length - 1);
else
index = (index === -1) ? 0 : Math.max(index - 1, 0);
rows[index].scrollIntoView({ block: 'nearest' });
openMessage(parseInt(rows[index].getAttribute('data-uid'), 10));
});
if (document.getElementById('email-connect-form')) {
wireConnectForm();
} else if (document.getElementById('email-client')) {
wireClientView();
wireAddAccountForm();
syncActiveFolder();
loadMessageList();
loadAccounts();
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -0,0 +1,678 @@
function b64encode(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function submitNewLetter(form) {
var name = document.getElementById('filename_display').value;
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
alert('File name may only contain letters, numbers, underscores and hyphens.');
return false;
}
form.filename.value = name;
return true;
}
var mainpage;
var control;
var result;
var mousedownx;
var mousedowny;
var dragHint = "move";
var data;
var showGrid = false;
var snapToGrid = false;
var gridSize = 10;
var selected;
var firstSelected;
var itemparameters;
var grid;
var context;
var draggingHTML;
var window_dragging = false;
const undo = [];
var undoIndex = 0;
var lastRedo;
var mode = 'MOUSEUP';
var textobj;
var lastdragover;
var pendingDragover;
var dropIndicator;
var sequence = 0;
function showDropIndicator(target) {
if(!dropIndicator) {
dropIndicator = document.createElement('div');
dropIndicator.style.position = 'fixed';
dropIndicator.style.height = '4px';
dropIndicator.style.background = '#0088ff';
dropIndicator.style.zIndex = '99999';
dropIndicator.style.pointerEvents = 'none';
document.body.appendChild(dropIndicator);
}
let rect = target.getBoundingClientRect();
dropIndicator.style.left = rect.left + 'px';
dropIndicator.style.width = rect.width + 'px';
dropIndicator.style.top = (rect.top - 2) + 'px';
dropIndicator.style.display = 'block';
}
function hideDropIndicator() {
if(dropIndicator) {
dropIndicator.style.display = 'none';
}
}
function saveUndo() {
undo[undoIndex++] = mainpage.innerHTML;
}
function do_undo() {
if(undoIndex > 0) {
mainpage.innerHTML = undo[--undoIndex];
}
return true;
}
function do_redo() {
return true;
}
function init() {
mainpage = document.getElementById("mainpage");
data = document.getElementById("data");
itemparameters = document.getElementById("itemparameters");
var gridsize = document.getElementById("gridsize");
gridsize.value = gridSize;
grid = document.getElementById("grid");
context = grid.getContext("2d");
drawGrid();
buildToolbar();
document.designMode = 'off';
}
function getLayerMap(element) {
var map = '';
if(element.id != 'mainpage') {
var curelem = element;
while(curelem.id != 'mainpage') {
map = '&lt;' + curelem.nodeName + '&gt;' + map;
curelem = curelem.parentNode;
}
}
document.getElementById('map').innerHTML = map;
}
function jetEscape(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildToolbar() {
let tbitems = [
{
"label": "&lt;div>",
"html": "<div id=\"newitem\" contenteditable=\"false\" style=\"width: 300px; height: 200px; background-color: #789;\"></div>"
},
{
"label": "&lt;input>",
"html": "<input id=\"newitem\" type=\"text\">"
},
{
"label": "&lt;button>",
"html": "<button id=\"newitem\" type=\"button\">Label</button>"
},
{
"label": "&lt;password>",
"html": "<input id=\"newitem\" type=\"password\">"
},
{
"label": "&lt;checkbox>",
"html": "<input id=\"newitem\" type=\"checkbox\">"
},
{
"label": "&lt;label>",
"html": "<label style=\"color: #000f;\" id=\"newitem\">Label text</label>"
},
{
"label": "&lt;p>",
"html": "<p style=\"color: #000f;\" id=\"newitem\">Type Here</p>"
},
{
"label": "&lt;hr>",
"html": "<hr id=\"newitem\">"
},
{
"label": "Jet: " + "$" + "[var]",
"html": "<code id=\"newitem\" contenteditable=\"true\" style=\"display:inline-block; background:#eef; border:1px dashed #99c; padding:4px; font-family:monospace; white-space:pre;\">" + jetEscape("$" + "[varname]") + "</code>"
},
{
"label": "Jet: &lt;set&gt;",
"html": "<code id=\"newitem\" contenteditable=\"true\" style=\"display:inline-block; background:#eef; border:1px dashed #99c; padding:4px; font-family:monospace; white-space:pre;\">" + jetEscape("<set name=\"varname\" value=\"\">") + "</code>"
},
{
"label": "Jet: &lt;if&gt;",
"html": "<code id=\"newitem\" contenteditable=\"true\" style=\"display:inline-block; background:#eef; border:1px dashed #99c; padding:4px; font-family:monospace; white-space:pre-wrap;\">" + jetEscape("<if value1=\"" + "$" + "[varname]\" value2=\"\" type=\"eq\">\ncontent\n</if>") + "</code>"
}
];
tbitems.forEach(doit);
function doit(currentValue, index) {
let index2 = index + 1;
let toolbar = document.getElementById('toolbar');
var itemHTML = '<button type="button" id="button' + index2;
itemHTML += '" style="width: 100%; height: 40px; border: 2px solid green;"';
itemHTML += '>' + currentValue.label + '</button>';
toolbar.insertAdjacentHTML("beforeend", itemHTML);
let id = 'button' + index2;
let button = document.getElementById(id);
button.html = currentValue.html;
button.addEventListener("mousedown",
(e) => {
e.preventDefault();
saveUndo();
draggingHTML = currentValue.html;
mainpage.insertAdjacentHTML('beforeend', draggingHTML);
let newEl = mainpage.lastElementChild;
newEl.style.position = 'absolute';
newEl.style.zIndex = '10000';
newEl.style.left = '0px';
newEl.style.top = '0px';
startNewItemDrag(newEl);
}
, false);
}
}
function startNewItemDrag(el) {
let rect = mainpage.getBoundingClientRect();
function ndMove(e) {
el.style.left = (e.clientX - rect.left - el.offsetWidth / 2) + 'px';
el.style.top = (e.clientY - rect.top - el.offsetHeight / 2) + 'px';
}
function ndUp(e) {
document.removeEventListener('mousemove', ndMove);
document.removeEventListener('mouseup', ndUp);
draggingHTML = null;
if((typeof selected !== 'undefined') && selected) {
selected.style.outline = '';
}
selected = el;
selected.style.outline = '2px solid #0088ff';
getLayerMap(selected);
displayParameters();
mode = "MOUSEUP";
}
document.addEventListener('mousemove', ndMove);
document.addEventListener('mouseup', ndUp);
}
function window_mousedown(win, e) {
mousedownx = e.offsetX;
mousedowny = e.offsetY;
if(e.offsetX >= (win.clientWidth - 3)) {
win.style.cursor = 'ew-resize';
window_dragging = true;
}
else {
win.style.cursor = 'default';
window_dragging = false;
}
}
function window_mousemove(win, e) {
}
function window_mouseup(win, e) {
}
function drawGrid() {
if(showGrid == true) {
context.clearRect(0,0,grid.width,grid.height);
context.lineWidth = 0.5;
context.strokeStyle = "#808080";
for(var ix = 0; ix < grid.width; ix += gridSize) {
context.beginPath();
context.moveTo(ix, 0);
context.lineTo(ix, grid.height);
context.stroke();
context.beginPath();
context.moveTo(0, ix);
context.lineTo(grid.width, ix);
context.stroke();
}
} else {
context.clearRect(0,0,grid.width,grid.height);
}
}
function resetDragHint(e) {
if(!selected) {
return;
}
if(selected.style.position == 'absolute') {
if((e.offsetX >= (parseFloat(selected.style.width) - 10)) && (e.offsetY >= (parseFloat(selected.style.height) - 10))) {
selected.style.cursor = "nwse-resize";
dragHint = "rightbottomresize";
} else if((e.offsetY < 10) && (e.offsetX >= (parseFloat(selected.style.width) - 10))) {
selected.style.cursor = "nesw-resize";
dragHint = "righttopresize";
} else if((e.offsetX <= 10) && (e.offsetY >= (parseFloat(selected.style.height) - 10))) {
selected.style.cursor = "nesw-resize";
dragHint = "leftbottomresize";
} else if((e.offsetX <= 10) && (e.offsetY <= 10)) {
selected.style.cursor = "nwse-resize";
dragHint = "lefttopresize";
} else if(e.offsetX >= (parseFloat(selected.style.width) - 10)) {
selected.style.cursor = "ew-resize";
dragHint = "rightresize";
} else if(e.offsetY >= (parseFloat(selected.style.height) - 10)) {
selected.style.cursor = "ns-resize";
dragHint = "bottomresize";
} else if(e.offsetX <= 10) {
selected.style.cursor = "ew-resize";
dragHint = "leftresize";
} else if(e.offsetY <= 10) {
selected.style.cursor = "ns-resize";
dragHint = "topresize";
} else {
selected.style.cursor = "move";
dragHint = "move";
}
} else {
if((e.offsetX >= (parseFloat(selected.style.width) - 10)) && (e.offsetY >= (parseFloat(selected.style.height) - 10))) {
selected.style.cursor = "nwse-resize";
dragHint = "rightbottomresize";
} else if(e.offsetX >= (parseFloat(selected.style.width) - 10)) {
selected.style.cursor = "ew-resize";
dragHint = "rightresize";
} else if(e.offsetY >= (parseFloat(selected.style.height) - 10)) {
selected.style.cursor = "ns-resize";
dragHint = "bottomresize";
} else {
selected.style.cursor = "move";
dragHint = "move";
}
}
}
function mousedown(e) {
let mouseabove = document.elementFromPoint(e.clientX, e.clientY);
if((typeof selected === 'undefined') || (selected != mouseabove)) {
mode = 'NEWSELECTION';
if((typeof selected !== 'undefined') && (selected.nodeName == "DIV")) {
document.designMode = 'off';
selected.contentEditable = false;
selected.blur();
selected.style.userSelect = 'none';
}
if((typeof selected !== 'undefined') && selected) {
selected.style.outline = '';
}
selected = mouseabove;
selected.style.outline = '2px solid #0088ff';
getLayerMap(selected);
} else {
if(mode != "TEXT") {
mode = "DOWNONSELECTED";
}
}
if(mode != 'TEXT') {
resetDragHint(e);
}
mousedownx = e.clientX;
mousedowny = e.clientY;
displayParameters();
return true;
}
function mouseup(e) {
if(mode == "TEXT") {
return true;
}
if(mode == "DOWNONSELECTED") {
if((selected.nodeName == "DIV") || (selected.nodeName == "CODE")) {
document.designMode = 'on';
selected.style.userSelect = 'auto';
selected.contentEditable = true;
selected.style.cursor = 'text';
selected.focus();
mode = "TEXT";
return true;
}
}
if(draggingHTML) {
draggingHTML = null;
}
if(selected && (mode == 'MOVED') && (selected.style.position == 'absolute')) {
selected.style.pointerEvents = 'none';
let dragover = document.elementFromPoint(e.clientX, e.clientY);
selected.style.pointerEvents = 'auto';
if(dragover.nodeName == 'DIV') {
if(dragover.id != selected.parentElement.id) {
dragover.appendChild(selected);
selected.style.left = ((selected.getBoundingClientRect().x + e.offsetX - mousedownx) - dragover.getBoundingClientRect().x) + 'px';
selected.style.top = ((selected.getBoundingClientRect().y + e.offsetY - mousedowny) - dragover.getBoundingClientRect().y) + 'px';
}
}
} else if(selected && (mode == 'MOVED') && pendingDragover) {
pendingDragover.prepend(selected);
selected.style.left = parseFloat(pendingDragover.style.left) + "px";
selected.style.top = parseFloat(pendingDragover.style.top) + "px";
}
pendingDragover = null;
hideDropIndicator();
mode = "MOUSEUP";
return true;
}
function mousemove(e) {
if(mode == "MOUSEUP") {
resetDragHint(e);
} else {
if(mode == "TEXT") {
return true;
}
if((selected.style.position == 'static') ||
(typeof selected.style.position === 'undefined') ||
(selected.style.position == '')) {
if(dragHint == "move") {
if(mode != "MOVED") {
mode = "MOVED";
}
selected.style.pointerEvents = 'none';
let dragover = document.elementFromPoint(e.clientX, e.clientY);
selected.style.pointerEvents = 'auto';
if(dragover.nodeName == 'DIV') {
pendingDragover = dragover;
showDropIndicator(dragover);
}
return true;
} else if(dragHint == "rightbottomresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.width = (parseFloat(selected.style.width) + e.movementX) + "px";
selected.style.height = (parseFloat(selected.style.height) + e.movementY) + "px";
} else if(dragHint == "rightresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.width = (parseFloat(selected.style.width) + e.movementX) + "px";
} else if(dragHint == "bottomresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.height = (parseFloat(selected.style.height) + e.movementY) + "px";
}
} else if(selected.style.position == 'absolute') {
if(dragHint == "move") {
if(mode != "MOVED") {
saveUndo();
mode = "MOVED";
}
selected.style.left = (parseFloat(selected.style.left) + e.movementX) + "px";
selected.style.top = (parseFloat(selected.style.top) + e.movementY) + "px";
} else if(dragHint == "rightbottomresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.width = (parseFloat(selected.style.width) + e.movementX) + "px";
selected.style.height = (parseFloat(selected.style.height) + e.movementY) + "px";
} else if(dragHint == "lefttopresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.left = (parseFloat(selected.style.left) + e.movementX) + "px";
selected.style.top = (parseFloat(selected.style.top) + e.movementY) + "px";
selected.style.width = (parseFloat(selected.style.width) - e.movementX) + "px";
selected.style.height = (parseFloat(selected.style.height) - e.movementY) + "px";
} else if(dragHint == "leftbottomresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.left = (parseFloat(selected.style.left) + e.movementX) + "px";
selected.style.width = (parseFloat(selected.style.width) - e.movementX) + "px";
selected.style.height = (parseFloat(selected.style.height) + e.movementY) + "px";
} else if(dragHint == "righttopresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.top = (parseFloat(selected.style.top) + e.movementY) + "px";
selected.style.height = (parseFloat(selected.style.height) - e.movementY) + "px";
selected.style.width = (parseFloat(selected.style.width) + e.movementX) + "px";
} else if(dragHint == "rightresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.width = (parseFloat(selected.style.width) + e.movementX) + "px";
} else if(dragHint == "bottomresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.height = (parseFloat(selected.style.height) + e.movementY) + "px";
} else if(dragHint == "leftresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.left = (parseFloat(selected.style.left) + e.movementX) + "px";
selected.style.width = (parseFloat(selected.style.width) - e.movementX) + "px";
} else if(dragHint == "topresize") {
if(mode != "RESIZED") {
saveUndo();
mode = "RESIZED";
}
selected.style.top = (parseFloat(selected.style.top) + e.movementY) + "px";
selected.style.height = (parseFloat(selected.style.height) - e.movementY) + "px";
}
displayParameters();
}
return true;
}
}
function heading(name) {
let properties = document.getElementById('properties');
properties.insertAdjacentHTML('beforeend', "<tr><td colspan=\"2\" style=\"background-color: #abc; padding: 4px; font-weight: bold;\">" + name + ":</td></tr>");
}
function setColorProperty(event) {
selected.style.setProperty(event.target.id, document.getElementById(event.target.id).value);
}
function getColorProperty(cssprop) {
let value = selected.style.getPropertyValue(cssprop);
if(value.substring(0, 4) == "rgb(") {
document.getElementById(cssprop).value = value;
}
else {
selected.style.removeProperty(cssprop);
}
}
function setSizeProperty(event) {
var value = document.getElementById(event.target.id);
var units = document.getElementById(event.target.id + "Units");
if(value.value == "") {
units.value = "";
} else if(units && units.value == "") {
units.value = "px";
}
if(units) {
selected.style.setProperty(event.target.id, value.value + units.value);
}
}
function getSizeProperty(cssprop) {
let size = selected.style.getPropertyValue(cssprop);
if(size) {
document.getElementById(cssprop).value = parseInt(size);
document.getElementById(cssprop + "Units").value = size.replace(/[0-9]/g, '');
}
}
function property(name, payload, cssprop, initfun, changefun) {
let properties = document.getElementById('properties');
properties.insertAdjacentHTML('beforeend', "<tr><td style=\"background-color: #cdf; padding: 3px 4px; vertical-align: middle; white-space: nowrap;\">" + name + ":</td><td style=\"background-color: #cdf; padding: 3px 4px; vertical-align: middle;\">" + payload + "</td></tr>");
if(initfun) {
initfun(cssprop);
}
if(cssprop) {
document.getElementById(cssprop).addEventListener('input', changefun);
let prop = document.getElementById(cssprop + "Units");
if(prop) {
prop.addEventListener('change', changefun);
}
}
}
function propsize(title, cssprop) {
property(title,
"<div style=\"display: flex; align-items: center; gap: 4px;\">" +
"<input type=\"number\" id=\"" + cssprop + "\" style=\"flex: 1 1 auto; min-width: 0; box-sizing: border-box;\">" +
"<select id=\"" + cssprop + "Units\" style=\"flex: 0 0 auto;\">" +
" <option value=\"\"></option>" +
" <option value=\"px\">px</option>" +
" <option value=\"%\">%</option>" +
"</select></div>",
cssprop,
getSizeProperty,
setSizeProperty);
}
function propcolor(title, cssprop) {
property(title,
"<input type=\"color\" id=\"" + cssprop + "\" style=\"height: 18px; width: 50px; margin: 0px; padding: 0px; border: none; width:85%;\">",
cssprop,
getColorProperty,
setColorProperty);
}
function displayParameters() {
if(!selected) {
itemparameters.innerHTML = "";
return;
}
itemparameters.innerHTML = "<div style=\"width: 300px; max-width: 300px;\"><table id=\"properties\" style=\"width: 300px; table-layout: fixed;\"><colgroup><col style=\"width: 35%;\"><col style=\"width: 65%;\"></colgroup></table></div>";
property("Type", selected.nodeName);
property("Id", selected.id);
property("Display",
"<select id=\"display\" style=\"width: 100%; float: right;\">" +
" <option value=\"inline\">Inline</option>" +
" <option value=\"block\">Block</option>" +
" <option value=\"inline-block\">Inline Block</option>" +
" <option value=\"none\">None</option>" +
"</select>",
"display",
() => { document.getElementById('display').value = selected.style.display; },
() => { selected.style.display = document.getElementById('display').value; }
);
if(selected.id != "mainpage") {
heading("Geometry");
property("Position",
"<select id=\"position\" style=\"width: 100%; float: right;\">" +
" <option value=\"absolute\">Absolute</option>" +
" <option value=\"static\">Static</option>" +
" <option value=\"relative\">Relative</option>" +
"</select>",
"position",
() => { document.getElementById('position').value = selected.style.position; },
() => { saveUndo();
selected.style.position = document.getElementById('position').value;
if(document.getElementById('position').value == 'absolute') {
selected.style.left = selected.getBoundingClientRect().x + 'px';
selected.style.top = selected.getBoundingClientRect().y + 'px';
}
displayParameters();
}
);
if(selected.style.position == 'absolute') {
propsize("Left", "left");
propsize("Top", "top");
}
propsize("Width", "width");
propsize("Height", "height");
}
if(selected.nodeName != 'IMG') {
heading("Font");
propcolor("Color", "color");
}
heading("Margins");
propsize("Left", "margin-left");
propsize("Top", "margin-top");
propsize("Right", "margin-right");
propsize("Bottom", "margin-bottom");
if(selected.id != "mainpage") {
heading("Border");
propsize("Width", "border-width");
propcolor("Color", "border-color");
}
heading("Background");
propcolor("Color", "background-color");
heading("Padding");
propsize("Left", "padding-left");
propsize("Top", "padding-top");
propsize("Right", "padding-right");
propsize("Bottom", "padding-bottom");
}
function newImage(filename, e) {
saveUndo();
let img = '<img id="newitem" src="/assets/' + filename + '" style="width: 150px;">';
mainpage.insertAdjacentHTML('beforeend', img);
}
function handleFiles(files) {
}
function b64encode(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function save(form) {
form.html.value = b64encode(document.getElementById('mainpage').innerHTML);
form.exit.value = '';
form.submit();
}
function savenexit(form) {
form.html.value = b64encode(document.getElementById('mainpage').innerHTML);
form.exit.value = 'yes';
form.submit();
}
function exitnosave(form) {
form.html.value = '';
form.exit.value = 'yes';
form.submit();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.
+156 -1
View File
@@ -42,6 +42,31 @@
* { box-sizing: border-box; }
* {
scrollbar-width: thin;
scrollbar-color: var(--bond-gold) var(--bond-bg);
}
::-webkit-scrollbar {
width: 11px;
height: 11px;
}
::-webkit-scrollbar-track {
background: var(--bond-bg);
}
::-webkit-scrollbar-thumb {
background-color: var(--bond-gold);
background-image: linear-gradient(180deg, var(--bond-gold-bright), var(--bond-gold));
background-clip: padding-box;
border: 2px solid var(--bond-bg);
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background-image: linear-gradient(180deg, var(--bond-gold-bright), var(--bond-gold-bright));
}
::-webkit-scrollbar-corner {
background: var(--bond-bg);
}
body {
background: radial-gradient(ellipse at top, #1a1a20 0%, #0a0a0d 60%, #060607 100%);
background-attachment: fixed;
@@ -99,6 +124,10 @@ li { font-family: 8pt exo2; color: var(--bond-text); }
padding: 0px;
max-width: 1050px;
min-width: 1050px;
min-height: calc(100vh - 150px);
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: auto;
font-family: exo2;
font-size: 10pt;
@@ -106,6 +135,18 @@ li { font-family: 8pt exo2; color: var(--bond-text); }
overflow: hidden;
}
/* The email client is the one page whose own content (a long message
list) can outgrow the viewport - everywhere else .pageclass is meant
to grow taller than the viewport for long content (normal document
scroll). Scoped to only this page via :has() so no other page's
layout changes: caps .pageclass at the same height it already uses
as a floor, so #email-list/#email-reading-pane's own overflow-y:auto
scrolls instead of the whole page growing past the viewport. */
.pageclass:has(#email-app) {
height: calc(100vh - 150px);
overflow: hidden;
}
input {
background: #101014;
color: var(--bond-text);
@@ -194,6 +235,19 @@ textarea.fieldinputerror {
background: var(--bond-line);
}
.user-badge {
display: inline-block;
padding: 3px 12px;
margin-bottom: 4px;
border-radius: 6px;
border: 1px solid var(--bond-gold);
background: linear-gradient(180deg, rgba(197, 164, 90, 0.28), rgba(197, 164, 90, 0.12));
box-shadow: 0 0 0.5em rgba(197, 164, 90, 0.25);
color: var(--bond-gold-bright);
font-weight: 600;
letter-spacing: 0.03em;
}
.sectionframecontents {
padding: 10px;
text-align: justify;
@@ -319,6 +373,28 @@ textarea.fieldinputerror {
border-radius: 3px;
}
#panelmenu-resizer {
flex-shrink: 0;
width: 9px;
cursor: col-resize;
position: relative;
background: var(--bond-line-soft);
border-radius: 3px;
}
#panelmenu-resizer:hover, #panelmenu-resizer.resizing {
background: var(--bond-gold);
}
#panelmenu-resizer svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
color: var(--bond-gold-bright);
pointer-events: none;
}
.panel table {
font: 12px exo2;
padding: 5px;
@@ -398,11 +474,12 @@ textarea.fieldinputerror {
padding: 2px 10px 3px 10px;
margin: 0px;
border-radius: 3px;
box-shadow: 0 0 0.5em rgba(197, 164, 90, 0.3), 0 0 0.15em rgba(227, 196, 126, 0.4);
transition: box-shadow 0.2s ease, background 0.2s ease;
}
.button:hover {
box-shadow: 0 0 0.6em rgba(197, 164, 90, 0.35);
box-shadow: 0 0 0.9em rgba(197, 164, 90, 0.55), 0 0 0.3em rgba(227, 196, 126, 0.6);
background: linear-gradient(180deg, #24242e, #1a1a20);
}
@@ -688,3 +765,81 @@ textarea.fieldinputerror {
margin-top: 12px;
min-height: 1.2em;
}
/* Site-wide floating file transfer manager (see documents.js) - lives
outside #content in mainpagetemplate.jet so it survives SPA
navigation, letting an upload/download keep running while browsing
to a different page. */
#filemgr-panel {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9000;
font-family: exo2;
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 var(--bond-gold);
background: linear-gradient(180deg, #1c1c24, #131318);
color: var(--bond-gold-bright);
box-shadow: 0 0 0.6em rgba(197, 164, 90, 0.35);
transition: box-shadow 0.2s ease, background 0.2s ease;
margin-left: auto;
}
#filemgr-toggle:hover {
box-shadow: 0 0 1em rgba(197, 164, 90, 0.6), 0 0 0.3em rgba(227, 196, 126, 0.6);
background: linear-gradient(180deg, #24242e, #1a1a20);
}
#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: var(--bond-gold-bright);
color: #131318;
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 var(--bond-line);
background: var(--bond-bg-2);
box-shadow: 0 10px 40px rgba(0,0,0,0.6);
overflow: hidden;
display: flex;
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 var(--bond-line-soft);
color: var(--bond-gold-bright);
font-weight: 600;
}
#filemgr-clear { font-weight: normal; font-size: 8pt; color: var(--bond-text-dim); cursor: pointer; }
#filemgr-clear:hover { color: var(--bond-text); }
#filemgr-list { overflow-y: auto; padding: 6px; }
#filemgr-empty { color: var(--bond-text-dim); padding: 10px; text-align: center; }
.filemgr-item { padding: 8px; border-radius: 6px; margin-bottom: 4px; background: rgba(255,255,255,0.03); }
.filemgr-item-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.filemgr-item-icon { flex-shrink: 0; width: 14px; height: 14px; color: var(--bond-gold-bright); }
.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: var(--bond-text-dim); }
.filemgr-item.filemgr-failed .filemgr-item-status { color: #c66; }
.filemgr-item.filemgr-done .filemgr-item-status { color: var(--bond-gold-bright); }
.filemgr-progress-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.08); overflow: hidden; }
.filemgr-progress-fill { height: 100%; width: 0%; background: var(--bond-gold-bright); transition: width 0.15s ease; }
.filemgr-item.filemgr-done .filemgr-progress-fill { background: #6c6; }
.filemgr-item.filemgr-failed .filemgr-progress-fill { background: #c66; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

@@ -0,0 +1,76 @@
(function() {
var form = document.getElementById('newemail-form');
if (!form)
return;
function post(path, params) {
return fetch(path, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params || {}).toString()
}).then(function(r) { return r.json(); });
}
function navigateTo(url) {
var a = document.createElement('a');
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
var body = document.getElementById('newemail-body');
var toolbar = document.getElementById('newemail-toolbar');
toolbar.addEventListener('click', function(e) {
var btn = e.target.closest('button');
if (!btn)
return;
body.focus();
var cmd = btn.getAttribute('data-cmd');
if (cmd === 'createLink') {
var url = window.prompt('Link URL:');
if (url)
document.execCommand('createLink', false, url);
} else {
document.execCommand(cmd, false, null);
}
});
var errorDiv = document.getElementById('newemail-error');
var sendBtn = document.getElementById('newemail-send-btn');
var cancelBtn = document.getElementById('newemail-cancel-btn');
cancelBtn.addEventListener('click', function() {
navigateTo('/admin/email');
});
sendBtn.addEventListener('click', function() {
var to = document.getElementById('newemail-to').value.trim();
var subject = document.getElementById('newemail-subject').value.trim();
var html = body.innerHTML.trim();
errorDiv.textContent = '';
if (!to) {
errorDiv.textContent = 'A recipient address is required.';
return;
}
if (!html) {
errorDiv.textContent = 'The message body is empty.';
return;
}
sendBtn.classList.add('newemail-sending');
post('/email/send', { to: to, subject: subject, body: html })
.then(function(res) {
sendBtn.classList.remove('newemail-sending');
if (!res.ok) {
errorDiv.textContent = res.error || 'Could not send the message.';
return;
}
navigateTo('/admin/email');
})
.catch(function() {
sendBtn.classList.remove('newemail-sending');
errorDiv.textContent = 'Could not send the message.';
});
});
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+322
View File
@@ -0,0 +1,322 @@
(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. async=false on src="" ones preserves normal in-order
// execution (dynamically-inserted external scripts default to
// async otherwise), matching how a real page load would run them.
var scripts = content.querySelectorAll('script');
for (var i = 0; i < scripts.length; i++) {
var old = scripts[i];
var fresh = document.createElement('script');
for (var j = 0; j < old.attributes.length; j++)
fresh.setAttribute(old.attributes[j].name, old.attributes[j].value);
if (fresh.src) fresh.async = false;
fresh.textContent = old.textContent;
old.parentNode.replaceChild(fresh, old);
}
// #_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.
}
});
})();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.
Binary file not shown.