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.
245 lines
9.4 KiB
JavaScript
245 lines
9.4 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// Server-side hint: did the user have paid privileges when the page
|
|
// rendered? Used to decide whether to offer the 'Import & sync' button.
|
|
// We still call CassandraSync.getStatus() at click time as the source
|
|
// of truth, but this lets us skip rendering a button we know is dead.
|
|
// Value is passed via data-paid attribute on #drop-zone.
|
|
|
|
function ready(fn) {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', fn);
|
|
} else { fn(); }
|
|
}
|
|
|
|
ready(function () {
|
|
var P = window.CassandraPortfolio;
|
|
if (!P) return;
|
|
var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls;
|
|
|
|
var dropZone = document.getElementById('drop-zone');
|
|
var fileInput = document.getElementById('file-input');
|
|
var browseLink = document.getElementById('browse-link');
|
|
var filenameEl = document.getElementById('dz-filename');
|
|
var previewEl = document.getElementById('import-preview');
|
|
var resultEl = document.getElementById('import-result');
|
|
if (!dropZone) return;
|
|
|
|
var IS_PAID = dropZone.dataset.paid === 'true';
|
|
|
|
var currentPie = null; // most recently parsed pie, awaiting commit
|
|
|
|
function showError(msg) {
|
|
previewEl.hidden = true;
|
|
resultEl.className = 'result result--err';
|
|
resultEl.innerHTML =
|
|
'<div class="result__head">✕ Import failed</div>' +
|
|
'<div class="result__row">' + esc(msg) + '</div>';
|
|
resultEl.hidden = false;
|
|
}
|
|
|
|
function showSuccess(headline, sub) {
|
|
previewEl.hidden = true;
|
|
resultEl.className = 'result result--ok';
|
|
resultEl.innerHTML =
|
|
'<div class="result__head">' + esc(headline) + '</div>' +
|
|
(sub ? '<div class="result__row">' + sub + '</div>' : '') +
|
|
'<div class="result__row" style="margin-top:14px;">' +
|
|
'<a href="/">Open dashboard →</a>' +
|
|
'</div>';
|
|
resultEl.hidden = false;
|
|
}
|
|
|
|
function renderPreview(pie) {
|
|
currentPie = pie;
|
|
resultEl.hidden = true;
|
|
|
|
var t = pie.totals || {};
|
|
var rows = (pie.positions || []).map(function (p) {
|
|
var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null;
|
|
return '<tr>' +
|
|
'<td class="label">' + esc(p.yahoo_ticker || p.t212_slice || '') + '</td>' +
|
|
'<td>' + esc(p.name || '') + '</td>' +
|
|
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
|
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
|
|
'<td class="num">' + fmt(invested) + '</td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
|
|
var warnings = (pie.warnings || []).map(function (w) {
|
|
return '<div class="result__warn">' + esc(w) + '</div>';
|
|
}).join('');
|
|
|
|
var syncBtn = IS_PAID
|
|
? ('<div class="import-choice">' +
|
|
'<button type="button" id="commit-sync">Import & sync to cloud</button>' +
|
|
'<div class="settings-row__hint">' +
|
|
'Also stores an <strong>encrypted</strong> copy on the server, ' +
|
|
'restorable on any device with your PIN. Only you can decrypt ' +
|
|
'it — losing the PIN means losing the backup.' +
|
|
'</div>' +
|
|
'</div>')
|
|
: ('<div class="import-choice">' +
|
|
'<button type="button" disabled>Import & sync to cloud</button>' +
|
|
'<div class="settings-row__hint">' +
|
|
'Encrypted cloud backup is available on the paid tier.' +
|
|
'</div>' +
|
|
'</div>');
|
|
|
|
previewEl.innerHTML =
|
|
'<div class="result result--ok" style="margin:0;">' +
|
|
'<div class="result__head">' +
|
|
'▸ Preview: <strong>' + esc(pie.pie_name || 'pie') + '</strong>' +
|
|
'</div>' +
|
|
'<div class="result__grid">' +
|
|
'<div><div class="k">Positions</div><div class="v">' + (pie.positions || []).length + '</div></div>' +
|
|
'<div><div class="k">Invested</div><div class="v">' + fmt(t.invested) + '</div></div>' +
|
|
'<div><div class="k">Value</div><div class="v">' + fmt(t.value) + '</div></div>' +
|
|
'<div><div class="k">Result</div><div class="v ' + cls(t.result) + '">' + signed(t.result) + '</div></div>' +
|
|
'</div>' +
|
|
warnings +
|
|
(rows
|
|
? '<div style="max-height:280px;overflow:auto;margin-top:12px;">' +
|
|
'<table class="dense">' +
|
|
'<thead><tr>' +
|
|
'<th>Ticker</th><th>Name</th>' +
|
|
'<th class="num">Qty</th>' +
|
|
'<th class="num">Avg</th>' +
|
|
'<th class="num">Invested</th>' +
|
|
'</tr></thead>' +
|
|
'<tbody>' + rows + '</tbody>' +
|
|
'</table>' +
|
|
'</div>'
|
|
: ''
|
|
) +
|
|
'<div class="import-actions">' +
|
|
'<div class="import-choice">' +
|
|
'<button type="button" id="commit-local">Import to this browser</button>' +
|
|
'<div class="settings-row__hint">' +
|
|
'Saved to this browser only. No server-side copy of your holdings.' +
|
|
'</div>' +
|
|
'</div>' +
|
|
syncBtn +
|
|
'<div style="flex-basis:100%;">' +
|
|
'<button type="button" id="commit-cancel" class="pf-secondary">' +
|
|
'Cancel</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
previewEl.hidden = false;
|
|
|
|
document.getElementById('commit-local').addEventListener('click', commitLocal);
|
|
document.getElementById('commit-cancel').addEventListener('click', resetUploader);
|
|
var syncEl = document.getElementById('commit-sync');
|
|
if (syncEl) syncEl.addEventListener('click', commitSync);
|
|
}
|
|
|
|
function commitLocal() {
|
|
if (!currentPie) return;
|
|
P.savePie(currentPie);
|
|
showSuccess('▸ Imported to this browser.',
|
|
'Pie kept locally; no server-side copy.');
|
|
currentPie = null;
|
|
}
|
|
|
|
async function commitSync() {
|
|
if (!currentPie) return;
|
|
// Save locally first so the cloud-sync flow uses the freshly-imported
|
|
// pie (the enable-PIN modal in this same page reads from localStorage).
|
|
P.savePie(currentPie);
|
|
var S = window.CassandraSync;
|
|
if (!S) { showError('Cloud sync module not loaded.'); return; }
|
|
|
|
var status;
|
|
try { status = await S.getStatus(); }
|
|
catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; }
|
|
|
|
if (!status.paid) {
|
|
showError('Cloud sync requires the paid tier.');
|
|
return;
|
|
}
|
|
|
|
if (status.exists) {
|
|
// Already enabled — try a direct push using the cached session
|
|
// key. If no key is cached (fresh browser session), this throws,
|
|
// and we fall back to the enable-PIN modal so the user can
|
|
// re-enter their PIN.
|
|
try {
|
|
await S.pushSync(currentPie, null);
|
|
showSuccess('▸ Imported and synced.',
|
|
'Encrypted copy updated on the server.');
|
|
currentPie = null;
|
|
if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus();
|
|
return;
|
|
} catch (e) {
|
|
// Fall through to modal so the user can re-auth with their PIN.
|
|
console.warn('direct push failed, falling back to PIN modal', e);
|
|
}
|
|
}
|
|
|
|
// !status.exists OR cached-key push failed → use the modal.
|
|
if (window.cassandraOpenSyncModal) {
|
|
window.cassandraOpenSyncModal({
|
|
onSuccess: function () {
|
|
showSuccess('▸ Imported and synced.',
|
|
'Cloud sync is now enabled and the pie is stored encrypted.');
|
|
currentPie = null;
|
|
},
|
|
});
|
|
} else {
|
|
showError('Cloud sync UI unavailable on this page. ' +
|
|
'Use the Cloud sync section below to enable.');
|
|
}
|
|
}
|
|
|
|
function resetUploader() {
|
|
currentPie = null;
|
|
previewEl.hidden = true;
|
|
previewEl.innerHTML = '';
|
|
resultEl.hidden = true;
|
|
filenameEl.textContent = '';
|
|
fileInput.value = '';
|
|
}
|
|
|
|
async function parseFile(file) {
|
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…';
|
|
previewEl.hidden = true;
|
|
resultEl.hidden = true;
|
|
try {
|
|
var pie = await P.parseCsv(file);
|
|
renderPreview(pie);
|
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
|
} catch (e) {
|
|
filenameEl.textContent = file.name + ' (failed)';
|
|
showError(e.message || 'Unknown error');
|
|
}
|
|
}
|
|
|
|
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
|
fileInput.addEventListener('change', function () {
|
|
if (fileInput.files[0]) parseFile(fileInput.files[0]);
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(function (ev) {
|
|
dropZone.addEventListener(ev, function (e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
dropZone.classList.add('dz--over');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(function (ev) {
|
|
dropZone.addEventListener(ev, function (e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
dropZone.classList.remove('dz--over');
|
|
});
|
|
});
|
|
dropZone.addEventListener('drop', function (e) {
|
|
var f = e.dataTransfer.files && e.dataTransfer.files[0];
|
|
if (f) parseFile(f);
|
|
});
|
|
dropZone.addEventListener('click', function (e) {
|
|
if (e.target.tagName !== 'A') fileInput.click();
|
|
});
|
|
});
|
|
})();
|