馃 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 error: string | null = null; 27 28 static override styles = css` 29 :host { 30 display: none; 31 } 32 33 :host([open]) { 34 display: block; 35 } 36 37 .overlay { 38 position: fixed; 39 top: 0; 40 left: 0; 41 right: 0; 42 bottom: 0; 43 background: rgba(0, 0, 0, 0.5); 44 display: flex; 45 align-items: center; 46 justify-content: center; 47 z-index: 1000; 48 } 49 50 .modal { 51 background: var(--background); 52 border-radius: 8px; 53 padding: 2rem; 54 max-width: 32rem; 55 width: 90%; 56 max-height: 90vh; 57 overflow-y: auto; 58 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); 59 } 60 61 .modal-header { 62 display: flex; 63 justify-content: space-between; 64 align-items: center; 65 margin-bottom: 1.5rem; 66 } 67 68 .modal-header h2 { 69 margin: 0; 70 color: var(--text); 71 font-size: 1.5rem; 72 } 73 74 .close-button { 75 background: none; 76 border: none; 77 font-size: 1.5rem; 78 color: var(--paynes-gray); 79 cursor: pointer; 80 padding: 0; 81 width: 2rem; 82 height: 2rem; 83 display: flex; 84 align-items: center; 85 justify-content: center; 86 border-radius: 4px; 87 transition: background 0.2s; 88 } 89 90 .close-button:hover { 91 background: var(--secondary); 92 } 93 94 .form-group { 95 margin-bottom: 1.5rem; 96 } 97 98 label { 99 display: block; 100 font-weight: 500; 101 color: var(--text); 102 margin-bottom: 0.5rem; 103 font-size: 0.875rem; 104 } 105 106 .file-input-wrapper { 107 position: relative; 108 border: 2px dashed var(--secondary); 109 border-radius: 8px; 110 padding: 2rem; 111 text-align: center; 112 cursor: pointer; 113 transition: all 0.2s; 114 } 115 116 .file-input-wrapper:hover { 117 border-color: var(--accent); 118 background: color-mix(in srgb, var(--accent) 5%, transparent); 119 } 120 121 .file-input-wrapper.has-file { 122 border-color: var(--accent); 123 background: color-mix(in srgb, var(--accent) 10%, transparent); 124 } 125 126 input[type="file"] { 127 position: absolute; 128 opacity: 0; 129 width: 100%; 130 height: 100%; 131 top: 0; 132 left: 0; 133 cursor: pointer; 134 } 135 136 .file-input-label { 137 color: var(--paynes-gray); 138 font-size: 0.875rem; 139 } 140 141 .file-input-label strong { 142 color: var(--accent); 143 } 144 145 .selected-file { 146 margin-top: 0.5rem; 147 color: var(--text); 148 font-weight: 500; 149 } 150 151 select { 152 width: 100%; 153 padding: 0.75rem; 154 border: 1px solid var(--secondary); 155 border-radius: 4px; 156 font-size: 0.875rem; 157 color: var(--text); 158 background: var(--background); 159 cursor: pointer; 160 } 161 162 select:focus { 163 outline: none; 164 border-color: var(--primary); 165 } 166 167 .help-text { 168 font-size: 0.75rem; 169 color: var(--paynes-gray); 170 margin-top: 0.25rem; 171 } 172 173 .error { 174 background: color-mix(in srgb, red 10%, transparent); 175 border: 1px solid red; 176 color: red; 177 padding: 0.75rem; 178 border-radius: 4px; 179 margin-bottom: 1rem; 180 font-size: 0.875rem; 181 } 182 183 .modal-footer { 184 display: flex; 185 gap: 0.75rem; 186 justify-content: flex-end; 187 margin-top: 2rem; 188 } 189 190 button { 191 padding: 0.75rem 1.5rem; 192 border-radius: 4px; 193 font-size: 0.875rem; 194 font-weight: 600; 195 cursor: pointer; 196 transition: opacity 0.2s; 197 border: none; 198 } 199 200 button:hover:not(:disabled) { 201 opacity: 0.9; 202 } 203 204 button:disabled { 205 opacity: 0.5; 206 cursor: not-allowed; 207 } 208 209 .btn-cancel { 210 background: var(--secondary); 211 color: var(--text); 212 } 213 214 .btn-upload { 215 background: var(--accent); 216 color: var(--white); 217 } 218 219 .uploading-text { 220 display: flex; 221 align-items: center; 222 gap: 0.5rem; 223 } 224 `; 225 226 private handleFileSelect(e: Event) { 227 const input = e.target as HTMLInputElement; 228 if (input.files && input.files.length > 0) { 229 this.selectedFile = input.files[0] ?? null; 230 this.error = null; 231 } 232 } 233 234 private handleMeetingTimeChange(e: Event) { 235 const select = e.target as HTMLSelectElement; 236 this.selectedMeetingTimeId = select.value || null; 237 } 238 239 private handleSectionChange(e: Event) { 240 const select = e.target as HTMLSelectElement; 241 this.selectedSectionId = select.value || null; 242 } 243 244 private handleClose() { 245 if (this.uploading) return; 246 this.open = false; 247 this.selectedFile = null; 248 this.selectedMeetingTimeId = null; 249 this.selectedSectionId = null; 250 this.error = null; 251 this.dispatchEvent(new CustomEvent("close")); 252 } 253 254 private async handleUpload() { 255 if (!this.selectedFile) { 256 this.error = "Please select a file to upload"; 257 return; 258 } 259 260 if (!this.selectedMeetingTimeId) { 261 this.error = "Please select a meeting time"; 262 return; 263 } 264 265 this.uploading = true; 266 this.error = null; 267 268 try { 269 const formData = new FormData(); 270 formData.append("audio", this.selectedFile); 271 formData.append("class_id", this.classId); 272 formData.append("meeting_time_id", this.selectedMeetingTimeId); 273 274 // Use user's section by default, or allow override 275 const sectionToUse = this.selectedSectionId || this.userSection; 276 if (sectionToUse) { 277 formData.append("section_id", sectionToUse); 278 } 279 280 const response = await fetch("/api/transcriptions", { 281 method: "POST", 282 body: formData, 283 }); 284 285 if (!response.ok) { 286 const data = await response.json(); 287 throw new Error(data.error || "Upload failed"); 288 } 289 290 // Success - close modal and notify parent 291 this.dispatchEvent(new CustomEvent("upload-success")); 292 this.handleClose(); 293 } catch (error) { 294 console.error("Upload failed:", error); 295 this.error = 296 error instanceof Error 297 ? error.message 298 : "Upload failed. Please try again."; 299 } finally { 300 this.uploading = false; 301 } 302 } 303 304 override render() { 305 if (!this.open) return null; 306 307 return html` 308 <div class="overlay" @click=${(e: Event) => e.target === e.currentTarget && this.handleClose()}> 309 <div class="modal"> 310 <div class="modal-header"> 311 <h2>Upload Recording</h2> 312 <button class="close-button" @click=${this.handleClose} ?disabled=${this.uploading}> 313314 </button> 315 </div> 316 317 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 318 319 <form @submit=${(e: Event) => e.preventDefault()}> 320 <div class="form-group"> 321 <label>Audio File</label> 322 <div class="file-input-wrapper ${this.selectedFile ? "has-file" : ""}"> 323 <input 324 type="file" 325 accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.ogg,.webm,.flac" 326 @change=${this.handleFileSelect} 327 ?disabled=${this.uploading} 328 /> 329 <div class="file-input-label"> 330 ${ 331 this.selectedFile 332 ? html`<div class="selected-file">馃搸 ${this.selectedFile.name}</div>` 333 : html` 334 <div>馃摛 <strong>Choose a file</strong> or drag it here</div> 335 <div style="margin-top: 0.5rem; font-size: 0.75rem;"> 336 Supported: MP3, WAV, M4A, AAC, OGG, WebM, FLAC, MP4 337 </div> 338 ` 339 } 340 </div> 341 </div> 342 <div class="help-text">Maximum file size: 100MB</div> 343 </div> 344 345 <div class="form-group"> 346 <label for="meeting-time">Meeting Time</label> 347 <select 348 id="meeting-time" 349 @change=${this.handleMeetingTimeChange} 350 ?disabled=${this.uploading} 351 required 352 > 353 <option value="">Select a meeting time...</option> 354 ${this.meetingTimes.map( 355 (meeting) => html` 356 <option value=${meeting.id}>${meeting.label}</option> 357 `, 358 )} 359 </select> 360 <div class="help-text"> 361 Select which meeting this recording is for 362 </div> 363 </div> 364 365 ${ 366 this.sections.length > 1 367 ? html` 368 <div class="form-group"> 369 <label for="section">Section (optional)</label> 370 <select 371 id="section" 372 @change=${this.handleSectionChange} 373 ?disabled=${this.uploading} 374 > 375 <option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option> 376 ${this.sections.map( 377 (section) => html` 378 <option value=${section.id}>${section.section_number}</option> 379 `, 380 )} 381 </select> 382 <div class="help-text"> 383 Override which section this recording is for 384 </div> 385 </div> 386 ` 387 : "" 388 } 389 </form> 390 391 <div class="modal-footer"> 392 <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading}> 393 Cancel 394 </button> 395 <button 396 class="btn-upload" 397 @click=${this.handleUpload} 398 ?disabled=${this.uploading || !this.selectedFile || !this.selectedMeetingTimeId} 399 > 400 ${ 401 this.uploading 402 ? html`<span class="uploading-text">Uploading...</span>` 403 : "Upload" 404 } 405 </button> 406 </div> 407 </div> 408 </div> 409 `; 410 } 411}