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