feat: draggable wallet reorder for chart layering

This commit is contained in:
Dione
2026-06-11 19:16:18 +00:00
parent b52da96ba0
commit d96e56bde5
2 changed files with 189 additions and 63 deletions

View File

@ -109,6 +109,17 @@ window.WalletManager = WalletManager;
::-webkit-scrollbar-track { background: #05070B; }
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #374151; }
/* Draggable pills */
#wallet-pills { display: flex; align-items: center; gap: 6px; }
#wallet-pills .pill-btn { user-select: none; position: relative; }
#wallet-pills .pill-btn.dragging { opacity: 0.3; transform: scale(0.95); }
#wallet-pills .pill-btn.pill-dragging { opacity: 0.3; transform: scale(0.95); }
.drag-insert-line {
width: 3px; min-height: 32px; background: #FF7A00;
border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px #FF7A00;
position: absolute; left: -3px; top: 0;
}
</style>
</head>
<body class="p-4 md:p-8 min-h-screen text-white">
@ -384,16 +395,29 @@ window.WalletManager = WalletManager;
w.nickname = String(nickname).trim();
this._persist();
}
verifyWallet(address, chain, signature, messageData) {
const w = this.findWallet(address, chain);
if (!w) return { success: false, error: 'Wallet not found' };
w.isVerified = true;
w.signature = String(signature);
w.messageData = messageData || null;
this._persist();
return { success: true };
}
};
verifyWallet(address, chain, signature, messageData) {
const w = this.findWallet(address, chain);
if (!w) return { success: false, error: 'Wallet not found' };
w.isVerified = true;
w.signature = String(signature);
w.messageData = messageData || null;
this._persist();
return { success: true };
}
setWalletOrder(addresses) {
const addrSet = this._wallets.map(w => w.address);
const ordered = [];
for (const addr of addresses) {
if (addrSet.includes(addr)) {
const w = this._wallets.find(x => x.address === addr);
if (w) ordered.push(w);
}
}
const remaining = this._wallets.filter(w => !ordered.includes(w));
this._wallets = [...ordered, ...remaining];
this._persist();
}
};
}
})();
@ -404,7 +428,7 @@ const orangeBrandColor = '#FF7A00';
const blueBrandColor = '#3b82f6';
const API_BASE = window.location.origin;
const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
/* ===================================================================
App State
@ -1007,8 +1031,8 @@ function renderWalletPills() {
const nick = w.nickname || 'Wallet';
const isActive = isWalletInLedger(w.address);
const platformBadge = getPlatformBadge(w.lendingPlatform);
html += '<label class="pill-btn flex items-center gap-2 bg-[#090D14] border border-[#1A1F2C]/60 px-3 py-1.5 rounded-lg transition-all cursor-pointer select-none" data-address="' + w.address + '">' +
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-address="' + w.address + '" ' + (isActive ? 'checked' : '') + ' onchange="event.stopPropagation();toggleWalletPill(\'' + w.address + '\')">' +
html += '<label class="pill-btn flex items-center gap-2 bg-[#090D14] border border-[#1A1F2C]/60 px-3 py-1.5 rounded-lg transition-all cursor-pointer select-none" data-address="' + w.address + '" draggable="true">' +
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-address="' + w.address + '" ' + (isActive ? 'checked' : '') + ' onchange="if(!_dragAddr){event.stopPropagation();toggleWalletPill(\'' + w.address + '\')}">' +
'<div class="flex flex-col">' +
'<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider leading-tight">' + nick + syncIndicator + platformBadge + '</span>' +
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
@ -1016,6 +1040,7 @@ function renderWalletPills() {
'</label>';
});
container.innerHTML = html;
setupPillDragListeners(container);
}
function toggleWalletPill(address) {
@ -1023,6 +1048,117 @@ function toggleWalletPill(address) {
renderLedgerFilterWallets();
}
/* ===================================================================
Drag Reorder (wallet pills)
=================================================================== */
let _dragAddr = null;
let _dropTargetAddr = null;
let _dropBefore = true;
function startPillDrag(e) {
if (!e.currentTarget.dataset.address) return;
_dragAddr = e.currentTarget.dataset.address;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', _dragAddr);
}
e.stopPropagation();
e.currentTarget.classList.add('pill-dragging');
}
function endPillDrag(e) {
e.currentTarget.classList.remove('pill-dragging');
cleanupDrag();
}
function cleanupDrag() {
_dragAddr = null;
_dropTargetAddr = null;
_dropBefore = true;
document.querySelectorAll('.pill-dragging').forEach(el => el.classList.remove('pill-dragging'));
document.querySelectorAll('.drag-insert-line').forEach(el => el.remove());
}
function dropWalletReorder(e, targetAddress) {
e.preventDefault();
e.stopPropagation();
if (!_dragAddr) return cleanupDrag();
const wallets = wm.getWallets();
const addrs = wallets.map(w => w.address);
const dragIdx = addrs.indexOf(_dragAddr);
if (dragIdx === -1) return cleanupDrag();
if (targetAddress && targetAddress !== _dragAddr) {
const targetIdx = addrs.indexOf(targetAddress);
if (targetIdx !== -1) {
const moved = addrs.splice(dragIdx, 1)[0];
const insertIdx = _dropBefore ? targetIdx : targetIdx + 1;
addrs.splice(insertIdx, 0, moved);
wm.setWalletOrder(addrs);
renderAll();
}
} else if (!_dropTargetAddr) {
const moved = addrs.splice(dragIdx, 1)[0];
addrs.push(moved);
wm.setWalletOrder(addrs);
renderAll();
}
cleanupDrag();
}
function setupPillDragListeners(container) {
container.querySelectorAll('.pill-btn').forEach(pill => {
pill.addEventListener('dragstart', startPillDrag);
pill.addEventListener('dragend', endPillDrag);
pill.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
_dropTargetAddr = pill.dataset.address;
const rect = pill.getBoundingClientRect();
_dropBefore = e.clientX < rect.left + rect.width / 2;
});
pill.addEventListener('dragleave', () => { document.querySelector('.drag-insert-line')?.remove(); });
pill.addEventListener('drop', (e) => { dropWalletReorder(e, pill.dataset.address); });
});
}
const pillsContainer = document.getElementById('wallet-pills');
pillsContainer.addEventListener('dragover', (e) => {
if (!_dragAddr) return;
document.querySelector('.drag-insert-line')?.remove();
const children = Array.from(pillsContainer.children).filter(c => c.classList && c.classList.contains('pill-btn'));
for (const child of children) {
if (child.dataset.address === _dragAddr) continue;
const rect = child.getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) {
const line = document.createElement('div');
line.className = 'drag-insert-line';
pillsContainer.insertBefore(line, child);
break;
}
}
});
pillsContainer.addEventListener('drop', (e) => {
if (!_dragAddr || e.target.classList.contains('pill-btn')) return;
e.preventDefault();
e.stopPropagation();
const wallets = wm.getWallets();
const addrs = wallets.map(w => w.address);
const dragIdx = addrs.indexOf(_dragAddr);
if (dragIdx !== -1) {
const moved = addrs.splice(dragIdx, 1)[0];
addrs.push(moved);
wm.setWalletOrder(addrs);
renderAll();
}
cleanupDrag();
});
function getSelectedAddresses() {
const unchecked = getWalletFilter();
const wallets = wm.getWallets();
@ -1164,6 +1300,15 @@ function getTokenAmount(raw, symbol) {
return parseFloat(raw || '0') / Math.pow(10, dec);
}
function getTotalBTC(obj) {
let total = 0;
if (!obj) return 0;
for (const sym of Object.keys(TOKENS)) {
if (TOKENS[sym].priceSymbol === 'BTC') total += getTokenAmount(obj[sym], sym);
}
return total;
}
function priceForToken(symbol, dateStr) {
const today = new Date().toISOString().slice(0, 10);
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
@ -1265,7 +1410,7 @@ function buildCumulativeSeries(addresses) {
const snaps = addressSnapshots[addr] || [];
walletDated[addr] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcAmt: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
btcAmt: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
});
@ -1321,22 +1466,22 @@ function calculateCurrentHoldings() {
const snaps = addressSnapshots[addr] || [];
if (snaps.length > 0) {
const latest = snaps[0];
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
currentNetHeld += getTotalBTC(latest?.wallet);
currentNetHeld += getTotalBTC(latest?.collateral);
}
});
/* Derive buy cost from snapshot deltas: positive cbBTC changes across days */
/* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
if (snaps.length < 2) return;
if (snaps.length === 0) return;
let prevBtc = 0;
snaps.slice().reverse().forEach(snap => {
const currentDate = snap.block_timestamp.slice(0, 10);
const currentBtc = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
const currentBtc = getTotalBTC(snap?.wallet) + getTotalBTC(snap?.collateral);
const delta = currentBtc - prevBtc;
if (delta > 0) {
const price = priceForToken('cbBTC', currentDate) || 0;
const price = priceForToken('BTC', currentDate) || 0;
if (price > 0) currentBuyCost += delta * price;
currentBuyAmount += delta;
}
@ -1707,7 +1852,7 @@ function setupSatsCard(cutoff) {
const snaps = addressSnapshots[addr] || [];
const dated = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcVal: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
btcVal: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let balance = 0;
for (const ds of sortedDates) {
@ -1835,8 +1980,8 @@ function setupBreakdownCard(cutoff) {
const snaps = addressSnapshots[addr] || [];
walletDated[addr] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
cold: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC'),
collateral: getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
cold: getTotalBTC(s?.wallet),
collateral: getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
});
@ -1925,44 +2070,6 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => {
cb.addEventListener('change', () => updateDashboard());
});
/* ===================================================================
Sync Polling
=================================================================== */
async function pollSyncStatus() {
const selectedAddr = getSelectedAddresses();
const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing' || walletSyncState[a] === 'pending');
if (syncing.length === 0) return;
const promises = syncing.map(async (addr) => {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${addr}/base/aave`);
if (resp.status === 400) {
const body = await resp.json().catch(() => ({}));
const details = (body?.detail || body?.details || '').toString().toUpperCase();
if (details.includes('PENDING') || details.includes('SYNCING')) {
walletSyncState[addr] = 'syncing';
} else {
walletSyncState[addr] = 'synced';
}
return;
}
if (!resp.ok) return;
const events = await resp.json();
walletSyncState[addr] = 'synced';
addressSnapshots[addr] = snapshotsToDaily(events);
renderCombinedTable();
updateDashboard();
} catch (err) {
console.error('Sync poll failed for ' + addr, err);
}
});
await Promise.all(promises);
renderWalletPills();
}
function startSyncPolling() {
setInterval(() => pollSyncStatus(), 15000);
}
/* ===================================================================
Polling for Verified Wallets
=================================================================== */
@ -1973,7 +2080,7 @@ async function pollVerifiedWallets() {
const verified = wm.getVerifiedWallets() || [];
for (const w of verified) {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/base/aave`);
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/${w.chain}/aave`);
if (!resp.ok) continue;
const events = await resp.json();
const newestTs = events[events.length - 1].block_timestamp;
@ -2026,7 +2133,6 @@ async function initDashboardGrid() {
currentCutoffMs = cutoff;
refreshAllCharts(cutoff);
startSyncPolling();
startPolling();
} catch (error) {
console.error("Dashboard error:", error);

View File

@ -361,6 +361,26 @@ export class WalletManager {
this._persist();
return { success: true };
}
/**
* Reorder wallets to match the given address sequence.
* Addresses listed first are moved to front; remaining keep their original relative order.
* Only recognized addresses are kept in the new order.
* @param {string[]} addresses
*/
setWalletOrder(addresses) {
const addrSet = this._wallets.map(w => w.address);
const ordered = [];
for (const addr of addresses) {
if (addrSet.includes(addr)) {
const w = this._wallets.find(x => x.address === addr);
if (w) ordered.push(w);
}
}
const remaining = this._wallets.filter(w => !ordered.includes(w));
this._wallets = [...ordered, ...remaining];
this._persist();
}
}
export default WalletManager;