Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.
- new portfolio_sync table (migration 0015)
- POST/GET/DELETE /api/portfolio/sync + /status
- app/services/portfolio_sync.py crypto + rate limit
- app/routers/sync.py paid-gated
- app/static/js/portfolio-sync.js WebCrypto wrapper
- settings page: enable/disable + PIN modal
- PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)
Settings + import rework:
- /upload merged into /settings#import (legacy route 302s)
- drop CSV → auto-parse → preview → Import only / Import & sync
- nav slimmed to Dashboard / News / Log
- Settings + Logout moved to a user dropdown
- brand logo links to /
Collateral fixes:
- settings 500: re-fetch User in current session before mutating
referral_code (assign_code_if_missing was refreshing a User
loaded in the auth dep's now-closed session)
- csv_import: distinct error for unfunded T212 pies (all qty=0)
- db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
isolation_level=READ COMMITTED to avoid gap-lock deadlocks
- alembic env: disable_existing_loggers=False so in-process
migrations don't silence uvicorn's loggers
- docker-compose.override.yml: dev-only volume mount + --reload
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
578 lines
22 KiB
HTML
578 lines
22 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ BRAND_NAME }} · Settings{% endblock %}
|
|
|
|
{% block main %}
|
|
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
|
|
<div class="panel-header">
|
|
<span class="title">Settings</span>
|
|
<span class="meta">your account · client-only data unchanged</span>
|
|
</div>
|
|
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
|
|
|
|
{% if not user %}
|
|
<div class="empty">no per-user settings (admin bearer-token session)</div>
|
|
{% else %}
|
|
|
|
<div class="settings-row">
|
|
<div class="settings-row__label">Email</div>
|
|
<div class="settings-row__value">{{ user.email }}</div>
|
|
</div>
|
|
|
|
<div class="settings-row">
|
|
<div class="settings-row__label">Tier</div>
|
|
<div class="settings-row__value">
|
|
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
|
|
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
|
|
</span>
|
|
{% if paid and paid.active %}
|
|
{% if paid.source == "credit" %}
|
|
<span class="settings-row__hint">
|
|
Paid features active via credit · {{ paid.days_remaining }} day(s) remaining
|
|
(expires {{ paid.expires_at.strftime("%Y-%m-%d") }}).
|
|
</span>
|
|
{% else %}
|
|
<span class="settings-row__hint">Paid subscription active.</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="settings-row__hint">Paid features unlock with Paddle (D.3) or invite credits.</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# --- Import portfolio --------------------------------------------- #}
|
|
<div class="settings-section" id="import">
|
|
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
|
|
<p class="settings-section__lede">
|
|
Export your pie from T212
|
|
(<span class="neu">Investing → Your Pie → ··· → Export</span>)
|
|
and drop the CSV here. We’ll parse it and show a preview before
|
|
importing anywhere.
|
|
</p>
|
|
|
|
<div id="drop-zone" class="dz">
|
|
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
|
<div class="dz__icon">▱</div>
|
|
<div class="dz__label">Drop a T212 pie CSV here</div>
|
|
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 1 MB</div>
|
|
<div class="dz__filename" id="dz-filename"></div>
|
|
</div>
|
|
|
|
<div id="import-preview" hidden style="margin-top:14px;"></div>
|
|
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
|
|
</div>
|
|
|
|
{# --- Referral block ---------------------------------------------- #}
|
|
<div class="settings-section">
|
|
<div class="settings-section__head">Invite a friend</div>
|
|
<p class="settings-section__lede">
|
|
Share your invite link. When your friend subscribes, you and
|
|
they each get <strong>50% off for 3 months</strong>.
|
|
</p>
|
|
|
|
<div class="invite-block">
|
|
<label class="invite-block__label">Your code</label>
|
|
<div class="invite-block__code">{{ user.referral_code }}</div>
|
|
|
|
<label class="invite-block__label">Invite link</label>
|
|
<div class="invite-block__link">
|
|
<input type="text" id="invite-url" readonly value="{{ invite_url }}">
|
|
<button type="button" id="invite-copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="invite-stats">
|
|
<div>
|
|
<div class="invite-stats__label">Pending signups</div>
|
|
<div class="invite-stats__value">{{ pending_count }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="invite-stats__label">Converted (paid)</div>
|
|
<div class="invite-stats__value">{{ converted_count }}</div>
|
|
</div>
|
|
<div>
|
|
<div class="invite-stats__label">Active credits</div>
|
|
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# --- Cloud sync block --------------------------------------------- #}
|
|
<div class="settings-section">
|
|
<div class="settings-section__head">Cloud sync (encrypted)</div>
|
|
<p class="settings-section__lede">
|
|
Manage the encrypted server-side copy of your portfolio. Sync is
|
|
opted-in per import (see the Import section above).
|
|
</p>
|
|
|
|
{% if paid and paid.active %}
|
|
<div id="sync-status" class="settings-row">
|
|
<div class="settings-row__label">Status</div>
|
|
<div class="settings-row__value">
|
|
<span class="settings-row__hint">checking…</span>
|
|
</div>
|
|
</div>
|
|
<div id="sync-actions" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;"></div>
|
|
<div id="sync-feedback" class="settings-row__hint" style="margin-top:10px;"></div>
|
|
{% else %}
|
|
<p class="settings-row__hint">
|
|
Available on the paid tier. Upgrade or apply an invite credit
|
|
above to enable cloud sync.
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
|
|
|
|
{% endif %}
|
|
|
|
</div>
|
|
</section>
|
|
|
|
{% if user and paid and paid.active %}
|
|
<div id="sync-modal" class="modal"
|
|
style="position:fixed;inset:0;background:rgba(0,0,0,0.45);
|
|
display:none;align-items:center;justify-content:center;z-index:1000;">
|
|
<div style="background:var(--panel-bg,#fff);color:var(--text,#000);
|
|
padding:22px 26px;border-radius:8px;max-width:440px;width:90%;">
|
|
<div class="result__head" id="sync-modal-title" style="margin-bottom:8px;">
|
|
Enable cloud sync
|
|
</div>
|
|
<div id="sync-modal-body" style="font-size:13px;line-height:1.55;
|
|
color:var(--muted,#666);margin-bottom:14px;">
|
|
Choose a PIN (4–12 characters). The same PIN unlocks the
|
|
portfolio on any device. There is no recovery if you forget it.
|
|
</div>
|
|
<form id="sync-modal-form" autocomplete="off">
|
|
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
|
|
<input id="sync-pin1" type="password" inputmode="numeric"
|
|
style="width:100%;padding:8px;margin-bottom:10px;" required>
|
|
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
|
|
<input id="sync-pin2" type="password" inputmode="numeric"
|
|
style="width:100%;padding:8px;margin-bottom:10px;" required>
|
|
<label style="display:flex;align-items:flex-start;gap:8px;
|
|
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
|
|
<input id="sync-ack" type="checkbox" required>
|
|
I understand that losing this PIN means I'll have to re-import my CSV.
|
|
</label>
|
|
<div id="sync-modal-err" class="pf-warn" hidden style="margin-bottom:10px;"></div>
|
|
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
|
<button type="button" id="sync-modal-cancel" class="pf-secondary">Cancel</button>
|
|
<button type="submit" id="sync-modal-submit">Enable</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
|
|
<script>
|
|
(function () {
|
|
function $(id) { return document.getElementById(id); }
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[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(--ok,#2a9d57)' : '';
|
|
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) {
|
|
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 — ' +
|
|
'<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();
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
|
|
<script>
|
|
(function () {
|
|
var btn = document.getElementById('invite-copy');
|
|
var fld = document.getElementById('invite-url');
|
|
if (!btn || !fld) return;
|
|
btn.addEventListener('click', async function () {
|
|
try {
|
|
await navigator.clipboard.writeText(fld.value);
|
|
var orig = btn.textContent;
|
|
btn.textContent = 'Copied';
|
|
setTimeout(function () { btn.textContent = orig; }, 1500);
|
|
} catch (e) {
|
|
// Fallback for older browsers: select the input.
|
|
fld.select();
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{% if user %}
|
|
{# Import widget wiring — auto-parse on drop, preview, then commit. #}
|
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
|
<script>
|
|
(function () {
|
|
// 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.
|
|
var IS_PAID = {{ 'true' if paid and paid.active else 'false' }};
|
|
|
|
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 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();
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|