🪻 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, property, state } from "lit/decorators.js"; 3 4interface MeetingTime { 5 id: string; 6 label: string; 7} 8 9interface ClassSection { 10 id: string; 11 section_number: string; 12} 13 14@customElement("upload-recording-modal") 15export class UploadRecordingModal extends LitElement { 16 @property({ type: Boolean }) open = false; 17 @property({ type: String }) classId = ""; 18 @property({ type: Array }) meetingTimes: MeetingTime[] = []; 19 @property({ type: Array }) sections: ClassSection[] = []; 20 @property({ type: String }) userSection: string | null = null; 21 22 @state() private selectedFile: File | null = null; 23 @state() private selectedMeetingTimeId: string | null = null; 24 @state() private selectedSectionId: string | null = null; 25 @state() private uploading = false; 26 @state() private uploadProgress = 0; 27 @state() private error: string | null = null; 28 @state() private detectedMeetingTime: string | null = null; 29 @state() private detectingMeetingTime = false; 30 @state() private uploadComplete = false; 31 @state() private uploadedTranscriptionId: string | null = null; 32 @state() private submitting = false; 33 34 static override styles = css` 35 :host { 36 display: none; 37 } 38 39 :host([open]) { 40 display: block; 41 } 42 43 .overlay { 44 position: fixed; 45 top: 0; 46 left: 0; 47 right: 0; 48 bottom: 0; 49 background: rgba(0, 0, 0, 0.5); 50 display: flex; 51 align-items: center; 52 justify-content: center; 53 z-index: 1000; 54 } 55 56 .modal { 57 background: var(--background); 58 border-radius: 8px; 59 padding: 2rem; 60 max-width: 32rem; 61 width: 90%; 62 max-height: 90vh; 63 overflow-y: auto; 64 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); 65 } 66 67 .modal-header { 68 display: flex; 69 justify-content: space-between; 70 align-items: center; 71 margin-bottom: 1.5rem; 72 } 73 74 .modal-header h2 { 75 margin: 0; 76 color: var(--text); 77 font-size: 1.5rem; 78 } 79 80 .close-button { 81 background: none; 82 border: none; 83 font-size: 1.5rem; 84 color: var(--paynes-gray); 85 cursor: pointer; 86 padding: 0; 87 width: 2rem; 88 height: 2rem; 89 display: flex; 90 align-items: center; 91 justify-content: center; 92 border-radius: 4px; 93 transition: background 0.2s; 94 } 95 96 .close-button:hover { 97 background: var(--secondary); 98 } 99 100 .form-group { 101 margin-bottom: 1.5rem; 102 } 103 104 label { 105 display: block; 106 font-weight: 500; 107 color: var(--text); 108 margin-bottom: 0.5rem; 109 font-size: 0.875rem; 110 } 111 112 .file-input-wrapper { 113 position: relative; 114 border: 2px dashed var(--secondary); 115 border-radius: 8px; 116 padding: 2rem; 117 text-align: center; 118 cursor: pointer; 119 transition: all 0.2s; 120 } 121 122 .file-input-wrapper:hover { 123 border-color: var(--accent); 124 background: color-mix(in srgb, var(--accent) 5%, transparent); 125 } 126 127 .file-input-wrapper.has-file { 128 border-color: var(--accent); 129 background: color-mix(in srgb, var(--accent) 10%, transparent); 130 } 131 132 input[type="file"] { 133 position: absolute; 134 opacity: 0; 135 width: 100%; 136 height: 100%; 137 top: 0; 138 left: 0; 139 cursor: pointer; 140 } 141 142 .file-input-label { 143 color: var(--paynes-gray); 144 font-size: 0.875rem; 145 } 146 147 .file-input-label strong { 148 color: var(--accent); 149 } 150 151 .selected-file { 152 margin-top: 0.5rem; 153 color: var(--text); 154 font-weight: 500; 155 } 156 157 select { 158 width: 100%; 159 padding: 0.75rem; 160 border: 1px solid var(--secondary); 161 border-radius: 4px; 162 font-size: 0.875rem; 163 color: var(--text); 164 background: var(--background); 165 cursor: pointer; 166 } 167 168 select:focus { 169 outline: none; 170 border-color: var(--primary); 171 } 172 173 .help-text { 174 font-size: 0.75rem; 175 color: var(--paynes-gray); 176 margin-top: 0.25rem; 177 } 178 179 .error { 180 background: color-mix(in srgb, red 10%, transparent); 181 border: 1px solid red; 182 color: red; 183 padding: 0.75rem; 184 border-radius: 4px; 185 margin-bottom: 1rem; 186 font-size: 0.875rem; 187 } 188 189 .modal-footer { 190 display: flex; 191 gap: 0.75rem; 192 justify-content: flex-end; 193 margin-top: 2rem; 194 } 195 196 button { 197 padding: 0.75rem 1.5rem; 198 border-radius: 4px; 199 font-size: 0.875rem; 200 font-weight: 600; 201 cursor: pointer; 202 transition: opacity 0.2s; 203 border: none; 204 } 205 206 button:hover:not(:disabled) { 207 opacity: 0.9; 208 } 209 210 button:disabled { 211 opacity: 0.5; 212 cursor: not-allowed; 213 } 214 215 .btn-cancel { 216 background: var(--secondary); 217 color: var(--text); 218 } 219 220 .btn-upload { 221 background: var(--accent); 222 color: var(--white); 223 } 224 225 .uploading-text { 226 display: flex; 227 align-items: center; 228 gap: 0.5rem; 229 } 230 231 .meeting-time-selector { 232 display: flex; 233 flex-direction: column; 234 gap: 0.5rem; 235 } 236 237 .meeting-time-button { 238 padding: 0.75rem 1rem; 239 background: var(--background); 240 border: 2px solid var(--secondary); 241 border-radius: 6px; 242 font-size: 0.875rem; 243 font-weight: 500; 244 cursor: pointer; 245 transition: all 0.2s; 246 font-family: inherit; 247 color: var(--text); 248 text-align: left; 249 display: flex; 250 align-items: center; 251 gap: 0.5rem; 252 } 253 254 .meeting-time-button:hover { 255 border-color: var(--primary); 256 background: color-mix(in srgb, var(--primary) 5%, transparent); 257 } 258 259 .meeting-time-button.selected { 260 background: var(--primary); 261 border-color: var(--primary); 262 color: white; 263 } 264 265 .meeting-time-button.detected { 266 border-color: var(--accent); 267 } 268 269 .meeting-time-button.detected::after { 270 content: "✨ Auto-detected"; 271 margin-left: auto; 272 font-size: 0.75rem; 273 opacity: 0.8; 274 } 275 276 .detecting-text { 277 font-size: 0.875rem; 278 color: var(--paynes-gray); 279 padding: 0.5rem; 280 text-align: center; 281 font-style: italic; 282 } 283 `; 284 285 private async handleFileSelect(e: Event) { 286 const input = e.target as HTMLInputElement; 287 if (input.files && input.files.length > 0) { 288 this.selectedFile = input.files[0] ?? null; 289 this.error = null; 290 this.detectedMeetingTime = null; 291 this.selectedMeetingTimeId = null; 292 this.uploadComplete = false; 293 this.uploadedTranscriptionId = null; 294 this.submitting = false; 295 296 if (this.selectedFile && this.classId) { 297 // Start both detection and upload in parallel 298 this.detectMeetingTime(); 299 this.startBackgroundUpload(); 300 } 301 } 302 } 303 304 private async startBackgroundUpload() { 305 if (!this.selectedFile) return; 306 307 this.uploading = true; 308 this.uploadProgress = 0; 309 310 try { 311 const formData = new FormData(); 312 formData.append("audio", this.selectedFile); 313 formData.append("class_id", this.classId); 314 315 // Use user's section by default, or allow override 316 const sectionToUse = this.selectedSectionId || this.userSection; 317 if (sectionToUse) { 318 formData.append("section_id", sectionToUse); 319 } 320 321 const xhr = new XMLHttpRequest(); 322 323 // Track upload progress 324 xhr.upload.addEventListener("progress", (e) => { 325 if (e.lengthComputable) { 326 this.uploadProgress = Math.round((e.loaded / e.total) * 100); 327 } 328 }); 329 330 // Handle completion 331 xhr.addEventListener("load", () => { 332 if (xhr.status >= 200 && xhr.status < 300) { 333 this.uploadComplete = true; 334 this.uploading = false; 335 const response = JSON.parse(xhr.responseText); 336 this.uploadedTranscriptionId = response.id; 337 } else { 338 this.uploading = false; 339 const response = JSON.parse(xhr.responseText); 340 this.error = response.error || "Upload failed"; 341 } 342 }); 343 344 // Handle errors 345 xhr.addEventListener("error", () => { 346 this.uploading = false; 347 this.error = "Upload failed. Please try again."; 348 }); 349 350 xhr.open("POST", "/api/transcriptions"); 351 xhr.send(formData); 352 } catch (error) { 353 console.error("Upload failed:", error); 354 this.uploading = false; 355 this.error = 356 error instanceof Error 357 ? error.message 358 : "Upload failed. Please try again."; 359 } 360 } 361 362 private async detectMeetingTime() { 363 if (!this.selectedFile || !this.classId) return; 364 365 this.detectingMeetingTime = true; 366 367 try { 368 const formData = new FormData(); 369 formData.append("audio", this.selectedFile); 370 formData.append("class_id", this.classId); 371 372 // Send the file's original lastModified timestamp (preserved by browser) 373 // This is more accurate than server-side file timestamps 374 if (this.selectedFile.lastModified) { 375 formData.append( 376 "file_timestamp", 377 this.selectedFile.lastModified.toString(), 378 ); 379 } 380 381 const response = await fetch("/api/transcriptions/detect-meeting-time", { 382 method: "POST", 383 body: formData, 384 }); 385 386 if (!response.ok) { 387 console.warn("Failed to detect meeting time"); 388 return; 389 } 390 391 const data = await response.json(); 392 393 if (data.detected && data.meeting_time_id) { 394 this.detectedMeetingTime = data.meeting_time_id; 395 this.selectedMeetingTimeId = data.meeting_time_id; 396 } 397 } catch (error) { 398 console.warn("Error detecting meeting time:", error); 399 } finally { 400 this.detectingMeetingTime = false; 401 } 402 } 403 404 private handleMeetingTimeSelect(meetingTimeId: string) { 405 this.selectedMeetingTimeId = meetingTimeId; 406 } 407 408 private handleSectionChange(e: Event) { 409 const select = e.target as HTMLSelectElement; 410 this.selectedSectionId = select.value || null; 411 } 412 413 private async handleSubmit() { 414 if (!this.uploadedTranscriptionId || !this.selectedMeetingTimeId) return; 415 416 this.submitting = true; 417 this.error = null; 418 419 try { 420 const response = await fetch( 421 `/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`, 422 { 423 method: "PATCH", 424 headers: { "Content-Type": "application/json" }, 425 body: JSON.stringify({ 426 meeting_time_id: this.selectedMeetingTimeId, 427 }), 428 }, 429 ); 430 431 if (!response.ok) { 432 const data = await response.json(); 433 this.error = data.error || "Failed to update meeting time"; 434 this.submitting = false; 435 return; 436 } 437 438 // Success - close modal and refresh 439 this.dispatchEvent(new CustomEvent("upload-success")); 440 this.handleClose(); 441 } catch (error) { 442 console.error("Failed to update meeting time:", error); 443 this.error = "Failed to update meeting time"; 444 this.submitting = false; 445 } 446 } 447 448 private handleClose() { 449 if (this.uploading || this.submitting) return; 450 this.open = false; 451 this.selectedFile = null; 452 this.selectedMeetingTimeId = null; 453 this.selectedSectionId = null; 454 this.error = null; 455 this.detectedMeetingTime = null; 456 this.detectingMeetingTime = false; 457 this.uploadComplete = false; 458 this.uploadProgress = 0; 459 this.uploadedTranscriptionId = null; 460 this.submitting = false; 461 this.dispatchEvent(new CustomEvent("close")); 462 } 463 464 override render() { 465 if (!this.open) return null; 466 467 return html` 468 <div class="overlay" @click=${(e: Event) => e.target === e.currentTarget && this.handleClose()}> 469 <div class="modal"> 470 <div class="modal-header"> 471 <h2>Upload Recording</h2> 472 <button class="close-button" @click=${this.handleClose} ?disabled=${this.uploading}> 473 × 474 </button> 475 </div> 476 477 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 478 479 <form @submit=${(e: Event) => e.preventDefault()}> 480 <div class="form-group"> 481 <label>Audio File</label> 482 <div class="file-input-wrapper ${this.selectedFile ? "has-file" : ""}"> 483 <input 484 type="file" 485 accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.ogg,.webm,.flac" 486 @change=${this.handleFileSelect} 487 ?disabled=${this.uploading} 488 /> 489 <div class="file-input-label"> 490 ${ 491 this.selectedFile 492 ? html`<div class="selected-file">📎 ${this.selectedFile.name}</div>` 493 : html` 494 <div>📤 <strong>Choose a file</strong> or drag it here</div> 495 <div style="margin-top: 0.5rem; font-size: 0.75rem;"> 496 Supported: MP3, WAV, M4A, AAC, OGG, WebM, FLAC, MP4 497 </div> 498 ` 499 } 500 </div> 501 </div> 502 <div class="help-text">Maximum file size: 100MB</div> 503 </div> 504 505 ${ 506 this.selectedFile 507 ? html` 508 <div class="form-group"> 509 <label>Meeting Time</label> 510 ${ 511 this.detectingMeetingTime 512 ? html`<div class="detecting-text">Detecting meeting time from audio metadata...</div>` 513 : html` 514 <div class="meeting-time-selector"> 515 ${this.meetingTimes.map( 516 (meeting) => html` 517 <button 518 type="button" 519 class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}" 520 @click=${() => this.handleMeetingTimeSelect(meeting.id)} 521 ?disabled=${this.uploading} 522 > 523 ${meeting.label} 524 </button> 525 `, 526 )} 527 </div> 528 ` 529 } 530 <div class="help-text"> 531 ${ 532 this.detectedMeetingTime 533 ? "Auto-detected based on recording date. You can change if needed." 534 : "Select which meeting this recording is for" 535 } 536 </div> 537 </div> 538 ` 539 : "" 540 } 541 542 ${ 543 this.sections.length > 1 && this.selectedFile 544 ? html` 545 <div class="form-group"> 546 <label for="section">Section (optional)</label> 547 <select 548 id="section" 549 @change=${this.handleSectionChange} 550 ?disabled=${this.uploading} 551 > 552 <option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option> 553 ${this.sections.map( 554 (section) => html` 555 <option value=${section.id}>${section.section_number}</option> 556 `, 557 )} 558 </select> 559 <div class="help-text"> 560 Override which section this recording is for 561 </div> 562 </div> 563 ` 564 : "" 565 } 566 567 ${ 568 this.uploading || this.uploadComplete 569 ? html` 570 <div class="form-group"> 571 <label>Upload Status</label> 572 <div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;"> 573 ${ 574 this.uploadComplete 575 ? html` 576 <div style="color: green; font-weight: 500;"> 577 ✓ Upload complete! Select a meeting time to continue. 578 </div> 579 ` 580 : html` 581 <div style="color: var(--text); font-weight: 500; margin-bottom: 0.5rem;"> 582 Uploading... ${this.uploadProgress}% 583 </div> 584 <div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;"> 585 <div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div> 586 </div> 587 ` 588 } 589 </div> 590 </div> 591 ` 592 : "" 593 } 594 </form> 595 596 <div class="modal-footer"> 597 <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}> 598 Cancel 599 </button> 600 ${this.uploadComplete && this.selectedMeetingTimeId ? html` 601 <button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}> 602 ${this.submitting ? "Submitting..." : "Confirm & Submit"} 603 </button> 604 ` : ""} 605 </div> 606 </div> 607 </div> 608 `; 609 } 610}