🪻 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3 4interface PendingRecording { 5 id: string; 6 original_filename: string; 7 user_id: number; 8 user_name: string | null; 9 user_email: string; 10 class_id: string; 11 class_name: string; 12 course_code: string; 13 meeting_time_id: string | null; 14 meeting_label: string | null; 15 created_at: number; 16 status: string; 17} 18 19@customElement("admin-pending-recordings") 20export class AdminPendingRecordings extends LitElement { 21 @state() recordings: PendingRecording[] = []; 22 @state() isLoading = true; 23 @state() error: string | null = null; 24 25 static override styles = css` 26 :host { 27 display: block; 28 } 29 30 .loading, 31 .empty-state { 32 text-align: center; 33 padding: 3rem; 34 color: var(--paynes-gray); 35 } 36 37 .error { 38 background: color-mix(in srgb, red 10%, transparent); 39 border: 1px solid red; 40 color: red; 41 padding: 1rem; 42 border-radius: 4px; 43 margin-bottom: 1rem; 44 } 45 46 .recordings-grid { 47 display: grid; 48 gap: 1.5rem; 49 } 50 51 .recording-card { 52 background: var(--background); 53 border: 2px solid var(--secondary); 54 border-radius: 8px; 55 padding: 1.5rem; 56 transition: border-color 0.2s; 57 } 58 59 .recording-card:hover { 60 border-color: var(--primary); 61 } 62 63 .card-header { 64 display: flex; 65 justify-content: space-between; 66 align-items: flex-start; 67 margin-bottom: 1rem; 68 } 69 70 .file-info { 71 flex: 1; 72 } 73 74 .filename { 75 font-size: 1.125rem; 76 font-weight: 600; 77 color: var(--text); 78 margin-bottom: 0.5rem; 79 } 80 81 .meta-row { 82 display: flex; 83 gap: 2rem; 84 flex-wrap: wrap; 85 margin-bottom: 1rem; 86 } 87 88 .meta-item { 89 display: flex; 90 flex-direction: column; 91 gap: 0.25rem; 92 } 93 94 .meta-label { 95 font-size: 0.75rem; 96 font-weight: 600; 97 text-transform: uppercase; 98 color: var(--paynes-gray); 99 letter-spacing: 0.05em; 100 } 101 102 .meta-value { 103 font-size: 0.875rem; 104 color: var(--text); 105 } 106 107 .class-info { 108 display: flex; 109 flex-direction: column; 110 gap: 0.25rem; 111 } 112 113 .course-code { 114 font-weight: 600; 115 color: var(--accent); 116 font-size: 0.875rem; 117 } 118 119 .class-name { 120 font-size: 0.875rem; 121 color: var(--text); 122 } 123 124 .meeting-label { 125 display: inline-block; 126 background: color-mix(in srgb, var(--primary) 10%, transparent); 127 color: var(--primary); 128 padding: 0.25rem 0.5rem; 129 border-radius: 4px; 130 font-size: 0.875rem; 131 font-weight: 500; 132 } 133 134 .user-info { 135 display: flex; 136 align-items: center; 137 gap: 0.5rem; 138 } 139 140 .user-avatar { 141 width: 1.5rem; 142 height: 1.5rem; 143 border-radius: 50%; 144 } 145 146 .timestamp { 147 color: var(--paynes-gray); 148 font-size: 0.875rem; 149 } 150 151 .audio-player { 152 margin: 1rem 0; 153 } 154 155 .audio-player audio { 156 width: 100%; 157 height: 2.5rem; 158 } 159 160 .actions { 161 display: flex; 162 gap: 0.75rem; 163 margin-top: 1rem; 164 } 165 166 .approve-btn { 167 background: var(--accent); 168 color: var(--white); 169 border: none; 170 padding: 0.75rem 1.5rem; 171 border-radius: 4px; 172 cursor: pointer; 173 font-size: 0.875rem; 174 font-weight: 600; 175 transition: opacity 0.2s; 176 flex: 1; 177 } 178 179 .approve-btn:hover:not(:disabled) { 180 opacity: 0.9; 181 } 182 183 .approve-btn:disabled { 184 opacity: 0.5; 185 cursor: not-allowed; 186 } 187 188 .delete-btn { 189 background: transparent; 190 border: 2px solid #dc2626; 191 color: #dc2626; 192 padding: 0.75rem 1.5rem; 193 border-radius: 4px; 194 cursor: pointer; 195 font-size: 0.875rem; 196 font-weight: 600; 197 transition: all 0.2s; 198 } 199 200 .delete-btn:hover:not(:disabled) { 201 background: #dc2626; 202 color: var(--white); 203 } 204 205 .delete-btn:disabled { 206 opacity: 0.5; 207 cursor: not-allowed; 208 } 209 `; 210 211 override async connectedCallback() { 212 super.connectedCallback(); 213 await this.loadRecordings(); 214 } 215 216 private async loadRecordings() { 217 this.isLoading = true; 218 this.error = null; 219 220 try { 221 // Get all classes with their transcriptions 222 const response = await fetch("/api/classes"); 223 if (!response.ok) { 224 throw new Error("Failed to load classes"); 225 } 226 227 const data = await response.json(); 228 const classesGrouped = data.classes || {}; 229 230 // Flatten all classes 231 const allClasses: any[] = []; 232 for (const classes of Object.values(classesGrouped)) { 233 allClasses.push(...(classes as any[])); 234 } 235 236 // Fetch transcriptions for each class 237 const pendingRecordings: PendingRecording[] = []; 238 239 await Promise.all( 240 allClasses.map(async (cls) => { 241 try { 242 const classResponse = await fetch(`/api/classes/${cls.id}`); 243 if (!classResponse.ok) return; 244 245 const classData = await classResponse.json(); 246 const pendingTranscriptions = (classData.transcriptions || []).filter( 247 (t: any) => t.status === "pending", 248 ); 249 250 for (const transcription of pendingTranscriptions) { 251 // Get user info 252 const userResponse = await fetch( 253 `/api/admin/transcriptions/${transcription.id}/details`, 254 ); 255 if (!userResponse.ok) continue; 256 257 const transcriptionDetails = await userResponse.json(); 258 259 // Find meeting label 260 const meetingTime = classData.meetingTimes.find( 261 (m: any) => m.id === transcription.meeting_time_id, 262 ); 263 264 pendingRecordings.push({ 265 id: transcription.id, 266 original_filename: transcription.original_filename, 267 user_id: transcriptionDetails.user_id, 268 user_name: transcriptionDetails.user_name, 269 user_email: transcriptionDetails.user_email, 270 class_id: cls.id, 271 class_name: cls.name, 272 course_code: cls.course_code, 273 meeting_time_id: transcription.meeting_time_id, 274 meeting_label: meetingTime?.label || null, 275 created_at: transcription.created_at, 276 status: transcription.status, 277 }); 278 } 279 } catch (error) { 280 console.error(`Failed to load class ${cls.id}:`, error); 281 } 282 }), 283 ); 284 285 // Sort by created_at descending 286 pendingRecordings.sort((a, b) => b.created_at - a.created_at); 287 288 this.recordings = pendingRecordings; 289 } catch (error) { 290 console.error("Failed to load pending recordings:", error); 291 this.error = "Failed to load pending recordings. Please try again."; 292 } finally { 293 this.isLoading = false; 294 } 295 } 296 297 private async handleApprove(recordingId: string) { 298 try { 299 const response = await fetch(`/api/transcripts/${recordingId}/select`, { 300 method: "PUT", 301 }); 302 303 if (!response.ok) { 304 throw new Error("Failed to approve recording"); 305 } 306 307 // Reload recordings 308 await this.loadRecordings(); 309 } catch (error) { 310 console.error("Failed to approve recording:", error); 311 alert("Failed to approve recording. Please try again."); 312 } 313 } 314 315 private async handleDelete(recordingId: string) { 316 if ( 317 !confirm( 318 "Are you sure you want to delete this recording? This cannot be undone.", 319 ) 320 ) { 321 return; 322 } 323 324 try { 325 const response = await fetch(`/api/admin/transcriptions/${recordingId}`, { 326 method: "DELETE", 327 }); 328 329 if (!response.ok) { 330 throw new Error("Failed to delete recording"); 331 } 332 333 // Reload recordings 334 await this.loadRecordings(); 335 } catch (error) { 336 console.error("Failed to delete recording:", error); 337 alert("Failed to delete recording. Please try again."); 338 } 339 } 340 341 private formatTimestamp(timestamp: number): string { 342 const date = new Date(timestamp * 1000); 343 return date.toLocaleString(); 344 } 345 346 override render() { 347 if (this.isLoading) { 348 return html`<div class="loading">Loading pending recordings...</div>`; 349 } 350 351 if (this.error) { 352 return html` 353 <div class="error">${this.error}</div> 354 <button @click=${this.loadRecordings}>Retry</button> 355 `; 356 } 357 358 if (this.recordings.length === 0) { 359 return html` 360 <div class="empty-state"> 361 <p>No pending recordings</p> 362 </div> 363 `; 364 } 365 366 return html` 367 <div class="recordings-grid"> 368 ${this.recordings.map( 369 (recording) => html` 370 <div class="recording-card"> 371 <div class="card-header"> 372 <div class="file-info"> 373 <div class="filename">${recording.original_filename}</div> 374 </div> 375 </div> 376 377 <div class="meta-row"> 378 <div class="meta-item"> 379 <div class="meta-label">Class</div> 380 <div class="meta-value"> 381 <div class="class-info"> 382 <span class="course-code">${recording.course_code}</span> 383 <span class="class-name">${recording.class_name}</span> 384 </div> 385 </div> 386 </div> 387 388 <div class="meta-item"> 389 <div class="meta-label">Meeting Time</div> 390 <div class="meta-value"> 391 ${ 392 recording.meeting_label 393 ? html`<span class="meeting-label">${recording.meeting_label}</span>` 394 : html`<span style="color: var(--paynes-gray); font-style: italic;">Not specified</span>` 395 } 396 </div> 397 </div> 398 399 <div class="meta-item"> 400 <div class="meta-label">Uploaded By</div> 401 <div class="meta-value"> 402 <div class="user-info"> 403 <img 404 src="https://hostedboringavatars.vercel.app/api/marble?size=24&name=${recording.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 405 alt="Avatar" 406 class="user-avatar" 407 /> 408 <span>${recording.user_name || recording.user_email}</span> 409 </div> 410 </div> 411 </div> 412 413 <div class="meta-item"> 414 <div class="meta-label">Uploaded At</div> 415 <div class="meta-value timestamp"> 416 ${this.formatTimestamp(recording.created_at)} 417 </div> 418 </div> 419 </div> 420 421 <div class="audio-player"> 422 <audio controls preload="metadata" src="/api/transcriptions/${recording.id}/audio"></audio> 423 </div> 424 425 <div class="actions"> 426 <button class="approve-btn" @click=${() => this.handleApprove(recording.id)}> 427 ✓ Approve & Transcribe 428 </button> 429 <button class="delete-btn" @click=${() => this.handleDelete(recording.id)}> 430 Delete 431 </button> 432 </div> 433 </div> 434 `, 435 )} 436 </div> 437 `; 438 } 439}