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