import { css, html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import "./upload-recording-modal.ts"; import "./vtt-viewer.ts"; import "./pending-recordings-view.ts"; interface Class { id: string; course_code: string; name: string; professor: string; semester: string; year: number; archived: boolean; } interface MeetingTime { id: string; class_id: string; label: string; created_at: number; } interface Transcription { id: string; user_id: number; meeting_time_id: string | null; section_id: string | null; filename: string; original_filename: string; status: | "pending" | "selected" | "uploading" | "processing" | "transcribing" | "completed" | "failed"; progress: number; error_message: string | null; created_at: number; updated_at: number; vttContent?: string; audioUrl?: string; } interface ClassSection { id: string; section_number: string; } @customElement("class-view") export class ClassView extends LitElement { @state() classId = ""; @state() classInfo: Class | null = null; @state() meetingTimes: MeetingTime[] = []; @state() sections: ClassSection[] = []; @state() userSection: string | null = null; @state() selectedSectionFilter: string | null = null; @state() transcriptions: Transcription[] = []; @state() isLoading = true; @state() error: string | null = null; @state() searchQuery = ""; @state() uploadModalOpen = false; @state() hasSubscription = false; @state() isAdmin = false; private eventSources: Map = new Map(); static override styles = css` :host { display: block; } .header { margin-bottom: 2rem; } .back-link { color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem; display: flex; align-items: center; gap: 0.25rem; margin-bottom: 0.5rem; } .back-link:hover { color: var(--accent); } .class-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; } .class-info h1 { color: var(--text); margin: 0 0 0.5rem 0; } .course-code { font-size: 1rem; color: var(--accent); font-weight: 600; text-transform: uppercase; } .professor { color: var(--paynes-gray); font-size: 0.875rem; margin-top: 0.25rem; } .semester { color: var(--paynes-gray); font-size: 0.875rem; } .archived-banner { background: var(--paynes-gray); color: var(--white); padding: 0.5rem 1rem; border-radius: 4px; font-weight: 600; margin-bottom: 1rem; } .search-upload { display: flex; gap: 1rem; align-items: center; margin-bottom: 2rem; } .search-box { flex: 1; padding: 0.5rem 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background); } .search-box:focus { outline: none; border-color: var(--primary); } .upload-button { background: var(--accent); color: var(--white); border: none; padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; } .upload-button:hover:not(:disabled) { opacity: 0.9; } .upload-button:disabled { opacity: 0.5; cursor: not-allowed; } .meetings-section { margin-bottom: 2rem; } .meetings-section h2 { font-size: 1.25rem; color: var(--text); margin-bottom: 1rem; } .meetings-list { display: flex; gap: 0.75rem; flex-wrap: wrap; } .meeting-tag { background: color-mix(in srgb, var(--primary) 10%, transparent); color: var(--primary); padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.875rem; font-weight: 500; } .transcription-card { background: var(--background); border: 1px solid var(--secondary); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; } .transcription-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .transcription-filename { font-weight: 500; color: var(--text); } .transcription-date { font-size: 0.875rem; color: var(--paynes-gray); } .transcription-status { padding: 0.25rem 0.75rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .status-pending { background: color-mix(in srgb, var(--paynes-gray) 10%, transparent); color: var(--paynes-gray); } .status-selected, .status-uploading, .status-processing, .status-transcribing { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent); } .status-completed { background: color-mix(in srgb, green 10%, transparent); color: green; } .status-failed { background: color-mix(in srgb, red 10%, transparent); color: red; } .progress-bar { width: 100%; height: 4px; background: var(--secondary); border-radius: 2px; margin-bottom: 1rem; overflow: hidden; } .progress-fill { height: 100%; background: var(--primary); border-radius: 2px; transition: width 0.3s; } .progress-fill.indeterminate { width: 30%; animation: progress-slide 1.5s ease-in-out infinite; } @keyframes progress-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(333%); } } .audio-player audio { width: 100%; height: 2.5rem; } .empty-state { text-align: center; padding: 4rem 2rem; color: var(--paynes-gray); } .empty-state h2 { color: var(--text); margin-bottom: 1rem; } .loading { text-align: center; padding: 4rem 2rem; color: var(--paynes-gray); } .error { background: color-mix(in srgb, red 10%, transparent); border: 1px solid red; color: red; padding: 1rem; border-radius: 4px; margin-bottom: 2rem; } `; override async connectedCallback() { super.connectedCallback(); this.extractClassId(); await this.checkAuth(); await this.loadClass(); this.connectToTranscriptionStreams(); window.addEventListener("auth-changed", this.handleAuthChange); } override disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener("auth-changed", this.handleAuthChange); // Close all event sources for (const eventSource of this.eventSources.values()) { eventSource.close(); } this.eventSources.clear(); } private handleAuthChange = async () => { await this.loadClass(); }; private extractClassId() { const path = window.location.pathname; const match = path.match(/^\/classes\/(.+)$/); if (match?.[1]) { this.classId = match[1]; } } private async checkAuth() { try { const response = await fetch("/api/auth/me"); if (response.ok) { const data = await response.json(); this.hasSubscription = data.has_subscription || false; this.isAdmin = data.role === "admin"; } } catch (error) { console.warn("Failed to check auth:", error); } } private async loadClass() { this.isLoading = true; this.error = null; try { const response = await fetch(`/api/classes/${this.classId}`); if (!response.ok) { if (response.status === 401) { window.location.href = "/"; return; } if (response.status === 403) { this.error = "You don't have access to this class."; return; } throw new Error("Failed to load class"); } const data = await response.json(); this.classInfo = data.class; this.meetingTimes = data.meetingTimes || []; this.sections = data.sections || []; this.userSection = data.userSection || null; this.transcriptions = data.transcriptions || []; // Default to user's section for filtering if (this.userSection && !this.selectedSectionFilter) { this.selectedSectionFilter = this.userSection; } // Load VTT for completed transcriptions await this.loadVTTForCompleted(); } catch (error) { console.error("Failed to load class:", error); this.error = "Failed to load class. Please try again."; } finally { this.isLoading = false; } } private async loadVTTForCompleted() { const completed = this.transcriptions.filter( (t) => t.status === "completed", ); await Promise.all( completed.map(async (transcription) => { try { const response = await fetch( `/api/transcriptions/${transcription.id}?format=vtt`, ); if (response.ok) { const vttContent = await response.text(); transcription.vttContent = vttContent; transcription.audioUrl = `/api/transcriptions/${transcription.id}/audio`; this.requestUpdate(); } } catch (error) { console.error(`Failed to load VTT for ${transcription.id}:`, error); } }), ); } private connectToTranscriptionStreams() { const activeStatuses = [ "selected", "uploading", "processing", "transcribing", ]; for (const transcription of this.transcriptions) { if (activeStatuses.includes(transcription.status)) { this.connectToStream(transcription.id); } } } private connectToStream(transcriptionId: string) { if (this.eventSources.has(transcriptionId)) return; const eventSource = new EventSource( `/api/transcriptions/${transcriptionId}/stream`, ); eventSource.addEventListener("update", async (event) => { const update = JSON.parse(event.data); const transcription = this.transcriptions.find( (t) => t.id === transcriptionId, ); if (transcription) { if (update.status !== undefined) transcription.status = update.status; if (update.progress !== undefined) transcription.progress = update.progress; if (update.status === "completed") { await this.loadVTTForCompleted(); eventSource.close(); this.eventSources.delete(transcriptionId); } this.requestUpdate(); } }); eventSource.onerror = () => { eventSource.close(); this.eventSources.delete(transcriptionId); }; this.eventSources.set(transcriptionId, eventSource); } private get filteredTranscriptions() { let filtered = this.transcriptions; // Filter by selected section (or user's section by default) const sectionFilter = this.selectedSectionFilter || this.userSection; // Only filter by section if: // 1. There are sections in the class // 2. User has a section OR has selected one if (this.sections.length > 0 && sectionFilter) { // For admins: show all transcriptions // For users: show their section + transcriptions with no section (legacy/unassigned) if (!this.isAdmin) { filtered = filtered.filter( (t) => t.section_id === sectionFilter || t.section_id === null, ); } } // Filter by search query if (this.searchQuery) { const query = this.searchQuery.toLowerCase(); filtered = filtered.filter((t) => t.original_filename.toLowerCase().includes(query), ); } // Exclude pending recordings (they're shown in the voting section) filtered = filtered.filter((t) => t.status !== "pending"); return filtered; } private formatDate(timestamp: number): string { const date = new Date(timestamp * 1000); return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } private getMeetingLabel(meetingTimeId: string | null): string { if (!meetingTimeId) return ""; const meeting = this.meetingTimes.find((m) => m.id === meetingTimeId); return meeting ? meeting.label : ""; } private handleUploadClick() { this.uploadModalOpen = true; } private handleModalClose() { this.uploadModalOpen = false; } private async handleUploadSuccess() { this.uploadModalOpen = false; // Reload class data to show new recording await this.loadClass(); } override render() { if (this.isLoading) { return html`
Loading class...
`; } if (this.error) { return html`
${this.error}
← Back to classes `; } if (!this.classInfo) { return html`
Class not found
← Back to classes `; } const canAccessTranscriptions = this.hasSubscription || this.isAdmin; return html`
← Back to all classes ${this.classInfo.archived ? html`
⚠️ This class is archived - no new recordings can be uploaded
` : ""}
${this.classInfo.course_code}

${this.classInfo.name}

Professor: ${this.classInfo.professor}
${this.classInfo.semester} ${this.classInfo.year} ${ this.userSection ? ` • Section ${this.sections.find((s) => s.id === this.userSection)?.section_number || ""}` : "" }
${ this.meetingTimes.length > 0 ? html`

Meeting Times

${this.meetingTimes.map((meeting) => html`
${meeting.label}
`)}
` : "" } ${ !canAccessTranscriptions ? html`

Subscribe to Access Recordings

You need an active subscription to upload and view transcriptions.

Subscribe Now
` : html`
${ this.sections.length > 1 ? html` ` : "" } { this.searchQuery = (e.target as HTMLInputElement).value; }} />
${ this.meetingTimes.map((meeting) => { // Apply section filtering to pending recordings const sectionFilter = this.selectedSectionFilter || this.userSection; const pendingCount = this.transcriptions.filter((t) => { if (t.meeting_time_id !== meeting.id || t.status !== "pending") { return false; } // Filter by section if applicable if (this.sections.length > 0 && sectionFilter) { // Show recordings from user's section or no section (unassigned) return t.section_id === sectionFilter || t.section_id === null; } return true; }).length; // Only show if there are pending recordings if (pendingCount === 0) return ""; return html`
`; }) } ${ this.filteredTranscriptions.length === 0 ? html`

${this.searchQuery ? "No matching recordings" : "No recordings yet"}

${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}

` : html` ${this.filteredTranscriptions.map( (t) => html`
${t.original_filename}
${ t.meeting_time_id ? html`
${this.getMeetingLabel(t.meeting_time_id)} • ${this.formatDate(t.created_at)}
` : html`
${this.formatDate(t.created_at)}
` }
${t.status}
${ ["uploading", "processing", "transcribing", "selected"].includes( t.status, ) ? html`
` : "" } ${ t.status === "completed" && t.audioUrl && t.vttContent ? html`
` : "" } ${t.error_message ? html`
${t.error_message}
` : ""}
`, )} ` } ` }
({ id: m.id, label: m.label }))} .sections=${this.sections} .userSection=${this.userSection} @close=${this.handleModalClose} @upload-success=${this.handleUploadSuccess} > `; } }