···
77
-
border-collapse: collapse;
78
-
background: var(--background);
79
-
border: 2px solid var(--secondary);
85
-
background: var(--primary);
97
-
border-top: 1px solid var(--secondary);
102
-
background: rgba(0, 0, 0, 0.02);
106
-
display: inline-block;
107
-
padding: 0.25rem 0.75rem;
108
-
border-radius: 4px;
109
-
font-size: 0.875rem;
113
-
.status-completed {
114
-
background: #dcfce7;
118
-
.status-processing,
119
-
.status-uploading {
120
-
background: #fef3c7;
125
-
background: #fee2e2;
130
-
background: #e0e7ff;
135
-
background: var(--accent);
137
-
padding: 0.25rem 0.5rem;
138
-
border-radius: 4px;
139
-
font-size: 0.75rem;
141
-
margin-left: 0.5rem;
146
-
align-items: center;
153
-
border-radius: 50%;
···
211
-
background: transparent;
212
-
border: 2px solid #dc2626;
214
-
padding: 0.25rem 0.75rem;
215
-
border-radius: 4px;
217
-
font-size: 0.875rem;
219
-
font-family: inherit;
220
-
transition: all 0.2s;
223
-
.delete-btn:hover {
224
-
background: #dc2626;
228
-
.delete-btn:disabled {
230
-
cursor: not-allowed;
239
-
padding: 0.25rem 0.5rem;
240
-
border: 2px solid var(--secondary);
241
-
border-radius: 4px;
242
-
font-size: 0.875rem;
243
-
font-family: inherit;
244
-
background: var(--background);
245
-
color: var(--text);
249
-
.role-select:focus {
251
-
border-color: var(--primary);
255
-
background: transparent;
256
-
border: 2px solid #dc2626;
258
-
padding: 0.25rem 0.75rem;
259
-
border-radius: 4px;
261
-
font-size: 0.875rem;
263
-
font-family: inherit;
264
-
transition: all 0.2s;
267
-
.delete-user-btn:hover {
268
-
background: #dc2626;
272
-
.delete-user-btn:disabled {
274
-
cursor: not-allowed;
277
-
.users-table tbody tr {
281
-
.users-table tbody tr:hover {
282
-
background: rgba(0, 0, 0, 0.04);
285
-
.transcriptions-table tbody tr {
289
-
.transcriptions-table tbody tr:hover {
290
-
background: rgba(0, 0, 0, 0.04);
296
-
margin-bottom: 1rem;
297
-
padding: 0.5rem 0.75rem;
298
-
border: 2px solid var(--secondary);
299
-
border-radius: 4px;
301
-
font-family: inherit;
302
-
background: var(--background);
303
-
color: var(--text);
308
-
border-color: var(--primary);
314
-
position: relative;
317
-
th.sortable:hover {
318
-
background: var(--gunmetal);
321
-
th.sortable::after {
323
-
margin-left: 0.5rem;
327
-
th.sortable.asc::after {
332
-
th.sortable.desc::after {
···
<div id="transcriptions-tab" class="tab-content">
<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>
181
+
<admin-transcriptions id="transcriptions-component"></admin-transcriptions>
<div id="users-tab" class="tab-content">
<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>
188
+
<admin-users id="users-component"></admin-users>
···
<script type="module" src="../components/auth.ts"></script>
<script type="module" src="../components/admin-pending-recordings.ts"></script>
199
+
<script type="module" src="../components/admin-transcriptions.ts"></script>
200
+
<script type="module" src="../components/admin-users.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
204
+
const transcriptionsComponent = document.getElementById('transcriptions-component');
205
+
const usersComponent = document.getElementById('users-component');
206
+
const userModal = document.getElementById('user-modal');
207
+
const transcriptModal = document.getElementById('transcript-modal');
const errorMessage = document.getElementById('error-message');
const loading = document.getElementById('loading');
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');
420
-
let currentUserEmail = null;
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 = '';
430
-
// Get current user info
431
-
async function getCurrentUser() {
433
-
const res = await fetch('/api/auth/me');
435
-
const user = await res.json();
436
-
currentUserEmail = user.email;
443
-
function showError(message) {
444
-
errorMessage.textContent = message;
445
-
errorMessage.style.display = 'block';
446
-
loading.style.display = 'none';
449
-
function formatTimestamp(timestamp) {
450
-
const date = new Date(timestamp * 1000);
451
-
return date.toLocaleString();
function openUserModal(userId) {
···
transcriptModal.transcriptId = null;
475
-
// Listen for modal close and user update events
233
+
// Listen for component events
234
+
transcriptionsComponent.addEventListener('open-transcription', (e) => {
235
+
openTranscriptModal(e.detail.id);
238
+
usersComponent.addEventListener('open-user', (e) => {
239
+
openUserModal(e.detail.id);
242
+
// Listen for modal close events
userModal.addEventListener('close', closeUserModal);
477
-
userModal.addEventListener('user-updated', () => loadData());
244
+
userModal.addEventListener('user-updated', async () => {
userModal.addEventListener('click', (e) => {
479
-
if (e.target === userModal) {
248
+
if (e.target === userModal) closeUserModal();
484
-
// Listen for transcript modal events
transcriptModal.addEventListener('close', closeTranscriptModal);
486
-
transcriptModal.addEventListener('transcript-deleted', () => loadData());
252
+
transcriptModal.addEventListener('transcript-deleted', async () => {
transcriptModal.addEventListener('click', (e) => {
488
-
if (e.target === transcriptModal) {
489
-
closeTranscriptModal();
256
+
if (e.target === transcriptModal) closeTranscriptModal();
494
-
function renderTranscriptions(transcriptions) {
495
-
allTranscriptions = transcriptions;
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);
507
-
// Sort transcriptions
508
-
filteredTranscriptions.sort((a, b) => {
509
-
let aVal = a[transcriptSortKey];
510
-
let bVal = b[transcriptSortKey];
512
-
// Handle null values
513
-
if (aVal === null || aVal === undefined) aVal = '';
514
-
if (bVal === null || bVal === undefined) bVal = '';
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;
522
-
comparison = String(aVal).localeCompare(String(bVal));
525
-
return transcriptSortDirection === 'asc' ? comparison : -comparison;
528
-
if (filteredTranscriptions.length === 0) {
529
-
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>';
533
-
const failed = transcriptions.filter(t => t.status === 'failed');
534
-
document.getElementById('failed-transcriptions').textContent = failed.length;
536
-
const table = document.createElement('table');
537
-
table.innerHTML = `
540
-
<th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</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>
548
-
${filteredTranscriptions.map(t => `
549
-
<tr data-id="${t.id}">
550
-
<td>${t.original_filename}</td>
552
-
<div class="user-info">
554
-
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${t.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
556
-
class="user-avatar"
558
-
<span>${t.user_name || t.user_email}</span>
561
-
<td><span class="status-badge status-${t.status}">${t.status}</span></td>
562
-
<td class="timestamp">${formatTimestamp(t.created_at)}</td>
564
-
<button class="delete-btn" data-id="${t.id}">Delete</button>
570
-
transcriptionsTable.innerHTML = '';
571
-
transcriptionsTable.appendChild(table);
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';
580
-
transcriptSortKey = sortKey;
581
-
transcriptSortDirection = 'asc';
583
-
renderTranscriptions(allTranscriptions);
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;
594
-
if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
598
-
button.disabled = true;
599
-
button.textContent = 'Deleting...';
602
-
const res = await fetch(`/api/admin/transcriptions/${id}`, {
607
-
throw new Error('Failed to delete');
613
-
alert('Failed to delete transcription');
614
-
button.disabled = false;
615
-
button.textContent = 'Delete';
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')) {
628
-
const transcriptId = row.dataset.id;
629
-
openTranscriptModal(transcriptId);
634
-
function renderUsers(users) {
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);
647
-
filteredUsers.sort((a, b) => {
648
-
let aVal = a[userSortKey];
649
-
let bVal = b[userSortKey];
651
-
// Handle null values
652
-
if (aVal === null || aVal === undefined) aVal = '';
653
-
if (bVal === null || bVal === undefined) bVal = '';
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;
661
-
comparison = String(aVal).localeCompare(String(bVal));
664
-
return userSortDirection === 'asc' ? comparison : -comparison;
667
-
if (filteredUsers.length === 0) {
668
-
usersTable.innerHTML = '<div class="empty-state">No users found</div>';
672
-
const table = document.createElement('table');
673
-
table.innerHTML = `
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>
686
-
${filteredUsers.map(u => `
689
-
<div class="user-info">
691
-
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${u.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
693
-
class="user-avatar"
695
-
<span>${u.name || 'Anonymous'}</span>
696
-
${u.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
699
-
<td>${u.email}</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>
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>
710
-
<div class="actions">
711
-
<button class="delete-user-btn" data-user-id="${u.id}" data-user-email="${u.email}">Delete</button>
718
-
usersTable.innerHTML = '';
719
-
usersTable.appendChild(table);
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';
728
-
userSortKey = sortKey;
729
-
userSortDirection = 'asc';
731
-
renderUsers(allUsers);
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;
743
-
// Get user email from the row
744
-
const row = selectEl.closest('tr');
745
-
const userEmail = row.querySelector('td:nth-child(2)').textContent;
747
-
// Check if user is demoting themselves
748
-
const isDemotingSelf = userEmail === currentUserEmail && oldRole === 'admin' && newRole === 'user';
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;
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;
761
-
if (!confirm(`Change user role to ${newRole}?`)) {
762
-
selectEl.value = oldRole;
768
-
const res = await fetch(`/api/admin/users/${userId}/role`, {
770
-
headers: {'Content-Type': 'application/json'},
771
-
body: JSON.stringify({role: newRole})
775
-
throw new Error('Failed to update role');
778
-
selectEl.dataset.currentRole = newRole;
780
-
// If demoting self, redirect to home
781
-
if (isDemotingSelf) {
782
-
window.location.href = '/';
787
-
alert('Failed to update user role');
788
-
selectEl.value = oldRole;
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;
800
-
if (!confirm(`Are you sure you want to delete user ${userEmail}? This will delete all their transcriptions and cannot be undone.`)) {
804
-
button.disabled = true;
805
-
button.textContent = 'Deleting...';
808
-
const res = await fetch(`/api/admin/users/${userId}`, {
813
-
throw new Error('Failed to delete user');
818
-
alert('Failed to delete user');
819
-
button.disabled = false;
820
-
button.textContent = 'Delete';
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')) {
833
-
const userId = row.querySelector('.delete-user-btn').dataset.userId;
834
-
openUserModal(userId);
839
-
async function loadData() {
259
+
async function loadStats() {
const [transcriptionsRes, usersRes] = await Promise.all([
fetch('/api/admin/transcriptions'),
···
document.getElementById('total-users').textContent = users.length;
document.getElementById('total-transcriptions').textContent = transcriptions.length;
860
-
renderTranscriptions(transcriptions);
861
-
renderUsers(users);
280
+
const failed = transcriptions.filter(t => t.status === 'failed');
281
+
document.getElementById('failed-transcriptions').textContent = failed.length;
loading.style.display = 'none';
content.style.display = 'block';
866
-
showError(error.message);
286
+
errorMessage.textContent = error.message;
287
+
errorMessage.style.display = 'block';
288
+
loading.style.display = 'none';
···
888
-
document.getElementById('user-search').addEventListener('input', (e) => {
889
-
userSearchTerm = e.target.value.trim();
890
-
renderUsers(allUsers);
893
-
// Transcript search
894
-
document.getElementById('transcript-search').addEventListener('input', (e) => {
895
-
transcriptSearchTerm = e.target.value.trim();
896
-
renderTranscriptions(allTranscriptions);
900
-
getCurrentUser().then(() => loadData());