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