🪻 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="transcriptions">Transcriptions</button>
375 <button class="tab" data-tab="users">Users</button>
376 </div>
377
378 <div id="transcriptions-tab" class="tab-content active">
379 <div class="section">
380 <h2 class="section-title">All Transcriptions</h2>
381 <input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." />
382 <div id="transcriptions-table" class="transcriptions-table"></div>
383 </div>
384 </div>
385
386 <div id="users-tab" class="tab-content">
387 <div class="section">
388 <h2 class="section-title">All Users</h2>
389 <input type="text" id="user-search" class="search" placeholder="Search by name or email..." />
390 <div id="users-table" class="users-table"></div>
391 </div>
392 </div>
393 </div>
394 </main>
395
396 <user-modal id="user-modal"></user-modal>
397 <transcript-modal id="transcript-modal"></transcript-modal>
398
399 <script type="module" src="../components/auth.ts"></script>
400 <script type="module" src="../components/user-modal.ts"></script>
401 <script type="module" src="../components/transcript-view-modal.ts"></script>
402 <script type="module">
403 const errorMessage = document.getElementById('error-message');
404 const loading = document.getElementById('loading');
405 const content = document.getElementById('content');
406 const transcriptionsTable = document.getElementById('transcriptions-table');
407 const usersTable = document.getElementById('users-table');
408 const userModal = document.getElementById('user-modal');
409 const transcriptModal = document.getElementById('transcript-modal');
410
411 let currentUserEmail = null;
412 let allUsers = [];
413 let allTranscriptions = [];
414 let userSortKey = 'created_at';
415 let userSortDirection = 'desc';
416 let userSearchTerm = '';
417 let transcriptSortKey = 'created_at';
418 let transcriptSortDirection = 'desc';
419 let transcriptSearchTerm = '';
420
421 // Get current user info
422 async function getCurrentUser() {
423 try {
424 const res = await fetch('/api/auth/me');
425 if (res.ok) {
426 const user = await res.json();
427 currentUserEmail = user.email;
428 }
429 } catch {
430 // Ignore errors
431 }
432 }
433
434 function showError(message) {
435 errorMessage.textContent = message;
436 errorMessage.style.display = 'block';
437 loading.style.display = 'none';
438 }
439
440 function formatTimestamp(timestamp) {
441 const date = new Date(timestamp * 1000);
442 return date.toLocaleString();
443 }
444
445 // Modal functions
446 function openUserModal(userId) {
447 userModal.setAttribute('open', '');
448 userModal.userId = userId;
449 }
450
451 function closeUserModal() {
452 userModal.removeAttribute('open');
453 userModal.userId = null;
454 }
455
456 function openTranscriptModal(transcriptId) {
457 transcriptModal.setAttribute('open', '');
458 transcriptModal.transcriptId = transcriptId;
459 }
460
461 function closeTranscriptModal() {
462 transcriptModal.removeAttribute('open');
463 transcriptModal.transcriptId = null;
464 }
465
466 // Listen for modal close and user update events
467 userModal.addEventListener('close', closeUserModal);
468 userModal.addEventListener('user-updated', () => loadData());
469 userModal.addEventListener('click', (e) => {
470 if (e.target === userModal) {
471 closeUserModal();
472 }
473 });
474
475 // Listen for transcript modal events
476 transcriptModal.addEventListener('close', closeTranscriptModal);
477 transcriptModal.addEventListener('transcript-deleted', () => loadData());
478 transcriptModal.addEventListener('click', (e) => {
479 if (e.target === transcriptModal) {
480 closeTranscriptModal();
481 }
482 });
483
484
485 function renderTranscriptions(transcriptions) {
486 allTranscriptions = transcriptions;
487
488 // Filter transcriptions based on search term
489 const filteredTranscriptions = transcriptions.filter(t => {
490 if (!transcriptSearchTerm) return true;
491 const term = transcriptSearchTerm.toLowerCase();
492 const filename = (t.original_filename || '').toLowerCase();
493 const userName = (t.user_name || '').toLowerCase();
494 const userEmail = (t.user_email || '').toLowerCase();
495 return filename.includes(term) || userName.includes(term) || userEmail.includes(term);
496 });
497
498 // Sort transcriptions
499 filteredTranscriptions.sort((a, b) => {
500 let aVal = a[transcriptSortKey];
501 let bVal = b[transcriptSortKey];
502
503 // Handle null values
504 if (aVal === null || aVal === undefined) aVal = '';
505 if (bVal === null || bVal === undefined) bVal = '';
506
507 let comparison = 0;
508 if (typeof aVal === 'string' && typeof bVal === 'string') {
509 comparison = aVal.localeCompare(bVal);
510 } else if (typeof aVal === 'number' && typeof bVal === 'number') {
511 comparison = aVal - bVal;
512 } else {
513 comparison = String(aVal).localeCompare(String(bVal));
514 }
515
516 return transcriptSortDirection === 'asc' ? comparison : -comparison;
517 });
518
519 if (filteredTranscriptions.length === 0) {
520 transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>';
521 return;
522 }
523
524 const failed = transcriptions.filter(t => t.status === 'failed');
525 document.getElementById('failed-transcriptions').textContent = failed.length;
526
527 const table = document.createElement('table');
528 table.innerHTML = `
529 <thead>
530 <tr>
531 <th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</th>
532 <th>User</th>
533 <th class="sortable ${transcriptSortKey === 'status' ? transcriptSortDirection : ''}" data-sort="status">Status</th>
534 <th class="sortable ${transcriptSortKey === 'created_at' ? transcriptSortDirection : ''}" data-sort="created_at">Created At</th>
535 <th>Actions</th>
536 </tr>
537 </thead>
538 <tbody>
539 ${filteredTranscriptions.map(t => `
540 <tr data-id="${t.id}">
541 <td>${t.original_filename}</td>
542 <td>
543 <div class="user-info">
544 <img
545 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
546 alt="Avatar"
547 class="user-avatar"
548 />
549 <span>${t.user_name || t.user_email}</span>
550 </div>
551 </td>
552 <td><span class="status-badge status-${t.status}">${t.status}</span></td>
553 <td class="timestamp">${formatTimestamp(t.created_at)}</td>
554 <td>
555 <button class="delete-btn" data-id="${t.id}">Delete</button>
556 </td>
557 </tr>
558 `).join('')}
559 </tbody>
560 `;
561 transcriptionsTable.innerHTML = '';
562 transcriptionsTable.appendChild(table);
563
564 // Add sort event listeners
565 table.querySelectorAll('th.sortable').forEach(th => {
566 th.addEventListener('click', () => {
567 const sortKey = th.dataset.sort;
568 if (transcriptSortKey === sortKey) {
569 transcriptSortDirection = transcriptSortDirection === 'asc' ? 'desc' : 'asc';
570 } else {
571 transcriptSortKey = sortKey;
572 transcriptSortDirection = 'asc';
573 }
574 renderTranscriptions(allTranscriptions);
575 });
576 });
577
578 // Add delete event listeners
579 table.querySelectorAll('.delete-btn').forEach(btn => {
580 btn.addEventListener('click', async (e) => {
581 e.stopPropagation(); // Prevent row click
582 const button = e.target;
583 const id = button.dataset.id;
584
585 if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
586 return;
587 }
588
589 button.disabled = true;
590 button.textContent = 'Deleting...';
591
592 try {
593 const res = await fetch(`/api/admin/transcriptions/${id}`, {
594 method: 'DELETE'
595 });
596
597 if (!res.ok) {
598 throw new Error('Failed to delete');
599 }
600
601 // Reload data
602 await loadData();
603 } catch {
604 alert('Failed to delete transcription');
605 button.disabled = false;
606 button.textContent = 'Delete';
607 }
608 });
609 });
610
611 // Add click event to table rows to open modal
612 table.querySelectorAll('tbody tr').forEach(row => {
613 row.addEventListener('click', (e) => {
614 // Don't open modal if clicking on delete button
615 if (e.target.closest('.delete-btn')) {
616 return;
617 }
618
619 const transcriptId = row.dataset.id;
620 openTranscriptModal(transcriptId);
621 });
622 });
623 }
624
625 function renderUsers(users) {
626 allUsers = users;
627
628 // Filter users based on search term
629 const filteredUsers = users.filter(u => {
630 if (!userSearchTerm) return true;
631 const term = userSearchTerm.toLowerCase();
632 const name = (u.name || '').toLowerCase();
633 const email = u.email.toLowerCase();
634 return name.includes(term) || email.includes(term);
635 });
636
637 // Sort users
638 filteredUsers.sort((a, b) => {
639 let aVal = a[userSortKey];
640 let bVal = b[userSortKey];
641
642 // Handle null values
643 if (aVal === null || aVal === undefined) aVal = '';
644 if (bVal === null || bVal === undefined) bVal = '';
645
646 let comparison = 0;
647 if (typeof aVal === 'string' && typeof bVal === 'string') {
648 comparison = aVal.localeCompare(bVal);
649 } else if (typeof aVal === 'number' && typeof bVal === 'number') {
650 comparison = aVal - bVal;
651 } else {
652 comparison = String(aVal).localeCompare(String(bVal));
653 }
654
655 return userSortDirection === 'asc' ? comparison : -comparison;
656 });
657
658 if (filteredUsers.length === 0) {
659 usersTable.innerHTML = '<div class="empty-state">No users found</div>';
660 return;
661 }
662
663 const table = document.createElement('table');
664 table.innerHTML = `
665 <thead>
666 <tr>
667 <th class="sortable ${userSortKey === 'name' ? userSortDirection : ''}" data-sort="name">User</th>
668 <th class="sortable ${userSortKey === 'email' ? userSortDirection : ''}" data-sort="email">Email</th>
669 <th class="sortable ${userSortKey === 'role' ? userSortDirection : ''}" data-sort="role">Role</th>
670 <th class="sortable ${userSortKey === 'transcription_count' ? userSortDirection : ''}" data-sort="transcription_count">Transcriptions</th>
671 <th class="sortable ${userSortKey === 'last_login' ? userSortDirection : ''}" data-sort="last_login">Last Login</th>
672 <th class="sortable ${userSortKey === 'created_at' ? userSortDirection : ''}" data-sort="created_at">Joined</th>
673 <th>Actions</th>
674 </tr>
675 </thead>
676 <tbody>
677 ${filteredUsers.map(u => `
678 <tr>
679 <td>
680 <div class="user-info">
681 <img
682 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
683 alt="Avatar"
684 class="user-avatar"
685 />
686 <span>${u.name || 'Anonymous'}</span>
687 ${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
688 </div>
689 </td>
690 <td>${u.email}</td>
691 <td>
692 <select class="role-select" data-user-id="${u.id}" data-current-role="${u.role}">
693 <option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option>
694 <option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
695 </select>
696 </td>
697 <td>${u.transcription_count}</td>
698 <td class="timestamp">${u.last_login ? formatTimestamp(u.last_login) : 'Never'}</td>
699 <td class="timestamp">${formatTimestamp(u.created_at)}</td>
700 <td>
701 <div class="actions">
702 <button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
703 </div>
704 </td>
705 </tr>
706 `).join('')}
707 </tbody>
708 `;
709 usersTable.innerHTML = '';
710 usersTable.appendChild(table);
711
712 // Add sort event listeners
713 table.querySelectorAll('th.sortable').forEach(th => {
714 th.addEventListener('click', () => {
715 const sortKey = th.dataset.sort;
716 if (userSortKey === sortKey) {
717 userSortDirection = userSortDirection === 'asc' ? 'desc' : 'asc';
718 } else {
719 userSortKey = sortKey;
720 userSortDirection = 'asc';
721 }
722 renderUsers(allUsers);
723 });
724 });
725
726 // Add role change event listeners
727 table.querySelectorAll('.role-select').forEach(select => {
728 select.addEventListener('change', async (e) => {
729 const selectEl = e.target;
730 const userId = selectEl.dataset.userId;
731 const newRole = selectEl.value;
732 const oldRole = selectEl.dataset.currentRole;
733
734 // Get user email from the row
735 const row = selectEl.closest('tr');
736 const userEmail = row.querySelector('td:nth-child(2)').textContent;
737
738 // Check if user is demoting themselves
739 const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user';
740
741 if (isDemotingSelf) {
742 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?')) {
743 selectEl.value = oldRole;
744 return;
745 }
746
747 if (!confirm('⚠️ FINAL WARNING: This action cannot be undone by you. Another admin will need to restore your admin access. Continue?')) {
748 selectEl.value = oldRole;
749 return;
750 }
751 } else {
752 if (!confirm(`Change user role to ${newRole}?`)) {
753 selectEl.value = oldRole;
754 return;
755 }
756 }
757
758 try {
759 const res = await fetch(`/api/admin/users/${userId}/role`, {
760 method: 'PUT',
761 headers: {'Content-Type': 'application/json'},
762 body: JSON.stringify({role: newRole})
763 });
764
765 if (!res.ok) {
766 throw new Error('Failed to update role');
767 }
768
769 selectEl.dataset.currentRole = newRole;
770
771 // If demoting self, redirect to home
772 if (isDemotingSelf) {
773 window.location.href = '/';
774 } else {
775 await loadData();
776 }
777 } catch {
778 alert('Failed to update user role');
779 selectEl.value = oldRole;
780 }
781 });
782 });
783
784 // Add delete user event listeners
785 table.querySelectorAll('.delete-user-btn').forEach(btn => {
786 btn.addEventListener('click', async (e) => {
787 const button = e.target;
788 const userId = button.dataset.userId;
789 const userEmail = button.dataset.userEmail;
790
791 if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) {
792 return;
793 }
794
795 button.disabled = true;
796 button.textContent = 'Deleting...';
797
798 try {
799 const res = await fetch(`/api/admin/users/${userId}`, {
800 method: 'DELETE'
801 });
802
803 if (!res.ok) {
804 throw new Error('Failed to delete user');
805 }
806
807 await loadData();
808 } catch {
809 alert('Failed to delete user');
810 button.disabled = false;
811 button.textContent = 'Delete';
812 }
813 });
814 });
815
816 // Add click event to table rows to open modal
817 table.querySelectorAll('tbody tr').forEach(row => {
818 row.addEventListener('click', (e) => {
819 // Don't open modal if clicking on delete button or role select
820 if (e.target.closest('.delete-user-btn') || e.target.closest('.role-select')) {
821 return;
822 }
823
824 const userId = row.querySelector('.delete-user-btn').dataset.userId;
825 openUserModal(userId);
826 });
827 });
828 }
829
830 async function loadData() {
831 try {
832 const [transcriptionsRes, usersRes] = await Promise.all([
833 fetch('/api/admin/transcriptions'),
834 fetch('/api/admin/users')
835 ]);
836
837 if (!transcriptionsRes.ok || !usersRes.ok) {
838 if (transcriptionsRes.status === 403 || usersRes.status === 403) {
839 window.location.href = '/';
840 return;
841 }
842 throw new Error('Failed to load admin data');
843 }
844
845 const transcriptions = await transcriptionsRes.json();
846 const users = await usersRes.json();
847
848 document.getElementById('total-users').textContent = users.length;
849 document.getElementById('total-transcriptions').textContent = transcriptions.length;
850
851 renderTranscriptions(transcriptions);
852 renderUsers(users);
853
854 loading.style.display = 'none';
855 content.style.display = 'block';
856 } catch (error) {
857 showError(error.message);
858 }
859 }
860
861 // Tab switching
862 document.querySelectorAll('.tab').forEach(tab => {
863 tab.addEventListener('click', () => {
864 const tabName = tab.dataset.tab;
865
866 document.querySelectorAll('.tab').forEach(t => {
867 t.classList.remove('active');
868 });
869 document.querySelectorAll('.tab-content').forEach(c => {
870 c.classList.remove('active');
871 });
872
873 tab.classList.add('active');
874 document.getElementById(`${tabName}-tab`).classList.add('active');
875 });
876 });
877
878 // User search
879 document.getElementById('user-search').addEventListener('input', (e) => {
880 userSearchTerm = e.target.value.trim();
881 renderUsers(allUsers);
882 });
883
884 // Transcript search
885 document.getElementById('transcript-search').addEventListener('input', (e) => {
886 transcriptSearchTerm = e.target.value.trim();
887 renderTranscriptions(allTranscriptions);
888 });
889
890 // Initialize
891 getCurrentUser().then(() => loadData());
892 </script>
893</body>
894
895</html>