馃 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}>
313 脳
314 </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}