🪻 distributed transcription service thistle.dunkirk.sh
at main 19 kB view raw
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 @state() private selectedDate: string = ""; 34 35 static override styles = css` 36 :host { 37 display: none; 38 } 39 40 :host([open]) { 41 display: block; 42 } 43 44 .overlay { 45 position: fixed; 46 top: 0; 47 left: 0; 48 right: 0; 49 bottom: 0; 50 background: rgba(0, 0, 0, 0.5); 51 display: flex; 52 align-items: center; 53 justify-content: center; 54 z-index: 1000; 55 } 56 57 .modal { 58 background: var(--background); 59 border-radius: 8px; 60 padding: 2rem; 61 max-width: 32rem; 62 width: 90%; 63 max-height: 90vh; 64 overflow-y: auto; 65 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); 66 } 67 68 .modal-header { 69 display: flex; 70 justify-content: space-between; 71 align-items: center; 72 margin-bottom: 1.5rem; 73 } 74 75 .modal-header h2 { 76 margin: 0; 77 color: var(--text); 78 font-size: 1.5rem; 79 } 80 81 .close-button { 82 background: none; 83 border: none; 84 font-size: 1.5rem; 85 color: var(--paynes-gray); 86 cursor: pointer; 87 padding: 0; 88 width: 2rem; 89 height: 2rem; 90 display: flex; 91 align-items: center; 92 justify-content: center; 93 border-radius: 4px; 94 transition: background 0.2s; 95 } 96 97 .close-button:hover { 98 background: var(--secondary); 99 } 100 101 .form-group { 102 margin-bottom: 1.5rem; 103 } 104 105 label { 106 display: block; 107 font-weight: 500; 108 color: var(--text); 109 margin-bottom: 0.5rem; 110 font-size: 0.875rem; 111 } 112 113 .file-input-wrapper { 114 position: relative; 115 border: 2px dashed var(--secondary); 116 border-radius: 8px; 117 padding: 2rem; 118 text-align: center; 119 cursor: pointer; 120 transition: all 0.2s; 121 } 122 123 .file-input-wrapper:hover { 124 border-color: var(--accent); 125 background: color-mix(in srgb, var(--accent) 5%, transparent); 126 } 127 128 .file-input-wrapper.has-file { 129 border-color: var(--accent); 130 background: color-mix(in srgb, var(--accent) 10%, transparent); 131 } 132 133 input[type="file"] { 134 position: absolute; 135 opacity: 0; 136 width: 100%; 137 height: 100%; 138 top: 0; 139 left: 0; 140 cursor: pointer; 141 } 142 143 .file-input-label { 144 color: var(--paynes-gray); 145 font-size: 0.875rem; 146 } 147 148 .file-input-label strong { 149 color: var(--accent); 150 } 151 152 .selected-file { 153 margin-top: 0.5rem; 154 color: var(--text); 155 font-weight: 500; 156 } 157 158 select { 159 width: 100%; 160 padding: 0.75rem; 161 border: 1px solid var(--secondary); 162 border-radius: 4px; 163 font-size: 0.875rem; 164 color: var(--text); 165 background: var(--background); 166 cursor: pointer; 167 } 168 169 select:focus { 170 outline: none; 171 border-color: var(--primary); 172 } 173 174 .help-text { 175 font-size: 0.75rem; 176 color: var(--paynes-gray); 177 margin-top: 0.25rem; 178 } 179 180 .error { 181 background: color-mix(in srgb, red 10%, transparent); 182 border: 1px solid red; 183 color: red; 184 padding: 0.75rem; 185 border-radius: 4px; 186 margin-bottom: 1rem; 187 font-size: 0.875rem; 188 } 189 190 .modal-footer { 191 display: flex; 192 gap: 0.75rem; 193 justify-content: flex-end; 194 margin-top: 2rem; 195 } 196 197 button { 198 padding: 0.75rem 1.5rem; 199 border-radius: 4px; 200 font-size: 0.875rem; 201 font-weight: 600; 202 cursor: pointer; 203 transition: opacity 0.2s; 204 border: none; 205 } 206 207 button:hover:not(:disabled) { 208 opacity: 0.9; 209 } 210 211 button:disabled { 212 opacity: 0.5; 213 cursor: not-allowed; 214 } 215 216 .btn-cancel { 217 background: var(--secondary); 218 color: var(--text); 219 } 220 221 .btn-upload { 222 background: var(--accent); 223 color: var(--white); 224 } 225 226 .uploading-text { 227 display: flex; 228 align-items: center; 229 gap: 0.5rem; 230 } 231 232 .meeting-time-selector { 233 display: flex; 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 this.selectedDate = ""; 296 297 if (this.selectedFile && this.classId) { 298 // Set initial date from file 299 const fileDate = new Date(this.selectedFile.lastModified); 300 this.selectedDate = fileDate.toISOString().split("T")[0] || ""; 301 // Start both detection and upload in parallel 302 this.detectMeetingTime(); 303 this.startBackgroundUpload(); 304 } 305 } 306 } 307 308 private async startBackgroundUpload() { 309 if (!this.selectedFile) return; 310 311 this.uploading = true; 312 this.uploadProgress = 0; 313 314 try { 315 const formData = new FormData(); 316 formData.append("audio", this.selectedFile); 317 formData.append("class_id", this.classId); 318 319 // Send recording date (from date picker or file timestamp) 320 if (this.selectedDate) { 321 // Convert YYYY-MM-DD to timestamp (noon local time) 322 const date = new Date(`${this.selectedDate}T12:00:00`); 323 formData.append("recording_date", Math.floor(date.getTime() / 1000).toString()); 324 } else if (this.selectedFile.lastModified) { 325 // Use file's lastModified as recording date 326 formData.append("recording_date", Math.floor(this.selectedFile.lastModified / 1000).toString()); 327 } 328 329 // Don't send section_id yet - will be set via PATCH when user confirms 330 331 const xhr = new XMLHttpRequest(); 332 333 // Track upload progress 334 xhr.upload.addEventListener("progress", (e) => { 335 if (e.lengthComputable) { 336 this.uploadProgress = Math.round((e.loaded / e.total) * 100); 337 } 338 }); 339 340 // Handle completion 341 xhr.addEventListener("load", () => { 342 if (xhr.status >= 200 && xhr.status < 300) { 343 this.uploadComplete = true; 344 this.uploading = false; 345 const response = JSON.parse(xhr.responseText); 346 this.uploadedTranscriptionId = response.id; 347 } else { 348 this.uploading = false; 349 const response = JSON.parse(xhr.responseText); 350 this.error = response.error || "Upload failed"; 351 } 352 }); 353 354 // Handle errors 355 xhr.addEventListener("error", () => { 356 this.uploading = false; 357 this.error = "Upload failed. Please try again."; 358 }); 359 360 xhr.open("POST", "/api/transcriptions"); 361 xhr.send(formData); 362 } catch (error) { 363 console.error("Upload failed:", error); 364 this.uploading = false; 365 this.error = 366 error instanceof Error 367 ? error.message 368 : "Upload failed. Please try again."; 369 } 370 } 371 372 private async detectMeetingTime() { 373 if (!this.classId) return; 374 375 this.detectingMeetingTime = true; 376 377 try { 378 const formData = new FormData(); 379 formData.append("class_id", this.classId); 380 381 // Use selected date or file's lastModified timestamp 382 let timestamp: number; 383 if (this.selectedDate) { 384 // Convert YYYY-MM-DD to timestamp (noon local time to avoid timezone issues) 385 const date = new Date(`${this.selectedDate}T12:00:00`); 386 timestamp = date.getTime(); 387 } else if (this.selectedFile?.lastModified) { 388 timestamp = this.selectedFile.lastModified; 389 } else { 390 return; 391 } 392 393 formData.append("file_timestamp", timestamp.toString()); 394 395 const response = await fetch("/api/transcriptions/detect-meeting-time", { 396 method: "POST", 397 body: formData, 398 }); 399 400 if (!response.ok) { 401 console.warn("Failed to detect meeting time"); 402 return; 403 } 404 405 const data = await response.json(); 406 407 if (data.detected && data.meeting_time_id) { 408 this.detectedMeetingTime = data.meeting_time_id; 409 this.selectedMeetingTimeId = data.meeting_time_id; 410 } 411 } catch (error) { 412 console.warn("Error detecting meeting time:", error); 413 } finally { 414 this.detectingMeetingTime = false; 415 } 416 } 417 418 private handleMeetingTimeSelect(meetingTimeId: string) { 419 this.selectedMeetingTimeId = meetingTimeId; 420 } 421 422 private handleDateChange(e: Event) { 423 const input = e.target as HTMLInputElement; 424 this.selectedDate = input.value; 425 // Re-detect meeting time when date changes 426 if (this.selectedDate && this.classId) { 427 this.detectMeetingTime(); 428 } 429 } 430 431 private handleSectionChange(e: Event) { 432 const select = e.target as HTMLSelectElement; 433 this.selectedSectionId = select.value || null; 434 } 435 436 private async handleSubmit() { 437 if (!this.uploadedTranscriptionId || !this.selectedMeetingTimeId) return; 438 439 this.submitting = true; 440 this.error = null; 441 442 try { 443 // Get section to use (selected override or user's section) 444 const sectionToUse = this.selectedSectionId || this.userSection; 445 446 const response = await fetch( 447 `/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`, 448 { 449 method: "PATCH", 450 headers: { "Content-Type": "application/json" }, 451 body: JSON.stringify({ 452 meeting_time_id: this.selectedMeetingTimeId, 453 section_id: sectionToUse, 454 }), 455 }, 456 ); 457 458 if (!response.ok) { 459 const data = await response.json(); 460 this.error = data.error || "Failed to update meeting time"; 461 this.submitting = false; 462 return; 463 } 464 465 // Success - close modal and refresh 466 this.dispatchEvent(new CustomEvent("upload-success")); 467 this.handleClose(); 468 } catch (error) { 469 console.error("Failed to update meeting time:", error); 470 this.error = "Failed to update meeting time"; 471 this.submitting = false; 472 } 473 } 474 475 private handleClose() { 476 if (this.uploading || this.submitting) return; 477 this.open = false; 478 this.selectedFile = null; 479 this.selectedMeetingTimeId = null; 480 this.selectedSectionId = null; 481 this.error = null; 482 this.detectedMeetingTime = null; 483 this.detectingMeetingTime = false; 484 this.uploadComplete = false; 485 this.uploadProgress = 0; 486 this.uploadedTranscriptionId = null; 487 this.submitting = false; 488 this.selectedDate = ""; 489 this.dispatchEvent(new CustomEvent("close")); 490 } 491 492 override render() { 493 if (!this.open) return null; 494 495 return html` 496 <div class="overlay" @click=${(e: Event) => e.target === e.currentTarget && this.handleClose()}> 497 <div class="modal"> 498 <div class="modal-header"> 499 <h2>Upload Recording</h2> 500 <button class="close-button" @click=${this.handleClose} ?disabled=${this.uploading}> 501 × 502 </button> 503 </div> 504 505 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 506 507 <form @submit=${(e: Event) => e.preventDefault()}> 508 <div class="form-group"> 509 <label>Audio File</label> 510 <div class="file-input-wrapper ${this.selectedFile ? "has-file" : ""}"> 511 <input 512 type="file" 513 accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.ogg,.webm,.flac" 514 @change=${this.handleFileSelect} 515 ?disabled=${this.uploading} 516 /> 517 <div class="file-input-label"> 518 ${ 519 this.selectedFile 520 ? html`<div class="selected-file">📎 ${this.selectedFile.name}</div>` 521 : html` 522 <div>📤 <strong>Choose a file</strong> or drag it here</div> 523 <div style="margin-top: 0.5rem; font-size: 0.75rem;"> 524 Supported: MP3, WAV, M4A, AAC, OGG, WebM, FLAC, MP4 525 </div> 526 ` 527 } 528 </div> 529 </div> 530 <div class="help-text">Maximum file size: 100MB</div> 531 </div> 532 533 ${ 534 this.selectedFile 535 ? html` 536 <div class="form-group"> 537 <label for="date">Recording Date</label> 538 <input 539 type="date" 540 id="date" 541 .value=${this.selectedDate} 542 @change=${this.handleDateChange} 543 ?disabled=${this.uploading} 544 style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);" 545 /> 546 <div class="help-text"> 547 Change the date to detect the correct meeting time 548 </div> 549 </div> 550 551 <div class="form-group"> 552 <label>Meeting Time</label> 553 ${ 554 this.detectingMeetingTime 555 ? html`<div class="detecting-text">Detecting meeting time from audio metadata...</div>` 556 : html` 557 <div class="meeting-time-selector"> 558 ${this.meetingTimes.map( 559 (meeting) => html` 560 <button 561 type="button" 562 class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}" 563 @click=${() => this.handleMeetingTimeSelect(meeting.id)} 564 ?disabled=${this.uploading} 565 > 566 ${meeting.label} 567 </button> 568 `, 569 )} 570 </div> 571 ` 572 } 573 <div class="help-text"> 574 ${ 575 this.detectedMeetingTime 576 ? "Auto-detected based on recording date. You can change if needed." 577 : "Select which meeting this recording is for" 578 } 579 </div> 580 </div> 581 ` 582 : "" 583 } 584 585 ${ 586 this.sections.length > 0 && this.selectedFile 587 ? html` 588 <div class="form-group"> 589 <label for="section">Section</label> 590 <select 591 id="section" 592 @change=${this.handleSectionChange} 593 ?disabled=${this.uploading} 594 .value=${this.selectedSectionId || this.userSection || ""} 595 > 596 <option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option> 597 ${this.sections 598 .filter((section) => section.id !== this.userSection) 599 .map( 600 (section) => html` 601 <option value=${section.id}>${section.section_number}</option> 602 `, 603 )} 604 </select> 605 <div class="help-text"> 606 Select which section this recording is for (defaults to your section) 607 </div> 608 </div> 609 ` 610 : "" 611 } 612 613 ${ 614 this.uploading || this.uploadComplete 615 ? html` 616 <div class="form-group"> 617 <label>Upload Status</label> 618 <div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;"> 619 ${ 620 this.uploadComplete 621 ? html` 622 <div style="color: green; font-weight: 500;"> 623 ✓ Upload complete! Select a meeting time to continue. 624 </div> 625 ` 626 : html` 627 <div style="color: var(--text); font-weight: 500; margin-bottom: 0.5rem;"> 628 Uploading... ${this.uploadProgress}% 629 </div> 630 <div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;"> 631 <div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div> 632 </div> 633 ` 634 } 635 </div> 636 </div> 637 ` 638 : "" 639 } 640 </form> 641 642 <div class="modal-footer"> 643 <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}> 644 Cancel 645 </button> 646 ${ 647 this.uploadComplete && this.selectedMeetingTimeId 648 ? html` 649 <button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}> 650 ${this.submitting ? "Submitting..." : "Confirm & Submit"} 651 </button> 652 ` 653 : "" 654 } 655 </div> 656 </div> 657 </div> 658 `; 659 } 660}