···
5
+
<meta charset="UTF-8">
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+
<title>Admin - Thistle</title>
9
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
10
+
<link rel="stylesheet" href="../styles/main.css">
19
+
margin-bottom: 2rem;
24
+
margin-bottom: 3rem;
31
+
margin-bottom: 1rem;
33
+
align-items: center;
40
+
border-bottom: 2px solid var(--secondary);
41
+
margin-bottom: 2rem;
45
+
padding: 0.75rem 1.5rem;
47
+
background: transparent;
52
+
font-family: inherit;
53
+
border-bottom: 2px solid transparent;
54
+
margin-bottom: -2px;
55
+
transition: all 0.2s;
59
+
color: var(--primary);
63
+
color: var(--primary);
64
+
border-bottom-color: var(--primary);
71
+
.tab-content.active {
77
+
border-collapse: collapse;
78
+
background: var(--background);
79
+
border: 2px solid var(--secondary);
85
+
background: var(--primary);
97
+
border-top: 1px solid var(--secondary);
102
+
background: rgba(0, 0, 0, 0.02);
106
+
display: inline-block;
107
+
padding: 0.25rem 0.75rem;
108
+
border-radius: 4px;
109
+
font-size: 0.875rem;
113
+
.status-completed {
114
+
background: #dcfce7;
118
+
.status-processing,
119
+
.status-uploading {
120
+
background: #fef3c7;
125
+
background: #fee2e2;
130
+
background: #e0e7ff;
135
+
background: var(--accent);
137
+
padding: 0.25rem 0.5rem;
138
+
border-radius: 4px;
139
+
font-size: 0.75rem;
141
+
margin-left: 0.5rem;
146
+
align-items: center;
153
+
border-radius: 50%;
157
+
text-align: center;
159
+
color: var(--text);
164
+
text-align: center;
166
+
color: var(--text);
170
+
background: #fee2e2;
173
+
border-radius: 6px;
174
+
margin-bottom: 1rem;
179
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
181
+
margin-bottom: 2rem;
185
+
background: var(--background);
186
+
border: 2px solid var(--secondary);
187
+
border-radius: 8px;
194
+
color: var(--primary);
195
+
margin-bottom: 0.25rem;
199
+
color: var(--text);
201
+
font-size: 0.875rem;
205
+
color: var(--text);
207
+
font-size: 0.875rem;
211
+
background: transparent;
212
+
border: 2px solid #dc2626;
214
+
padding: 0.25rem 0.75rem;
215
+
border-radius: 4px;
217
+
font-size: 0.875rem;
219
+
font-family: inherit;
220
+
transition: all 0.2s;
223
+
.delete-btn:hover {
224
+
background: #dc2626;
228
+
.delete-btn:disabled {
230
+
cursor: not-allowed;
239
+
padding: 0.25rem 0.5rem;
240
+
border: 2px solid var(--secondary);
241
+
border-radius: 4px;
242
+
font-size: 0.875rem;
243
+
font-family: inherit;
244
+
background: var(--background);
245
+
color: var(--text);
249
+
.role-select:focus {
251
+
border-color: var(--primary);
255
+
background: transparent;
256
+
border: 2px solid #dc2626;
258
+
padding: 0.25rem 0.75rem;
259
+
border-radius: 4px;
261
+
font-size: 0.875rem;
263
+
font-family: inherit;
264
+
transition: all 0.2s;
267
+
.delete-user-btn:hover {
268
+
background: #dc2626;
272
+
.delete-user-btn:disabled {
274
+
cursor: not-allowed;
281
+
<div class="header-content">
282
+
<a href="/" class="site-title">
284
+
<span>Thistle</span>
286
+
<auth-component></auth-component>
291
+
<h1>Admin Dashboard</h1>
293
+
<div id="error-message" class="error" style="display: none;"></div>
295
+
<div id="loading" class="loading">Loading...</div>
297
+
<div id="content" style="display: none;">
298
+
<div class="stats">
299
+
<div class="stat-card">
300
+
<div class="stat-value" id="total-users">0</div>
301
+
<div class="stat-label">Total Users</div>
303
+
<div class="stat-card">
304
+
<div class="stat-value" id="total-transcriptions">0</div>
305
+
<div class="stat-label">Total Transcriptions</div>
307
+
<div class="stat-card">
308
+
<div class="stat-value" id="failed-transcriptions">0</div>
309
+
<div class="stat-label">Failed Transcriptions</div>
314
+
<button class="tab active" data-tab="transcriptions">Transcriptions</button>
315
+
<button class="tab" data-tab="users">Users</button>
318
+
<div id="transcriptions-tab" class="tab-content active">
319
+
<div class="section">
320
+
<h2 class="section-title">All Transcriptions</h2>
321
+
<div id="transcriptions-table"></div>
325
+
<div id="users-tab" class="tab-content">
326
+
<div class="section">
327
+
<h2 class="section-title">All Users</h2>
328
+
<div id="users-table"></div>
334
+
<script type="module" src="../components/auth.ts"></script>
335
+
<script type="module">
336
+
const errorMessage = document.getElementById('error-message');
337
+
const loading = document.getElementById('loading');
338
+
const content = document.getElementById('content');
339
+
const transcriptionsTable = document.getElementById('transcriptions-table');
340
+
const usersTable = document.getElementById('users-table');
342
+
let currentUserEmail = null;
344
+
// Get current user info
345
+
async function getCurrentUser() {
347
+
const res = await fetch('/api/auth/me');
349
+
const user = await res.json();
350
+
currentUserEmail = user.email;
357
+
function showError(message) {
358
+
errorMessage.textContent = message;
359
+
errorMessage.style.display = 'block';
360
+
loading.style.display = 'none';
363
+
function formatTimestamp(timestamp) {
364
+
const date = new Date(timestamp * 1000);
365
+
return date.toLocaleString();
368
+
function renderTranscriptions(transcriptions) {
369
+
if (transcriptions.length === 0) {
370
+
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>';
374
+
const failed = transcriptions.filter(t => t.status === 'failed');
375
+
document.getElementById('failed-transcriptions').textContent = failed.length;
377
+
const table = document.createElement('table');
378
+
table.innerHTML = `
384
+
<th>Created At</th>
390
+
${transcriptions.map(t => `
392
+
<td>${t.original_filename}</td>
394
+
<div class="user-info">
396
+
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
398
+
class="user-avatar"
400
+
<span>${t.user_name || t.user_email}</span>
403
+
<td><span class="status-badge status-${t.status}">${t.status}</span></td>
404
+
<td class="timestamp">${formatTimestamp(t.created_at)}</td>
405
+
<td>${t.error_message || '-'}</td>
407
+
<button class="delete-btn" data-id="${t.id}">Delete</button>
413
+
transcriptionsTable.innerHTML = '';
414
+
transcriptionsTable.appendChild(table);
416
+
// Add delete event listeners
417
+
table.querySelectorAll('.delete-btn').forEach(btn => {
418
+
btn.addEventListener('click', async (e) => {
419
+
const button = e.target;
420
+
const id = button.dataset.id;
422
+
if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
426
+
button.disabled = true;
427
+
button.textContent = 'Deleting...';
430
+
const res = await fetch(`/api/admin/transcriptions/${id}`, {
435
+
throw new Error('Failed to delete');
441
+
alert('Failed to delete transcription');
442
+
button.disabled = false;
443
+
button.textContent = 'Delete';
449
+
function renderUsers(users) {
450
+
if (users.length === 0) {
451
+
usersTable.innerHTML = '<div class="empty-state">No users yet</div>';
455
+
const table = document.createElement('table');
456
+
table.innerHTML = `
470
+
<div class="user-info">
472
+
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
474
+
class="user-avatar"
476
+
<span>${u.name || 'Anonymous'}</span>
477
+
${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
480
+
<td>${u.email}</td>
482
+
<select class="role-select" data-user-id="${u.id}" data-current-role="${u.role}">
483
+
<option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option>
484
+
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
487
+
<td class="timestamp">${formatTimestamp(u.created_at)}</td>
489
+
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
495
+
usersTable.innerHTML = '';
496
+
usersTable.appendChild(table);
498
+
// Add role change event listeners
499
+
table.querySelectorAll('.role-select').forEach(select => {
500
+
select.addEventListener('change', async (e) => {
501
+
const selectEl = e.target;
502
+
const userId = selectEl.dataset.userId;
503
+
const newRole = selectEl.value;
504
+
const oldRole = selectEl.dataset.currentRole;
506
+
// Get user email from the row
507
+
const row = selectEl.closest('tr');
508
+
const userEmail = row.querySelector('td:nth-child(2)').textContent;
510
+
// Check if user is demoting themselves
511
+
const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user';
513
+
if (isDemotingSelf) {
514
+
if (!confirm('⚠️ WARNING: You are about to demote yourself from admin to user. You will lose access to this admin panel immediately. Are you sure?')) {
515
+
selectEl.value = oldRole;
519
+
if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) {
520
+
selectEl.value = oldRole;
524
+
if (!confirm(`Change user role to ${newRole}?`)) {
525
+
selectEl.value = oldRole;
531
+
const res = await fetch(`/api/admin/users/${userId}/role`, {
533
+
headers: { 'Content-Type': 'application/json' },
534
+
body: JSON.stringify({ role: newRole })
538
+
throw new Error('Failed to update role');
541
+
selectEl.dataset.currentRole = newRole;
543
+
// If demoting self, redirect to home
544
+
if (isDemotingSelf) {
545
+
window.location.href = '/';
550
+
alert('Failed to update user role');
551
+
selectEl.value = oldRole;
556
+
// Add delete user event listeners
557
+
table.querySelectorAll('.delete-user-btn').forEach(btn => {
558
+
btn.addEventListener('click', async (e) => {
559
+
const button = e.target;
560
+
const userId = button.dataset.userId;
561
+
const userEmail = button.dataset.userEmail;
563
+
if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) {
567
+
button.disabled = true;
568
+
button.textContent = 'Deleting...';
571
+
const res = await fetch(`/api/admin/users/${userId}`, {
576
+
throw new Error('Failed to delete user');
581
+
alert('Failed to delete user');
582
+
button.disabled = false;
583
+
button.textContent = 'Delete';
589
+
async function loadData() {
591
+
const [transcriptionsRes, usersRes] = await Promise.all([
592
+
fetch('/api/admin/transcriptions'),
593
+
fetch('/api/admin/users')
596
+
if (!transcriptionsRes.ok || !usersRes.ok) {
597
+
if (transcriptionsRes.status === 403 || usersRes.status === 403) {
598
+
window.location.href = '/';
601
+
throw new Error('Failed to load admin data');
604
+
const transcriptions = await transcriptionsRes.json();
605
+
const users = await usersRes.json();
607
+
document.getElementById('total-users').textContent = users.length;
608
+
document.getElementById('total-transcriptions').textContent = transcriptions.length;
610
+
renderTranscriptions(transcriptions);
611
+
renderUsers(users);
613
+
loading.style.display = 'none';
614
+
content.style.display = 'block';
616
+
showError(error.message);
621
+
document.querySelectorAll('.tab').forEach(tab => {
622
+
tab.addEventListener('click', () => {
623
+
const tabName = tab.dataset.tab;
625
+
document.querySelectorAll('.tab').forEach(t => {
626
+
t.classList.remove('active');
628
+
document.querySelectorAll('.tab-content').forEach(c => {
629
+
c.classList.remove('active');
632
+
tab.classList.add('active');
633
+
document.getElementById(`${tabName}-tab`).classList.add('active');
638
+
getCurrentUser().then(() => loadData());