feat: draggable wallet reorder for chart layering
This commit is contained in:
232
index.html
232
index.html
@ -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);
|
||||
|
||||
20
wallets.js
20
wallets.js
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user