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