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