🪻 distributed transcription service thistle.dunkirk.sh
at v0.1.0 26 kB view raw
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>