Shows users below 1M/2M/3M/4M/5M tokens with filter buttons. Sorted lowest first so admins can quickly find and top up users running low. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
const app = {
|
|
apiKey: localStorage.getItem('bm_api_key') || '',
|
|
currentView: 'home',
|
|
currentPage: 1,
|
|
allData: null,
|
|
searchTimeout: null,
|
|
|
|
init() {
|
|
if (!this.apiKey) {
|
|
this.promptApiKey();
|
|
} else {
|
|
this.loadHome();
|
|
}
|
|
this.bindNav();
|
|
this.bindSearch();
|
|
this.bindModalClose();
|
|
},
|
|
|
|
promptApiKey() {
|
|
const key = prompt('Enter your API key:');
|
|
if (key) {
|
|
this.apiKey = key;
|
|
localStorage.setItem('bm_api_key', key);
|
|
this.loadHome();
|
|
}
|
|
},
|
|
|
|
async api(path, options = {}) {
|
|
const base = document.baseURI.replace(/\/$/, '');
|
|
const url = `${base}/api${path}`;
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': this.apiKey,
|
|
...options.headers,
|
|
},
|
|
});
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('bm_api_key');
|
|
this.apiKey = '';
|
|
this.promptApiKey();
|
|
return null;
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
bindNav() {
|
|
document.querySelectorAll('.nav-item').forEach(item => {
|
|
item.addEventListener('click', () => this.navigate(item.dataset.view));
|
|
});
|
|
document.querySelectorAll('.home-card[data-nav]').forEach(card => {
|
|
card.addEventListener('click', () => this.navigate(card.dataset.nav));
|
|
});
|
|
},
|
|
|
|
bindSearch() {
|
|
const input = document.getElementById('search-input');
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = setTimeout(() => this.doSearch(input.value), 300);
|
|
});
|
|
},
|
|
|
|
bindModalClose() {
|
|
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
|
if (e.target.id === 'modal-overlay') this.closeModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') this.closeModal();
|
|
});
|
|
},
|
|
|
|
navigate(view) {
|
|
this.currentView = view;
|
|
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
|
document.getElementById(`view-${view}`).style.display = 'block';
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
document.querySelector(`.nav-item[data-view="${view}"]`).classList.add('active');
|
|
|
|
if (view === 'home') this.loadHome();
|
|
if (view === 'all') this.loadAll();
|
|
if (view === 'low') this.loadLow(1000000);
|
|
if (view === 'search') document.getElementById('search-input').focus();
|
|
},
|
|
|
|
async loadHome() {
|
|
const stats = await this.api('/stats');
|
|
if (!stats) return;
|
|
document.getElementById('home-stats').innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Users</div>
|
|
<div class="stat-value">${stats.totalUsers.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">With Balance</div>
|
|
<div class="stat-value green">${stats.usersWithBalance.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Zero Balance</div>
|
|
<div class="stat-value red">${stats.zeroBalance.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Avg Balance</div>
|
|
<div class="stat-value purple">${stats.avgCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Credits</div>
|
|
<div class="stat-value">${stats.totalCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Min Balance</div>
|
|
<div class="stat-value red">${stats.minCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Max Balance</div>
|
|
<div class="stat-value green">${stats.maxCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">No Balance Record</div>
|
|
<div class="stat-value red">${stats.usersWithoutBalance.toLocaleString()}</div>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
async loadAll(page = 1) {
|
|
this.currentPage = page;
|
|
const data = await this.api(`/balances?page=${page}&limit=50`);
|
|
if (!data) return;
|
|
this.allData = data;
|
|
document.getElementById('all-count').textContent = `${data.total} Users`;
|
|
document.getElementById('all-tbody').innerHTML = data.users.map(u => this.userRow(u)).join('');
|
|
this.renderPagination(data);
|
|
},
|
|
|
|
async loadLow(threshold) {
|
|
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
btn.classList.toggle('active', parseInt(btn.dataset.threshold) === threshold);
|
|
});
|
|
const data = await this.api(`/balances/low?threshold=${threshold}`);
|
|
if (!data) return;
|
|
const tbody = document.getElementById('low-tbody');
|
|
document.getElementById('low-count').textContent = `${data.users.length} User${data.users.length !== 1 ? 's' : ''} under ${(threshold / 1000000)}M`;
|
|
if (data.users.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users below this threshold</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
|
|
}
|
|
},
|
|
|
|
userRow(u) {
|
|
const credits = u.tokenCredits || 0;
|
|
const balanceClass = credits > 0 ? 'balance-positive' : 'balance-zero';
|
|
const name = u.name || u.username || '-';
|
|
const email = u.email || '-';
|
|
return `
|
|
<tr>
|
|
<td class="email-cell">${this.esc(email)}</td>
|
|
<td class="name-cell">${this.esc(name)}</td>
|
|
<td class="balance-cell ${balanceClass}">${credits.toLocaleString()}</td>
|
|
<td>
|
|
<div class="actions-cell">
|
|
<button class="btn btn-sm btn-green" onclick="app.showEditModal('${u.userId}', '${this.esc(email)}', ${credits}, 'add')">Top Up</button>
|
|
<button class="btn btn-sm btn-secondary" onclick="app.showEditModal('${u.userId}', '${this.esc(email)}', ${credits}, 'set')">Set</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
},
|
|
|
|
renderPagination(data) {
|
|
const el = document.getElementById('pagination');
|
|
if (data.totalPages <= 1) {
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<button ${data.page <= 1 ? 'disabled' : ''} onclick="app.loadAll(${data.page - 1})">Prev</button>
|
|
<span class="page-info">Page ${data.page} of ${data.totalPages}</span>
|
|
<button ${data.page >= data.totalPages ? 'disabled' : ''} onclick="app.loadAll(${data.page + 1})">Next</button>
|
|
`;
|
|
},
|
|
|
|
async doSearch(query) {
|
|
const container = document.getElementById('search-results-container');
|
|
const tbody = document.getElementById('search-tbody');
|
|
|
|
if (!query || query.length < 2) {
|
|
container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const data = await this.api(`/balances/search?q=${encodeURIComponent(query)}`);
|
|
if (!data) return;
|
|
|
|
container.style.display = 'block';
|
|
document.getElementById('search-count').textContent = `${data.users.length} Result${data.users.length !== 1 ? 's' : ''}`;
|
|
|
|
if (data.users.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users found</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
|
|
}
|
|
},
|
|
|
|
showEditModal(userId, email, currentBalance, mode) {
|
|
const isAdd = mode === 'add';
|
|
const modal = document.getElementById('modal');
|
|
modal.innerHTML = `
|
|
<h3>${isAdd ? 'Top Up Balance' : 'Set Balance'}</h3>
|
|
<div class="modal-subtitle">${email}</div>
|
|
<div class="modal-field">
|
|
<label>Current Balance</label>
|
|
<div class="current-balance">${currentBalance.toLocaleString()}</div>
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>${isAdd ? 'Amount to Add' : 'New Balance'}</label>
|
|
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
|
|
<div class="bulk-presets" style="margin-top:10px">
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="app.submitEdit('${userId}', '${mode}')">
|
|
${isAdd ? 'Add Tokens' : 'Set Balance'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('modal-overlay').classList.add('active');
|
|
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
|
|
},
|
|
|
|
async submitEdit(userId, mode) {
|
|
const amount = parseInt(document.getElementById('modal-amount').value);
|
|
if (isNaN(amount)) {
|
|
this.toast('Please enter a valid number', 'error');
|
|
return;
|
|
}
|
|
if (mode === 'set' && amount < 0) {
|
|
this.toast('Balance cannot be negative', 'error');
|
|
return;
|
|
}
|
|
|
|
const endpoint = mode === 'add' ? 'add' : 'set';
|
|
const result = await this.api(`/balances/${userId}/${endpoint}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amount }),
|
|
});
|
|
|
|
if (result && !result.error) {
|
|
this.toast(`Balance updated to ${result.tokenCredits.toLocaleString()}`, 'success');
|
|
this.closeModal();
|
|
if (this.currentView === 'all') this.loadAll(this.currentPage);
|
|
if (this.currentView === 'search') {
|
|
const q = document.getElementById('search-input').value;
|
|
if (q) this.doSearch(q);
|
|
}
|
|
if (this.currentView === 'home') this.loadHome();
|
|
} else {
|
|
this.toast(result?.error || 'Failed to update', 'error');
|
|
}
|
|
},
|
|
|
|
showBulkModal(mode) {
|
|
const isAdd = mode === 'add';
|
|
const modal = document.getElementById('modal');
|
|
modal.innerHTML = `
|
|
<h3>${isAdd ? 'Add Tokens to All Users' : 'Set All Users Balance'}</h3>
|
|
<div class="modal-subtitle">${isAdd
|
|
? 'This will increment every user\'s balance by the specified amount.'
|
|
: 'This will set every user\'s balance to the exact amount. Existing balances will be overwritten.'
|
|
}</div>
|
|
<div class="modal-field">
|
|
<label>${isAdd ? 'Amount to Add' : 'New Balance for All'}</label>
|
|
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
|
|
<div class="bulk-presets" style="margin-top:10px">
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=10000000">10M</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
|
|
<button class="btn ${isAdd ? 'btn-green' : 'btn-red'}" onclick="app.submitBulk('${mode}')">
|
|
${isAdd ? 'Add to All' : 'Set All Balances'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('modal-overlay').classList.add('active');
|
|
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
|
|
},
|
|
|
|
async submitBulk(mode) {
|
|
const amount = parseInt(document.getElementById('modal-amount').value);
|
|
if (isNaN(amount)) {
|
|
this.toast('Please enter a valid number', 'error');
|
|
return;
|
|
}
|
|
if (mode === 'set' && amount < 0) {
|
|
this.toast('Balance cannot be negative', 'error');
|
|
return;
|
|
}
|
|
|
|
const confirmed = confirm(`Are you sure you want to ${mode === 'add' ? 'add ' + amount.toLocaleString() + ' tokens to' : 'set'} ALL users${mode === 'set' ? ' to ' + amount.toLocaleString() : ''}?`);
|
|
if (!confirmed) return;
|
|
|
|
const result = await this.api(`/balances/bulk/${mode}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amount }),
|
|
});
|
|
|
|
if (result && !result.error) {
|
|
this.toast(`Updated ${result.modifiedCount} users`, 'success');
|
|
this.closeModal();
|
|
} else {
|
|
this.toast(result?.error || 'Bulk operation failed', 'error');
|
|
}
|
|
},
|
|
|
|
exportCSV() {
|
|
if (!this.allData || !this.allData.users.length) return;
|
|
const rows = [['Email', 'Name', 'Balance']];
|
|
for (const u of this.allData.users) {
|
|
rows.push([u.email || '', u.name || u.username || '', u.tokenCredits || 0]);
|
|
}
|
|
const csv = rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `balances-page${this.currentPage}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
|
|
closeModal() {
|
|
document.getElementById('modal-overlay').classList.remove('active');
|
|
},
|
|
|
|
toast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transition = 'opacity 0.3s';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
},
|
|
|
|
esc(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
},
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', () => app.init());
|