settings: extract sync + import widget JS to static files

The two largest inline <script> blocks in settings.html — the cloud
sync modal/management UI (~145 lines) and the import widget wiring
(~245 lines) — moved to app/static/js/settings-sync.js and
settings-import.js respectively, included via <script src="..."
defer> at the bottom of the template.

Where the inline code referenced Jinja vars or {% if %} guards,
those values are now passed via data-* attributes on the relevant
DOM elements (or via window.cassandra* config objects for structured
data) and read in the static JS.

Smaller blocks (Stripe portal, digest prefs, language select,
invite copy) stay inline — each <40 lines and easier to follow
next to their markup. settings.html drops from 758 lines to roughly
half that.
This commit is contained in:
Giorgio Gilestro 2026-05-27 20:55:49 +02:00
parent dcc2c07111
commit f4d9c9f2ec
3 changed files with 403 additions and 399 deletions

View file

@ -0,0 +1,154 @@
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
document.addEventListener('DOMContentLoaded', function () {
if (!window.CassandraSync) return;
const statusEl = $('sync-status');
const actionsEl = $('sync-actions');
const feedbackEl = $('sync-feedback');
const modal = $('sync-modal');
const pin1 = $('sync-pin1');
const pin2 = $('sync-pin2');
const ack = $('sync-ack');
const errEl = $('sync-modal-err');
function setFeedback(msg, ok) {
feedbackEl.style.color = ok ? 'var(--positive)' : '';
feedbackEl.textContent = msg || '';
}
// External callers (the Import section above) can pass a callback
// that fires after a successful enable-and-push.
let pendingOnSuccess = null;
function openModal(opts) {
pendingOnSuccess = (opts && opts.onSuccess) || null;
modal.style.display = 'flex';
// Focus PIN field after the layout flush so the caret lands.
setTimeout(() => pin1.focus(), 0);
}
function closeModal() {
modal.style.display = 'none';
pin1.value = ''; pin2.value = '';
ack.checked = false; errEl.hidden = true;
pendingOnSuccess = null;
}
$('sync-modal-cancel').addEventListener('click', closeModal);
// Backdrop click + Esc key dismiss the modal.
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
});
$('sync-modal-form').addEventListener('submit', async function (e) {
e.preventDefault();
errEl.hidden = true;
if (pin1.value !== pin2.value) {
errEl.textContent = 'PINs do not match.';
errEl.hidden = false; return;
}
if (pin1.value.length < 4) {
errEl.textContent = 'PIN must be at least 4 characters.';
errEl.hidden = false; return;
}
const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null');
if (!pie) {
errEl.textContent =
'No portfolio in this browser yet. Import a CSV first, then enable sync.';
errEl.hidden = false; return;
}
try {
await window.CassandraSync.pushSync(pie, pin1.value);
const cb = pendingOnSuccess;
closeModal(); // clears pendingOnSuccess
await refresh();
setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true);
if (typeof cb === 'function') {
try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); }
}
} catch (e2) {
errEl.textContent = e2.message || 'Failed to enable sync.';
errEl.hidden = false;
}
});
async function refresh() {
let status;
try { status = await window.CassandraSync.getStatus(); }
catch (e) {
statusEl.querySelector('.settings-row__value').innerHTML =
'<span class="pf-warn">' + esc(e.message || 'status check failed') + '</span>';
return;
}
const valueEl = statusEl.querySelector('.settings-row__value');
actionsEl.innerHTML = '';
if (status.exists && status.orphaned) {
// The stored blob can no longer be decrypted (server key rotated
// since it was written). The data is permanently unrecoverable,
// so silently clean up the dead row and re-render in the
// standard "off" state — leaving a soft one-liner so the user
// knows why they need to re-import.
try { await window.CassandraSync.disableSync(); }
catch (e) { console.warn('auto-clear stale sync failed', e); }
setFeedback('Your previous cloud backup couldnt be restored. Re-import your portfolio to enable cloud sync again.', true);
await refresh();
return;
} else if (status.exists) {
const when = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
: '—';
valueEl.innerHTML =
'<span class="badge badge--ok">On</span> ' +
'<span class="settings-row__hint">last synced ' + esc(when) + '</span>';
const disable = document.createElement('button');
disable.type = 'button';
disable.className = 'pf-secondary';
disable.textContent = 'Disable sync';
disable.addEventListener('click', async function () {
if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return;
try {
await window.CassandraSync.disableSync();
await refresh();
setFeedback('Cloud sync disabled. Server copy removed.', true);
} catch (e) { setFeedback(e.message || 'Disable failed.', false); }
});
actionsEl.appendChild(disable);
} else {
valueEl.innerHTML = '<span class="badge badge--ver">Off</span>';
// Only offer 'Enable' when there's actually a pie to encrypt;
// otherwise the user would hit a dead-end at the modal.
const hasPie = !!localStorage.getItem('cassandra.pie');
if (!hasPie) {
const hint = document.createElement('span');
hint.className = 'settings-row__hint';
hint.innerHTML =
'Nothing to sync yet &mdash; ' +
'<a href="#import">import a portfolio</a> first, then come back to enable cloud sync.';
actionsEl.appendChild(hint);
return;
}
const enable = document.createElement('button');
enable.type = 'button';
enable.textContent = 'Enable cloud sync';
enable.addEventListener('click', openModal);
actionsEl.appendChild(enable);
}
}
// Hooks for the Import section to drive this modal + status row.
window.cassandraOpenSyncModal = openModal;
window.cassandraRefreshSyncStatus = refresh;
refresh();
});
})();