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