🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: organize transcripts by class

+1431 -430
+2 -2
src/components/admin-data-table.ts
··· 1 - import { LitElement, html, css } from "lit"; 1 + import { css, html, LitElement } from "lit"; 2 2 import { customElement, property } from "lit/decorators.js"; 3 3 4 4 export interface TableColumn { ··· 14 14 @property({ type: Array }) data: unknown[] = []; 15 15 @property({ type: String }) searchPlaceholder = "Search..."; 16 16 @property({ type: String }) emptyMessage = "No data available"; 17 - @property({ type: Boolean}) loading = false; 17 + @property({ type: Boolean }) loading = false; 18 18 19 19 @property({ type: String }) private searchTerm = ""; 20 20 @property({ type: String }) private sortKey = "";
+6 -3
src/components/auth.ts
··· 627 627 <button 628 628 type="submit" 629 629 class="btn-primary" 630 - ?disabled=${this.isSubmitting || 631 - (this.passwordStrength?.isChecking ?? false) || 632 - (this.needsRegistration && !(this.passwordStrength?.isValid ?? false))} 630 + ?disabled=${ 631 + this.isSubmitting || 632 + (this.passwordStrength?.isChecking ?? false) || 633 + (this.needsRegistration && 634 + !(this.passwordStrength?.isValid ?? false)) 635 + } 633 636 > 634 637 ${ 635 638 this.isSubmitting
+392
src/components/class-view.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import "../components/vtt-viewer.ts"; 4 + 5 + interface 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") 17 + export 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 + }
+251
src/components/classes-overview.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + interface ClassStats { 5 + name: string; 6 + count: number; 7 + lastUpdated: number; 8 + } 9 + 10 + @customElement("classes-overview") 11 + export class ClassesOverview extends LitElement { 12 + @state() classes: ClassStats[] = []; 13 + @state() uncategorizedCount = 0; 14 + 15 + static override styles = css` 16 + :host { 17 + display: block; 18 + } 19 + 20 + h1 { 21 + color: var(--text); 22 + margin-bottom: 2rem; 23 + } 24 + 25 + .classes-grid { 26 + display: grid; 27 + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); 28 + gap: 1.5rem; 29 + margin-top: 2rem; 30 + } 31 + 32 + .class-card { 33 + background: var(--background); 34 + border: 1px solid var(--secondary); 35 + border-radius: 8px; 36 + padding: 1.5rem; 37 + cursor: pointer; 38 + transition: all 0.2s; 39 + text-decoration: none; 40 + color: var(--text); 41 + display: block; 42 + } 43 + 44 + .class-card:hover { 45 + border-color: var(--accent); 46 + transform: translateY(-2px); 47 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 48 + } 49 + 50 + .class-name { 51 + font-size: 1.25rem; 52 + font-weight: 600; 53 + margin-bottom: 0.5rem; 54 + color: var(--text); 55 + } 56 + 57 + .class-stats { 58 + font-size: 0.875rem; 59 + color: var(--paynes-gray); 60 + } 61 + 62 + .class-count { 63 + font-weight: 500; 64 + color: var(--accent); 65 + } 66 + 67 + .upload-section { 68 + background: color-mix(in srgb, var(--accent) 10%, transparent); 69 + border: 2px dashed var(--accent); 70 + border-radius: 8px; 71 + padding: 2rem; 72 + margin-bottom: 2rem; 73 + text-align: center; 74 + } 75 + 76 + .upload-button { 77 + background: var(--accent); 78 + color: var(--white); 79 + border: none; 80 + padding: 0.75rem 1.5rem; 81 + border-radius: 4px; 82 + font-size: 1rem; 83 + font-weight: 600; 84 + cursor: pointer; 85 + transition: opacity 0.2s; 86 + } 87 + 88 + .upload-button:hover { 89 + opacity: 0.9; 90 + } 91 + 92 + .empty-state { 93 + text-align: center; 94 + padding: 4rem 2rem; 95 + color: var(--paynes-gray); 96 + } 97 + 98 + .empty-state h2 { 99 + color: var(--text); 100 + margin-bottom: 1rem; 101 + } 102 + `; 103 + 104 + override async connectedCallback() { 105 + super.connectedCallback(); 106 + await this.loadClasses(); 107 + 108 + window.addEventListener("auth-changed", this.handleAuthChange); 109 + } 110 + 111 + override disconnectedCallback() { 112 + super.disconnectedCallback(); 113 + window.removeEventListener("auth-changed", this.handleAuthChange); 114 + } 115 + 116 + private handleAuthChange = async () => { 117 + await this.loadClasses(); 118 + }; 119 + 120 + private async loadClasses() { 121 + try { 122 + const response = await fetch("/api/transcriptions"); 123 + if (!response.ok) { 124 + if (response.status === 401) { 125 + this.classes = []; 126 + this.uncategorizedCount = 0; 127 + return; 128 + } 129 + throw new Error("Failed to load classes"); 130 + } 131 + 132 + const data = await response.json(); 133 + const jobs = data.jobs || []; 134 + 135 + // Group by class and count 136 + const classMap = new Map< 137 + string, 138 + { count: number; lastUpdated: number } 139 + >(); 140 + let uncategorized = 0; 141 + 142 + for (const job of jobs) { 143 + const className = job.class_name; 144 + if (!className) { 145 + uncategorized++; 146 + } else { 147 + const existing = classMap.get(className); 148 + if (existing) { 149 + existing.count++; 150 + existing.lastUpdated = Math.max( 151 + existing.lastUpdated, 152 + job.created_at, 153 + ); 154 + } else { 155 + classMap.set(className, { 156 + count: 1, 157 + lastUpdated: job.created_at, 158 + }); 159 + } 160 + } 161 + } 162 + 163 + this.uncategorizedCount = uncategorized; 164 + this.classes = Array.from(classMap.entries()) 165 + .map(([name, stats]) => ({ 166 + name, 167 + count: stats.count, 168 + lastUpdated: stats.lastUpdated, 169 + })) 170 + .sort((a, b) => b.lastUpdated - a.lastUpdated); 171 + } catch (error) { 172 + console.error("Failed to load classes:", error); 173 + } 174 + } 175 + 176 + private navigateToUpload() { 177 + window.location.href = "/transcribe"; 178 + } 179 + 180 + private formatDate(timestamp: number): string { 181 + const date = new Date(timestamp * 1000); 182 + const now = new Date(); 183 + const diffMs = now.getTime() - date.getTime(); 184 + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); 185 + 186 + if (diffDays === 0) { 187 + return "Today"; 188 + } 189 + if (diffDays === 1) { 190 + return "Yesterday"; 191 + } 192 + if (diffDays < 7) { 193 + return `${diffDays} days ago`; 194 + } 195 + return date.toLocaleDateString(); 196 + } 197 + 198 + override render() { 199 + const hasClasses = this.classes.length > 0 || this.uncategorizedCount > 0; 200 + 201 + return html` 202 + <h1>Your Classes</h1> 203 + 204 + <div class="upload-section"> 205 + <button class="upload-button" @click=${this.navigateToUpload}> 206 + 📤 Upload New Transcription 207 + </button> 208 + </div> 209 + 210 + ${ 211 + hasClasses 212 + ? html` 213 + <div class="classes-grid"> 214 + ${this.classes.map( 215 + (classInfo) => html` 216 + <a class="class-card" href="/class/${encodeURIComponent(classInfo.name)}"> 217 + <div class="class-name">${classInfo.name}</div> 218 + <div class="class-stats"> 219 + <span class="class-count">${classInfo.count}</span> 220 + ${classInfo.count === 1 ? "transcription" : "transcriptions"} 221 + • ${this.formatDate(classInfo.lastUpdated)} 222 + </div> 223 + </a> 224 + `, 225 + )} 226 + 227 + ${ 228 + this.uncategorizedCount > 0 229 + ? html` 230 + <a class="class-card" href="/class/uncategorized"> 231 + <div class="class-name">Uncategorized</div> 232 + <div class="class-stats"> 233 + <span class="class-count">${this.uncategorizedCount}</span> 234 + ${this.uncategorizedCount === 1 ? "transcription" : "transcriptions"} 235 + </div> 236 + </a> 237 + ` 238 + : "" 239 + } 240 + </div> 241 + ` 242 + : html` 243 + <div class="empty-state"> 244 + <h2>No transcriptions yet</h2> 245 + <p>Upload your first audio file to get started!</p> 246 + </div> 247 + ` 248 + } 249 + `; 250 + } 251 + }
+67 -28
src/components/transcript-view-modal.ts
··· 1 - import { LitElement, html, css } from "lit"; 1 + import { css, html, LitElement } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 3 import "./vtt-viewer.ts"; 4 4 ··· 277 277 // Fetch transcript details 278 278 const [detailsRes, vttRes] = await Promise.all([ 279 279 fetch(`/api/admin/transcriptions/${this.transcriptId}/details`), 280 - fetch(`/api/transcriptions/${this.transcriptId}?format=vtt`).catch(() => null), 280 + fetch(`/api/transcriptions/${this.transcriptId}?format=vtt`).catch( 281 + () => null, 282 + ), 281 283 ]); 282 284 283 285 if (!detailsRes.ok) { ··· 302 304 vtt_content: vttContent, 303 305 }; 304 306 } catch (err) { 305 - this.error = err instanceof Error ? err.message : "Failed to load transcript details"; 307 + this.error = 308 + err instanceof Error 309 + ? err.message 310 + : "Failed to load transcript details"; 306 311 this.transcript = null; 307 312 } finally { 308 313 this.loading = false; ··· 311 316 312 317 private close() { 313 318 this.stopAudioPlayback(); 314 - this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); 319 + this.dispatchEvent( 320 + new CustomEvent("close", { bubbles: true, composed: true }), 321 + ); 315 322 } 316 323 317 324 private formatTimestamp(timestamp: number) { ··· 322 329 private stopAudioPlayback() { 323 330 try { 324 331 // stop audio inside this modal's shadow root 325 - const aud = this.shadowRoot?.querySelector('audio') as HTMLAudioElement | null; 332 + const aud = this.shadowRoot?.querySelector( 333 + "audio", 334 + ) as HTMLAudioElement | null; 326 335 if (aud) { 327 336 aud.pause(); 328 - try { aud.currentTime = 0; } catch (e) { /* ignore */ } 337 + try { 338 + aud.currentTime = 0; 339 + } catch (_e) { 340 + /* ignore */ 341 + } 329 342 } 330 343 331 344 // Also stop any audio elements in light DOM that match the transcript audio id ··· 334 347 const outside = document.getElementById(id) as HTMLAudioElement | null; 335 348 if (outside && outside !== aud) { 336 349 outside.pause(); 337 - try { outside.currentTime = 0; } catch (e) { /* ignore */ } 350 + try { 351 + outside.currentTime = 0; 352 + } catch (_e) { 353 + /* ignore */ 354 + } 338 355 } 339 356 } 340 - } catch (e) { 357 + } catch (_e) { 341 358 // ignore 342 359 } 343 360 } 344 361 345 362 private async handleDelete() { 346 - if (!confirm("Are you sure you want to delete this transcription? This cannot be undone.")) { 363 + if ( 364 + !confirm( 365 + "Are you sure you want to delete this transcription? This cannot be undone.", 366 + ) 367 + ) { 347 368 return; 348 369 } 349 370 350 371 try { 351 - const res = await fetch(`/api/admin/transcriptions/${this.transcriptId}`, { 352 - method: "DELETE", 353 - }); 372 + const res = await fetch( 373 + `/api/admin/transcriptions/${this.transcriptId}`, 374 + { 375 + method: "DELETE", 376 + }, 377 + ); 354 378 355 379 if (!res.ok) { 356 380 throw new Error("Failed to delete transcription"); 357 381 } 358 382 359 - this.dispatchEvent(new CustomEvent("transcript-deleted", { bubbles: true, composed: true })); 383 + this.dispatchEvent( 384 + new CustomEvent("transcript-deleted", { 385 + bubbles: true, 386 + composed: true, 387 + }), 388 + ); 360 389 this.close(); 361 390 } catch { 362 391 alert("Failed to delete transcription"); ··· 375 404 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 376 405 ${this.transcript ? this.renderTranscriptDetails() : ""} 377 406 </div> 378 - ${this.transcript 379 - ? html` 407 + ${ 408 + this.transcript 409 + ? html` 380 410 <div class="modal-footer"> 381 411 <button class="btn-danger" @click=${this.handleDelete}>Delete Transcription</button> 382 412 </div> 383 413 ` 384 - : ""} 414 + : "" 415 + } 385 416 </div> 386 417 `; 387 418 } ··· 404 435 <span class="detail-label">Created At</span> 405 436 <span class="detail-value">${this.formatTimestamp(this.transcript.created_at)}</span> 406 437 </div> 407 - ${this.transcript.completed_at 408 - ? html` 438 + ${ 439 + this.transcript.completed_at 440 + ? html` 409 441 <div class="detail-row"> 410 442 <span class="detail-label">Completed At</span> 411 443 <span class="detail-value">${this.formatTimestamp(this.transcript.completed_at)}</span> 412 444 </div> 413 445 ` 414 - : ""} 415 - ${this.transcript.error_message 416 - ? html` 446 + : "" 447 + } 448 + ${ 449 + this.transcript.error_message 450 + ? html` 417 451 <div class="detail-row"> 418 452 <span class="detail-label">Error Message</span> 419 453 <span class="detail-value" style="color: #dc2626;">${this.transcript.error_message}</span> 420 454 </div> 421 455 ` 422 - : ""} 456 + : "" 457 + } 423 458 </div> 424 459 425 460 <div class="detail-section"> ··· 441 476 </div> 442 477 </div> 443 478 444 - ${this.transcript.status === "completed" 445 - ? html` 479 + ${ 480 + this.transcript.status === "completed" 481 + ? html` 446 482 <div class="detail-section"> 447 483 <h3 class="detail-section-title">Audio</h3> 448 484 <div class="audio-player"> ··· 450 486 </div> 451 487 </div> 452 488 ` 453 - : ""} 489 + : "" 490 + } 454 491 455 492 <div class="detail-section"> 456 493 <h3 class="detail-section-title">Transcript</h3> 457 - ${this.transcript.status === "completed" && this.transcript.vtt_content 458 - ? html`<vtt-viewer .vttContent=${this.transcript.vtt_content ?? ""} .audioId=${`audio-${this.transcript.id}`}></vtt-viewer>` 459 - : html`<div class="transcript-text">${this.transcript.vtt_content || "No transcript available"}</div>`} 494 + ${ 495 + this.transcript.status === "completed" && this.transcript.vtt_content 496 + ? html`<vtt-viewer .vttContent=${this.transcript.vtt_content ?? ""} .audioId=${`audio-${this.transcript.id}`}></vtt-viewer>` 497 + : html`<div class="transcript-text">${this.transcript.vtt_content || "No transcript available"}</div>` 498 + } 460 499 </div> 461 500 `; 462 501 }
+208 -80
src/components/transcription.ts
··· 5 5 interface TranscriptionJob { 6 6 id: string; 7 7 filename: string; 8 + class_name?: string; 8 9 status: "uploading" | "processing" | "transcribing" | "completed" | "failed"; 9 10 progress: number; 10 11 transcript?: string; ··· 20 21 text: string; 21 22 index?: string; 22 23 } 23 - 24 - 25 - 26 - 27 24 28 25 class WordStreamer { 29 26 private queue: string[] = []; ··· 51 48 this.isProcessing = true; 52 49 53 50 while (this.queue.length > 0) { 54 - const word = this.queue.shift()!; 51 + const word = this.queue.shift(); 52 + if (!word) break; 55 53 this.onWord(word); 56 54 await new Promise((resolve) => setTimeout(resolve, this.wordDelay)); 57 55 } ··· 62 60 showAll() { 63 61 // Drain entire queue immediately 64 62 while (this.queue.length > 0) { 65 - const word = this.queue.shift()!; 63 + const word = this.queue.shift(); 64 + if (!word) break; 66 65 this.onWord(word); 67 66 } 68 67 this.isProcessing = false; ··· 80 79 @state() isUploading = false; 81 80 @state() dragOver = false; 82 81 @state() serviceAvailable = true; 82 + @state() existingClasses: string[] = []; 83 + @state() showNewClassInput = false; 83 84 // Word streamers for each job 84 85 private wordStreamers = new Map<string, WordStreamer>(); 85 86 // Displayed transcripts ··· 285 286 .file-input { 286 287 display: none; 287 288 } 289 + 290 + .upload-form { 291 + margin-top: 1rem; 292 + display: flex; 293 + flex-direction: column; 294 + gap: 0.75rem; 295 + } 296 + 297 + .class-input { 298 + padding: 0.5rem 0.75rem; 299 + border: 1px solid var(--secondary); 300 + border-radius: 4px; 301 + font-size: 0.875rem; 302 + color: var(--text); 303 + background: var(--background); 304 + } 305 + 306 + .class-input:focus { 307 + outline: none; 308 + border-color: var(--primary); 309 + } 310 + 311 + .class-input::placeholder { 312 + color: var(--paynes-gray); 313 + opacity: 0.6; 314 + } 315 + 316 + .class-select { 317 + width: 100%; 318 + padding: 0.5rem 0.75rem; 319 + border: 1px solid var(--secondary); 320 + border-radius: 4px; 321 + font-size: 0.875rem; 322 + color: var(--text); 323 + background: var(--background); 324 + cursor: pointer; 325 + } 326 + 327 + .class-select:focus { 328 + outline: none; 329 + border-color: var(--primary); 330 + } 331 + 332 + .class-select option { 333 + padding: 0.5rem; 334 + } 335 + 336 + .class-group { 337 + margin-bottom: 2rem; 338 + } 339 + 340 + .class-header { 341 + font-size: 1.25rem; 342 + font-weight: 600; 343 + color: var(--text); 344 + margin-bottom: 1rem; 345 + padding-bottom: 0.5rem; 346 + border-bottom: 2px solid var(--accent); 347 + } 348 + 349 + .no-class-header { 350 + border-bottom-color: var(--secondary); 351 + } 288 352 `; 289 353 290 354 private eventSources: Map<string, EventSource> = new Map(); 291 355 private handleAuthChange = async () => { 292 356 await this.checkHealth(); 293 357 await this.loadJobs(); 358 + await this.loadExistingClasses(); 294 359 this.connectToJobStreams(); 295 360 }; 296 361 362 + private async loadExistingClasses() { 363 + try { 364 + const response = await fetch("/api/transcriptions"); 365 + if (!response.ok) { 366 + this.existingClasses = []; 367 + return; 368 + } 369 + 370 + const data = await response.json(); 371 + const jobs = data.jobs || []; 372 + 373 + // Extract unique class names 374 + const classSet = new Set<string>(); 375 + for (const job of jobs) { 376 + if (job.class_name) { 377 + classSet.add(job.class_name); 378 + } 379 + } 380 + 381 + this.existingClasses = Array.from(classSet).sort(); 382 + } catch (error) { 383 + console.error("Failed to load classes:", error); 384 + this.existingClasses = []; 385 + } 386 + } 387 + 297 388 override async connectedCallback() { 298 389 super.connectedCallback(); 299 390 await this.checkHealth(); 300 391 await this.loadJobs(); 392 + await this.loadExistingClasses(); 301 393 this.connectToJobStreams(); 302 394 303 395 // Listen for auth changes to reload jobs ··· 311 403 es.close(); 312 404 } 313 405 this.eventSources.clear(); 314 - 406 + 315 407 for (const streamer of this.wordStreamers.values()) { 316 408 streamer.clear(); 317 409 } 318 410 this.wordStreamers.clear(); 319 411 this.displayedTranscripts.clear(); 320 412 this.lastTranscripts.clear(); 321 - 413 + 322 414 window.removeEventListener("auth-changed", this.handleAuthChange); 323 415 } 324 416 ··· 354 446 if (update.progress !== undefined) job.progress = update.progress; 355 447 if (update.transcript !== undefined) { 356 448 job.transcript = update.transcript; 357 - 449 + 358 450 // Get or create word streamer for this job 359 451 if (!this.wordStreamers.has(jobId)) { 360 452 const streamer = new WordStreamer(50, (word) => { ··· 364 456 }); 365 457 this.wordStreamers.set(jobId, streamer); 366 458 } 367 - 368 - const streamer = this.wordStreamers.get(jobId)!; 459 + 460 + const streamer = this.wordStreamers.get(jobId); 461 + if (!streamer) return; 369 462 const lastTranscript = this.lastTranscripts.get(jobId) || ""; 370 463 const newTranscript = update.transcript; 371 - 464 + 372 465 // Check if this is new content we haven't seen 373 466 if (newTranscript !== lastTranscript) { 374 467 // If new transcript starts with last transcript, it's cumulative - add diff ··· 386 479 } 387 480 this.lastTranscripts.set(jobId, newTranscript); 388 481 } 389 - 482 + 390 483 // On completion, show everything immediately 391 484 if (update.status === "completed") { 392 485 streamer.showAll(); ··· 402 495 if (update.status === "completed" || update.status === "failed") { 403 496 eventSource.close(); 404 497 this.eventSources.delete(jobId); 405 - 498 + 406 499 // Clean up streamer 407 500 const streamer = this.wordStreamers.get(jobId); 408 501 if (streamer) { ··· 410 503 this.wordStreamers.delete(jobId); 411 504 } 412 505 this.lastTranscripts.delete(jobId); 413 - 506 + 414 507 // Load VTT for completed jobs 415 508 if (update.status === "completed") { 416 509 await this.loadVTTForJob(jobId); ··· 474 567 if (response.ok) { 475 568 const data = await response.json(); 476 569 this.jobs = data.jobs; 477 - 570 + 478 571 // Initialize displayedTranscripts for completed/failed jobs 479 572 for (const job of this.jobs) { 480 - if ((job.status === "completed" || job.status === "failed") && job.transcript) { 573 + if ( 574 + (job.status === "completed" || job.status === "failed") && 575 + job.transcript 576 + ) { 481 577 this.displayedTranscripts.set(job.id, job.transcript); 482 578 } 483 - 579 + 484 580 // Fetch VTT for completed jobs 485 581 if (job.status === "completed") { 486 582 await this.loadVTTForJob(job.id); ··· 519 615 } 520 616 } 521 617 522 - 523 - 524 618 private handleDragOver(e: DragEvent) { 525 619 e.preventDefault(); 526 620 this.dragOver = true; ··· 550 644 } 551 645 } 552 646 647 + private handleClassSelectChange(e: Event) { 648 + const select = e.target as HTMLSelectElement; 649 + this.showNewClassInput = select.value === "__new__"; 650 + } 651 + 553 652 private async uploadFile(file: File) { 554 653 const allowedTypes = [ 555 654 "audio/mpeg", // MP3 ··· 585 684 this.isUploading = true; 586 685 587 686 try { 687 + // Get class name from dropdown or input 688 + let className = ""; 689 + 690 + if (this.showNewClassInput) { 691 + const classInput = this.shadowRoot?.querySelector( 692 + "#class-name-input", 693 + ) as HTMLInputElement; 694 + className = classInput?.value?.trim() || ""; 695 + } else { 696 + const classSelect = this.shadowRoot?.querySelector( 697 + "#class-select", 698 + ) as HTMLSelectElement; 699 + const selectedValue = classSelect?.value; 700 + if ( 701 + selectedValue && 702 + selectedValue !== "__new__" && 703 + selectedValue !== "" 704 + ) { 705 + className = selectedValue; 706 + } 707 + } 708 + 588 709 const formData = new FormData(); 589 710 formData.append("audio", file); 711 + if (className) { 712 + formData.append("class_name", className); 713 + } 590 714 591 715 const response = await fetch("/api/transcriptions", { 592 716 method: "POST", ··· 600 724 "Upload failed - transcription service may be unavailable", 601 725 ); 602 726 } else { 603 - const result = await response.json(); 604 - await this.loadJobs(); 605 - // Connect to SSE stream for this new job 606 - this.connectToJobStream(result.id); 727 + await response.json(); 728 + // Redirect to class page after successful upload 729 + let className = ""; 730 + 731 + if (this.showNewClassInput) { 732 + const classInput = this.shadowRoot?.querySelector( 733 + "#class-name-input", 734 + ) as HTMLInputElement; 735 + className = classInput?.value?.trim() || ""; 736 + } else { 737 + const classSelect = this.shadowRoot?.querySelector( 738 + "#class-select", 739 + ) as HTMLSelectElement; 740 + const selectedValue = classSelect?.value; 741 + if ( 742 + selectedValue && 743 + selectedValue !== "__new__" && 744 + selectedValue !== "" 745 + ) { 746 + className = selectedValue; 747 + } 748 + } 749 + 750 + if (className) { 751 + window.location.href = `/class/${encodeURIComponent(className)}`; 752 + } else { 753 + window.location.href = "/class/uncategorized"; 754 + } 607 755 } 608 756 } catch { 609 757 alert("Upload failed - transcription service may be unavailable"); ··· 612 760 } 613 761 } 614 762 615 - private getStatusClass(status: string) { 616 - return `status-${status}`; 617 - } 618 - 619 - private renderTranscript(job: TranscriptionJob) { 620 - if (!job.vttContent) { 621 - const displayed = this.displayedTranscripts.get(job.id) || ""; 622 - return displayed; 623 - } 624 - 625 - // Delegate VTT rendering and highlighting to the vtt-viewer component 626 - return html`<vtt-viewer .vttContent=${job.vttContent ?? ""} .audioId=${`audio-${job.id}`}></vtt-viewer>`; 627 - } 628 - 629 - 630 - 631 763 override render() { 632 764 return html` 633 765 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}" ··· 651 783 <input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!this.serviceAvailable ? "disabled" : ""} /> 652 784 </div> 653 785 654 - <div class="jobs-section ${this.jobs.length === 0 ? "hidden" : ""}"> 655 - <h3 class="jobs-title">Your Transcriptions</h3> 656 - ${this.jobs.map( 657 - (job) => html` 658 - <div class="job-card"> 659 - <div class="job-header"> 660 - <span class="job-filename">${job.filename}</span> 661 - <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span> 662 - </div> 663 - 664 - ${ 665 - job.status === "uploading" || 666 - job.status === "processing" || 667 - job.status === "transcribing" 668 - ? html` 669 - <div class="progress-bar"> 670 - <div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}" style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div> 671 - </div> 672 - ` 673 - : "" 674 - } 675 - 676 - ${ 677 - job.status === "completed" && job.audioUrl && job.vttContent 678 - ? html` 679 - <div class="audio-player"> 680 - <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio> 681 - </div> 682 - ${this.renderTranscript(job)} 683 - ` 684 - : this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id) 685 - ? html` 686 - <div class="job-transcript">${this.renderTranscript(job)}</div> 687 - ` 688 - : "" 689 - } 690 - </div> 691 - `, 692 - )} 693 - </div> 786 + ${ 787 + this.serviceAvailable 788 + ? html` 789 + <div class="upload-form"> 790 + <select 791 + id="class-select" 792 + class="class-select" 793 + ?disabled=${this.isUploading} 794 + @change=${this.handleClassSelectChange} 795 + > 796 + <option value="">Select a class (optional)</option> 797 + ${this.existingClasses.map( 798 + (className) => html` 799 + <option value=${className}>${className}</option> 800 + `, 801 + )} 802 + <option value="__new__">+ Add new class</option> 803 + </select> 804 + 805 + ${ 806 + this.showNewClassInput 807 + ? html` 808 + <input 809 + type="text" 810 + id="class-name-input" 811 + class="class-input" 812 + placeholder="Enter new class name" 813 + ?disabled=${this.isUploading} 814 + /> 815 + ` 816 + : "" 817 + } 818 + </div> 819 + ` 820 + : "" 821 + } 694 822 `; 695 823 } 696 824 }
+56 -19
src/components/user-modal.ts
··· 1 - import { LitElement, html, css } from "lit"; 1 + import { css, html, LitElement } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 3 4 4 interface Session { ··· 327 327 328 328 this.user = await res.json(); 329 329 } catch (err) { 330 - this.error = err instanceof Error ? err.message : "Failed to load user details"; 330 + this.error = 331 + err instanceof Error ? err.message : "Failed to load user details"; 331 332 this.user = null; 332 333 } finally { 333 334 this.loading = false; ··· 335 336 } 336 337 337 338 private close() { 338 - this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); 339 + this.dispatchEvent( 340 + new CustomEvent("close", { bubbles: true, composed: true }), 341 + ); 339 342 } 340 343 341 344 private formatTimestamp(timestamp: number) { ··· 365 368 return; 366 369 } 367 370 368 - const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; 371 + const submitBtn = form.querySelector( 372 + 'button[type="submit"]', 373 + ) as HTMLButtonElement; 369 374 submitBtn.disabled = true; 370 375 submitBtn.textContent = "Updating..."; 371 376 ··· 382 387 383 388 alert("Name updated successfully"); 384 389 await this.loadUserDetails(); 385 - this.dispatchEvent(new CustomEvent("user-updated", { bubbles: true, composed: true })); 390 + this.dispatchEvent( 391 + new CustomEvent("user-updated", { bubbles: true, composed: true }), 392 + ); 386 393 } catch { 387 394 alert("Failed to update name"); 388 395 } finally { ··· 402 409 return; 403 410 } 404 411 405 - const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; 412 + const submitBtn = form.querySelector( 413 + 'button[type="submit"]', 414 + ) as HTMLButtonElement; 406 415 submitBtn.disabled = true; 407 416 submitBtn.textContent = "Updating..."; 408 417 ··· 420 429 421 430 alert("Email updated successfully"); 422 431 await this.loadUserDetails(); 423 - this.dispatchEvent(new CustomEvent("user-updated", { bubbles: true, composed: true })); 432 + this.dispatchEvent( 433 + new CustomEvent("user-updated", { bubbles: true, composed: true }), 434 + ); 424 435 } catch (error) { 425 436 alert(error instanceof Error ? error.message : "Failed to update email"); 426 437 } finally { ··· 440 451 return; 441 452 } 442 453 443 - if (!confirm("Are you sure you want to change this user's password? This will log them out of all devices.")) { 454 + if ( 455 + !confirm( 456 + "Are you sure you want to change this user's password? This will log them out of all devices.", 457 + ) 458 + ) { 444 459 return; 445 460 } 446 461 447 - const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; 462 + const submitBtn = form.querySelector( 463 + 'button[type="submit"]', 464 + ) as HTMLButtonElement; 448 465 submitBtn.disabled = true; 449 466 submitBtn.textContent = "Updating..."; 450 467 ··· 459 476 throw new Error("Failed to update password"); 460 477 } 461 478 462 - alert("Password updated successfully. User has been logged out of all devices."); 479 + alert( 480 + "Password updated successfully. User has been logged out of all devices.", 481 + ); 463 482 input.value = ""; 464 483 await this.loadUserDetails(); 465 484 } catch { ··· 471 490 } 472 491 473 492 private async handleLogoutAll() { 474 - if (!confirm("Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.")) { 493 + if ( 494 + !confirm( 495 + "Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.", 496 + ) 497 + ) { 475 498 return; 476 499 } 477 500 ··· 492 515 } 493 516 494 517 private async handleRevokeSession(sessionId: string) { 495 - if (!confirm("Revoke this session? The user will be logged out of this device.")) { 518 + if ( 519 + !confirm( 520 + "Revoke this session? The user will be logged out of this device.", 521 + ) 522 + ) { 496 523 return; 497 524 } 498 525 499 526 try { 500 - const res = await fetch(`/api/admin/users/${this.userId}/sessions/${sessionId}`, { 501 - method: "DELETE", 502 - }); 527 + const res = await fetch( 528 + `/api/admin/users/${this.userId}/sessions/${sessionId}`, 529 + { 530 + method: "DELETE", 531 + }, 532 + ); 503 533 504 534 if (!res.ok) { 505 535 throw new Error("Failed to revoke session"); ··· 512 542 } 513 543 514 544 private async handleRevokePasskey(passkeyId: string) { 515 - if (!confirm("Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.")) { 545 + if ( 546 + !confirm( 547 + "Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.", 548 + ) 549 + ) { 516 550 return; 517 551 } 518 552 519 553 try { 520 - const res = await fetch(`/api/admin/users/${this.userId}/passkeys/${passkeyId}`, { 521 - method: "DELETE", 522 - }); 554 + const res = await fetch( 555 + `/api/admin/users/${this.userId}/passkeys/${passkeyId}`, 556 + { 557 + method: "DELETE", 558 + }, 559 + ); 523 560 524 561 if (!res.ok) { 525 562 throw new Error("Failed to revoke passkey");
+5 -8
src/components/user-settings.ts
··· 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import { UAParser } from "ua-parser-js"; 4 4 import { hashPasswordClient } from "../lib/client-auth"; 5 - import { 6 - isPasskeySupported, 7 - registerPasskey, 8 - } from "../lib/client-passkey"; 5 + import { isPasskeySupported, registerPasskey } from "../lib/client-passkey"; 9 6 10 7 interface User { 11 8 email: string; ··· 1029 1026 </div> 1030 1027 <div style="margin-top: 1rem;"> 1031 1028 ${ 1032 - session.is_current 1033 - ? html` 1029 + session.is_current 1030 + ? html` 1034 1031 <button 1035 1032 class="btn btn-rejection" 1036 1033 @click=${this.handleLogout} ··· 1038 1035 Logout 1039 1036 </button> 1040 1037 ` 1041 - : html` 1038 + : html` 1042 1039 <button 1043 1040 class="btn btn-rejection" 1044 1041 @click=${() => this.handleKillSession(session.id)} ··· 1046 1043 Kill Session 1047 1044 </button> 1048 1045 ` 1049 - } 1046 + } 1050 1047 </div> 1051 1048 </div> 1052 1049 `,
+102 -62
src/components/vtt-viewer.ts
··· 1 - import { LitElement, html, css } from "lit"; 1 + import { css, html, LitElement } from "lit"; 2 2 import { customElement, property } from "lit/decorators.js"; 3 3 4 4 interface VTTSegment { ··· 132 132 `; 133 133 134 134 private audioElement: HTMLAudioElement | null = null; 135 - private boundTimeUpdate: ((this: HTMLAudioElement, ev: Event) => any) | null = null; 136 - private boundTranscriptClick: ((e: Event) => any) | null = null; 137 - 138 - private _viewerId = `vtt-${Math.random().toString(36).slice(2,9)}`; 135 + private boundTimeUpdate: 136 + | ((this: HTMLAudioElement, ev: Event) => void) 137 + | null = null; 138 + private boundTranscriptClick: ((e: Event) => void) | null = null; 139 139 140 140 private findAudioElementById(id: string): HTMLAudioElement | null { 141 - let root: any = this.getRootNode(); 141 + let root: Node | Document = this.getRootNode(); 142 142 let depth = 0; 143 143 while (root && depth < 10) { 144 144 if (root instanceof ShadowRoot) { ··· 162 162 this.detachHighlighting(); 163 163 164 164 const audioElement = this.findAudioElementById(this.audioId); 165 - const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null; 165 + const transcriptDiv = this.shadowRoot?.querySelector( 166 + ".transcript", 167 + ) as HTMLDivElement | null; 166 168 if (!audioElement || !transcriptDiv) return; 167 169 168 170 // Clear any lingering highlights from prior instances 169 - transcriptDiv.querySelectorAll('.current-segment').forEach((el) => { (el as HTMLElement).classList.remove('current-segment'); }); 171 + transcriptDiv.querySelectorAll(".current-segment").forEach((el) => { 172 + (el as HTMLElement).classList.remove("current-segment"); 173 + }); 170 174 171 175 this.audioElement = audioElement; 172 176 let currentSegmentElement: HTMLElement | null = null; 173 177 174 178 this.boundTimeUpdate = () => { 175 179 const currentTime = this.audioElement?.currentTime ?? 0; 176 - const segmentElements = transcriptDiv.querySelectorAll('[data-start]'); 180 + const segmentElements = transcriptDiv.querySelectorAll("[data-start]"); 177 181 let found = false; 178 182 179 183 for (const el of Array.from(segmentElements)) { 180 - const start = Number.parseFloat((el as HTMLElement).dataset.start || '0'); 181 - const end = Number.parseFloat((el as HTMLElement).dataset.end || '0'); 184 + const start = Number.parseFloat( 185 + (el as HTMLElement).dataset.start || "0", 186 + ); 187 + const end = Number.parseFloat((el as HTMLElement).dataset.end || "0"); 182 188 183 189 if (currentTime >= start && currentTime <= end) { 184 190 found = true; 185 191 if (currentSegmentElement !== el) { 186 - currentSegmentElement?.classList.remove('current-segment'); 187 - (el as HTMLElement).classList.add('current-segment'); 192 + currentSegmentElement?.classList.remove("current-segment"); 193 + (el as HTMLElement).classList.add("current-segment"); 188 194 currentSegmentElement = el as HTMLElement; 189 - (el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' }); 195 + (el as HTMLElement).scrollIntoView({ 196 + behavior: "smooth", 197 + block: "center", 198 + }); 190 199 } 191 200 break; 192 201 } ··· 194 203 195 204 // If no segment matched, clear any existing highlight 196 205 if (!found && currentSegmentElement) { 197 - currentSegmentElement.classList.remove('current-segment'); 206 + currentSegmentElement.classList.remove("current-segment"); 198 207 currentSegmentElement = null; 199 208 } 200 209 }; 201 210 202 - audioElement.addEventListener('timeupdate', this.boundTimeUpdate as EventListener); 211 + audioElement.addEventListener( 212 + "timeupdate", 213 + this.boundTimeUpdate as EventListener, 214 + ); 203 215 204 216 this.boundTranscriptClick = (e: Event) => { 205 217 const target = e.target as HTMLElement; 206 - if (target.dataset.start) { 207 - this.audioElement!.currentTime = Number.parseFloat(target.dataset.start); 208 - this.audioElement!.play(); 218 + if (target.dataset.start && this.audioElement) { 219 + this.audioElement.currentTime = Number.parseFloat(target.dataset.start); 220 + this.audioElement.play(); 209 221 } 210 222 }; 211 223 212 - transcriptDiv.addEventListener('click', this.boundTranscriptClick); 224 + transcriptDiv.addEventListener("click", this.boundTranscriptClick); 213 225 } 214 226 215 227 private detachHighlighting() { 216 228 try { 217 - const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null; 229 + const transcriptDiv = this.shadowRoot?.querySelector( 230 + ".transcript", 231 + ) as HTMLDivElement | null; 218 232 if (this.audioElement) { 219 233 // Pause playback to avoid audio continuing after the viewer is removed 220 234 try { 221 235 this.audioElement.pause(); 222 - } catch (e) { 236 + } catch (_e) { 223 237 // ignore 224 238 } 225 239 if (this.boundTimeUpdate) { 226 - this.audioElement.removeEventListener('timeupdate', this.boundTimeUpdate); 240 + this.audioElement.removeEventListener( 241 + "timeupdate", 242 + this.boundTimeUpdate, 243 + ); 227 244 } 228 245 } 229 246 if (transcriptDiv && this.boundTranscriptClick) { 230 - transcriptDiv.removeEventListener('click', this.boundTranscriptClick); 247 + transcriptDiv.removeEventListener("click", this.boundTranscriptClick); 231 248 } 232 249 } finally { 233 250 this.audioElement = null; ··· 238 255 239 256 override disconnectedCallback() { 240 257 this.detachHighlighting(); 241 - super.disconnectedCallback && super.disconnectedCallback(); 258 + super.disconnectedCallback?.(); 242 259 } 243 260 244 - override updated(changed: Map<string, any>) { 261 + override updated(changed: Map<string, unknown>) { 245 262 super.updated(changed); 246 - if (changed.has('vttContent') || changed.has('audioId')) { 263 + if (changed.has("vttContent") || changed.has("audioId")) { 247 264 this.setupHighlighting(); 248 265 } 249 266 } ··· 256 273 for (const segment of segments) { 257 274 const id = (segment.index || "").trim(); 258 275 const match = id.match(/^Paragraph\s+(\d+)-/); 259 - const paraNum = match ? match[1] : '0'; 276 + const paraNum = match?.[1] ?? "0"; 260 277 if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []); 261 - paragraphGroups.get(paraNum)!.push(segment); 278 + const group = paragraphGroups.get(paraNum); 279 + if (group) group.push(segment); 262 280 } 263 281 264 - const paragraphs = Array.from(paragraphGroups.entries()).map(([_, groupSegments]) => { 265 - const fullText = groupSegments.map(s => s.text || '').join(' '); 266 - const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean); 267 - const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length); 268 - const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0)); 269 - const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0)); 270 - const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart)); 271 - let acc = 0; 272 - const paraDuration = paraEnd - paraStart; 282 + const paragraphs = Array.from(paragraphGroups.entries()).map( 283 + ([_, groupSegments]) => { 284 + const fullText = groupSegments.map((s) => s.text || "").join(" "); 285 + const sentences = fullText.split(/(?<=[.!?])\s+/g).filter(Boolean); 286 + const wordCounts = sentences.map( 287 + (s) => s.split(/\s+/).filter(Boolean).length, 288 + ); 289 + const totalWords = Math.max( 290 + 1, 291 + wordCounts.reduce((a, b) => a + b, 0), 292 + ); 293 + const paraStart = Math.min(...groupSegments.map((s) => s.start ?? 0)); 294 + const paraEnd = Math.max( 295 + ...groupSegments.map((s) => s.end ?? paraStart), 296 + ); 297 + let acc = 0; 298 + const paraDuration = paraEnd - paraStart; 273 299 274 - return html`<div class="paragraph">${sentences.map((sent, si) => { 275 - const startOffset = (acc / totalWords) * paraDuration; 276 - acc += wordCounts[si]; 277 - const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration; 278 - const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart; 279 - const spanStart = paraStart + startOffset; 280 - const spanEnd = paraStart + endOffset; 281 - return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`; 282 - })}</div>`; 283 - }); 300 + return html`<div class="paragraph">${sentences.map((sent, si) => { 301 + const wordCount = wordCounts[si]; 302 + if (wordCount === undefined) return ""; 303 + 304 + const startOffset = (acc / totalWords) * paraDuration; 305 + acc += wordCount; 306 + const sentenceDuration = (wordCount / totalWords) * paraDuration; 307 + const endOffset = 308 + si < sentences.length - 1 309 + ? startOffset + sentenceDuration - 0.001 310 + : paraEnd - paraStart; 311 + const spanStart = paraStart + startOffset; 312 + const spanEnd = paraStart + endOffset; 313 + return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? " " : ""}`; 314 + })}</div>`; 315 + }, 316 + ); 284 317 285 318 return html`${paragraphs}`; 286 319 } ··· 293 326 for (const s of segments) { 294 327 const id = (s.index || "").trim(); 295 328 const match = id.match(/^Paragraph\s+(\d+)-/); 296 - const paraNum = match ? match[1] : '0'; 329 + const paraNum = match?.[1] ?? "0"; 297 330 if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []); 298 - paragraphGroups.get(paraNum)!.push(s.text || ''); 331 + const group = paragraphGroups.get(paraNum); 332 + if (group) group.push(s.text || ""); 299 333 } 300 - const paragraphs = Array.from(paragraphGroups.values()).map(group => group.join(' ').replace(/\s+/g, ' ').trim()); 301 - return paragraphs.join('\n\n').trim(); 334 + const paragraphs = Array.from(paragraphGroups.values()).map((group) => 335 + group.join(" ").replace(/\s+/g, " ").trim(), 336 + ); 337 + return paragraphs.join("\n\n").trim(); 302 338 } 303 339 304 340 private async copyTranscript(e?: Event) { 305 - e && e.stopPropagation(); 341 + e?.stopPropagation(); 306 342 const text = this.extractPlainText(); 307 343 if (!text) return; 308 344 try { 309 - if (navigator && (navigator as any).clipboard && (navigator as any).clipboard.writeText) { 310 - await (navigator as any).clipboard.writeText(text); 345 + if (navigator?.clipboard?.writeText) { 346 + await navigator.clipboard.writeText(text); 311 347 } else { 312 348 // Fallback 313 - const ta = document.createElement('textarea'); 349 + const ta = document.createElement("textarea"); 314 350 ta.value = text; 315 - ta.style.position = 'fixed'; 316 - ta.style.opacity = '0'; 351 + ta.style.position = "fixed"; 352 + ta.style.opacity = "0"; 317 353 document.body.appendChild(ta); 318 354 ta.select(); 319 - document.execCommand('copy'); 355 + document.execCommand("copy"); 320 356 document.body.removeChild(ta); 321 357 } 322 - const btn = this.shadowRoot?.querySelector('.copy-btn') as HTMLButtonElement | null; 358 + const btn = this.shadowRoot?.querySelector( 359 + ".copy-btn", 360 + ) as HTMLButtonElement | null; 323 361 if (btn) { 324 362 const orig = btn.innerText; 325 - btn.innerText = 'Copied!'; 326 - setTimeout(() => { btn.innerText = orig; }, 1500); 363 + btn.innerText = "Copied!"; 364 + setTimeout(() => { 365 + btn.innerText = orig; 366 + }, 1500); 327 367 } 328 368 } catch { 329 369 // ignore
+8
src/db/schema.ts
··· 146 146 CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login); 147 147 `, 148 148 }, 149 + { 150 + version: 9, 151 + name: "Add class_name to transcriptions", 152 + sql: ` 153 + ALTER TABLE transcriptions ADD COLUMN class_name TEXT; 154 + CREATE INDEX IF NOT EXISTS idx_transcriptions_class_name ON transcriptions(class_name); 155 + `, 156 + }, 149 157 ]; 150 158 151 159 function getCurrentVersion(): number {
+39 -19
src/index.ts
··· 10 10 deleteTranscription, 11 11 deleteUser, 12 12 getAllTranscriptions, 13 - getAllUsers, 14 13 getAllUsersWithStats, 15 14 getSession, 16 15 getSessionFromRequest, 17 16 getSessionsForUser, 18 17 getUserBySession, 19 18 getUserSessionsForUser, 19 + type UserRole, 20 20 updateUserAvatar, 21 21 updateUserEmail, 22 22 updateUserEmailAddress, 23 23 updateUserName, 24 24 updateUserPassword, 25 25 updateUserRole, 26 - type UserRole, 27 26 } from "./lib/auth"; 27 + import { handleError, ValidationErrors } from "./lib/errors"; 28 + import { requireAdmin, requireAuth } from "./lib/middleware"; 28 29 import { 29 30 createAuthenticationOptions, 30 31 createRegistrationOptions, ··· 34 35 verifyAndAuthenticatePasskey, 35 36 verifyAndCreatePasskey, 36 37 } from "./lib/passkey"; 37 - import { handleError, ValidationErrors } from "./lib/errors"; 38 - import { requireAdmin, requireAuth } from "./lib/middleware"; 39 38 import { enforceRateLimit } from "./lib/rate-limit"; 39 + import { getTranscriptVTT } from "./lib/transcript-storage"; 40 40 import { 41 41 MAX_FILE_SIZE, 42 42 TranscriptionEventEmitter, 43 43 type TranscriptionUpdate, 44 44 WhisperServiceManager, 45 45 } from "./lib/transcription"; 46 - import { getTranscriptVTT } from "./lib/transcript-storage"; 46 + import adminHTML from "./pages/admin.html"; 47 + import classHTML from "./pages/class.html"; 48 + import classesHTML from "./pages/classes.html"; 47 49 import indexHTML from "./pages/index.html"; 48 - import adminHTML from "./pages/admin.html"; 49 50 import settingsHTML from "./pages/settings.html"; 50 51 import transcribeHTML from "./pages/transcribe.html"; 51 52 ··· 108 109 "/admin": adminHTML, 109 110 "/settings": settingsHTML, 110 111 "/transcribe": transcribeHTML, 112 + "/classes": classesHTML, 113 + "/class/:className": classHTML, 111 114 "/api/auth/register": { 112 115 POST: async (req) => { 113 116 try { ··· 264 267 "/api/passkeys/register/verify": { 265 268 POST: async (req) => { 266 269 try { 267 - const user = requireAuth(req); 270 + const _user = requireAuth(req); 268 271 const body = await req.json(); 269 272 const { response: credentialResponse, challenge, name } = body; 270 273 ··· 797 800 798 801 // return info on transcript 799 802 const transcript = { 800 - id: transcription.id, 801 - filename: transcription.original_filename, 802 - status: transcription.status, 803 - progress: transcription.progress, 804 - created_at: transcription.created_at, 805 - } 803 + id: transcription.id, 804 + filename: transcription.original_filename, 805 + status: transcription.status, 806 + progress: transcription.progress, 807 + created_at: transcription.created_at, 808 + }; 806 809 return new Response(JSON.stringify(transcript), { 807 810 headers: { 808 811 "Content-Type": "application/json", ··· 914 917 id: string; 915 918 filename: string; 916 919 original_filename: string; 920 + class_name: string | null; 917 921 status: string; 918 922 progress: number; 919 923 created_at: number; 920 924 }, 921 925 [number] 922 926 >( 923 - "SELECT id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 927 + "SELECT id, filename, original_filename, class_name, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 924 928 ) 925 929 .all(user.id); 926 930 ··· 930 934 return { 931 935 id: t.id, 932 936 filename: t.original_filename, 937 + class_name: t.class_name, 933 938 status: t.status, 934 939 progress: t.progress, 935 940 created_at: t.created_at, ··· 948 953 949 954 const formData = await req.formData(); 950 955 const file = formData.get("audio") as File; 956 + const className = formData.get("class_name") as string | null; 951 957 952 958 if (!file) throw ValidationErrors.missingField("audio"); 953 959 ··· 986 992 const uploadDir = "./uploads"; 987 993 await Bun.write(`${uploadDir}/${filename}`, file); 988 994 989 - // Create database record 990 - db.run( 991 - "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 992 - [transcriptionId, user.id, filename, file.name, "uploading"], 993 - ); 995 + // Create database record with optional class_name 996 + if (className?.trim()) { 997 + db.run( 998 + "INSERT INTO transcriptions (id, user_id, filename, original_filename, class_name, status) VALUES (?, ?, ?, ?, ?, ?)", 999 + [ 1000 + transcriptionId, 1001 + user.id, 1002 + filename, 1003 + file.name, 1004 + className.trim(), 1005 + "uploading", 1006 + ], 1007 + ); 1008 + } else { 1009 + db.run( 1010 + "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 1011 + [transcriptionId, user.id, filename, file.name, "uploading"], 1012 + ); 1013 + } 994 1014 995 1015 // Start transcription in background 996 1016 whisperService.startTranscription(transcriptionId, filename);
+1 -2
src/lib/admin.test.ts
··· 1 - import { afterEach, beforeEach, expect, test } from "bun:test"; 2 1 import { Database } from "bun:sqlite"; 2 + import { afterEach, beforeEach, expect, test } from "bun:test"; 3 3 4 4 let testDb: Database; 5 5 ··· 183 183 .all(userId); 184 184 expect(sessions.length).toBe(0); 185 185 }); 186 -
+6 -6
src/lib/auth.test.ts
··· 1 - import { test, expect } from "bun:test"; 1 + import { expect, test } from "bun:test"; 2 + import db from "../db/schema"; 2 3 import { 3 4 createSession, 4 - getSession, 5 5 deleteSession, 6 + getSession, 6 7 getSessionFromRequest, 7 8 } from "./auth"; 8 - import db from "../db/schema"; 9 9 10 10 test("createSession generates UUID and stores in database", () => { 11 11 const userId = 1; ··· 125 125 } 126 126 127 127 // Verify sessions table still exists 128 - const result = db 129 - .query("SELECT COUNT(*) as count FROM sessions") 130 - .get() as { count: number }; 128 + const result = db.query("SELECT COUNT(*) as count FROM sessions").get() as { 129 + count: number; 130 + }; 131 131 expect(typeof result.count).toBe("number"); 132 132 });
+9 -13
src/lib/auth.ts
··· 231 231 last_login: number | null; 232 232 }, 233 233 [] 234 - >("SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC") 234 + >( 235 + "SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC", 236 + ) 235 237 .all(); 236 238 } 237 239 ··· 329 331 .all(userId, now); 330 332 } 331 333 332 - export function deleteSessionById( 333 - sessionId: string, 334 - userId: number, 335 - ): boolean { 336 - const result = db.run( 337 - "DELETE FROM sessions WHERE id = ? AND user_id = ?", 338 - [sessionId, userId], 339 - ); 334 + export function deleteSessionById(sessionId: string, userId: number): boolean { 335 + const result = db.run("DELETE FROM sessions WHERE id = ? AND user_id = ?", [ 336 + sessionId, 337 + userId, 338 + ]); 340 339 return result.changes > 0; 341 340 } 342 341 ··· 344 343 db.run("DELETE FROM sessions WHERE user_id = ?", [userId]); 345 344 } 346 345 347 - export function updateUserEmailAddress( 348 - userId: number, 349 - newEmail: string, 350 - ): void { 346 + export function updateUserEmailAddress(userId: number, newEmail: string): void { 351 347 db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]); 352 348 } 353 349
+1 -1
src/lib/client-auth.test.ts
··· 1 - import { test, expect } from "bun:test"; 1 + import { expect, test } from "bun:test"; 2 2 import { hashPasswordClient } from "./client-auth"; 3 3 4 4 test("hashPasswordClient produces consistent output", async () => {
+4 -3
src/lib/client-passkey.ts
··· 68 68 } catch (err) { 69 69 return { 70 70 success: false, 71 - error: 72 - err instanceof Error ? err.message : "Failed to register passkey", 71 + error: err instanceof Error ? err.message : "Failed to register passkey", 73 72 }; 74 73 } 75 74 } ··· 137 136 return { 138 137 success: false, 139 138 error: 140 - err instanceof Error ? err.message : "Failed to authenticate with passkey", 139 + err instanceof Error 140 + ? err.message 141 + : "Failed to authenticate with passkey", 141 142 }; 142 143 } 143 144 }
+16 -9
src/lib/passkey.ts
··· 1 1 import { 2 2 generateAuthenticationOptions, 3 3 generateRegistrationOptions, 4 + type VerifiedAuthenticationResponse, 5 + type VerifiedRegistrationResponse, 4 6 verifyAuthenticationResponse, 5 7 verifyRegistrationResponse, 6 - type VerifiedAuthenticationResponse, 7 - type VerifiedRegistrationResponse, 8 8 } from "@simplewebauthn/server"; 9 9 import type { 10 10 AuthenticationResponseJSON, ··· 163 163 // credential.publicKey is a Uint8Array that needs conversion 164 164 const passkeyId = crypto.randomUUID(); 165 165 const credentialIdBase64 = credential.id; 166 - const publicKeyBase64 = Buffer.from(credential.publicKey).toString("base64url"); 166 + const publicKeyBase64 = Buffer.from(credential.publicKey).toString( 167 + "base64url", 168 + ); 167 169 const transports = response.response.transports?.join(",") || null; 168 170 169 171 db.run( ··· 224 226 225 227 const options = await generateAuthenticationOptions({ 226 228 rpID, 227 - allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined, 229 + allowCredentials: 230 + allowCredentials.length > 0 ? allowCredentials : undefined, 228 231 userVerification: "preferred", 229 232 }); 230 233 ··· 299 302 300 303 // Update last used timestamp and counter for passkey 301 304 const now = Math.floor(Date.now() / 1000); 302 - db.run( 303 - "UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?", 304 - [now, verification.authenticationInfo.newCounter, passkey.id], 305 - ); 305 + db.run("UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?", [ 306 + now, 307 + verification.authenticationInfo.newCounter, 308 + passkey.id, 309 + ]); 306 310 307 311 // Update user's last_login 308 - db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, passkey.user_id]); 312 + db.run("UPDATE users SET last_login = ? WHERE id = ?", [ 313 + now, 314 + passkey.user_id, 315 + ]); 309 316 310 317 // Get user 311 318 const user = db
+1 -1
src/lib/rate-limit.test.ts
··· 1 1 import { expect, test } from "bun:test"; 2 - import { checkRateLimit, cleanupOldAttempts } from "./rate-limit"; 3 2 import db from "../db/schema"; 3 + import { checkRateLimit, cleanupOldAttempts } from "./rate-limit"; 4 4 5 5 // Clean up before tests 6 6 db.run("DELETE FROM rate_limit_attempts");
+3 -4
src/lib/transcript-storage.test.ts
··· 1 1 import { expect, test } from "bun:test"; 2 2 import { 3 + deleteTranscript, 4 + getTranscript, 3 5 getTranscriptVTT, 4 - saveTranscriptVTT, 5 6 hasTranscript, 6 7 saveTranscript, 7 - getTranscript, 8 - deleteTranscript, 8 + saveTranscriptVTT, 9 9 } from "./transcript-storage"; 10 10 11 11 test("transcript storage", async () => { ··· 107 107 // Clean up 108 108 await deleteTranscript(testId); 109 109 }); 110 -
+11 -15
src/lib/transcription.ts
··· 190 190 } 191 191 } 192 192 193 - private streamWhisperJob( 194 - transcriptionId: string, 195 - jobId: string, 196 - ) { 193 + private streamWhisperJob(transcriptionId: string, jobId: string) { 197 194 // Prevent duplicate streams using locks 198 195 if (this.streamLocks.has(transcriptionId)) { 199 196 return; ··· 312 309 `${this.serviceUrl}/transcribe/${whisperJobId}?format=vtt`, 313 310 ); 314 311 if (vttResponse.ok) { 315 - const vttContent = await vttResponse.text(); 316 - const cleanedVTT = await cleanVTT(transcriptionId, vttContent); 317 - await saveTranscriptVTT(transcriptionId, cleanedVTT); 318 - this.updateTranscription(transcriptionId, {}); 319 - } 312 + const vttContent = await vttResponse.text(); 313 + const cleanedVTT = await cleanVTT(transcriptionId, vttContent); 314 + await saveTranscriptVTT(transcriptionId, cleanedVTT); 315 + this.updateTranscription(transcriptionId, {}); 316 + } 320 317 } catch (error) { 321 318 console.warn( 322 319 `[Transcription] Failed to fetch VTT for ${transcriptionId}:`, ··· 392 389 updates.push("error_message = ?"); 393 390 values.push(data.error_message); 394 391 } 395 - 396 392 397 393 updates.push("updated_at = ?"); 398 394 values.push(Math.floor(Date.now() / 1000)); ··· 535 531 `${this.serviceUrl}/transcribe/${whisperJob.id}?format=vtt`, 536 532 ); 537 533 if (vttResponse.ok) { 538 - const vttContent = await vttResponse.text(); 539 - const cleanedVTT = await cleanVTT(transcriptionId, vttContent); 540 - await saveTranscriptVTT(transcriptionId, cleanedVTT); 541 - this.updateTranscription(transcriptionId, {}); 542 - } 534 + const vttContent = await vttResponse.text(); 535 + const cleanedVTT = await cleanVTT(transcriptionId, vttContent); 536 + await saveTranscriptVTT(transcriptionId, cleanedVTT); 537 + this.updateTranscription(transcriptionId, {}); 538 + } 543 539 } catch (error) { 544 540 console.warn( 545 541 `[Sync] Failed to fetch VTT for ${transcriptionId}:`,
+7 -7
src/lib/vtt-cleaner.test.ts
··· 1 - import { test, expect } from "bun:test"; 1 + import { expect, test } from "bun:test"; 2 2 import { cleanVTT, parseVTT } from "./vtt-cleaner"; 3 3 4 4 const sampleVTT = `WEBVTT ··· 38 38 test("cleanVTT preserves VTT format when AI key not available", async () => { 39 39 // Save original env var 40 40 const originalKey = process.env.LLM_API_KEY; 41 - 41 + 42 42 // Remove key to test fallback 43 43 delete process.env.LLM_API_KEY; 44 - 44 + 45 45 const result = await cleanVTT("test-vtt", sampleVTT); 46 46 47 47 expect(result).toContain("WEBVTT"); 48 48 expect(result).toContain("-->"); 49 - 49 + 50 50 // Restore original key 51 51 if (originalKey) { 52 52 process.env.LLM_API_KEY = originalKey; ··· 72 72 73 73 expect(result).toContain("WEBVTT"); 74 74 expect(result).toContain("-->"); 75 - 75 + 76 76 // AI should clean up tags 77 77 expect(result).not.toContain("<|startoftranscript|>"); 78 78 expect(result).not.toContain("[SIDE CONVERSATION]"); 79 - 79 + 80 80 // Should have paragraph formatting 81 81 expect(result).toContain("Paragraph"); 82 - 82 + 83 83 console.log("AI-cleaned VTT preview:", result.substring(0, 300)); 84 84 }, 30000);
+96 -78
src/lib/vtt-cleaner.ts
··· 111 111 * Find paragraph boundaries in processed VTT content 112 112 * Returns the segments in the last paragraph and highest paragraph number found 113 113 */ 114 - function extractLastParagraphAndHighestNumber(vttContent: string): { 115 - segments: string, 116 - paragraphNumber: string | null, 117 - highestParagraphNumber: number 114 + function extractLastParagraphAndHighestNumber(vttContent: string): { 115 + segments: string; 116 + paragraphNumber: string | null; 117 + highestParagraphNumber: number; 118 118 } { 119 - if (!vttContent) return { segments: '', paragraphNumber: null, highestParagraphNumber: 0 }; 120 - 119 + if (!vttContent) 120 + return { segments: "", paragraphNumber: null, highestParagraphNumber: 0 }; 121 + 121 122 // Split into segments (separated by double newline) 122 - const segments = vttContent.split('\n\n').filter(Boolean); 123 - if (segments.length === 0) return { segments: '', paragraphNumber: null, highestParagraphNumber: 0 }; 124 - 123 + const segments = vttContent.split("\n\n").filter(Boolean); 124 + if (segments.length === 0) 125 + return { segments: "", paragraphNumber: null, highestParagraphNumber: 0 }; 126 + 125 127 // Get all segments from the last paragraph number 126 128 const lastSegments: string[] = []; 127 129 let currentParagraphNumber: string | null = null; 128 130 let highestParagraphNumber = 0; 129 - 131 + 130 132 // First, scan through all segments to find the highest paragraph number 131 133 for (const segment of segments) { 132 134 if (!segment) continue; 133 - 134 - const lines = segment.split('\n'); 135 - const firstLine = lines[0] || ''; 136 - 135 + 136 + const lines = segment.split("\n"); 137 + const firstLine = lines[0] || ""; 138 + 137 139 // Check for paragraph number pattern 138 140 const paragraphMatch = /Paragraph (\d+)-\d+/.exec(firstLine); 139 141 if (paragraphMatch?.[1]) { 140 142 const paragraphNum = parseInt(paragraphMatch[1], 10); 141 - if (!Number.isNaN(paragraphNum) && paragraphNum > highestParagraphNumber) { 143 + if ( 144 + !Number.isNaN(paragraphNum) && 145 + paragraphNum > highestParagraphNumber 146 + ) { 142 147 highestParagraphNumber = paragraphNum; 143 148 } 144 149 } 145 150 } 146 - 151 + 147 152 // Start from the end and work backwards to find the last paragraph 148 153 for (let i = segments.length - 1; i >= 0; i--) { 149 154 const segment = segments[i]; 150 155 if (!segment) continue; 151 - 152 - const lines = segment.split('\n'); 153 - const firstLine = lines[0] || ''; 154 - 156 + 157 + const lines = segment.split("\n"); 158 + const firstLine = lines[0] || ""; 159 + 155 160 // Check for paragraph number pattern 156 161 const paragraphMatch = /Paragraph (\d+)-\d+/.exec(firstLine); 157 162 if (paragraphMatch?.[1]) { 158 163 const paragraphNumber = paragraphMatch[1]; 159 - 164 + 160 165 if (!currentParagraphNumber) { 161 166 // This is the first paragraph number we've found working backwards 162 167 currentParagraphNumber = paragraphNumber; ··· 176 181 } 177 182 } 178 183 } 179 - 184 + 180 185 return { 181 - segments: lastSegments.join('\n\n'), 186 + segments: lastSegments.join("\n\n"), 182 187 paragraphNumber: currentParagraphNumber, 183 - highestParagraphNumber 188 + highestParagraphNumber, 184 189 }; 185 190 } 186 191 ··· 189 194 */ 190 195 async function processVTTChunk( 191 196 transcriptionId: string, 192 - inputSegments: Array<{index: number, timestamp: string, text: string}>, 197 + inputSegments: Array<{ index: number; timestamp: string; text: string }>, 193 198 chunkIndex: number, 194 199 previousParagraphNumber: string | null, 195 200 apiKey: string, ··· 198 203 previousParagraphText?: string, 199 204 ): Promise<string> { 200 205 const chunkId = `${transcriptionId}-chunk${chunkIndex}`; 201 - 206 + 202 207 const hasTextContext = !!previousParagraphText; 203 - 204 - console.log(`[VTTCleaner] Processing chunk ${chunkIndex} with ${inputSegments.length} segments${hasTextContext ? ' and previous paragraph text context' : ''}`); 205 - 206 - const nextParagraphNumber = previousParagraphNumber ? String(parseInt(previousParagraphNumber, 10) + 1) : '1'; 207 - 208 + 209 + console.log( 210 + `[VTTCleaner] Processing chunk ${chunkIndex} with ${inputSegments.length} segments${hasTextContext ? " and previous paragraph text context" : ""}`, 211 + ); 212 + 213 + const nextParagraphNumber = previousParagraphNumber 214 + ? String(parseInt(previousParagraphNumber, 10) + 1) 215 + : "1"; 216 + 208 217 const prompt = `Can you turn this into a paragraph separated vtt file? 209 218 210 219 Use the format "Paragraph X-Y" where X is the paragraph number and Y is the segment number within that paragraph: ··· 237 246 238 247 Also go through and rewrite the words to extract the meaning and not necessarily the exact phrasing if it sounds unnatural when written. I want the text to remain lined up with the original though so don't rewrite entire paragraphs but you can remove ums, alrights, and similar. Also remove all contextual tags like [background noise]. Add punctuation if it's missing to make the text readable. If there is no more context to fit a segment then just skip it and move to the next one. 239 248 240 - ${hasTextContext ? 241 - `The following is the last paragraph from the previous chunk and is provided for context only. DO NOT include it in your output - it's already in the transcript: 249 + ${ 250 + hasTextContext 251 + ? `The following is the last paragraph from the previous chunk and is provided for context only. DO NOT include it in your output - it's already in the transcript: 242 252 243 253 ${previousParagraphText} 244 254 245 - Now process the following new segments, continuing from the previous paragraph. ${previousParagraphNumber ? `Start your paragraphs with number ${nextParagraphNumber} (unless you're continuing the previous paragraph).` : ''}` 246 - : 'Process the following segments:'} 255 + Now process the following new segments, continuing from the previous paragraph. ${previousParagraphNumber ? `Start your paragraphs with number ${nextParagraphNumber} (unless you're continuing the previous paragraph).` : ""}` 256 + : "Process the following segments:" 257 + } 247 258 248 259 ${JSON.stringify(inputSegments, null, 2)} 249 260 250 261 Return ONLY the VTT content WITHOUT the "WEBVTT" header and nothing else. No explanations or additional text.`; 251 262 252 263 try { 253 - const response = await fetch( 254 - `${apiBaseUrl}/chat/completions`, 255 - { 256 - method: "POST", 257 - headers: { 258 - "Content-Type": "application/json", 259 - "Authorization": `Bearer ${apiKey}`, 260 - "HTTP-Referer": "https://thistle.app", 261 - "X-Title": `Thistle Transcription Chunk ${chunkIndex}`, 262 - }, 263 - body: JSON.stringify({ 264 - model, 265 - messages: [ 266 - { role: "user", content: prompt }, 267 - ], 268 - temperature: 0.3, 269 - max_tokens: 8192, // Reduced for chunks 270 - }), 264 + const response = await fetch(`${apiBaseUrl}/chat/completions`, { 265 + method: "POST", 266 + headers: { 267 + "Content-Type": "application/json", 268 + Authorization: `Bearer ${apiKey}`, 269 + "HTTP-Referer": "https://thistle.app", 270 + "X-Title": `Thistle Transcription Chunk ${chunkIndex}`, 271 271 }, 272 - ); 272 + body: JSON.stringify({ 273 + model, 274 + messages: [{ role: "user", content: prompt }], 275 + temperature: 0.3, 276 + max_tokens: 8192, // Reduced for chunks 277 + }), 278 + }); 273 279 274 280 if (!response.ok) { 275 281 const errorText = await response.text(); ··· 335 341 const apiKey = process.env.LLM_API_KEY; 336 342 const apiBaseUrl = process.env.LLM_API_BASE_URL; 337 343 const model = process.env.LLM_MODEL; 338 - 344 + 339 345 if (!apiKey || !apiBaseUrl || !model) { 340 - console.warn("[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT"); 346 + console.warn( 347 + "[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT", 348 + ); 341 349 return vttContent; 342 350 } 343 351 ··· 356 364 const end = Math.min(i + CHUNK_SIZE, inputSegments.length); 357 365 chunks.push(inputSegments.slice(i, end)); 358 366 } 359 - 360 - console.log(`[VTTCleaner] Split into ${chunks.length} chunks for sequential processing with paragraph context`); 361 - 367 + 368 + console.log( 369 + `[VTTCleaner] Split into ${chunks.length} chunks for sequential processing with paragraph context`, 370 + ); 371 + 362 372 // Process chunks sequentially with context from previous chunk 363 373 const processedChunks: string[] = []; 364 374 let previousParagraphText: string | undefined; 365 375 let previousParagraphNumber: string | null = null; 366 - 376 + 367 377 for (let i = 0; i < chunks.length; i++) { 368 378 const chunk = chunks[i]; 369 379 if (!chunk || chunk.length === 0) continue; 370 - 380 + 371 381 try { 372 382 const processedChunk = await processVTTChunk( 373 - transcriptionId, 374 - chunk, 383 + transcriptionId, 384 + chunk, 375 385 i, 376 386 previousParagraphNumber, 377 - apiKey, 378 - apiBaseUrl, 387 + apiKey, 388 + apiBaseUrl, 379 389 model, 380 - previousParagraphText 390 + previousParagraphText, 381 391 ); 382 392 processedChunks.push(processedChunk); 383 - console.log(`[VTTCleaner] Completed chunk ${i}/${chunks.length - 1}${previousParagraphText ? ' (with context)' : ''}`); 384 - 393 + console.log( 394 + `[VTTCleaner] Completed chunk ${i}/${chunks.length - 1}${previousParagraphText ? " (with context)" : ""}`, 395 + ); 396 + 385 397 // Extract context for the next chunk 386 398 if (i < chunks.length - 1) { 387 - const { segments: lastParagraphText, paragraphNumber, highestParagraphNumber } = extractLastParagraphAndHighestNumber(processedChunk); 388 - 399 + const { 400 + segments: lastParagraphText, 401 + paragraphNumber, 402 + highestParagraphNumber, 403 + } = extractLastParagraphAndHighestNumber(processedChunk); 404 + 389 405 if (lastParagraphText) { 390 - console.log(`[VTTCleaner] Using paragraph ${paragraphNumber || 'unknown'} as context for next chunk (highest paragraph: ${highestParagraphNumber})`); 406 + console.log( 407 + `[VTTCleaner] Using paragraph ${paragraphNumber || "unknown"} as context for next chunk (highest paragraph: ${highestParagraphNumber})`, 408 + ); 391 409 previousParagraphText = lastParagraphText; 392 410 previousParagraphNumber = highestParagraphNumber.toString(); 393 411 } else { ··· 398 416 } catch (error) { 399 417 console.error(`[VTTCleaner] Chunk ${i} failed:`, error); 400 418 // Return the original segments for this chunk if processing fails 401 - const fallbackChunk = chunk.map(seg => 402 - `${seg.index || ''}\n${seg.timestamp}\n${seg.text}` 403 - ).join('\n\n'); 419 + const fallbackChunk = chunk 420 + .map((seg) => `${seg.index || ""}\n${seg.timestamp}\n${seg.text}`) 421 + .join("\n\n"); 404 422 processedChunks.push(fallbackChunk); 405 423 previousParagraphText = undefined; 406 424 previousParagraphNumber = null; 407 425 } 408 426 } 409 - 427 + 410 428 // Combine all processed chunks 411 - const finalVTT = `WEBVTT\n\n${processedChunks.join('\n\n')}`; 412 - 429 + const finalVTT = `WEBVTT\n\n${processedChunks.join("\n\n")}`; 430 + 413 431 console.log( 414 432 `[VTTCleaner] Successfully cleaned ${segments.length} segments in ${chunks.length} sequential chunks with paragraph context`, 415 433 ); ··· 420 438 console.warn("[VTTCleaner] Falling back to uncleaned VTT"); 421 439 return vttContent; 422 440 } 423 - } 441 + }
+3 -3
src/pages/admin.html
··· 10 10 <link rel="stylesheet" href="../styles/main.css"> 11 11 <style> 12 12 main { 13 - max-width: 80rem !important; 13 + max-width: 80rem; 14 14 margin: 0 auto; 15 15 padding: 2rem; 16 16 } ··· 486 486 allTranscriptions = transcriptions; 487 487 488 488 // Filter transcriptions based on search term 489 - let filteredTranscriptions = transcriptions.filter(t => { 489 + const filteredTranscriptions = transcriptions.filter(t => { 490 490 if (!transcriptSearchTerm) return true; 491 491 const term = transcriptSearchTerm.toLowerCase(); 492 492 const filename = (t.original_filename || '').toLowerCase(); ··· 626 626 allUsers = users; 627 627 628 628 // Filter users based on search term 629 - let filteredUsers = users.filter(u => { 629 + const filteredUsers = users.filter(u => { 630 630 if (!userSearchTerm) return true; 631 631 const term = userSearchTerm.toLowerCase(); 632 632 const name = (u.name || '').toLowerCase();
+32
src/pages/class.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Class - Thistle</title> 8 + <link rel="icon" 9 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>"> 10 + <link rel="stylesheet" href="../styles/main.css"> 11 + </head> 12 + 13 + <body> 14 + <header> 15 + <div class="header-content"> 16 + <a href="/" class="site-title"> 17 + <span>🪻</span> 18 + <span>Thistle - Classes</span> 19 + </a> 20 + <auth-component></auth-component> 21 + </div> 22 + </header> 23 + 24 + <main class="container"> 25 + <class-view></class-view> 26 + </main> 27 + 28 + <script type="module" src="../components/auth.ts"></script> 29 + <script type="module" src="../components/class-view.ts"></script> 30 + </body> 31 + 32 + </html>
+32
src/pages/classes.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Classes - Thistle</title> 8 + <link rel="icon" 9 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>"> 10 + <link rel="stylesheet" href="../styles/main.css"> 11 + </head> 12 + 13 + <body> 14 + <header> 15 + <div class="header-content"> 16 + <a href="/" class="site-title"> 17 + <span>🪻</span> 18 + <span>Thistle - Classes</span> 19 + </a> 20 + <auth-component></auth-component> 21 + </div> 22 + </header> 23 + 24 + <main class="container"> 25 + <classes-overview></classes-overview> 26 + </main> 27 + 28 + <script type="module" src="../components/auth.ts"></script> 29 + <script type="module" src="../components/classes-overview.ts"></script> 30 + </body> 31 + 32 + </html>
+1 -1
src/pages/index.html
··· 110 110 const isLoggedIn = await authComponent.isAuthenticated(); 111 111 112 112 if (isLoggedIn) { 113 - window.location.href = '/transcribe'; 113 + window.location.href = '/classes'; 114 114 } else { 115 115 authComponent.openAuthModal(); 116 116 }
+1 -1
src/pages/settings.html
··· 11 11 12 12 <style> 13 13 main { 14 - max-width: 64rem !important; 14 + max-width: 64rem; 15 15 } 16 16 </style> 17 17 </head>
+7 -1
src/pages/transcribe.html
··· 41 41 </header> 42 42 43 43 <main> 44 + <div style="margin-bottom: 1rem;"> 45 + <a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;"> 46 + ← Back to classes 47 + </a> 48 + </div> 49 + 44 50 <div class="page-header"> 45 - <h1 class="page-title">Audio Transcription</h1> 51 + <h1 class="page-title">Upload Transcription</h1> 46 52 <p class="page-subtitle">Upload your audio files and get accurate transcripts powered by Whisper</p> 47 53 </div> 48 54
+20 -20
src/styles/header.css
··· 1 1 /* Header styles shared across all pages */ 2 2 3 3 header { 4 - position: sticky; 5 - top: 0; 6 - z-index: 1000; 7 - background: var(--background); 8 - border-bottom: 2px solid var(--secondary); 9 - padding: 1rem 2rem; 10 - margin: -2rem -2rem 2rem -2rem; 4 + position: sticky; 5 + top: 0; 6 + z-index: 1000; 7 + background: var(--background); 8 + border-bottom: 2px solid var(--secondary); 9 + padding: 1rem 2rem; 10 + margin: -2rem -2rem 2rem -2rem; 11 11 } 12 12 13 13 .header-content { 14 - max-width: 1200px; 15 - margin: 0 auto; 16 - display: flex; 17 - justify-content: space-between; 18 - align-items: center; 14 + max-width: 1200px; 15 + margin: 0 auto; 16 + display: flex; 17 + justify-content: space-between; 18 + align-items: center; 19 19 } 20 20 21 21 .site-title { 22 - font-size: 1.5rem; 23 - font-weight: 600; 24 - color: var(--text); 25 - text-decoration: none; 26 - display: flex; 27 - align-items: center; 28 - gap: 0.5rem; 22 + font-size: 1.5rem; 23 + font-weight: 600; 24 + color: var(--text); 25 + text-decoration: none; 26 + display: flex; 27 + align-items: center; 28 + gap: 0.5rem; 29 29 } 30 30 31 31 .site-title:hover { 32 - color: var(--primary); 32 + color: var(--primary); 33 33 }
+44 -44
src/styles/main.css
··· 1 - @import url('./buttons.css'); 2 - @import url('./header.css'); 1 + @import url("./buttons.css"); 2 + @import url("./header.css"); 3 3 4 4 :root { 5 - /* Color palette */ 6 - --gunmetal: #2d3142ff; 7 - --paynes-gray: #4f5d75ff; 8 - --silver: #bfc0c0ff; 9 - --off-white: #fcf6f1; 10 - --coral: #ef8354ff; 11 - --success-green: #16a34a; 5 + /* Color palette */ 6 + --gunmetal: #2d3142ff; 7 + --paynes-gray: #4f5d75ff; 8 + --silver: #bfc0c0ff; 9 + --off-white: #fcf6f1; 10 + --coral: #ef8354ff; 11 + --success-green: #16a34a; 12 12 13 - /* Semantic color assignments */ 14 - --text: var(--gunmetal); 15 - --background: var(--off-white); 16 - --primary: var(--paynes-gray); 17 - --secondary: var(--silver); 18 - --accent: var(--coral); 19 - --success: var(--success-green); 13 + /* Semantic color assignments */ 14 + --text: var(--gunmetal); 15 + --background: var(--off-white); 16 + --primary: var(--paynes-gray); 17 + --secondary: var(--silver); 18 + --accent: var(--coral); 19 + --success: var(--success-green); 20 20 } 21 21 22 22 body { 23 - font-family: 'Charter', 'Bitstream Charter', 'Sitka Text', Cambria, serif; 24 - font-weight: 400; 25 - margin: 0; 26 - padding: 2rem; 27 - line-height: 1.6; 28 - background: var(--background); 29 - color: var(--text); 23 + font-family: "Charter", "Bitstream Charter", "Sitka Text", Cambria, serif; 24 + font-weight: 400; 25 + margin: 0; 26 + padding: 2rem; 27 + line-height: 1.6; 28 + background: var(--background); 29 + color: var(--text); 30 30 } 31 31 32 32 h1, ··· 34 34 h3, 35 35 h4, 36 36 h5 { 37 - font-family: 'Charter', 'Bitstream Charter', 'Sitka Text', Cambria, serif; 38 - font-weight: 600; 39 - line-height: 1.2; 40 - color: var(--text); 37 + font-family: "Charter", "Bitstream Charter", "Sitka Text", Cambria, serif; 38 + font-weight: 600; 39 + line-height: 1.2; 40 + color: var(--text); 41 41 } 42 42 43 43 html { 44 - font-size: 100%; 44 + font-size: 100%; 45 45 } 46 46 47 47 /* 16px */ 48 48 49 49 h1 { 50 - font-size: 4.210rem; 51 - /* 67.36px */ 52 - margin-top: 0; 50 + font-size: 4.21rem; 51 + /* 67.36px */ 52 + margin-top: 0; 53 53 } 54 54 55 55 h2 { 56 - font-size: 3.158rem; 57 - /* 50.56px */ 56 + font-size: 3.158rem; 57 + /* 50.56px */ 58 58 } 59 59 60 60 h3 { 61 - font-size: 2.369rem; 62 - /* 37.92px */ 61 + font-size: 2.369rem; 62 + /* 37.92px */ 63 63 } 64 64 65 65 h4 { 66 - font-size: 1.777rem; 67 - /* 28.48px */ 66 + font-size: 1.777rem; 67 + /* 28.48px */ 68 68 } 69 69 70 70 h5 { 71 - font-size: 1.333rem; 72 - /* 21.28px */ 71 + font-size: 1.333rem; 72 + /* 21.28px */ 73 73 } 74 74 75 75 small { 76 - font-size: 0.750rem; 77 - /* 12px */ 76 + font-size: 0.75rem; 77 + /* 12px */ 78 78 } 79 79 80 80 main { 81 - margin: 0 auto; 82 - max-width: 48rem; 83 - } 81 + margin: 0 auto; 82 + max-width: 48rem; 83 + }