🪻 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 @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 // Use user's section by default, or allow override 320 const sectionToUse = this.selectedSectionId || this.userSection; 321 if (sectionToUse) { 322 formData.append("section_id", sectionToUse); 323 } 324 325 const xhr = new XMLHttpRequest(); 326 327 // Track upload progress 328 xhr.upload.addEventListener("progress", (e) => { 329 if (e.lengthComputable) { 330 this.uploadProgress = Math.round((e.loaded / e.total) * 100); 331 } 332 }); 333 334 // Handle completion 335 xhr.addEventListener("load", () => { 336 if (xhr.status >= 200 && xhr.status < 300) { 337 this.uploadComplete = true; 338 this.uploading = false; 339 const response = JSON.parse(xhr.responseText); 340 this.uploadedTranscriptionId = response.id; 341 } else { 342 this.uploading = false; 343 const response = JSON.parse(xhr.responseText); 344 this.error = response.error || "Upload failed"; 345 } 346 }); 347 348 // Handle errors 349 xhr.addEventListener("error", () => { 350 this.uploading = false; 351 this.error = "Upload failed. Please try again."; 352 }); 353 354 xhr.open("POST", "/api/transcriptions"); 355 xhr.send(formData); 356 } catch (error) { 357 console.error("Upload failed:", error); 358 this.uploading = false; 359 this.error = 360 error instanceof Error 361 ? error.message 362 : "Upload failed. Please try again."; 363 } 364 } 365 366 private async detectMeetingTime() { 367 if (!this.classId) return; 368 369 this.detectingMeetingTime = true; 370 371 try { 372 const formData = new FormData(); 373 formData.append("class_id", this.classId); 374 375 // Use selected date or file's lastModified timestamp 376 let timestamp: number; 377 if (this.selectedDate) { 378 // Convert YYYY-MM-DD to timestamp (noon local time to avoid timezone issues) 379 const date = new Date(`${this.selectedDate}T12:00:00`); 380 timestamp = date.getTime(); 381 } else if (this.selectedFile?.lastModified) { 382 timestamp = this.selectedFile.lastModified; 383 } else { 384 return; 385 } 386 387 formData.append("file_timestamp", timestamp.toString()); 388 389 const response = await fetch("/api/transcriptions/detect-meeting-time", { 390 method: "POST", 391 body: formData, 392 }); 393 394 if (!response.ok) { 395 console.warn("Failed to detect meeting time"); 396 return; 397 } 398 399 const data = await response.json(); 400 401 if (data.detected && data.meeting_time_id) { 402 this.detectedMeetingTime = data.meeting_time_id; 403 this.selectedMeetingTimeId = data.meeting_time_id; 404 } 405 } catch (error) { 406 console.warn("Error detecting meeting time:", error); 407 } finally { 408 this.detectingMeetingTime = false; 409 } 410 } 411 412 private handleMeetingTimeSelect(meetingTimeId: string) { 413 this.selectedMeetingTimeId = meetingTimeId; 414 } 415 416 private handleDateChange(e: Event) { 417 const input = e.target as HTMLInputElement; 418 this.selectedDate = input.value; 419 // Re-detect meeting time when date changes 420 if (this.selectedDate && this.classId) { 421 this.detectMeetingTime(); 422 } 423 } 424 425 private handleSectionChange(e: Event) { 426 const select = e.target as HTMLSelectElement; 427 this.selectedSectionId = select.value || null; 428 } 429 430 private async handleSubmit() { 431 if (!this.uploadedTranscriptionId || !this.selectedMeetingTimeId) return; 432 433 this.submitting = true; 434 this.error = null; 435 436 try { 437 const response = await fetch( 438 `/api/transcriptions/${this.uploadedTranscriptionId}/meeting-time`, 439 { 440 method: "PATCH", 441 headers: { "Content-Type": "application/json" }, 442 body: JSON.stringify({ 443 meeting_time_id: this.selectedMeetingTimeId, 444 }), 445 }, 446 ); 447 448 if (!response.ok) { 449 const data = await response.json(); 450 this.error = data.error || "Failed to update meeting time"; 451 this.submitting = false; 452 return; 453 } 454 455 // Success - close modal and refresh 456 this.dispatchEvent(new CustomEvent("upload-success")); 457 this.handleClose(); 458 } catch (error) { 459 console.error("Failed to update meeting time:", error); 460 this.error = "Failed to update meeting time"; 461 this.submitting = false; 462 } 463 } 464 465 private handleClose() { 466 if (this.uploading || this.submitting) return; 467 this.open = false; 468 this.selectedFile = null; 469 this.selectedMeetingTimeId = null; 470 this.selectedSectionId = null; 471 this.error = null; 472 this.detectedMeetingTime = null; 473 this.detectingMeetingTime = false; 474 this.uploadComplete = false; 475 this.uploadProgress = 0; 476 this.uploadedTranscriptionId = null; 477 this.submitting = false; 478 this.selectedDate = ""; 479 this.dispatchEvent(new CustomEvent("close")); 480 } 481 482 override render() { 483 if (!this.open) return null; 484 485 return html` 486 <div class="overlay" @click=${(e: Event) => e.target === e.currentTarget && this.handleClose()}> 487 <div class="modal"> 488 <div class="modal-header"> 489 <h2>Upload Recording</h2> 490 <button class="close-button" @click=${this.handleClose} ?disabled=${this.uploading}> 491 × 492 </button> 493 </div> 494 495 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 496 497 <form @submit=${(e: Event) => e.preventDefault()}> 498 <div class="form-group"> 499 <label>Audio File</label> 500 <div class="file-input-wrapper ${this.selectedFile ? "has-file" : ""}"> 501 <input 502 type="file" 503 accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.ogg,.webm,.flac" 504 @change=${this.handleFileSelect} 505 ?disabled=${this.uploading} 506 /> 507 <div class="file-input-label"> 508 ${ 509 this.selectedFile 510 ? html`<div class="selected-file">📎 ${this.selectedFile.name}</div>` 511 : html` 512 <div>📤 <strong>Choose a file</strong> or drag it here</div> 513 <div style="margin-top: 0.5rem; font-size: 0.75rem;"> 514 Supported: MP3, WAV, M4A, AAC, OGG, WebM, FLAC, MP4 515 </div> 516 ` 517 } 518 </div> 519 </div> 520 <div class="help-text">Maximum file size: 100MB</div> 521 </div> 522 523 ${ 524 this.selectedFile 525 ? html` 526 <div class="form-group"> 527 <label for="date">Recording Date</label> 528 <input 529 type="date" 530 id="date" 531 .value=${this.selectedDate} 532 @change=${this.handleDateChange} 533 ?disabled=${this.uploading} 534 style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);" 535 /> 536 <div class="help-text"> 537 Change the date to detect the correct meeting time 538 </div> 539 </div> 540 541 <div class="form-group"> 542 <label>Meeting Time</label> 543 ${ 544 this.detectingMeetingTime 545 ? html`<div class="detecting-text">Detecting meeting time from audio metadata...</div>` 546 : html` 547 <div class="meeting-time-selector"> 548 ${this.meetingTimes.map( 549 (meeting) => html` 550 <button 551 type="button" 552 class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}" 553 @click=${() => this.handleMeetingTimeSelect(meeting.id)} 554 ?disabled=${this.uploading} 555 > 556 ${meeting.label} 557 </button> 558 `, 559 )} 560 </div> 561 ` 562 } 563 <div class="help-text"> 564 ${ 565 this.detectedMeetingTime 566 ? "Auto-detected based on recording date. You can change if needed." 567 : "Select which meeting this recording is for" 568 } 569 </div> 570 </div> 571 ` 572 : "" 573 } 574 575 ${ 576 this.sections.length > 0 && this.selectedFile 577 ? html` 578 <div class="form-group"> 579 <label for="section">Section</label> 580 <select 581 id="section" 582 @change=${this.handleSectionChange} 583 ?disabled=${this.uploading} 584 .value=${this.selectedSectionId || this.userSection || ""} 585 > 586 <option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option> 587 ${this.sections 588 .filter((section) => section.id !== this.userSection) 589 .map( 590 (section) => html` 591 <option value=${section.id}>${section.section_number}</option> 592 `, 593 )} 594 </select> 595 <div class="help-text"> 596 Select which section this recording is for (defaults to your section) 597 </div> 598 </div> 599 ` 600 : "" 601 } 602 603 ${ 604 this.uploading || this.uploadComplete 605 ? html` 606 <div class="form-group"> 607 <label>Upload Status</label> 608 <div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;"> 609 ${ 610 this.uploadComplete 611 ? html` 612 <div style="color: green; font-weight: 500;"> 613 ✓ Upload complete! Select a meeting time to continue. 614 </div> 615 ` 616 : html` 617 <div style="color: var(--text); font-weight: 500; margin-bottom: 0.5rem;"> 618 Uploading... ${this.uploadProgress}% 619 </div> 620 <div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;"> 621 <div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div> 622 </div> 623 ` 624 } 625 </div> 626 </div> 627 ` 628 : "" 629 } 630 </form> 631 632 <div class="modal-footer"> 633 <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}> 634 Cancel 635 </button> 636 ${ 637 this.uploadComplete && this.selectedMeetingTimeId 638 ? html` 639 <button class="btn-upload" @click=${this.handleSubmit} ?disabled=${this.submitting}> 640 ${this.submitting ? "Submitting..." : "Confirm & Submit"} 641 </button> 642 ` 643 : "" 644 } 645 </div> 646 </div> 647 </div> 648 `; 649 } 650}