feat: draggable wallet reorder for chart layering
This commit is contained in:
212
index.html
212
index.html
@ -109,6 +109,17 @@ window.WalletManager = WalletManager;
|
|||||||
::-webkit-scrollbar-track { background: #05070B; }
|
::-webkit-scrollbar-track { background: #05070B; }
|
||||||
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #374151; }
|
::-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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="p-4 md:p-8 min-h-screen text-white">
|
<body class="p-4 md:p-8 min-h-screen text-white">
|
||||||
@ -393,6 +404,19 @@ window.WalletManager = WalletManager;
|
|||||||
this._persist();
|
this._persist();
|
||||||
return { success: true };
|
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 blueBrandColor = '#3b82f6';
|
||||||
const API_BASE = window.location.origin;
|
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 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
|
App State
|
||||||
@ -1007,8 +1031,8 @@ function renderWalletPills() {
|
|||||||
const nick = w.nickname || 'Wallet';
|
const nick = w.nickname || 'Wallet';
|
||||||
const isActive = isWalletInLedger(w.address);
|
const isActive = isWalletInLedger(w.address);
|
||||||
const platformBadge = getPlatformBadge(w.lendingPlatform);
|
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 + '">' +
|
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="event.stopPropagation();toggleWalletPill(\'' + w.address + '\')">' +
|
'<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">' +
|
'<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-[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>' +
|
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
|
||||||
@ -1016,6 +1040,7 @@ function renderWalletPills() {
|
|||||||
'</label>';
|
'</label>';
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
setupPillDragListeners(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleWalletPill(address) {
|
function toggleWalletPill(address) {
|
||||||
@ -1023,6 +1048,117 @@ function toggleWalletPill(address) {
|
|||||||
renderLedgerFilterWallets();
|
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() {
|
function getSelectedAddresses() {
|
||||||
const unchecked = getWalletFilter();
|
const unchecked = getWalletFilter();
|
||||||
const wallets = wm.getWallets();
|
const wallets = wm.getWallets();
|
||||||
@ -1164,6 +1300,15 @@ function getTokenAmount(raw, symbol) {
|
|||||||
return parseFloat(raw || '0') / Math.pow(10, dec);
|
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) {
|
function priceForToken(symbol, dateStr) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
|
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
|
||||||
@ -1265,7 +1410,7 @@ function buildCumulativeSeries(addresses) {
|
|||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
walletDated[addr] = snaps.map(s => ({
|
walletDated[addr] = snaps.map(s => ({
|
||||||
dateStr: s.block_timestamp.slice(0, 10),
|
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));
|
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1321,22 +1466,22 @@ function calculateCurrentHoldings() {
|
|||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
if (snaps.length > 0) {
|
if (snaps.length > 0) {
|
||||||
const latest = snaps[0];
|
const latest = snaps[0];
|
||||||
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
|
currentNetHeld += getTotalBTC(latest?.wallet);
|
||||||
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
|
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 => {
|
selectedAddr.forEach(addr => {
|
||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
if (snaps.length < 2) return;
|
if (snaps.length === 0) return;
|
||||||
let prevBtc = 0;
|
let prevBtc = 0;
|
||||||
snaps.slice().reverse().forEach(snap => {
|
snaps.slice().reverse().forEach(snap => {
|
||||||
const currentDate = snap.block_timestamp.slice(0, 10);
|
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;
|
const delta = currentBtc - prevBtc;
|
||||||
if (delta > 0) {
|
if (delta > 0) {
|
||||||
const price = priceForToken('cbBTC', currentDate) || 0;
|
const price = priceForToken('BTC', currentDate) || 0;
|
||||||
if (price > 0) currentBuyCost += delta * price;
|
if (price > 0) currentBuyCost += delta * price;
|
||||||
currentBuyAmount += delta;
|
currentBuyAmount += delta;
|
||||||
}
|
}
|
||||||
@ -1707,7 +1852,7 @@ function setupSatsCard(cutoff) {
|
|||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
const dated = snaps.map(s => ({
|
const dated = snaps.map(s => ({
|
||||||
dateStr: s.block_timestamp.slice(0, 10),
|
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));
|
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||||||
let balance = 0;
|
let balance = 0;
|
||||||
for (const ds of sortedDates) {
|
for (const ds of sortedDates) {
|
||||||
@ -1835,8 +1980,8 @@ function setupBreakdownCard(cutoff) {
|
|||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
walletDated[addr] = snaps.map(s => ({
|
walletDated[addr] = snaps.map(s => ({
|
||||||
dateStr: s.block_timestamp.slice(0, 10),
|
dateStr: s.block_timestamp.slice(0, 10),
|
||||||
cold: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC'),
|
cold: getTotalBTC(s?.wallet),
|
||||||
collateral: getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
|
collateral: getTotalBTC(s?.collateral)
|
||||||
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1925,44 +2070,6 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => {
|
|||||||
cb.addEventListener('change', () => updateDashboard());
|
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
|
Polling for Verified Wallets
|
||||||
=================================================================== */
|
=================================================================== */
|
||||||
@ -1973,7 +2080,7 @@ async function pollVerifiedWallets() {
|
|||||||
const verified = wm.getVerifiedWallets() || [];
|
const verified = wm.getVerifiedWallets() || [];
|
||||||
for (const w of verified) {
|
for (const w of verified) {
|
||||||
try {
|
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;
|
if (!resp.ok) continue;
|
||||||
const events = await resp.json();
|
const events = await resp.json();
|
||||||
const newestTs = events[events.length - 1].block_timestamp;
|
const newestTs = events[events.length - 1].block_timestamp;
|
||||||
@ -2026,7 +2133,6 @@ async function initDashboardGrid() {
|
|||||||
currentCutoffMs = cutoff;
|
currentCutoffMs = cutoff;
|
||||||
refreshAllCharts(cutoff);
|
refreshAllCharts(cutoff);
|
||||||
|
|
||||||
startSyncPolling();
|
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Dashboard error:", error);
|
console.error("Dashboard error:", error);
|
||||||
|
|||||||
20
wallets.js
20
wallets.js
@ -361,6 +361,26 @@ export class WalletManager {
|
|||||||
this._persist();
|
this._persist();
|
||||||
return { success: true };
|
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;
|
export default WalletManager;
|
||||||
|
|||||||
Reference in New Issue
Block a user