🪻 distributed transcription service
thistle.dunkirk.sh
1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <title>Admin - Thistle</title>
8 <link rel="icon"
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">
11 <style>
12 main {
13 max-width: 80rem;
14 margin: 0 auto;
15 padding: 2rem;
16 }
17
18 h1 {
19 margin-bottom: 2rem;
20 color: var(--text);
21 }
22
23 .section {
24 margin-bottom: 3rem;
25 }
26
27 .section-title {
28 font-size: 1.5rem;
29 font-weight: 600;
30 color: var(--text);
31 margin-bottom: 1rem;
32 display: flex;
33 align-items: center;
34 gap: 0.5rem;
35 }
36
37 .tabs {
38 display: flex;
39 gap: 1rem;
40 border-bottom: 2px solid var(--secondary);
41 margin-bottom: 2rem;
42 }
43
44 .tab {
45 padding: 0.75rem 1.5rem;
46 border: none;
47 background: transparent;
48 color: var(--text);
49 cursor: pointer;
50 font-size: 1rem;
51 font-weight: 500;
52 font-family: inherit;
53 border-bottom: 2px solid transparent;
54 margin-bottom: -2px;
55 transition: all 0.2s;
56 }
57
58 .tab:hover {
59 color: var(--primary);
60 }
61
62 .tab.active {
63 color: var(--primary);
64 border-bottom-color: var(--primary);
65 }
66
67 .tab-content {
68 display: none;
69 }
70
71 .tab-content.active {
72 display: block;
73 }
74
75 table {
76 width: 100%;
77 border-collapse: collapse;
78 background: var(--background);
79 border: 2px solid var(--secondary);
80 border-radius: 8px;
81 overflow: hidden;
82 }
83
84 thead {
85 background: var(--primary);
86 color: white;
87 }
88
89 th {
90 padding: 1rem;
91 text-align: left;
92 font-weight: 600;
93 }
94
95 td {
96 padding: 1rem;
97 border-top: 1px solid var(--secondary);
98 color: var(--text);
99 }
100
101 tr:hover {
102 background: rgba(0, 0, 0, 0.02);
103 }
104
105 .status-badge {
106 display: inline-block;
107 padding: 0.25rem 0.75rem;
108 border-radius: 4px;
109 font-size: 0.875rem;
110 font-weight: 500;
111 }
112
113 .status-completed {
114 background: #dcfce7;
115 color: #166534;
116 }
117
118 .status-processing,
119 .status-uploading {
120 background: #fef3c7;
121 color: #92400e;
122 }
123
124 .status-failed {
125 background: #fee2e2;
126 color: #991b1b;
127 }
128
129 .status-pending {
130 background: #e0e7ff;
131 color: #3730a3;
132 }
133
134 .admin-badge {
135 background: var(--accent);
136 color: white;
137 padding: 0.25rem 0.5rem;
138 border-radius: 4px;
139 font-size: 0.75rem;
140 font-weight: 600;
141 margin-left: 0.5rem;
142 }
143
144 .user-info {
145 display: flex;
146 align-items: center;
147 gap: 0.5rem;
148 }
149
150 .user-avatar {
151 width: 2rem;
152 height: 2rem;
153 border-radius: 50%;
154 }
155
156 .empty-state {
157 text-align: center;
158 padding: 3rem;
159 color: var(--text);
160 opacity: 0.6;
161 }
162
163 .loading {
164 text-align: center;
165 padding: 3rem;
166 color: var(--text);
167 }
168
169 .error {
170 background: #fee2e2;
171 color: #991b1b;
172 padding: 1rem;
173 border-radius: 6px;
174 margin-bottom: 1rem;
175 }
176
177 .stats {
178 display: grid;
179 grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
180 gap: 1rem;
181 margin-bottom: 2rem;
182 }
183
184 .stat-card {
185 background: var(--background);
186 border: 2px solid var(--secondary);
187 border-radius: 8px;
188 padding: 1.5rem;
189 }
190
191 .stat-value {
192 font-size: 2rem;
193 font-weight: 700;
194 color: var(--primary);
195 margin-bottom: 0.25rem;
196 }
197
198 .stat-label {
199 color: var(--text);
200 opacity: 0.7;
201 font-size: 0.875rem;
202 }
203
204 .timestamp {
205 color: var(--text);
206 opacity: 0.6;
207 font-size: 0.875rem;
208 }
209
210 .delete-btn {
211 background: transparent;
212 border: 2px solid #dc2626;
213 color: #dc2626;
214 padding: 0.25rem 0.75rem;
215 border-radius: 4px;
216 cursor: pointer;
217 font-size: 0.875rem;
218 font-weight: 500;
219 font-family: inherit;
220 transition: all 0.2s;
221 }
222
223 .delete-btn:hover {
224 background: #dc2626;
225 color: white;
226 }
227
228 .delete-btn:disabled {
229 opacity: 0.5;
230 cursor: not-allowed;
231 }
232
233 .actions {
234 display: flex;
235 gap: 0.5rem;
236 }
237
238 .role-select {
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);
246 cursor: pointer;
247 }
248
249 .role-select:focus {
250 outline: none;
251 border-color: var(--primary);
252 }
253
254 .delete-user-btn {
255 background: transparent;
256 border: 2px solid #dc2626;
257 color: #dc2626;
258 padding: 0.25rem 0.75rem;
259 border-radius: 4px;
260 cursor: pointer;
261 font-size: 0.875rem;
262 font-weight: 500;
263 font-family: inherit;
264 transition: all 0.2s;
265 }
266
267 .delete-user-btn:hover {
268 background: #dc2626;
269 color: white;
270 }
271
272 .delete-user-btn:disabled {
273 opacity: 0.5;
274 cursor: not-allowed;
275 }
276
277 .users-table tbody tr {
278 cursor: pointer;
279 }
280
281 .users-table tbody tr:hover {
282 background: rgba(0, 0, 0, 0.04);
283 }
284
285 .transcriptions-table tbody tr {
286 cursor: pointer;
287 }
288
289 .transcriptions-table tbody tr:hover {
290 background: rgba(0, 0, 0, 0.04);
291 }
292
293 .search {
294 width: 100%;
295 max-width: 30rem;
296 margin-bottom: 1rem;
297 padding: 0.5rem 0.75rem;
298 border: 2px solid var(--secondary);
299 border-radius: 4px;
300 font-size: 1rem;
301 font-family: inherit;
302 background: var(--background);
303 color: var(--text);
304 }
305
306 .search:focus {
307 outline: none;
308 border-color: var(--primary);
309 }
310
311 th.sortable {
312 cursor: pointer;
313 user-select: none;
314 position: relative;
315 }
316
317 th.sortable:hover {
318 background: var(--gunmetal);
319 }
320
321 th.sortable::after {
322 content: '';
323 margin-left: 0.5rem;
324 opacity: 0.3;
325 }
326
327 th.sortable.asc::after {
328 content: '▲';
329 opacity: 1;
330 }
331
332 th.sortable.desc::after {
333 content: '▼';
334 opacity: 1;
335 }
336 </style>
337</head>
338
339<body>
340 <header>
341 <div class="header-content">
342 <a href="/" class="site-title">
343 <span>🪻</span>
344 <span>Thistle</span>
345 </a>
346 <auth-component></auth-component>
347 </div>
348 </header>
349
350 <main>
351 <h1>Admin Dashboard</h1>
352
353 <div id="error-message" class="error" style="display: none;"></div>
354
355 <div id="loading" class="loading">Loading...</div>
356
357 <div id="content" style="display: none;">
358 <div class="stats">
359 <div class="stat-card">
360 <div class="stat-value" id="total-users">0</div>
361 <div class="stat-label">Total Users</div>
362 </div>
363 <div class="stat-card">
364 <div class="stat-value" id="total-transcriptions">0</div>
365 <div class="stat-label">Total Transcriptions</div>
366 </div>
367 <div class="stat-card">
368 <div class="stat-value" id="failed-transcriptions">0</div>
369 <div class="stat-label">Failed Transcriptions</div>
370 </div>
371 </div>
372
373 <div class="tabs">
374 <button class="tab active" data-tab="pending">Pending Recordings</button>
375 <button class="tab" data-tab="transcriptions">Transcriptions</button>
376 <button class="tab" data-tab="users">Users</button>
377 </div>
378
379 <div id="pending-tab" class="tab-content active">
380 <div class="section">
381 <h2 class="section-title">Pending Recordings</h2>
382 <admin-pending-recordings></admin-pending-recordings>
383 </div>
384 </div>
385
386 <div id="transcriptions-tab" class="tab-content">
387 <div class="section">
388 <h2 class="section-title">All Transcriptions</h2>
389 <input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." />
390 <div id="transcriptions-table" class="transcriptions-table"></div>
391 </div>
392 </div>
393
394 <div id="users-tab" class="tab-content">
395 <div class="section">
396 <h2 class="section-title">All Users</h2>
397 <input type="text" id="user-search" class="search" placeholder="Search by name or email..." />
398 <div id="users-table" class="users-table"></div>
399 </div>
400 </div>
401 </div>
402 </main>
403
404 <user-modal id="user-modal"></user-modal>
405 <transcript-modal id="transcript-modal"></transcript-modal>
406
407 <script type="module" src="../components/auth.ts"></script>
408 <script type="module" src="../components/admin-pending-recordings.ts"></script>
409 <script type="module" src="../components/user-modal.ts"></script>
410 <script type="module" src="../components/transcript-view-modal.ts"></script>
411 <script type="module">
412 const errorMessage = document.getElementById('error-message');
413 const loading = document.getElementById('loading');
414 const content = document.getElementById('content');
415 const transcriptionsTable = document.getElementById('transcriptions-table');
416 const usersTable = document.getElementById('users-table');
417 const userModal = document.getElementById('user-modal');
418 const transcriptModal = document.getElementById('transcript-modal');
419
420 let currentUserEmail = null;
421 let allUsers = [];
422 let allTranscriptions = [];
423 let userSortKey = 'created_at';
424 let userSortDirection = 'desc';
425 let userSearchTerm = '';
426 let transcriptSortKey = 'created_at';
427 let transcriptSortDirection = 'desc';
428 let transcriptSearchTerm = '';
429
430 // Get current user info
431 async function getCurrentUser() {
432 try {
433 const res = await fetch('/api/auth/me');
434 if (res.ok) {
435 const user = await res.json();
436 currentUserEmail = user.email;
437 }
438 } catch {
439 // Ignore errors
440 }
441 }
442
443 function showError(message) {
444 errorMessage.textContent = message;
445 errorMessage.style.display = 'block';
446 loading.style.display = 'none';
447 }
448
449 function formatTimestamp(timestamp) {
450 const date = new Date(timestamp * 1000);
451 return date.toLocaleString();
452 }
453
454 // Modal functions
455 function openUserModal(userId) {
456 userModal.setAttribute('open', '');
457 userModal.userId = userId;
458 }
459
460 function closeUserModal() {
461 userModal.removeAttribute('open');
462 userModal.userId = null;
463 }
464
465 function openTranscriptModal(transcriptId) {
466 transcriptModal.setAttribute('open', '');
467 transcriptModal.transcriptId = transcriptId;
468 }
469
470 function closeTranscriptModal() {
471 transcriptModal.removeAttribute('open');
472 transcriptModal.transcriptId = null;
473 }
474
475 // Listen for modal close and user update events
476 userModal.addEventListener('close', closeUserModal);
477 userModal.addEventListener('user-updated', () => loadData());
478 userModal.addEventListener('click', (e) => {
479 if (e.target === userModal) {
480 closeUserModal();
481 }
482 });
483
484 // Listen for transcript modal events
485 transcriptModal.addEventListener('close', closeTranscriptModal);
486 transcriptModal.addEventListener('transcript-deleted', () => loadData());
487 transcriptModal.addEventListener('click', (e) => {
488 if (e.target === transcriptModal) {
489 closeTranscriptModal();
490 }
491 });
492
493
494 function renderTranscriptions(transcriptions) {
495 allTranscriptions = transcriptions;
496
497 // Filter transcriptions based on search term
498 const filteredTranscriptions = transcriptions.filter(t => {
499 if (!transcriptSearchTerm) return true;
500 const term = transcriptSearchTerm.toLowerCase();
501 const filename = (t.original_filename || '').toLowerCase();
502 const userName = (t.user_name || '').toLowerCase();
503 const userEmail = (t.user_email || '').toLowerCase();
504 return filename.includes(term) || userName.includes(term) || userEmail.includes(term);
505 });
506
507 // Sort transcriptions
508 filteredTranscriptions.sort((a, b) => {
509 let aVal = a[transcriptSortKey];
510 let bVal = b[transcriptSortKey];
511
512 // Handle null values
513 if (aVal === null || aVal === undefined) aVal = '';
514 if (bVal === null || bVal === undefined) bVal = '';
515
516 let comparison = 0;
517 if (typeof aVal === 'string' && typeof bVal === 'string') {
518 comparison = aVal.localeCompare(bVal);
519 } else if (typeof aVal === 'number' && typeof bVal === 'number') {
520 comparison = aVal - bVal;
521 } else {
522 comparison = String(aVal).localeCompare(String(bVal));
523 }
524
525 return transcriptSortDirection === 'asc' ? comparison : -comparison;
526 });
527
528 if (filteredTranscriptions.length === 0) {
529 transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>';
530 return;
531 }
532
533 const failed = transcriptions.filter(t => t.status === 'failed');
534 document.getElementById('failed-transcriptions').textContent = failed.length;
535
536 const table = document.createElement('table');
537 table.innerHTML = `
538 <thead>
539 <tr>
540 <th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</th>
541 <th>User</th>
542 <th class="sortable ${transcriptSortKey === 'status' ? transcriptSortDirection : ''}" data-sort="status">Status</th>
543 <th class="sortable ${transcriptSortKey === 'created_at' ? transcriptSortDirection : ''}" data-sort="created_at">Created At</th>
544 <th>Actions</th>
545 </tr>
546 </thead>
547 <tbody>
548 ${filteredTranscriptions.map(t => `
549 <tr data-id="${t.id}">
550 <td>${t.original_filename}</td>
551 <td>
552 <div class="user-info">
553 <img
554 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
555 alt="Avatar"
556 class="user-avatar"
557 />
558 <span>${t.user_name || t.user_email}</span>
559 </div>
560 </td>
561 <td><span class="status-badge status-${t.status}">${t.status}</span></td>
562 <td class="timestamp">${formatTimestamp(t.created_at)}</td>
563 <td>
564 <button class="delete-btn" data-id="${t.id}">Delete</button>
565 </td>
566 </tr>
567 `).join('')}
568 </tbody>
569 `;
570 transcriptionsTable.innerHTML = '';
571 transcriptionsTable.appendChild(table);
572
573 // Add sort event listeners
574 table.querySelectorAll('th.sortable').forEach(th => {
575 th.addEventListener('click', () => {
576 const sortKey = th.dataset.sort;
577 if (transcriptSortKey === sortKey) {
578 transcriptSortDirection = transcriptSortDirection === 'asc' ? 'desc' : 'asc';
579 } else {
580 transcriptSortKey = sortKey;
581 transcriptSortDirection = 'asc';
582 }
583 renderTranscriptions(allTranscriptions);
584 });
585 });
586
587 // Add delete event listeners
588 table.querySelectorAll('.delete-btn').forEach(btn => {
589 btn.addEventListener('click', async (e) => {
590 e.stopPropagation(); // Prevent row click
591 const button = e.target;
592 const id = button.dataset.id;
593
594 if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
595 return;
596 }
597
598 button.disabled = true;
599 button.textContent = 'Deleting...';
600
601 try {
602 const res = await fetch(`/api/admin/transcriptions/${id}`, {
603 method: 'DELETE'
604 });
605
606 if (!res.ok) {
607 throw new Error('Failed to delete');
608 }
609
610 // Reload data
611 await loadData();
612 } catch {
613 alert('Failed to delete transcription');
614 button.disabled = false;
615 button.textContent = 'Delete';
616 }
617 });
618 });
619
620 // Add click event to table rows to open modal
621 table.querySelectorAll('tbody tr').forEach(row => {
622 row.addEventListener('click', (e) => {
623 // Don't open modal if clicking on delete button
624 if (e.target.closest('.delete-btn')) {
625 return;
626 }
627
628 const transcriptId = row.dataset.id;
629 openTranscriptModal(transcriptId);
630 });
631 });
632 }
633
634 function renderUsers(users) {
635 allUsers = users;
636
637 // Filter users based on search term
638 const filteredUsers = users.filter(u => {
639 if (!userSearchTerm) return true;
640 const term = userSearchTerm.toLowerCase();
641 const name = (u.name || '').toLowerCase();
642 const email = u.email.toLowerCase();
643 return name.includes(term) || email.includes(term);
644 });
645
646 // Sort users
647 filteredUsers.sort((a, b) => {
648 let aVal = a[userSortKey];
649 let bVal = b[userSortKey];
650
651 // Handle null values
652 if (aVal === null || aVal === undefined) aVal = '';
653 if (bVal === null || bVal === undefined) bVal = '';
654
655 let comparison = 0;
656 if (typeof aVal === 'string' && typeof bVal === 'string') {
657 comparison = aVal.localeCompare(bVal);
658 } else if (typeof aVal === 'number' && typeof bVal === 'number') {
659 comparison = aVal - bVal;
660 } else {
661 comparison = String(aVal).localeCompare(String(bVal));
662 }
663
664 return userSortDirection === 'asc' ? comparison : -comparison;
665 });
666
667 if (filteredUsers.length === 0) {
668 usersTable.innerHTML = '<div class="empty-state">No users found</div>';
669 return;
670 }
671
672 const table = document.createElement('table');
673 table.innerHTML = `
674 <thead>
675 <tr>
676 <th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th>
677 <th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th>
678 <th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th>
679 <th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th>
680 <th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th>
681 <th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th>
682 <th>Actions</th>
683 </tr>
684 </thead>
685 <tbody>
686 ${filteredUsers.map(u => `
687 <tr>
688 <td>
689 <div class="user-info">
690 <img
691 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
692 alt="Avatar"
693 class="user-avatar"
694 />
695 <span>${u.name || 'Anonymous'}</span>
696 ${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
697 </div>
698 </td>
699 <td>${u.email}</td>
700 <td>
701 <select class="role-select" data-user-id="${u.id}" data-current-role="${u.role}">
702 <option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option>
703 <option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
704 </select>
705 </td>
706 <td>${u.transcription_count}</td>
707 <td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td>
708 <td class="timestamp">${formatTimestamp(u.created_at)}</td>
709 <td>
710 <div class="actions">
711 <button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
712 </div>
713 </td>
714 </tr>
715 `).join('')}
716 </tbody>
717 `;
718 usersTable.innerHTML = '';
719 usersTable.appendChild(table);
720
721 // Add sort event listeners
722 table.querySelectorAll('th.sortable').forEach(th => {
723 th.addEventListener('click', () => {
724 const sortKey = th.dataset.sort;
725 if (userSortKey === sortKey) {
726 userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc';
727 } else {
728 userSortKey = sortKey;
729 userSortDirection = 'asc';
730 }
731 renderUsers(allUsers);
732 });
733 });
734
735 // Add role change event listeners
736 table.querySelectorAll('.role-select').forEach(select => {
737 select.addEventListener('change', async (e) => {
738 const selectEl = e.target;
739 const userId = selectEl.dataset.userId;
740 const newRole = selectEl.value;
741 const oldRole = selectEl.dataset.currentRole;
742
743 // Get user email from the row
744 const row = selectEl.closest('tr');
745 const userEmail = row.querySelector('td:nth-child(2)').textContent;
746
747 // Check if user is demoting themselves
748 const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user';
749
750 if (isDemotingSelf) {
751 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?')) {
752 selectEl.value = oldRole;
753 return;
754 }
755
756 if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) {
757 selectEl.value = oldRole;
758 return;
759 }
760 } else {
761 if (!confirm(`Change user role to ${newRole}?`)) {
762 selectEl.value = oldRole;
763 return;
764 }
765 }
766
767 try {
768 const res = await fetch(`/api/admin/users/${userId}/role`, {
769 method: 'PUT',
770 headers: {'Content-Type': 'application/json'},
771 body: JSON.stringify({role: newRole})
772 });
773
774 if (!res.ok) {
775 throw new Error('Failed to update role');
776 }
777
778 selectEl.dataset.currentRole = newRole;
779
780 // If demoting self, redirect to home
781 if (isDemotingSelf) {
782 window.location.href = '/';
783 } else {
784 await loadData();
785 }
786 } catch {
787 alert('Failed to update user role');
788 selectEl.value = oldRole;
789 }
790 });
791 });
792
793 // Add delete user event listeners
794 table.querySelectorAll('.delete-user-btn').forEach(btn => {
795 btn.addEventListener('click', async (e) => {
796 const button = e.target;
797 const userId = button.dataset.userId;
798 const userEmail = button.dataset.userEmail;
799
800 if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) {
801 return;
802 }
803
804 button.disabled = true;
805 button.textContent = 'Deleting...';
806
807 try {
808 const res = await fetch(`/api/admin/users/${userId}`, {
809 method: 'DELETE'
810 });
811
812 if (!res.ok) {
813 throw new Error('Failed to delete user');
814 }
815
816 await loadData();
817 } catch {
818 alert('Failed to delete user');
819 button.disabled = false;
820 button.textContent = 'Delete';
821 }
822 });
823 });
824
825 // Add click event to table rows to open modal
826 table.querySelectorAll('tbody tr').forEach(row => {
827 row.addEventListener('click', (e) => {
828 // Don't open modal if clicking on delete button or role select
829 if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) {
830 return;
831 }
832
833 const userId = row.querySelector('.delete-user-btn').dataset.userId;
834 openUserModal(userId);
835 });
836 });
837 }
838
839 async function loadData() {
840 try {
841 const [transcriptionsRes, usersRes] = await Promise.all([
842 fetch('/api/admin/transcriptions'),
843 fetch('/api/admin/users')
844 ]);
845
846 if (!transcriptionsRes.ok || !usersRes.ok) {
847 if (transcriptionsRes.status === 403 || usersRes.status === 403) {
848 window.location.href = '/';
849 return;
850 }
851 throw new Error('Failed to load admin data');
852 }
853
854 const transcriptions = await transcriptionsRes.json();
855 const users = await usersRes.json();
856
857 document.getElementById('total-users').textContent = users.length;
858 document.getElementById('total-transcriptions').textContent = transcriptions.length;
859
860 renderTranscriptions(transcriptions);
861 renderUsers(users);
862
863 loading.style.display = 'none';
864 content.style.display = 'block';
865 } catch (error) {
866 showError(error.message);
867 }
868 }
869
870 // Tab switching
871 document.querySelectorAll('.tab').forEach(tab => {
872 tab.addEventListener('click', () => {
873 const tabName = tab.dataset.tab;
874
875 document.querySelectorAll('.tab').forEach(t => {
876 t.classList.remove('active');
877 });
878 document.querySelectorAll('.tab-content').forEach(c => {
879 c.classList.remove('active');
880 });
881
882 tab.classList.add('active');
883 document.getElementById(`${tabName}-tab`).classList.add('active');
884 });
885 });
886
887 // User search
888 document.getElementById('user-search').addEventListener('input', (e) => {
889 userSearchTerm = e.target.value.trim();
890 renderUsers(allUsers);
891 });
892
893 // Transcript search
894 document.getElementById('transcript-search').addEventListener('input', (e) => {
895 transcriptSearchTerm = e.target.value.trim();
896 renderTranscriptions(allTranscriptions);
897 });
898
899 // Initialize
900 getCurrentUser().then(() => loadData());
901 </script>
902</body>
903
904</html>