馃 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
9@customElement("upload-recording-modal")
10export 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
277 ? error.message
278 : "Upload failed. Please try again.";
279 } finally {
280 this.uploading = false;
281 }
282 }
283
284 override render() {
285 if (!this.open) return null;
286
287 return html`
288 <div class="overlay" @click=${(e: Event) => e.target === e.currentTarget && this.handleClose()}>
289 <div class="modal">
290 <div class="modal-header">
291 <h2>Upload Recording</h2>
292 <button class="close-button" @click=${this.handleClose} ?disabled=${this.uploading}>
293 脳
294 </button>
295 </div>
296
297 ${this.error ? html`<div class="error">${this.error}</div>` : ""}
298
299 <form @submit=${(e: Event) => e.preventDefault()}>
300 <div class="form-group">
301 <label>Audio File</label>
302 <div class="file-input-wrapper ${this.selectedFile ? "has-file" : ""}">
303 <input
304 type="file"
305 accept="audio/*,video/mp4,.mp3,.wav,.m4a,.aac,.ogg,.webm,.flac"
306 @change=${this.handleFileSelect}
307 ?disabled=${this.uploading}
308 />
309 <div class="file-input-label">
310 ${
311 this.selectedFile
312 ? html`<div class="selected-file">馃搸 ${this.selectedFile.name}</div>`
313 : html`
314 <div>馃摛 <strong>Choose a file</strong> or drag it here</div>
315 <div style="margin-top: 0.5rem; font-size: 0.75rem;">
316 Supported: MP3, WAV, M4A, AAC, OGG, WebM, FLAC, MP4
317 </div>
318 `
319 }
320 </div>
321 </div>
322 <div class="help-text">Maximum file size: 100MB</div>
323 </div>
324
325 <div class="form-group">
326 <label for="meeting-time">Meeting Time</label>
327 <select
328 id="meeting-time"
329 @change=${this.handleMeetingTimeChange}
330 ?disabled=${this.uploading}
331 required
332 >
333 <option value="">Select a meeting time...</option>
334 ${this.meetingTimes.map(
335 (meeting) => html`
336 <option value=${meeting.id}>${meeting.label}</option>
337 `,
338 )}
339 </select>
340 <div class="help-text">
341 Select which meeting this recording is for
342 </div>
343 </div>
344 </form>
345
346 <div class="modal-footer">
347 <button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading}>
348 Cancel
349 </button>
350 <button
351 class="btn-upload"
352 @click=${this.handleUpload}
353 ?disabled=${this.uploading || !this.selectedFile || !this.selectedMeetingTimeId}
354 >
355 ${
356 this.uploading
357 ? html`<span class="uploading-text">Uploading...</span>`
358 : "Upload"
359 }
360 </button>
361 </div>
362 </div>
363 </div>
364 `;
365 }
366}