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