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