🪻 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import "./upload-recording-modal.ts";
4import "./vtt-viewer.ts";
5
6interface Class {
7 id: string;
8 course_code: string;
9 name: string;
10 professor: string;
11 semester: string;
12 year: number;
13 archived: boolean;
14}
15
16interface MeetingTime {
17 id: string;
18 class_id: string;
19 label: string;
20 created_at: number;
21}
22
23interface Transcription {
24 id: string;
25 user_id: number;
26 meeting_time_id: string | null;
27 filename: string;
28 original_filename: string;
29 status: "pending" | "selected" | "uploading" | "processing" | "transcribing" | "completed" | "failed";
30 progress: number;
31 error_message: string | null;
32 created_at: number;
33 updated_at: number;
34}
35
36@customElement("class-view")
37export class ClassView extends LitElement {
38 @state() classId = "";
39 @state() classInfo: Class | null = null;
40 @state() meetingTimes: MeetingTime[] = [];
41 @state() transcriptions: Transcription[] = [];
42 @state() isLoading = true;
43 @state() error: string | null = null;
44 @state() searchQuery = "";
45 @state() uploadModalOpen = false;
46 private eventSources: Map<string, EventSource> = new Map();
47
48 static override styles = css`
49 :host {
50 display: block;
51 }
52
53 .header {
54 margin-bottom: 2rem;
55 }
56
57 .back-link {
58 color: var(--paynes-gray);
59 text-decoration: none;
60 font-size: 0.875rem;
61 display: flex;
62 align-items: center;
63 gap: 0.25rem;
64 margin-bottom: 0.5rem;
65 }
66
67 .back-link:hover {
68 color: var(--accent);
69 }
70
71 .class-header {
72 display: flex;
73 justify-content: space-between;
74 align-items: flex-start;
75 margin-bottom: 1rem;
76 }
77
78 .class-info h1 {
79 color: var(--text);
80 margin: 0 0 0.5rem 0;
81 }
82
83 .course-code {
84 font-size: 1rem;
85 color: var(--accent);
86 font-weight: 600;
87 text-transform: uppercase;
88 }
89
90 .professor {
91 color: var(--paynes-gray);
92 font-size: 0.875rem;
93 margin-top: 0.25rem;
94 }
95
96 .semester {
97 color: var(--paynes-gray);
98 font-size: 0.875rem;
99 }
100
101 .archived-banner {
102 background: var(--paynes-gray);
103 color: var(--white);
104 padding: 0.5rem 1rem;
105 border-radius: 4px;
106 font-weight: 600;
107 margin-bottom: 1rem;
108 }
109
110 .search-upload {
111 display: flex;
112 gap: 1rem;
113 align-items: center;
114 margin-bottom: 2rem;
115 }
116
117 .search-box {
118 flex: 1;
119 padding: 0.5rem 0.75rem;
120 border: 1px solid var(--secondary);
121 border-radius: 4px;
122 font-size: 0.875rem;
123 color: var(--text);
124 background: var(--background);
125 }
126
127 .search-box:focus {
128 outline: none;
129 border-color: var(--primary);
130 }
131
132 .upload-button {
133 background: var(--accent);
134 color: var(--white);
135 border: none;
136 padding: 0.5rem 1rem;
137 border-radius: 4px;
138 font-size: 0.875rem;
139 font-weight: 600;
140 cursor: pointer;
141 transition: opacity 0.2s;
142 }
143
144 .upload-button:hover:not(:disabled) {
145 opacity: 0.9;
146 }
147
148 .upload-button:disabled {
149 opacity: 0.5;
150 cursor: not-allowed;
151 }
152
153 .meetings-section {
154 margin-bottom: 2rem;
155 }
156
157 .meetings-section h2 {
158 font-size: 1.25rem;
159 color: var(--text);
160 margin-bottom: 1rem;
161 }
162
163 .meetings-list {
164 display: flex;
165 gap: 0.75rem;
166 flex-wrap: wrap;
167 }
168
169 .meeting-tag {
170 background: color-mix(in srgb, var(--primary) 10%, transparent);
171 color: var(--primary);
172 padding: 0.5rem 1rem;
173 border-radius: 4px;
174 font-size: 0.875rem;
175 font-weight: 500;
176 }
177
178 .transcription-card {
179 background: var(--background);
180 border: 1px solid var(--secondary);
181 border-radius: 8px;
182 padding: 1.5rem;
183 margin-bottom: 1rem;
184 }
185
186 .transcription-header {
187 display: flex;
188 align-items: center;
189 justify-content: space-between;
190 margin-bottom: 1rem;
191 }
192
193 .transcription-filename {
194 font-weight: 500;
195 color: var(--text);
196 }
197
198 .transcription-date {
199 font-size: 0.875rem;
200 color: var(--paynes-gray);
201 }
202
203 .transcription-status {
204 padding: 0.25rem 0.75rem;
205 border-radius: 4px;
206 font-size: 0.75rem;
207 font-weight: 600;
208 text-transform: uppercase;
209 }
210
211 .status-pending {
212 background: color-mix(in srgb, var(--paynes-gray) 10%, transparent);
213 color: var(--paynes-gray);
214 }
215
216 .status-selected, .status-uploading, .status-processing, .status-transcribing {
217 background: color-mix(in srgb, var(--accent) 10%, transparent);
218 color: var(--accent);
219 }
220
221 .status-completed {
222 background: color-mix(in srgb, green 10%, transparent);
223 color: green;
224 }
225
226 .status-failed {
227 background: color-mix(in srgb, red 10%, transparent);
228 color: red;
229 }
230
231 .progress-bar {
232 width: 100%;
233 height: 4px;
234 background: var(--secondary);
235 border-radius: 2px;
236 margin-bottom: 1rem;
237 overflow: hidden;
238 }
239
240 .progress-fill {
241 height: 100%;
242 background: var(--primary);
243 border-radius: 2px;
244 transition: width 0.3s;
245 }
246
247 .progress-fill.indeterminate {
248 width: 30%;
249 animation: progress-slide 1.5s ease-in-out infinite;
250 }
251
252 @keyframes progress-slide {
253 0% { transform: translateX(-100%); }
254 100% { transform: translateX(333%); }
255 }
256
257 .audio-player audio {
258 width: 100%;
259 height: 2.5rem;
260 }
261
262 .empty-state {
263 text-align: center;
264 padding: 4rem 2rem;
265 color: var(--paynes-gray);
266 }
267
268 .empty-state h2 {
269 color: var(--text);
270 margin-bottom: 1rem;
271 }
272
273 .loading {
274 text-align: center;
275 padding: 4rem 2rem;
276 color: var(--paynes-gray);
277 }
278
279 .error {
280 background: color-mix(in srgb, red 10%, transparent);
281 border: 1px solid red;
282 color: red;
283 padding: 1rem;
284 border-radius: 4px;
285 margin-bottom: 2rem;
286 }
287 `;
288
289 override async connectedCallback() {
290 super.connectedCallback();
291 this.extractClassId();
292 await this.loadClass();
293 this.connectToTranscriptionStreams();
294
295 window.addEventListener("auth-changed", this.handleAuthChange);
296 }
297
298 override disconnectedCallback() {
299 super.disconnectedCallback();
300 window.removeEventListener("auth-changed", this.handleAuthChange);
301 // Close all event sources
302 for (const eventSource of this.eventSources.values()) {
303 eventSource.close();
304 }
305 this.eventSources.clear();
306 }
307
308 private handleAuthChange = async () => {
309 await this.loadClass();
310 };
311
312 private extractClassId() {
313 const path = window.location.pathname;
314 const match = path.match(/^\/classes\/(.+)$/);
315 if (match && match[1]) {
316 this.classId = match[1];
317 }
318 }
319
320 private async loadClass() {
321 this.isLoading = true;
322 this.error = null;
323
324 try {
325 const response = await fetch(`/api/classes/${this.classId}`);
326 if (!response.ok) {
327 if (response.status === 401) {
328 window.location.href = "/";
329 return;
330 }
331 if (response.status === 403) {
332 this.error = "You don't have access to this class.";
333 return;
334 }
335 throw new Error("Failed to load class");
336 }
337
338 const data = await response.json();
339 this.classInfo = data.class;
340 this.meetingTimes = data.meetingTimes || [];
341 this.transcriptions = data.transcriptions || [];
342
343 // Load VTT for completed transcriptions
344 await this.loadVTTForCompleted();
345 } catch (error) {
346 console.error("Failed to load class:", error);
347 this.error = "Failed to load class. Please try again.";
348 } finally {
349 this.isLoading = false;
350 }
351 }
352
353 private async loadVTTForCompleted() {
354 const completed = this.transcriptions.filter((t) => t.status === "completed");
355
356 await Promise.all(
357 completed.map(async (transcription) => {
358 try {
359 const response = await fetch(`/api/transcriptions/${transcription.id}?format=vtt`);
360 if (response.ok) {
361 const vttContent = await response.text();
362 (transcription as any).vttContent = vttContent;
363 (transcription as any).audioUrl = `/api/transcriptions/${transcription.id}/audio`;
364 this.requestUpdate();
365 }
366 } catch (error) {
367 console.error(`Failed to load VTT for ${transcription.id}:`, error);
368 }
369 }),
370 );
371 }
372
373 private connectToTranscriptionStreams() {
374 const activeStatuses = ["selected", "uploading", "processing", "transcribing"];
375 for (const transcription of this.transcriptions) {
376 if (activeStatuses.includes(transcription.status)) {
377 this.connectToStream(transcription.id);
378 }
379 }
380 }
381
382 private connectToStream(transcriptionId: string) {
383 if (this.eventSources.has(transcriptionId)) return;
384
385 const eventSource = new EventSource(`/api/transcriptions/${transcriptionId}/stream`);
386
387 eventSource.addEventListener("update", async (event) => {
388 const update = JSON.parse(event.data);
389 const transcription = this.transcriptions.find((t) => t.id === transcriptionId);
390
391 if (transcription) {
392 if (update.status !== undefined) transcription.status = update.status;
393 if (update.progress !== undefined) transcription.progress = update.progress;
394
395 if (update.status === "completed") {
396 await this.loadVTTForCompleted();
397 eventSource.close();
398 this.eventSources.delete(transcriptionId);
399 }
400
401 this.requestUpdate();
402 }
403 });
404
405 eventSource.onerror = () => {
406 eventSource.close();
407 this.eventSources.delete(transcriptionId);
408 };
409
410 this.eventSources.set(transcriptionId, eventSource);
411 }
412
413 private get filteredTranscriptions() {
414 if (!this.searchQuery) return this.transcriptions;
415
416 const query = this.searchQuery.toLowerCase();
417 return this.transcriptions.filter((t) =>
418 t.original_filename.toLowerCase().includes(query),
419 );
420 }
421
422 private formatDate(timestamp: number): string {
423 const date = new Date(timestamp * 1000);
424 return date.toLocaleDateString(undefined, {
425 year: "numeric",
426 month: "short",
427 day: "numeric",
428 hour: "2-digit",
429 minute: "2-digit",
430 });
431 }
432
433 private getMeetingLabel(meetingTimeId: string | null): string {
434 if (!meetingTimeId) return "";
435 const meeting = this.meetingTimes.find((m) => m.id === meetingTimeId);
436 return meeting ? meeting.label : "";
437 }
438
439 private handleUploadClick() {
440 this.uploadModalOpen = true;
441 }
442
443 private handleModalClose() {
444 this.uploadModalOpen = false;
445 }
446
447 private async handleUploadSuccess() {
448 this.uploadModalOpen = false;
449 // Reload class data to show new recording
450 await this.loadClass();
451 }
452
453 override render() {
454 if (this.isLoading) {
455 return html`<div class="loading">Loading class...</div>`;
456 }
457
458 if (this.error) {
459 return html`
460 <div class="error">${this.error}</div>
461 <a href="/classes">← Back to classes</a>
462 `;
463 }
464
465 if (!this.classInfo) {
466 return html`
467 <div class="error">Class not found</div>
468 <a href="/classes">← Back to classes</a>
469 `;
470 }
471
472 return html`
473 <div class="header">
474 <a href="/classes" class="back-link">← Back to all classes</a>
475
476 ${this.classInfo.archived ? html`<div class="archived-banner">⚠️ This class is archived - no new recordings can be uploaded</div>` : ""}
477
478 <div class="class-header">
479 <div class="class-info">
480 <div class="course-code">${this.classInfo.course_code}</div>
481 <h1>${this.classInfo.name}</h1>
482 <div class="professor">Professor: ${this.classInfo.professor}</div>
483 <div class="semester">${this.classInfo.semester} ${this.classInfo.year}</div>
484 </div>
485 </div>
486
487 ${
488 this.meetingTimes.length > 0
489 ? html`
490 <div class="meetings-section">
491 <h2>Meeting Times</h2>
492 <div class="meetings-list">
493 ${this.meetingTimes.map((meeting) => html`<div class="meeting-tag">${meeting.label}</div>`)}
494 </div>
495 </div>
496 `
497 : ""
498 }
499
500 <div class="search-upload">
501 <input
502 type="text"
503 class="search-box"
504 placeholder="Search recordings..."
505 .value=${this.searchQuery}
506 @input=${(e: Event) => {
507 this.searchQuery = (e.target as HTMLInputElement).value;
508 }}
509 />
510 <button
511 class="upload-button"
512 ?disabled=${this.classInfo.archived}
513 @click=${this.handleUploadClick}
514 >
515 📤 Upload Recording
516 </button>
517 </div>
518 </div>
519
520 ${
521 this.filteredTranscriptions.length === 0
522 ? html`
523 <div class="empty-state">
524 <h2>${this.searchQuery ? "No matching recordings" : "No recordings yet"}</h2>
525 <p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>
526 </div>
527 `
528 : html`
529 ${this.filteredTranscriptions.map(
530 (t) => html`
531 <div class="transcription-card">
532 <div class="transcription-header">
533 <div>
534 <div class="transcription-filename">${t.original_filename}</div>
535 ${
536 t.meeting_time_id
537 ? html`<div class="transcription-date">${this.getMeetingLabel(t.meeting_time_id)} • ${this.formatDate(t.created_at)}</div>`
538 : html`<div class="transcription-date">${this.formatDate(t.created_at)}</div>`
539 }
540 </div>
541 <span class="transcription-status status-${t.status}">${t.status}</span>
542 </div>
543
544 ${
545 ["uploading", "processing", "transcribing", "selected"].includes(
546 t.status,
547 )
548 ? html`
549 <div class="progress-bar">
550 <div
551 class="progress-fill ${t.status === "processing" ? "indeterminate" : ""}"
552 style="${t.status === "processing" ? "" : `width: ${t.progress}%`}"
553 ></div>
554 </div>
555 `
556 : ""
557 }
558
559 ${
560 t.status === "completed" && (t as any).audioUrl && (t as any).vttContent
561 ? html`
562 <div class="audio-player">
563 <audio id="audio-${t.id}" preload="metadata" controls src="${(t as any).audioUrl}"></audio>
564 </div>
565 <vtt-viewer .vttContent=${(t as any).vttContent} .audioId=${`audio-${t.id}`}></vtt-viewer>
566 `
567 : ""
568 }
569
570 ${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""}
571 </div>
572 `,
573 )}
574 `
575 }
576
577 <upload-recording-modal
578 ?open=${this.uploadModalOpen}
579 .classId=${this.classId}
580 .meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))}
581 @close=${this.handleModalClose}
582 @upload-success=${this.handleUploadSuccess}
583 ></upload-recording-modal>
584 `;
585 }
586}