🪻 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 .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 = err instanceof Error ? err.message : "Failed to load pending recordings. Please try again."; 321 } finally { 322 this.isLoading = false; 323 } 324 } 325 326 private async handleApprove(recordingId: string) { 327 this.error = null; 328 try { 329 const response = await fetch(`/api/transcripts/${recordingId}/select`, { 330 method: "PUT", 331 }); 332 333 if (!response.ok) { 334 const data = await response.json(); 335 throw new Error(data.error || "Failed to approve recording"); 336 } 337 338 // Reload recordings 339 await this.loadRecordings(); 340 } catch (err) { 341 this.error = err instanceof Error ? err.message : "Failed to approve recording. Please try again."; 342 } 343 } 344 345 private async handleDelete(recordingId: string) { 346 if ( 347 !confirm( 348 "Are you sure you want to delete this recording? This cannot be undone.", 349 ) 350 ) { 351 return; 352 } 353 354 this.error = null; 355 try { 356 const response = await fetch(`/api/admin/transcriptions/${recordingId}`, { 357 method: "DELETE", 358 }); 359 360 if (!response.ok) { 361 const data = await response.json(); 362 throw new Error(data.error || "Failed to delete recording"); 363 } 364 365 // Reload recordings 366 await this.loadRecordings(); 367 } catch (err) { 368 this.error = err instanceof Error ? err.message : "Failed to delete recording. Please try again."; 369 } 370 } 371 372 private formatTimestamp(timestamp: number): string { 373 const date = new Date(timestamp * 1000); 374 return date.toLocaleString(); 375 } 376 377 override render() { 378 if (this.isLoading) { 379 return html`<div class="loading">Loading pending recordings...</div>`; 380 } 381 382 if (this.error) { 383 return html` 384 <div class="error-banner">${this.error}</div> 385 <button @click=${this.loadRecordings}>Retry</button> 386 `; 387 } 388 389 if (this.recordings.length === 0) { 390 return html` 391 <div class="empty-state"> 392 <p>No pending recordings</p> 393 </div> 394 `; 395 } 396 397 return html` 398 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 399 400 <div class="recordings-grid"> 401 ${this.recordings.map( 402 (recording) => html` 403 <div class="recording-card"> 404 <div class="card-header"> 405 <div class="file-info"> 406 <div class="filename">${recording.original_filename}</div> 407 </div> 408 </div> 409 410 <div class="meta-row"> 411 <div class="meta-item"> 412 <div class="meta-label">Class</div> 413 <div class="meta-value"> 414 <div class="class-info"> 415 <span class="course-code">${recording.course_code}</span> 416 <span class="class-name">${recording.class_name}</span> 417 </div> 418 </div> 419 </div> 420 421 <div class="meta-item"> 422 <div class="meta-label">Meeting Time</div> 423 <div class="meta-value"> 424 ${ 425 recording.meeting_label 426 ? html`<span class="meeting-label">${recording.meeting_label}</span>` 427 : html`<span style="color: var(--paynes-gray); font-style: italic;">Not specified</span>` 428 } 429 </div> 430 </div> 431 432 <div class="meta-item"> 433 <div class="meta-label">Uploaded By</div> 434 <div class="meta-value"> 435 <div class="user-info"> 436 <img 437 src="https://hostedboringavatars.vercel.app/api/marble?size=24&name=${recording.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 438 alt="Avatar" 439 class="user-avatar" 440 /> 441 <span>${recording.user_name || recording.user_email}</span> 442 </div> 443 </div> 444 </div> 445 446 <div class="meta-item"> 447 <div class="meta-label">Uploaded At</div> 448 <div class="meta-value timestamp"> 449 ${this.formatTimestamp(recording.created_at)} 450 </div> 451 </div> 452 </div> 453 454 <div class="audio-player"> 455 <audio controls preload="metadata" src="/api/transcriptions/${recording.id}/audio"></audio> 456 </div> 457 458 <div class="actions"> 459 <button class="approve-btn" @click=${() => this.handleApprove(recording.id)}> 460 ✓ Approve & Transcribe 461 </button> 462 <button class="delete-btn" @click=${() => this.handleDelete(recording.id)}> 463 Delete 464 </button> 465 </div> 466 </div> 467 `, 468 )} 469 </div> 470 `; 471 } 472}