🪻 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import "./upload-recording-modal.ts"; 4import "./vtt-viewer.ts"; 5 6interface Class { 7 id: string; 8 course_code: string; 9 name: string; 10 professor: string; 11 semester: string; 12 year: number; 13 archived: boolean; 14} 15 16interface MeetingTime { 17 id: string; 18 class_id: string; 19 label: string; 20 created_at: number; 21} 22 23interface Transcription { 24 id: string; 25 user_id: number; 26 meeting_time_id: string | null; 27 filename: string; 28 original_filename: string; 29 status: "pending" | "selected" | "uploading" | "processing" | "transcribing" | "completed" | "failed"; 30 progress: number; 31 error_message: string | null; 32 created_at: number; 33 updated_at: number; 34} 35 36@customElement("class-view") 37export class ClassView extends LitElement { 38 @state() classId = ""; 39 @state() classInfo: Class | null = null; 40 @state() meetingTimes: MeetingTime[] = []; 41 @state() transcriptions: Transcription[] = []; 42 @state() isLoading = true; 43 @state() error: string | null = null; 44 @state() searchQuery = ""; 45 @state() uploadModalOpen = false; 46 private eventSources: Map<string, EventSource> = new Map(); 47 48 static override styles = css` 49 :host { 50 display: block; 51 } 52 53 .header { 54 margin-bottom: 2rem; 55 } 56 57 .back-link { 58 color: var(--paynes-gray); 59 text-decoration: none; 60 font-size: 0.875rem; 61 display: flex; 62 align-items: center; 63 gap: 0.25rem; 64 margin-bottom: 0.5rem; 65 } 66 67 .back-link:hover { 68 color: var(--accent); 69 } 70 71 .class-header { 72 display: flex; 73 justify-content: space-between; 74 align-items: flex-start; 75 margin-bottom: 1rem; 76 } 77 78 .class-info h1 { 79 color: var(--text); 80 margin: 0 0 0.5rem 0; 81 } 82 83 .course-code { 84 font-size: 1rem; 85 color: var(--accent); 86 font-weight: 600; 87 text-transform: uppercase; 88 } 89 90 .professor { 91 color: var(--paynes-gray); 92 font-size: 0.875rem; 93 margin-top: 0.25rem; 94 } 95 96 .semester { 97 color: var(--paynes-gray); 98 font-size: 0.875rem; 99 } 100 101 .archived-banner { 102 background: var(--paynes-gray); 103 color: var(--white); 104 padding: 0.5rem 1rem; 105 border-radius: 4px; 106 font-weight: 600; 107 margin-bottom: 1rem; 108 } 109 110 .search-upload { 111 display: flex; 112 gap: 1rem; 113 align-items: center; 114 margin-bottom: 2rem; 115 } 116 117 .search-box { 118 flex: 1; 119 padding: 0.5rem 0.75rem; 120 border: 1px solid var(--secondary); 121 border-radius: 4px; 122 font-size: 0.875rem; 123 color: var(--text); 124 background: var(--background); 125 } 126 127 .search-box:focus { 128 outline: none; 129 border-color: var(--primary); 130 } 131 132 .upload-button { 133 background: var(--accent); 134 color: var(--white); 135 border: none; 136 padding: 0.5rem 1rem; 137 border-radius: 4px; 138 font-size: 0.875rem; 139 font-weight: 600; 140 cursor: pointer; 141 transition: opacity 0.2s; 142 } 143 144 .upload-button:hover:not(:disabled) { 145 opacity: 0.9; 146 } 147 148 .upload-button:disabled { 149 opacity: 0.5; 150 cursor: not-allowed; 151 } 152 153 .meetings-section { 154 margin-bottom: 2rem; 155 } 156 157 .meetings-section h2 { 158 font-size: 1.25rem; 159 color: var(--text); 160 margin-bottom: 1rem; 161 } 162 163 .meetings-list { 164 display: flex; 165 gap: 0.75rem; 166 flex-wrap: wrap; 167 } 168 169 .meeting-tag { 170 background: color-mix(in srgb, var(--primary) 10%, transparent); 171 color: var(--primary); 172 padding: 0.5rem 1rem; 173 border-radius: 4px; 174 font-size: 0.875rem; 175 font-weight: 500; 176 } 177 178 .transcription-card { 179 background: var(--background); 180 border: 1px solid var(--secondary); 181 border-radius: 8px; 182 padding: 1.5rem; 183 margin-bottom: 1rem; 184 } 185 186 .transcription-header { 187 display: flex; 188 align-items: center; 189 justify-content: space-between; 190 margin-bottom: 1rem; 191 } 192 193 .transcription-filename { 194 font-weight: 500; 195 color: var(--text); 196 } 197 198 .transcription-date { 199 font-size: 0.875rem; 200 color: var(--paynes-gray); 201 } 202 203 .transcription-status { 204 padding: 0.25rem 0.75rem; 205 border-radius: 4px; 206 font-size: 0.75rem; 207 font-weight: 600; 208 text-transform: uppercase; 209 } 210 211 .status-pending { 212 background: color-mix(in srgb, var(--paynes-gray) 10%, transparent); 213 color: var(--paynes-gray); 214 } 215 216 .status-selected, .status-uploading, .status-processing, .status-transcribing { 217 background: color-mix(in srgb, var(--accent) 10%, transparent); 218 color: var(--accent); 219 } 220 221 .status-completed { 222 background: color-mix(in srgb, green 10%, transparent); 223 color: green; 224 } 225 226 .status-failed { 227 background: color-mix(in srgb, red 10%, transparent); 228 color: red; 229 } 230 231 .progress-bar { 232 width: 100%; 233 height: 4px; 234 background: var(--secondary); 235 border-radius: 2px; 236 margin-bottom: 1rem; 237 overflow: hidden; 238 } 239 240 .progress-fill { 241 height: 100%; 242 background: var(--primary); 243 border-radius: 2px; 244 transition: width 0.3s; 245 } 246 247 .progress-fill.indeterminate { 248 width: 30%; 249 animation: progress-slide 1.5s ease-in-out infinite; 250 } 251 252 @keyframes progress-slide { 253 0% { transform: translateX(-100%); } 254 100% { transform: translateX(333%); } 255 } 256 257 .audio-player audio { 258 width: 100%; 259 height: 2.5rem; 260 } 261 262 .empty-state { 263 text-align: center; 264 padding: 4rem 2rem; 265 color: var(--paynes-gray); 266 } 267 268 .empty-state h2 { 269 color: var(--text); 270 margin-bottom: 1rem; 271 } 272 273 .loading { 274 text-align: center; 275 padding: 4rem 2rem; 276 color: var(--paynes-gray); 277 } 278 279 .error { 280 background: color-mix(in srgb, red 10%, transparent); 281 border: 1px solid red; 282 color: red; 283 padding: 1rem; 284 border-radius: 4px; 285 margin-bottom: 2rem; 286 } 287 `; 288 289 override async connectedCallback() { 290 super.connectedCallback(); 291 this.extractClassId(); 292 await this.loadClass(); 293 this.connectToTranscriptionStreams(); 294 295 window.addEventListener("auth-changed", this.handleAuthChange); 296 } 297 298 override disconnectedCallback() { 299 super.disconnectedCallback(); 300 window.removeEventListener("auth-changed", this.handleAuthChange); 301 // Close all event sources 302 for (const eventSource of this.eventSources.values()) { 303 eventSource.close(); 304 } 305 this.eventSources.clear(); 306 } 307 308 private handleAuthChange = async () => { 309 await this.loadClass(); 310 }; 311 312 private extractClassId() { 313 const path = window.location.pathname; 314 const match = path.match(/^\/classes\/(.+)$/); 315 if (match && match[1]) { 316 this.classId = match[1]; 317 } 318 } 319 320 private async loadClass() { 321 this.isLoading = true; 322 this.error = null; 323 324 try { 325 const response = await fetch(`/api/classes/${this.classId}`); 326 if (!response.ok) { 327 if (response.status === 401) { 328 window.location.href = "/"; 329 return; 330 } 331 if (response.status === 403) { 332 this.error = "You don't have access to this class."; 333 return; 334 } 335 throw new Error("Failed to load class"); 336 } 337 338 const data = await response.json(); 339 this.classInfo = data.class; 340 this.meetingTimes = data.meetingTimes || []; 341 this.transcriptions = data.transcriptions || []; 342 343 // Load VTT for completed transcriptions 344 await this.loadVTTForCompleted(); 345 } catch (error) { 346 console.error("Failed to load class:", error); 347 this.error = "Failed to load class. Please try again."; 348 } finally { 349 this.isLoading = false; 350 } 351 } 352 353 private async loadVTTForCompleted() { 354 const completed = this.transcriptions.filter((t) => t.status === "completed"); 355 356 await Promise.all( 357 completed.map(async (transcription) => { 358 try { 359 const response = await fetch(`/api/transcriptions/${transcription.id}?format=vtt`); 360 if (response.ok) { 361 const vttContent = await response.text(); 362 (transcription as any).vttContent = vttContent; 363 (transcription as any).audioUrl = `/api/transcriptions/${transcription.id}/audio`; 364 this.requestUpdate(); 365 } 366 } catch (error) { 367 console.error(`Failed to load VTT for ${transcription.id}:`, error); 368 } 369 }), 370 ); 371 } 372 373 private connectToTranscriptionStreams() { 374 const activeStatuses = ["selected", "uploading", "processing", "transcribing"]; 375 for (const transcription of this.transcriptions) { 376 if (activeStatuses.includes(transcription.status)) { 377 this.connectToStream(transcription.id); 378 } 379 } 380 } 381 382 private connectToStream(transcriptionId: string) { 383 if (this.eventSources.has(transcriptionId)) return; 384 385 const eventSource = new EventSource(`/api/transcriptions/${transcriptionId}/stream`); 386 387 eventSource.addEventListener("update", async (event) => { 388 const update = JSON.parse(event.data); 389 const transcription = this.transcriptions.find((t) => t.id === transcriptionId); 390 391 if (transcription) { 392 if (update.status !== undefined) transcription.status = update.status; 393 if (update.progress !== undefined) transcription.progress = update.progress; 394 395 if (update.status === "completed") { 396 await this.loadVTTForCompleted(); 397 eventSource.close(); 398 this.eventSources.delete(transcriptionId); 399 } 400 401 this.requestUpdate(); 402 } 403 }); 404 405 eventSource.onerror = () => { 406 eventSource.close(); 407 this.eventSources.delete(transcriptionId); 408 }; 409 410 this.eventSources.set(transcriptionId, eventSource); 411 } 412 413 private get filteredTranscriptions() { 414 if (!this.searchQuery) return this.transcriptions; 415 416 const query = this.searchQuery.toLowerCase(); 417 return this.transcriptions.filter((t) => 418 t.original_filename.toLowerCase().includes(query), 419 ); 420 } 421 422 private formatDate(timestamp: number): string { 423 const date = new Date(timestamp * 1000); 424 return date.toLocaleDateString(undefined, { 425 year: "numeric", 426 month: "short", 427 day: "numeric", 428 hour: "2-digit", 429 minute: "2-digit", 430 }); 431 } 432 433 private getMeetingLabel(meetingTimeId: string | null): string { 434 if (!meetingTimeId) return ""; 435 const meeting = this.meetingTimes.find((m) => m.id === meetingTimeId); 436 return meeting ? meeting.label : ""; 437 } 438 439 private handleUploadClick() { 440 this.uploadModalOpen = true; 441 } 442 443 private handleModalClose() { 444 this.uploadModalOpen = false; 445 } 446 447 private async handleUploadSuccess() { 448 this.uploadModalOpen = false; 449 // Reload class data to show new recording 450 await this.loadClass(); 451 } 452 453 override render() { 454 if (this.isLoading) { 455 return html`<div class="loading">Loading class...</div>`; 456 } 457 458 if (this.error) { 459 return html` 460 <div class="error">${this.error}</div> 461 <a href="/classes">← Back to classes</a> 462 `; 463 } 464 465 if (!this.classInfo) { 466 return html` 467 <div class="error">Class not found</div> 468 <a href="/classes">← Back to classes</a> 469 `; 470 } 471 472 return html` 473 <div class="header"> 474 <a href="/classes" class="back-link">← Back to all classes</a> 475 476 ${this.classInfo.archived ? html`<div class="archived-banner">⚠️ This class is archived - no new recordings can be uploaded</div>` : ""} 477 478 <div class="class-header"> 479 <div class="class-info"> 480 <div class="course-code">${this.classInfo.course_code}</div> 481 <h1>${this.classInfo.name}</h1> 482 <div class="professor">Professor: ${this.classInfo.professor}</div> 483 <div class="semester">${this.classInfo.semester} ${this.classInfo.year}</div> 484 </div> 485 </div> 486 487 ${ 488 this.meetingTimes.length > 0 489 ? html` 490 <div class="meetings-section"> 491 <h2>Meeting Times</h2> 492 <div class="meetings-list"> 493 ${this.meetingTimes.map((meeting) => html`<div class="meeting-tag">${meeting.label}</div>`)} 494 </div> 495 </div> 496 ` 497 : "" 498 } 499 500 <div class="search-upload"> 501 <input 502 type="text" 503 class="search-box" 504 placeholder="Search recordings..." 505 .value=${this.searchQuery} 506 @input=${(e: Event) => { 507 this.searchQuery = (e.target as HTMLInputElement).value; 508 }} 509 /> 510 <button 511 class="upload-button" 512 ?disabled=${this.classInfo.archived} 513 @click=${this.handleUploadClick} 514 > 515 📤 Upload Recording 516 </button> 517 </div> 518 </div> 519 520 ${ 521 this.filteredTranscriptions.length === 0 522 ? html` 523 <div class="empty-state"> 524 <h2>${this.searchQuery ? "No matching recordings" : "No recordings yet"}</h2> 525 <p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p> 526 </div> 527 ` 528 : html` 529 ${this.filteredTranscriptions.map( 530 (t) => html` 531 <div class="transcription-card"> 532 <div class="transcription-header"> 533 <div> 534 <div class="transcription-filename">${t.original_filename}</div> 535 ${ 536 t.meeting_time_id 537 ? html`<div class="transcription-date">${this.getMeetingLabel(t.meeting_time_id)}${this.formatDate(t.created_at)}</div>` 538 : html`<div class="transcription-date">${this.formatDate(t.created_at)}</div>` 539 } 540 </div> 541 <span class="transcription-status status-${t.status}">${t.status}</span> 542 </div> 543 544 ${ 545 ["uploading", "processing", "transcribing", "selected"].includes( 546 t.status, 547 ) 548 ? html` 549 <div class="progress-bar"> 550 <div 551 class="progress-fill ${t.status === "processing" ? "indeterminate" : ""}" 552 style="${t.status === "processing" ? "" : `width: ${t.progress}%`}" 553 ></div> 554 </div> 555 ` 556 : "" 557 } 558 559 ${ 560 t.status === "completed" && (t as any).audioUrl && (t as any).vttContent 561 ? html` 562 <div class="audio-player"> 563 <audio id="audio-${t.id}" preload="metadata" controls src="${(t as any).audioUrl}"></audio> 564 </div> 565 <vtt-viewer .vttContent=${(t as any).vttContent} .audioId=${`audio-${t.id}`}></vtt-viewer> 566 ` 567 : "" 568 } 569 570 ${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""} 571 </div> 572 `, 573 )} 574 ` 575 } 576 577 <upload-recording-modal 578 ?open=${this.uploadModalOpen} 579 .classId=${this.classId} 580 .meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))} 581 @close=${this.handleModalClose} 582 @upload-success=${this.handleUploadSuccess} 583 ></upload-recording-modal> 584 `; 585 } 586}