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