- New 'See Moxie in Action' section with heading and subtitle - Embedded YouTube Short (b4ly_hECE_I) in a responsive 16:9 container - Decorative emerald glow effects and rounded card styling - 'Watch on YouTube' link with YouTube icon below the embed - Scroll-animate integration for reveal on scroll
698 lines
31 KiB
HTML
Executable File
698 lines
31 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="description" content="Moxiegen Admin Dashboard">
|
|
<title>Admin Dashboard • Moxiegen</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap');
|
|
|
|
:root {
|
|
--dark-background: #0F172A;
|
|
--dark-surface: #1E293B;
|
|
--dark-primary-text: #F8FAFC;
|
|
--dark-secondary-text: #94A3B8;
|
|
--dark-accent-primary: #38BDF8;
|
|
--dark-accent-neural: #A5B4FC;
|
|
--dark-border: #334155;
|
|
}
|
|
|
|
html, body {
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
body {
|
|
background: var(--dark-background);
|
|
color: var(--dark-primary-text);
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
|
|
.gradient-text {
|
|
background: linear-gradient(135deg, var(--dark-accent-primary), var(--dark-accent-neural));
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.card {
|
|
background: var(--dark-surface);
|
|
border: 1px solid var(--dark-border);
|
|
border-radius: 1rem;
|
|
}
|
|
|
|
.status-new { background: #3b82f6; }
|
|
.status-read { background: #6366f1; }
|
|
.status-replied { background: #22c55e; }
|
|
.status-archived { background: #64748b; }
|
|
.status-spam { background: #ef4444; }
|
|
|
|
.tab-active {
|
|
background: linear-gradient(135deg, #38BDF8, #A5B4FC);
|
|
color: #0F172A;
|
|
}
|
|
|
|
.table-row:hover {
|
|
background: rgba(56, 189, 248, 0.1);
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #38BDF8, #A5B4FC);
|
|
color: #0F172A;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px -5px rgba(56, 189, 248, 0.3);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--dark-surface);
|
|
border: 1px solid var(--dark-border);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
border-color: var(--dark-accent-primary);
|
|
}
|
|
|
|
.modal-overlay {
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.loading {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen">
|
|
<!-- Header -->
|
|
<header class="border-b border-slate-800 bg-slate-950/80 backdrop-blur-lg sticky top-0 z-50">
|
|
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<a href="index.html" class="flex items-center gap-2">
|
|
<img src="logo.svg" alt="Moxiegen" class="h-8 w-auto" onerror="this.onerror=null; this.src='logo.png';">
|
|
<span class="font-semibold text-xl" style="font-family: 'Space Grotesk', sans-serif;">Moxiegen</span>
|
|
</a>
|
|
<span class="text-slate-500">/</span>
|
|
<span class="gradient-text font-semibold">Admin</span>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<a href="index.html" class="text-slate-400 hover:text-white transition-colors">← Back to Site</a>
|
|
<button onclick="logout()" class="btn-secondary px-4 py-2 rounded-lg text-sm font-medium">Logout</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Access Denied Message -->
|
|
<div id="accessDenied" class="hidden min-h-screen flex items-center justify-center">
|
|
<div class="text-center">
|
|
<div class="text-6xl mb-4">🚫</div>
|
|
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
|
<p class="text-slate-400 mb-6">You need admin privileges to view this page.</p>
|
|
<a href="index.html" class="btn-primary px-6 py-3 rounded-lg font-semibold inline-block">Go Home</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<main id="mainContent" class="hidden max-w-7xl mx-auto px-6 py-8">
|
|
<!-- Stats -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
|
<div class="card p-6">
|
|
<div class="text-slate-400 text-sm mb-1">Total Users</div>
|
|
<div id="statUsers" class="text-3xl font-bold gradient-text">-</div>
|
|
</div>
|
|
<div class="card p-6">
|
|
<div class="text-slate-400 text-sm mb-1">New Messages</div>
|
|
<div id="statNewMessages" class="text-3xl font-bold text-blue-400">-</div>
|
|
</div>
|
|
<div class="card p-6">
|
|
<div class="text-slate-400 text-sm mb-1">Total Credits</div>
|
|
<div id="statCredits" class="text-3xl font-bold text-green-400">-</div>
|
|
</div>
|
|
<div class="card p-6">
|
|
<div class="text-slate-400 text-sm mb-1">Active API Keys</div>
|
|
<div id="statApiKeys" class="text-3xl font-bold text-purple-400">-</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="flex gap-2 mb-6">
|
|
<button onclick="switchTab('messages')" id="tabMessages" class="tab-active px-6 py-2 rounded-lg font-medium transition-all">
|
|
Messages
|
|
</button>
|
|
<button onclick="switchTab('users')" id="tabUsers" class="btn-secondary px-6 py-2 rounded-lg font-medium transition-all">
|
|
Users
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Messages Tab -->
|
|
<div id="messagesTab" class="card overflow-hidden">
|
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">Contact Submissions</h2>
|
|
<div class="flex gap-2">
|
|
<select id="statusFilter" onchange="loadMessages()" class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm">
|
|
<option value="">All Statuses</option>
|
|
<option value="new">New</option>
|
|
<option value="read">Read</option>
|
|
<option value="replied">Replied</option>
|
|
<option value="archived">Archived</option>
|
|
<option value="spam">Spam</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="messagesLoading" class="p-8 text-center">
|
|
<div class="loading inline-block text-4xl">⟳</div>
|
|
<p class="text-slate-400 mt-2">Loading messages...</p>
|
|
</div>
|
|
|
|
<div id="messagesEmpty" class="hidden p-8 text-center text-slate-400">
|
|
<div class="text-4xl mb-2">📭</div>
|
|
<p>No messages yet</p>
|
|
</div>
|
|
|
|
<div id="messagesContent" class="hidden divide-y divide-slate-800">
|
|
<!-- Messages will be inserted here -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users Tab -->
|
|
<div id="usersTab" class="card overflow-hidden hidden">
|
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">Users</h2>
|
|
</div>
|
|
|
|
<div id="usersLoading" class="p-8 text-center">
|
|
<div class="loading inline-block text-4xl">⟳</div>
|
|
<p class="text-slate-400 mt-2">Loading users...</p>
|
|
</div>
|
|
|
|
<div id="usersEmpty" class="hidden p-8 text-center text-slate-400">
|
|
<div class="text-4xl mb-2">👥</div>
|
|
<p>No users yet</p>
|
|
</div>
|
|
|
|
<div id="usersContent" class="hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead class="bg-slate-800/50">
|
|
<tr>
|
|
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">User</th>
|
|
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Email</th>
|
|
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Role</th>
|
|
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Credits</th>
|
|
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Created</th>
|
|
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="usersTableBody" class="divide-y divide-slate-800">
|
|
<!-- Users will be inserted here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Message Detail Modal -->
|
|
<div id="messageModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div class="modal-overlay absolute inset-0" onclick="closeModal()"></div>
|
|
<div class="card relative z-10 w-full max-w-2xl max-h-[90vh] overflow-auto">
|
|
<div class="p-6 border-b border-slate-700 flex items-start justify-between">
|
|
<div>
|
|
<h3 id="modalSubject" class="text-xl font-semibold">Message from <span id="modalName">-</span></h3>
|
|
<p id="modalMeta" class="text-sm text-slate-400 mt-1">-</p>
|
|
</div>
|
|
<button onclick="closeModal()" class="text-slate-400 hover:text-white text-2xl">×</button>
|
|
</div>
|
|
<div class="p-6">
|
|
<div class="mb-4">
|
|
<span class="text-slate-400 text-sm">From:</span>
|
|
<span id="modalEmail" class="ml-2">-</span>
|
|
</div>
|
|
<div class="mb-4">
|
|
<span class="text-slate-400 text-sm">Company:</span>
|
|
<span id="modalCompany" class="ml-2">-</span>
|
|
</div>
|
|
<div class="mb-4">
|
|
<span class="text-slate-400 text-sm">Status:</span>
|
|
<span id="modalStatus" class="ml-2 px-2 py-0.5 rounded text-xs font-medium">-</span>
|
|
</div>
|
|
<div class="border-t border-slate-700 pt-4">
|
|
<span class="text-slate-400 text-sm block mb-2">Message:</span>
|
|
<p id="modalMessage" class="whitespace-pre-wrap">-</p>
|
|
</div>
|
|
</div>
|
|
<div class="p-6 border-t border-slate-700 flex flex-wrap gap-2">
|
|
<button onclick="updateStatus('read')" class="btn-secondary px-4 py-2 rounded-lg text-sm">Mark Read</button>
|
|
<button onclick="updateStatus('replied')" class="btn-secondary px-4 py-2 rounded-lg text-sm">Mark Replied</button>
|
|
<button onclick="updateStatus('archived')" class="btn-secondary px-4 py-2 rounded-lg text-sm">Archive</button>
|
|
<button onclick="updateStatus('spam')" class="btn-secondary px-4 py-2 rounded-lg text-sm text-red-400">Spam</button>
|
|
<a id="replyLink" href="#" class="btn-primary px-4 py-2 rounded-lg text-sm font-medium ml-auto">Reply via Email →</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credits Modal -->
|
|
<div id="creditsModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div class="modal-overlay absolute inset-0" onclick="closeCreditsModal()"></div>
|
|
<div class="card relative z-10 w-full max-w-md">
|
|
<div class="p-6 border-b border-slate-700">
|
|
<h3 class="text-xl font-semibold">Adjust Credits</h3>
|
|
<p id="creditsModalUser" class="text-sm text-slate-400">User: -</p>
|
|
</div>
|
|
<div class="p-6">
|
|
<div class="mb-4">
|
|
<label class="block text-sm text-slate-400 mb-2">Current Balance</label>
|
|
<p id="creditsCurrentBalance" class="text-2xl font-bold gradient-text">0</p>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="block text-sm text-slate-400 mb-2">Amount</label>
|
|
<input type="number" id="creditsAmount" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2" placeholder="Enter amount (positive or negative)">
|
|
</div>
|
|
<div class="mb-4">
|
|
<label class="block text-sm text-slate-400 mb-2">Description</label>
|
|
<input type="text" id="creditsDescription" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2" placeholder="Reason for adjustment">
|
|
</div>
|
|
</div>
|
|
<div class="p-6 border-t border-slate-700 flex gap-2">
|
|
<button onclick="closeCreditsModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
|
<button onclick="submitCredits()" class="btn-primary px-4 py-2 rounded-lg font-medium">Apply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="js/config.js"></script>
|
|
<script src="js/auth.js"></script>
|
|
<script>
|
|
let currentTab = 'messages';
|
|
let currentMessageId = null;
|
|
let currentUserId = null;
|
|
let messages = [];
|
|
let users = [];
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
try {
|
|
await moxieAuth.init();
|
|
|
|
if (!moxieAuth.isAuthenticated) {
|
|
window.location.href = 'index.html';
|
|
return;
|
|
}
|
|
|
|
// Check if admin
|
|
const isAdmin = await checkAdmin();
|
|
if (!isAdmin) {
|
|
document.getElementById('accessDenied').classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('mainContent').classList.remove('hidden');
|
|
await Promise.all([loadStats(), loadMessages(), loadUsers()]);
|
|
} catch (error) {
|
|
console.error('Init error:', error);
|
|
window.location.href = 'index.html';
|
|
}
|
|
});
|
|
|
|
async function checkAdmin() {
|
|
const token = await moxieAuth.getToken();
|
|
try {
|
|
const response = await fetch('/api/users/me', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await response.json();
|
|
return data.success && data.data?.user?.role === 'admin';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
const token = await moxieAuth.getToken();
|
|
|
|
// Load users for stats
|
|
try {
|
|
const response = await fetch('/api/users', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const users = data.data.users || data.data;
|
|
const totalUsers = users.length;
|
|
const totalCredits = users.reduce((sum, u) => sum + (u.credits || 0), 0);
|
|
document.getElementById('statUsers').textContent = totalUsers;
|
|
document.getElementById('statCredits').textContent = totalCredits.toLocaleString();
|
|
}
|
|
} catch (error) {
|
|
console.error('Stats error:', error);
|
|
}
|
|
|
|
// Load messages for stats
|
|
try {
|
|
const response = await fetch('/api/contact', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const messages = data.data || [];
|
|
const newMessages = messages.filter(m => m.status === 'new').length;
|
|
document.getElementById('statNewMessages').textContent = newMessages;
|
|
}
|
|
} catch (error) {
|
|
console.error('Message stats error:', error);
|
|
}
|
|
}
|
|
|
|
async function loadMessages() {
|
|
const token = await moxieAuth.getToken();
|
|
const statusFilter = document.getElementById('statusFilter').value;
|
|
|
|
document.getElementById('messagesLoading').classList.remove('hidden');
|
|
document.getElementById('messagesContent').classList.add('hidden');
|
|
document.getElementById('messagesEmpty').classList.add('hidden');
|
|
|
|
try {
|
|
const response = await fetch('/api/contact', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
messages = data.data;
|
|
|
|
if (statusFilter) {
|
|
messages = messages.filter(m => m.status === statusFilter);
|
|
}
|
|
|
|
document.getElementById('messagesLoading').classList.add('hidden');
|
|
|
|
if (messages.length === 0) {
|
|
document.getElementById('messagesEmpty').classList.remove('hidden');
|
|
} else {
|
|
renderMessages();
|
|
document.getElementById('messagesContent').classList.remove('hidden');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Load messages error:', error);
|
|
document.getElementById('messagesLoading').innerHTML = '<p class="text-red-400">Failed to load messages</p>';
|
|
}
|
|
}
|
|
|
|
function renderMessages() {
|
|
const container = document.getElementById('messagesContent');
|
|
container.innerHTML = messages.map(m => `
|
|
<div class="table-row p-4 cursor-pointer" onclick="openMessage('${m.id}')">
|
|
<div class="flex items-start gap-4">
|
|
<div class="flex-shrink-0">
|
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-cyan-400 to-indigo-400 flex items-center justify-center font-semibold text-slate-900">
|
|
${(m.name || '?')[0].toUpperCase()}
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="font-medium">${escapeHtml(m.name)}</span>
|
|
<span class="status-${m.status} px-2 py-0.5 rounded text-xs">${m.status}</span>
|
|
<span class="text-slate-500 text-sm ml-auto">${formatDate(m.created_at)}</span>
|
|
</div>
|
|
<div class="text-slate-400 text-sm">${escapeHtml(m.email)}</div>
|
|
<div class="text-slate-500 text-sm mt-1 truncate">${escapeHtml(m.message || '').substring(0, 100)}...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function openMessage(id) {
|
|
const message = messages.find(m => m.id === id);
|
|
if (!message) return;
|
|
|
|
currentMessageId = id;
|
|
|
|
document.getElementById('modalName').textContent = message.name || '-';
|
|
document.getElementById('modalEmail').textContent = message.email || '-';
|
|
document.getElementById('modalCompany').textContent = message.company || 'Not provided';
|
|
document.getElementById('modalMessage').textContent = message.message || '-';
|
|
document.getElementById('modalMeta').textContent = `Submitted ${formatDate(message.created_at)}`;
|
|
|
|
const statusEl = document.getElementById('modalStatus');
|
|
statusEl.textContent = message.status;
|
|
statusEl.className = `ml-2 px-2 py-0.5 rounded text-xs font-medium status-${message.status}`;
|
|
|
|
document.getElementById('replyLink').href = `mailto:${message.email}?subject=Re: Your message to Moxiegen`;
|
|
|
|
document.getElementById('messageModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('messageModal').classList.add('hidden');
|
|
currentMessageId = null;
|
|
}
|
|
|
|
async function updateStatus(status) {
|
|
if (!currentMessageId) return;
|
|
|
|
const token = await moxieAuth.getToken();
|
|
|
|
try {
|
|
const response = await fetch(`/api/contact/${currentMessageId}/status`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ status })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
closeModal();
|
|
await loadMessages();
|
|
await loadStats();
|
|
} else {
|
|
alert('Failed to update status: ' + data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Update status error:', error);
|
|
alert('Failed to update status');
|
|
}
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const token = await moxieAuth.getToken();
|
|
|
|
document.getElementById('usersLoading').classList.remove('hidden');
|
|
document.getElementById('usersContent').classList.add('hidden');
|
|
document.getElementById('usersEmpty').classList.add('hidden');
|
|
|
|
try {
|
|
const response = await fetch('/api/users', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
users = data.data.users || data.data;
|
|
|
|
document.getElementById('usersLoading').classList.add('hidden');
|
|
|
|
if (users.length === 0) {
|
|
document.getElementById('usersEmpty').classList.remove('hidden');
|
|
} else {
|
|
renderUsers();
|
|
document.getElementById('usersContent').classList.remove('hidden');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Load users error:', error);
|
|
document.getElementById('usersLoading').innerHTML = '<p class="text-red-400">Failed to load users</p>';
|
|
}
|
|
}
|
|
|
|
function renderUsers() {
|
|
const tbody = document.getElementById('usersTableBody');
|
|
tbody.innerHTML = users.map(u => `
|
|
<tr class="table-row">
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-cyan-400 to-indigo-400 flex items-center justify-center font-medium text-slate-900 text-sm flex-shrink-0">
|
|
${(u.name || u.email || '?')[0].toUpperCase()}
|
|
</div>
|
|
<span class="truncate">${escapeHtml(u.name || 'No name')}</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3 text-slate-400 truncate max-w-[200px]">${escapeHtml(u.email || '-')}</td>
|
|
<td class="px-4 py-3">
|
|
<span class="px-2 py-0.5 rounded text-xs whitespace-nowrap ${u.role === 'admin' ? 'bg-purple-500/20 text-purple-400' : 'bg-slate-700 text-slate-300'}">
|
|
${u.role || 'user'}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3 font-medium whitespace-nowrap">${(u.credits || 0).toLocaleString()}</td>
|
|
<td class="px-4 py-3 text-slate-400 text-sm whitespace-nowrap">${formatDate(u.created_at)}</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex gap-2 flex-nowrap">
|
|
<button onclick="openCreditsModal('${u.id}', '${escapeHtml(u.name || u.email)}', ${u.credits || 0})" class="btn-secondary px-3 py-1 rounded text-sm whitespace-nowrap">
|
|
Credits
|
|
</button>
|
|
${u.role === 'admin' ? `
|
|
<button onclick="toggleAdmin('${u.id}', 'user')" class="px-3 py-1 rounded text-sm whitespace-nowrap bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 transition-colors">
|
|
Remove Admin
|
|
</button>
|
|
` : `
|
|
<button onclick="toggleAdmin('${u.id}', 'admin')" class="px-3 py-1 rounded text-sm whitespace-nowrap bg-purple-500/20 text-purple-400 hover:bg-purple-500/30 transition-colors">
|
|
Make Admin
|
|
</button>
|
|
`}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function openCreditsModal(userId, userName, currentCredits) {
|
|
currentUserId = userId;
|
|
document.getElementById('creditsModalUser').textContent = `User: ${userName}`;
|
|
document.getElementById('creditsCurrentBalance').textContent = currentCredits.toLocaleString();
|
|
document.getElementById('creditsAmount').value = '';
|
|
document.getElementById('creditsDescription').value = '';
|
|
document.getElementById('creditsModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeCreditsModal() {
|
|
document.getElementById('creditsModal').classList.add('hidden');
|
|
currentUserId = null;
|
|
}
|
|
|
|
async function submitCredits() {
|
|
if (!currentUserId) return;
|
|
|
|
const amount = parseInt(document.getElementById('creditsAmount').value);
|
|
const description = document.getElementById('creditsDescription').value;
|
|
|
|
if (isNaN(amount) || amount === 0) {
|
|
alert('Please enter a valid amount');
|
|
return;
|
|
}
|
|
|
|
const token = await moxieAuth.getToken();
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${currentUserId}/credits`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ amount, description: description || 'Admin adjustment' })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
closeCreditsModal();
|
|
await Promise.all([loadUsers(), loadStats()]);
|
|
} else {
|
|
alert('Failed to update credits: ' + data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Credits error:', error);
|
|
alert('Failed to update credits');
|
|
}
|
|
}
|
|
|
|
async function toggleAdmin(userId, newRole) {
|
|
const user = users.find(u => u.id === userId);
|
|
const userName = user?.name || user?.email || 'this user';
|
|
|
|
const action = newRole === 'admin' ? 'promote' : 'demote';
|
|
if (!confirm(`Are you sure you want to ${action} ${userName} to ${newRole}?`)) {
|
|
return;
|
|
}
|
|
|
|
const token = await moxieAuth.getToken();
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/role`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ role: newRole })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
await loadUsers();
|
|
} else {
|
|
alert('Failed to update role: ' + data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Role update error:', error);
|
|
alert('Failed to update role');
|
|
}
|
|
}
|
|
|
|
function switchTab(tab) {
|
|
currentTab = tab;
|
|
|
|
document.getElementById('tabMessages').classList.remove('tab-active');
|
|
document.getElementById('tabMessages').classList.add('btn-secondary');
|
|
document.getElementById('tabUsers').classList.remove('tab-active');
|
|
document.getElementById('tabUsers').classList.add('btn-secondary');
|
|
|
|
document.getElementById('messagesTab').classList.add('hidden');
|
|
document.getElementById('usersTab').classList.add('hidden');
|
|
|
|
if (tab === 'messages') {
|
|
document.getElementById('tabMessages').classList.add('tab-active');
|
|
document.getElementById('tabMessages').classList.remove('btn-secondary');
|
|
document.getElementById('messagesTab').classList.remove('hidden');
|
|
} else {
|
|
document.getElementById('tabUsers').classList.add('tab-active');
|
|
document.getElementById('tabUsers').classList.remove('btn-secondary');
|
|
document.getElementById('usersTab').classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
async function logout() {
|
|
await moxieAuth.logout();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|