🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add upload recording modal to class view

- Create upload-recording-modal component with file picker
- Meeting time selector dropdown
- File validation (audio formats, 100MB max)
- Upload to /api/transcriptions with class_id and meeting_time_id
- Modal with overlay, close on click outside or X button
- Disable upload when class is archived
- Reload class data after successful upload
- Upload status indicator (uploading state)
- Error display for failed uploads

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

+386 -3
+22 -3
src/components/class-view.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 - import "../components/vtt-viewer.ts"; 3 + import "./upload-recording-modal.ts"; 4 + import "./vtt-viewer.ts"; 4 5 5 6 interface Class { 6 7 id: string; ··· 41 42 @state() isLoading = true; 42 43 @state() error: string | null = null; 43 44 @state() searchQuery = ""; 45 + @state() uploadModalOpen = false; 44 46 private eventSources: Map<string, EventSource> = new Map(); 45 47 46 48 static override styles = css` ··· 435 437 } 436 438 437 439 private handleUploadClick() { 438 - // TODO: Open upload modal 439 - alert("Upload modal coming soon!"); 440 + this.uploadModalOpen = true; 441 + } 442 + 443 + private handleModalClose() { 444 + this.uploadModalOpen = false; 445 + } 446 + 447 + private async handleUploadSuccess() { 448 + this.uploadModalOpen = false; 449 + // Reload class data to show new recording 450 + await this.loadClass(); 440 451 } 441 452 442 453 override render() { ··· 562 573 )} 563 574 ` 564 575 } 576 + 577 + <upload-recording-modal 578 + ?open=${this.uploadModalOpen} 579 + .classId=${this.classId} 580 + .meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))} 581 + @close=${this.handleModalClose} 582 + @upload-success=${this.handleUploadSuccess} 583 + ></upload-recording-modal> 565 584 `; 566 585 } 567 586 }
+364
src/components/upload-recording-modal.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + interface MeetingTime { 5 + id: string; 6 + label: string; 7 + } 8 + 9 + @customElement("upload-recording-modal") 10 + export class UploadRecordingModal extends LitElement { 11 + @property({ type: Boolean }) open = false; 12 + @property({ type: String }) classId = ""; 13 + @property({ type: Array }) meetingTimes: MeetingTime[] = []; 14 + 15 + @state() private selectedFile: File | null = null; 16 + @state() private selectedMeetingTimeId: string | null = null; 17 + @state() private uploading = false; 18 + @state() private error: string | null = null; 19 + 20 + static override styles = css` 21 + :host { 22 + display: none; 23 + } 24 + 25 + :host([open]) { 26 + display: block; 27 + } 28 + 29 + .overlay { 30 + position: fixed; 31 + top: 0; 32 + left: 0; 33 + right: 0; 34 + bottom: 0; 35 + background: rgba(0, 0, 0, 0.5); 36 + display: flex; 37 + align-items: center; 38 + justify-content: center; 39 + z-index: 1000; 40 + } 41 + 42 + .modal { 43 + background: var(--background); 44 + border-radius: 8px; 45 + padding: 2rem; 46 + max-width: 32rem; 47 + width: 90%; 48 + max-height: 90vh; 49 + overflow-y: auto; 50 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); 51 + } 52 + 53 + .modal-header { 54 + display: flex; 55 + justify-content: space-between; 56 + align-items: center; 57 + margin-bottom: 1.5rem; 58 + } 59 + 60 + .modal-header h2 { 61 + margin: 0; 62 + color: var(--text); 63 + font-size: 1.5rem; 64 + } 65 + 66 + .close-button { 67 + background: none; 68 + border: none; 69 + font-size: 1.5rem; 70 + color: var(--paynes-gray); 71 + cursor: pointer; 72 + padding: 0; 73 + width: 2rem; 74 + height: 2rem; 75 + display: flex; 76 + align-items: center; 77 + justify-content: center; 78 + border-radius: 4px; 79 + transition: background 0.2s; 80 + } 81 + 82 + .close-button:hover { 83 + background: var(--secondary); 84 + } 85 + 86 + .form-group { 87 + margin-bottom: 1.5rem; 88 + } 89 + 90 + label { 91 + display: block; 92 + font-weight: 500; 93 + color: var(--text); 94 + margin-bottom: 0.5rem; 95 + font-size: 0.875rem; 96 + } 97 + 98 + .file-input-wrapper { 99 + position: relative; 100 + border: 2px dashed var(--secondary); 101 + border-radius: 8px; 102 + padding: 2rem; 103 + text-align: center; 104 + cursor: pointer; 105 + transition: all 0.2s; 106 + } 107 + 108 + .file-input-wrapper:hover { 109 + border-color: var(--accent); 110 + background: color-mix(in srgb, var(--accent) 5%, transparent); 111 + } 112 + 113 + .file-input-wrapper.has-file { 114 + border-color: var(--accent); 115 + background: color-mix(in srgb, var(--accent) 10%, transparent); 116 + } 117 + 118 + input[type="file"] { 119 + position: absolute; 120 + opacity: 0; 121 + width: 100%; 122 + height: 100%; 123 + top: 0; 124 + left: 0; 125 + cursor: pointer; 126 + } 127 + 128 + .file-input-label { 129 + color: var(--paynes-gray); 130 + font-size: 0.875rem; 131 + } 132 + 133 + .file-input-label strong { 134 + color: var(--accent); 135 + } 136 + 137 + .selected-file { 138 + margin-top: 0.5rem; 139 + color: var(--text); 140 + font-weight: 500; 141 + } 142 + 143 + select { 144 + width: 100%; 145 + padding: 0.75rem; 146 + border: 1px solid var(--secondary); 147 + border-radius: 4px; 148 + font-size: 0.875rem; 149 + color: var(--text); 150 + background: var(--background); 151 + cursor: pointer; 152 + } 153 + 154 + select:focus { 155 + outline: none; 156 + border-color: var(--primary); 157 + } 158 + 159 + .help-text { 160 + font-size: 0.75rem; 161 + color: var(--paynes-gray); 162 + margin-top: 0.25rem; 163 + } 164 + 165 + .error { 166 + background: color-mix(in srgb, red 10%, transparent); 167 + border: 1px solid red; 168 + color: red; 169 + padding: 0.75rem; 170 + border-radius: 4px; 171 + margin-bottom: 1rem; 172 + font-size: 0.875rem; 173 + } 174 + 175 + .modal-footer { 176 + display: flex; 177 + gap: 0.75rem; 178 + justify-content: flex-end; 179 + margin-top: 2rem; 180 + } 181 + 182 + button { 183 + padding: 0.75rem 1.5rem; 184 + border-radius: 4px; 185 + font-size: 0.875rem; 186 + font-weight: 600; 187 + cursor: pointer; 188 + transition: opacity 0.2s; 189 + border: none; 190 + } 191 + 192 + button:hover:not(:disabled) { 193 + opacity: 0.9; 194 + } 195 + 196 + button:disabled { 197 + opacity: 0.5; 198 + cursor: not-allowed; 199 + } 200 + 201 + .btn-cancel { 202 + background: var(--secondary); 203 + color: var(--text); 204 + } 205 + 206 + .btn-upload { 207 + background: var(--accent); 208 + color: var(--white); 209 + } 210 + 211 + .uploading-text { 212 + display: flex; 213 + align-items: center; 214 + gap: 0.5rem; 215 + } 216 + `; 217 + 218 + private handleFileSelect(e: Event) { 219 + const input = e.target as HTMLInputElement; 220 + if (input.files && input.files.length > 0) { 221 + this.selectedFile = input.files[0] ?? null; 222 + this.error = null; 223 + } 224 + } 225 + 226 + private handleMeetingTimeChange(e: Event) { 227 + const select = e.target as HTMLSelectElement; 228 + this.selectedMeetingTimeId = select.value || null; 229 + } 230 + 231 + private handleClose() { 232 + if (this.uploading) return; 233 + this.open = false; 234 + this.selectedFile = null; 235 + this.selectedMeetingTimeId = null; 236 + this.error = null; 237 + this.dispatchEvent(new CustomEvent("close")); 238 + } 239 + 240 + private async handleUpload() { 241 + if (!this.selectedFile) { 242 + this.error = "Please select a file to upload"; 243 + return; 244 + } 245 + 246 + if (!this.selectedMeetingTimeId) { 247 + this.error = "Please select a meeting time"; 248 + return; 249 + } 250 + 251 + this.uploading = true; 252 + this.error = null; 253 + 254 + try { 255 + const formData = new FormData(); 256 + formData.append("audio", this.selectedFile); 257 + formData.append("class_id", this.classId); 258 + formData.append("meeting_time_id", this.selectedMeetingTimeId); 259 + 260 + const response = await fetch("/api/transcriptions", { 261 + method: "POST", 262 + body: formData, 263 + }); 264 + 265 + if (!response.ok) { 266 + const data = await response.json(); 267 + throw new Error(data.error || "Upload failed"); 268 + } 269 + 270 + // Success - close modal and notify parent 271 + this.dispatchEvent(new CustomEvent("upload-success")); 272 + this.handleClose(); 273 + } catch (error) { 274 + console.error("Upload failed:", error); 275 + this.error = 276 + error instanceof Error ? error.message : "Upload failed. Please try again."; 277 + } finally { 278 + this.uploading = false; 279 + } 280 + } 281 + 282 + override render() { 283 + if (!this.open) return null; 284 + 285 + return html` 286 + <div class="overlay" @click=${(e: Event) => e.target === e.currentTarget && this.handleClose()}> 287 + <div class="modal"> 288 + <div class="modal-header"> 289 + <h2>Upload Recording</h2> 290 + <button class="close-button" @click=${this.handleClose} ?disabled=${this.uploading}> 291 + × 292 + </button> 293 + </div> 294 + 295 + ${this.error ? html`<div class="error">${this.error}</div>` : ""} 296 + 297 + <form @submit=${(e: Event) => e.preventDefault()}> 298 + <div class="form-group"> 299 + <label>Audio File</label> 300 + <div class="file-input-wrapper ${this.selectedFile ? "has-file" : ""}"> 301 + <input 302 + type="file" 303 + accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.ogg,.webm,.flac" 304 + @change=${this.handleFileSelect} 305 + ?disabled=${this.uploading} 306 + /> 307 + <div class="file-input-label"> 308 + ${ 309 + this.selectedFile 310 + ? html`<div class="selected-file">📎 ${this.selectedFile.name}</div>` 311 + : html` 312 + <div>📤 <strong>Choose a file</strong> or drag it here</div> 313 + <div style="margin-top: 0.5rem; font-size: 0.75rem;"> 314 + Supported: MP3, WAV, M4A, AAC, OGG, WebM, FLAC, MP4 315 + </div> 316 + ` 317 + } 318 + </div> 319 + </div> 320 + <div class="help-text">Maximum file size: 100MB</div> 321 + </div> 322 + 323 + <div class="form-group"> 324 + <label for="meeting-time">Meeting Time</label> 325 + <select 326 + id="meeting-time" 327 + @change=${this.handleMeetingTimeChange} 328 + ?disabled=${this.uploading} 329 + required 330 + > 331 + <option value="">Select a meeting time...</option> 332 + ${this.meetingTimes.map( 333 + (meeting) => html` 334 + <option value=${meeting.id}>${meeting.label}</option> 335 + `, 336 + )} 337 + </select> 338 + <div class="help-text"> 339 + Select which meeting this recording is for 340 + </div> 341 + </div> 342 + </form> 343 + 344 + <div class="modal-footer"> 345 + <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading}> 346 + Cancel 347 + </button> 348 + <button 349 + class="btn-upload" 350 + @click=${this.handleUpload} 351 + ?disabled=${this.uploading || !this.selectedFile || !this.selectedMeetingTimeId} 352 + > 353 + ${ 354 + this.uploading 355 + ? html`<span class="uploading-text">Uploading...</span>` 356 + : "Upload" 357 + } 358 + </button> 359 + </div> 360 + </div> 361 + </div> 362 + `; 363 + } 364 + }