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