🪻 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}