🪻 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>