🪻 distributed transcription service thistle.dunkirk.sh
at v0.1.0 9.5 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import "../components/vtt-viewer.ts"; 4 5interface TranscriptionJob { 6 id: string; 7 filename: string; 8 class_name?: string; 9 status: "uploading" | "processing" | "transcribing" | "completed" | "failed"; 10 progress: number; 11 created_at: number; 12 audioUrl?: string; 13 vttContent?: string; 14} 15 16@customElement("class-view") 17export class ClassView extends LitElement { 18 @state() override className = ""; 19 @state() jobs: TranscriptionJob[] = []; 20 @state() searchQuery = ""; 21 @state() isLoading = true; 22 private eventSources: Map<string, EventSource> = new Map(); 23 24 static override styles = css` 25 :host { 26 display: block; 27 } 28 29 .header { 30 display: flex; 31 justify-content: space-between; 32 align-items: center; 33 margin-bottom: 2rem; 34 } 35 36 .back-link { 37 color: var(--paynes-gray); 38 text-decoration: none; 39 font-size: 0.875rem; 40 display: flex; 41 align-items: center; 42 gap: 0.25rem; 43 margin-bottom: 0.5rem; 44 } 45 46 .back-link:hover { 47 color: var(--accent); 48 } 49 50 h1 { 51 color: var(--text); 52 margin: 0; 53 } 54 55 .search-box { 56 padding: 0.5rem 0.75rem; 57 border: 1px solid var(--secondary); 58 border-radius: 4px; 59 font-size: 0.875rem; 60 color: var(--text); 61 background: var(--background); 62 width: 20rem; 63 } 64 65 .search-box:focus { 66 outline: none; 67 border-color: var(--primary); 68 } 69 70 .job-card { 71 background: var(--background); 72 border: 1px solid var(--secondary); 73 border-radius: 8px; 74 padding: 1.5rem; 75 margin-bottom: 1rem; 76 } 77 78 .job-header { 79 display: flex; 80 align-items: center; 81 justify-content: space-between; 82 margin-bottom: 1rem; 83 } 84 85 .job-filename { 86 font-weight: 500; 87 color: var(--text); 88 } 89 90 .job-date { 91 font-size: 0.875rem; 92 color: var(--paynes-gray); 93 } 94 95 .job-status { 96 padding: 0.25rem 0.75rem; 97 border-radius: 4px; 98 font-size: 0.75rem; 99 font-weight: 600; 100 text-transform: uppercase; 101 } 102 103 .status-completed { 104 background: color-mix(in srgb, green 10%, transparent); 105 color: green; 106 } 107 108 .status-failed { 109 background: color-mix(in srgb, var(--text) 10%, transparent); 110 color: var(--text); 111 } 112 113 .status-processing, .status-transcribing, .status-uploading { 114 background: color-mix(in srgb, var(--accent) 10%, transparent); 115 color: var(--accent); 116 } 117 118 .audio-player audio { 119 width: 100%; 120 height: 2.5rem; 121 } 122 123 .empty-state { 124 text-align: center; 125 padding: 4rem 2rem; 126 color: var(--paynes-gray); 127 } 128 129 .empty-state h2 { 130 color: var(--text); 131 margin-bottom: 1rem; 132 } 133 134 .progress-bar { 135 width: 100%; 136 height: 4px; 137 background: var(--secondary); 138 border-radius: 2px; 139 margin-bottom: 1rem; 140 overflow: hidden; 141 position: relative; 142 } 143 144 .progress-fill { 145 height: 100%; 146 background: var(--primary); 147 border-radius: 2px; 148 transition: width 0.3s; 149 } 150 151 .progress-fill.indeterminate { 152 width: 30%; 153 background: var(--primary); 154 animation: progress-slide 1.5s ease-in-out infinite; 155 } 156 157 @keyframes progress-slide { 158 0% { 159 transform: translateX(-100%); 160 } 161 100% { 162 transform: translateX(333%); 163 } 164 } 165 `; 166 167 override async connectedCallback() { 168 super.connectedCallback(); 169 this.extractClassName(); 170 await this.loadJobs(); 171 this.connectToJobStreams(); 172 173 window.addEventListener("auth-changed", this.handleAuthChange); 174 } 175 176 override disconnectedCallback() { 177 super.disconnectedCallback(); 178 window.removeEventListener("auth-changed", this.handleAuthChange); 179 } 180 181 private handleAuthChange = async () => { 182 await this.loadJobs(); 183 }; 184 185 private extractClassName() { 186 const path = window.location.pathname; 187 const match = path.match(/^\/class\/(.+)$/); 188 if (match) { 189 this.className = decodeURIComponent(match[1] ?? ""); 190 } 191 } 192 193 private async loadJobs() { 194 this.isLoading = true; 195 try { 196 const response = await fetch("/api/transcriptions"); 197 if (!response.ok) { 198 if (response.status === 401) { 199 this.jobs = []; 200 return; 201 } 202 throw new Error("Failed to load transcriptions"); 203 } 204 205 const data = await response.json(); 206 const allJobs = data.jobs || []; 207 208 // Filter by class 209 if (this.className === "uncategorized") { 210 this.jobs = allJobs.filter((job: TranscriptionJob) => !job.class_name); 211 } else { 212 this.jobs = allJobs.filter( 213 (job: TranscriptionJob) => job.class_name === this.className, 214 ); 215 } 216 217 // Load VTT for completed jobs 218 await this.loadVTTForCompletedJobs(); 219 } catch (error) { 220 console.error("Failed to load jobs:", error); 221 } finally { 222 this.isLoading = false; 223 } 224 } 225 226 private async loadVTTForCompletedJobs() { 227 const completedJobs = this.jobs.filter((job) => job.status === "completed"); 228 229 await Promise.all( 230 completedJobs.map(async (job) => { 231 try { 232 const response = await fetch( 233 `/api/transcriptions/${job.id}?format=vtt`, 234 ); 235 if (response.ok) { 236 const vttContent = await response.text(); 237 job.vttContent = vttContent; 238 job.audioUrl = `/api/transcriptions/${job.id}/audio`; 239 this.requestUpdate(); 240 } 241 } catch (error) { 242 console.error(`Failed to load VTT for job ${job.id}:`, error); 243 } 244 }), 245 ); 246 } 247 248 private connectToJobStreams() { 249 // For active jobs, connect to SSE streams 250 for (const job of this.jobs) { 251 if ( 252 job.status === "processing" || 253 job.status === "transcribing" || 254 job.status === "uploading" 255 ) { 256 this.connectToJobStream(job.id); 257 } 258 } 259 } 260 261 private connectToJobStream(jobId: string) { 262 if (this.eventSources.has(jobId)) { 263 return; 264 } 265 266 const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`); 267 268 eventSource.addEventListener("update", async (event) => { 269 const update = JSON.parse(event.data); 270 271 const job = this.jobs.find((j) => j.id === jobId); 272 if (job) { 273 if (update.status !== undefined) job.status = update.status; 274 if (update.progress !== undefined) job.progress = update.progress; 275 276 if (update.status === "completed") { 277 await this.loadVTTForCompletedJobs(); 278 eventSource.close(); 279 this.eventSources.delete(jobId); 280 } 281 282 this.requestUpdate(); 283 } 284 }); 285 286 eventSource.onerror = () => { 287 eventSource.close(); 288 this.eventSources.delete(jobId); 289 }; 290 291 this.eventSources.set(jobId, eventSource); 292 } 293 294 private get filteredJobs(): TranscriptionJob[] { 295 if (!this.searchQuery) { 296 return this.jobs; 297 } 298 299 const query = this.searchQuery.toLowerCase(); 300 return this.jobs.filter((job) => 301 job.filename.toLowerCase().includes(query), 302 ); 303 } 304 305 private formatDate(timestamp: number): string { 306 const date = new Date(timestamp * 1000); 307 return date.toLocaleDateString(undefined, { 308 year: "numeric", 309 month: "short", 310 day: "numeric", 311 hour: "2-digit", 312 minute: "2-digit", 313 }); 314 } 315 316 private getStatusClass(status: string): string { 317 return `status-${status}`; 318 } 319 320 override render() { 321 const displayName = 322 this.className === "uncategorized" ? "Uncategorized" : this.className; 323 324 return html` 325 <div> 326 <a href="/classes" class="back-link">← Back to all classes</a> 327 328 <div class="header"> 329 <h1>${displayName}</h1> 330 <input 331 type="text" 332 class="search-box" 333 placeholder="Search transcriptions..." 334 .value=${this.searchQuery} 335 @input=${(e: Event) => { 336 this.searchQuery = (e.target as HTMLInputElement).value; 337 }} 338 /> 339 </div> 340 341 ${ 342 this.filteredJobs.length === 0 && !this.isLoading 343 ? html` 344 <div class="empty-state"> 345 <h2>${this.searchQuery ? "No matching transcriptions" : "No transcriptions yet"}</h2> 346 <p>${this.searchQuery ? "Try a different search term" : "Upload an audio file to get started!"}</p> 347 </div> 348 ` 349 : html` 350 ${this.filteredJobs.map( 351 (job) => html` 352 <div class="job-card"> 353 <div class="job-header"> 354 <div> 355 <div class="job-filename">${job.filename}</div> 356 <div class="job-date">${this.formatDate(job.created_at)}</div> 357 </div> 358 <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span> 359 </div> 360 361 ${ 362 job.status === "uploading" || 363 job.status === "processing" || 364 job.status === "transcribing" 365 ? html` 366 <div class="progress-bar"> 367 <div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}" 368 style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div> 369 </div> 370 ` 371 : "" 372 } 373 374 ${ 375 job.status === "completed" && job.audioUrl && job.vttContent 376 ? html` 377 <div class="audio-player"> 378 <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio> 379 </div> 380 <vtt-viewer .vttContent=${job.vttContent} .audioId=${`audio-${job.id}`}></vtt-viewer> 381 ` 382 : "" 383 } 384 </div> 385 `, 386 )} 387 ` 388 } 389 </div> 390 `; 391 } 392}