🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: move transcript into sepperate component and add modal in admin page

+971 -285
+469
src/components/transcript-view-modal.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + import "./vtt-viewer.ts"; 4 + 5 + interface TranscriptDetails { 6 + id: string; 7 + original_filename: string; 8 + status: string; 9 + created_at: number; 10 + completed_at: number | null; 11 + error_message: string | null; 12 + user_id: string; 13 + user_email: string; 14 + user_name: string | null; 15 + vtt_content: string | null; 16 + } 17 + 18 + @customElement("transcript-modal") 19 + export class TranscriptViewModal extends LitElement { 20 + @property({ type: String }) transcriptId: string | null = null; 21 + @state() private transcript: TranscriptDetails | null = null; 22 + @state() private loading = false; 23 + @state() private error: string | null = null; 24 + private wasOpen = false; 25 + 26 + static override styles = css` 27 + :host { 28 + display: none; 29 + position: fixed; 30 + top: 0; 31 + left: 0; 32 + right: 0; 33 + bottom: 0; 34 + background: rgba(0, 0, 0, 0.5); 35 + z-index: 1000; 36 + align-items: center; 37 + justify-content: center; 38 + padding: 2rem; 39 + } 40 + 41 + :host([open]) { 42 + display: flex; 43 + } 44 + 45 + .modal-content { 46 + background: var(--background); 47 + border-radius: 8px; 48 + max-width: 50rem; 49 + width: 100%; 50 + max-height: 80vh; 51 + display: flex; 52 + flex-direction: column; 53 + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3); 54 + } 55 + 56 + .modal-header { 57 + padding: 1.5rem; 58 + border-bottom: 2px solid var(--secondary); 59 + display: flex; 60 + justify-content: space-between; 61 + align-items: center; 62 + flex-shrink: 0; 63 + } 64 + 65 + .modal-title { 66 + font-size: 1.5rem; 67 + font-weight: 600; 68 + color: var(--text); 69 + margin: 0; 70 + } 71 + 72 + .modal-close { 73 + background: transparent; 74 + border: none; 75 + font-size: 1.5rem; 76 + cursor: pointer; 77 + color: var(--text); 78 + padding: 0; 79 + width: 2rem; 80 + height: 2rem; 81 + display: flex; 82 + align-items: center; 83 + justify-content: center; 84 + border-radius: 4px; 85 + transition: background 0.2s; 86 + } 87 + 88 + .modal-close:hover { 89 + background: var(--secondary); 90 + } 91 + 92 + .modal-body { 93 + padding: 1.5rem; 94 + overflow-y: auto; 95 + flex: 1; 96 + } 97 + 98 + .detail-section { 99 + margin-bottom: 2rem; 100 + } 101 + 102 + .detail-section:last-child { 103 + margin-bottom: 0; 104 + } 105 + 106 + .detail-section-title { 107 + font-size: 1.125rem; 108 + font-weight: 600; 109 + color: var(--text); 110 + margin-bottom: 1rem; 111 + padding-bottom: 0.5rem; 112 + border-bottom: 2px solid var(--secondary); 113 + } 114 + 115 + .detail-row { 116 + display: flex; 117 + justify-content: space-between; 118 + align-items: center; 119 + padding: 0.75rem 0; 120 + border-bottom: 1px solid var(--secondary); 121 + } 122 + 123 + .detail-row:last-child { 124 + border-bottom: none; 125 + } 126 + 127 + .detail-label { 128 + font-weight: 500; 129 + color: var(--text); 130 + } 131 + 132 + .detail-value { 133 + color: var(--text); 134 + opacity: 0.8; 135 + } 136 + 137 + .status-badge { 138 + display: inline-block; 139 + padding: 0.25rem 0.75rem; 140 + border-radius: 4px; 141 + font-size: 0.875rem; 142 + font-weight: 500; 143 + } 144 + 145 + .status-completed { 146 + background: #dcfce7; 147 + color: #166534; 148 + } 149 + 150 + .status-processing, 151 + .status-uploading { 152 + background: #fef3c7; 153 + color: #92400e; 154 + } 155 + 156 + .status-failed { 157 + background: #fee2e2; 158 + color: #991b1b; 159 + } 160 + 161 + .status-pending { 162 + background: #e0e7ff; 163 + color: #3730a3; 164 + } 165 + 166 + .user-info { 167 + display: flex; 168 + align-items: center; 169 + gap: 0.5rem; 170 + } 171 + 172 + .user-avatar { 173 + width: 2rem; 174 + height: 2rem; 175 + border-radius: 50%; 176 + } 177 + 178 + .transcript-text { 179 + background: color-mix(in srgb, var(--primary) 5%, transparent); 180 + border: 2px solid var(--secondary); 181 + border-radius: 6px; 182 + padding: 1rem; 183 + font-family: monospace; 184 + font-size: 0.875rem; 185 + line-height: 1.6; 186 + white-space: pre-wrap; 187 + color: var(--text); 188 + max-height: 30rem; 189 + overflow-y: auto; 190 + } 191 + 192 + .loading, .error { 193 + text-align: center; 194 + padding: 2rem; 195 + } 196 + 197 + .error { 198 + color: #dc2626; 199 + } 200 + 201 + .empty-state { 202 + text-align: center; 203 + padding: 2rem; 204 + color: var(--text); 205 + opacity: 0.6; 206 + background: rgba(0, 0, 0, 0.02); 207 + border-radius: 4px; 208 + } 209 + 210 + .btn-danger { 211 + background: #dc2626; 212 + color: white; 213 + padding: 0.5rem 1rem; 214 + border: none; 215 + border-radius: 4px; 216 + cursor: pointer; 217 + font-size: 1rem; 218 + font-weight: 500; 219 + font-family: inherit; 220 + transition: all 0.2s; 221 + } 222 + 223 + .btn-danger:hover { 224 + background: #b91c1c; 225 + } 226 + 227 + .btn-danger:disabled { 228 + opacity: 0.5; 229 + cursor: not-allowed; 230 + } 231 + 232 + .modal-footer { 233 + padding: 1.5rem; 234 + border-top: 2px solid var(--secondary); 235 + display: flex; 236 + justify-content: flex-end; 237 + gap: 0.5rem; 238 + flex-shrink: 0; 239 + } 240 + 241 + .audio-player { 242 + margin-bottom: 1rem; 243 + } 244 + 245 + .audio-player audio { 246 + width: 100%; 247 + } 248 + `; 249 + 250 + override connectedCallback() { 251 + super.connectedCallback(); 252 + if (this.transcriptId) { 253 + this.loadTranscriptDetails(); 254 + } 255 + } 256 + 257 + override updated(changedProperties: Map<string, unknown>) { 258 + if (changedProperties.has("transcriptId") && this.transcriptId) { 259 + this.loadTranscriptDetails(); 260 + } 261 + 262 + // If the host loses the [open] attribute, stop any playback inside the modal 263 + const isOpen = this.hasAttribute("open"); 264 + if (this.wasOpen && !isOpen) { 265 + this.stopAudioPlayback(); 266 + } 267 + this.wasOpen = isOpen; 268 + } 269 + 270 + private async loadTranscriptDetails() { 271 + if (!this.transcriptId) return; 272 + 273 + this.loading = true; 274 + this.error = null; 275 + 276 + try { 277 + // Fetch transcript details 278 + const [detailsRes, vttRes] = await Promise.all([ 279 + fetch(`/api/admin/transcriptions/${this.transcriptId}/details`), 280 + fetch(`/api/transcriptions/${this.transcriptId}?format=vtt`).catch(() => null), 281 + ]); 282 + 283 + if (!detailsRes.ok) { 284 + throw new Error("Failed to load transcript details"); 285 + } 286 + 287 + const vttContent = vttRes?.ok ? await vttRes.text() : null; 288 + 289 + // Get basic info from database 290 + const info = await detailsRes.json(); 291 + 292 + this.transcript = { 293 + id: this.transcriptId, 294 + original_filename: info?.original_filename || "Unknown", 295 + status: info?.status || "unknown", 296 + created_at: info?.created_at || 0, 297 + completed_at: info?.completed_at || null, 298 + error_message: info?.error_message || null, 299 + user_id: info?.user_id || "", 300 + user_email: info?.user_email || "", 301 + user_name: info?.user_name || null, 302 + vtt_content: vttContent, 303 + }; 304 + } catch (err) { 305 + this.error = err instanceof Error ? err.message : "Failed to load transcript details"; 306 + this.transcript = null; 307 + } finally { 308 + this.loading = false; 309 + } 310 + } 311 + 312 + private close() { 313 + this.stopAudioPlayback(); 314 + this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); 315 + } 316 + 317 + private formatTimestamp(timestamp: number) { 318 + const date = new Date(timestamp * 1000); 319 + return date.toLocaleString(); 320 + } 321 + 322 + private stopAudioPlayback() { 323 + try { 324 + // stop audio inside this modal's shadow root 325 + const aud = this.shadowRoot?.querySelector('audio') as HTMLAudioElement | null; 326 + if (aud) { 327 + aud.pause(); 328 + try { aud.currentTime = 0; } catch (e) { /* ignore */ } 329 + } 330 + 331 + // Also stop any audio elements in light DOM that match the transcript audio id 332 + if (this.transcript) { 333 + const id = `audio-${this.transcript.id}`; 334 + const outside = document.getElementById(id) as HTMLAudioElement | null; 335 + if (outside && outside !== aud) { 336 + outside.pause(); 337 + try { outside.currentTime = 0; } catch (e) { /* ignore */ } 338 + } 339 + } 340 + } catch (e) { 341 + // ignore 342 + } 343 + } 344 + 345 + private async handleDelete() { 346 + if (!confirm("Are you sure you want to delete this transcription? This cannot be undone.")) { 347 + return; 348 + } 349 + 350 + try { 351 + const res = await fetch(`/api/admin/transcriptions/${this.transcriptId}`, { 352 + method: "DELETE", 353 + }); 354 + 355 + if (!res.ok) { 356 + throw new Error("Failed to delete transcription"); 357 + } 358 + 359 + this.dispatchEvent(new CustomEvent("transcript-deleted", { bubbles: true, composed: true })); 360 + this.close(); 361 + } catch { 362 + alert("Failed to delete transcription"); 363 + } 364 + } 365 + 366 + override render() { 367 + return html` 368 + <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}> 369 + <div class="modal-header"> 370 + <h2 class="modal-title">Transcription Details</h2> 371 + <button class="modal-close" @click=${this.close} aria-label="Close">&times;</button> 372 + </div> 373 + <div class="modal-body"> 374 + ${this.loading ? html`<div class="loading">Loading...</div>` : ""} 375 + ${this.error ? html`<div class="error">${this.error}</div>` : ""} 376 + ${this.transcript ? this.renderTranscriptDetails() : ""} 377 + </div> 378 + ${this.transcript 379 + ? html` 380 + <div class="modal-footer"> 381 + <button class="btn-danger" @click=${this.handleDelete}>Delete Transcription</button> 382 + </div> 383 + ` 384 + : ""} 385 + </div> 386 + `; 387 + } 388 + 389 + private renderTranscriptDetails() { 390 + if (!this.transcript) return ""; 391 + 392 + return html` 393 + <div class="detail-section"> 394 + <h3 class="detail-section-title">File Information</h3> 395 + <div class="detail-row"> 396 + <span class="detail-label">File Name</span> 397 + <span class="detail-value">${this.transcript.original_filename}</span> 398 + </div> 399 + <div class="detail-row"> 400 + <span class="detail-label">Status</span> 401 + <span class="status-badge status-${this.transcript.status}">${this.transcript.status}</span> 402 + </div> 403 + <div class="detail-row"> 404 + <span class="detail-label">Created At</span> 405 + <span class="detail-value">${this.formatTimestamp(this.transcript.created_at)}</span> 406 + </div> 407 + ${this.transcript.completed_at 408 + ? html` 409 + <div class="detail-row"> 410 + <span class="detail-label">Completed At</span> 411 + <span class="detail-value">${this.formatTimestamp(this.transcript.completed_at)}</span> 412 + </div> 413 + ` 414 + : ""} 415 + ${this.transcript.error_message 416 + ? html` 417 + <div class="detail-row"> 418 + <span class="detail-label">Error Message</span> 419 + <span class="detail-value" style="color: #dc2626;">${this.transcript.error_message}</span> 420 + </div> 421 + ` 422 + : ""} 423 + </div> 424 + 425 + <div class="detail-section"> 426 + <h3 class="detail-section-title">User Information</h3> 427 + <div class="detail-row"> 428 + <span class="detail-label">User</span> 429 + <div class="user-info"> 430 + <img 431 + src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.transcript.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 432 + alt="Avatar" 433 + class="user-avatar" 434 + /> 435 + <span>${this.transcript.user_name || this.transcript.user_email}</span> 436 + </div> 437 + </div> 438 + <div class="detail-row"> 439 + <span class="detail-label">Email</span> 440 + <span class="detail-value">${this.transcript.user_email}</span> 441 + </div> 442 + </div> 443 + 444 + ${this.transcript.status === "completed" 445 + ? html` 446 + <div class="detail-section"> 447 + <h3 class="detail-section-title">Audio</h3> 448 + <div class="audio-player"> 449 + <audio id="audio-${this.transcript.id}" controls src="/api/transcriptions/${this.transcript.id}/audio"></audio> 450 + </div> 451 + </div> 452 + ` 453 + : ""} 454 + 455 + <div class="detail-section"> 456 + <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>`} 460 + </div> 461 + `; 462 + } 463 + } 464 + 465 + declare global { 466 + interface HTMLElementTagNameMap { 467 + "transcript-modal": TranscriptViewModal; 468 + } 469 + }
+7 -170
src/components/transcription.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 - import { parseVTT } from "../lib/vtt-cleaner"; 3 + import "./vtt-viewer.ts"; 4 4 5 5 interface TranscriptionJob { 6 6 id: string; ··· 23 23 24 24 25 25 26 - function parseVTT(vttContent: string): VTTSegment[] { 27 - const segments: VTTSegment[] = []; 28 - const lines = vttContent.split("\n"); 29 26 30 - let i = 0; 31 - // Skip WEBVTT header 32 - while (i < lines.length && lines[i]?.trim() !== "WEBVTT") { 33 - i++; 34 - } 35 - i++; // Skip WEBVTT 36 - 37 - while (i < lines.length) { 38 - let index: string | undefined; 39 - // Check for cue ID (line before timestamp) 40 - if (lines[i]?.trim() && !lines[i]?.includes("-->")) { 41 - index = lines[i]?.trim(); 42 - i++; 43 - } 44 - 45 - if (i < lines.length && lines[i]?.includes("-->")) { 46 - const [startStr, endStr] = lines[i].split("-->").map((s) => s.trim()); 47 - const start = parseVTTTimestamp(startStr || ""); 48 - const end = parseVTTTimestamp(endStr || ""); 49 - 50 - // Collect text lines until empty line 51 - const textLines: string[] = []; 52 - i++; 53 - while (i < lines.length && lines[i]?.trim()) { 54 - textLines.push(lines[i] || ""); 55 - i++; 56 - } 57 - 58 - segments.push({ 59 - start, 60 - end, 61 - text: textLines.join(" ").trim(), 62 - index, 63 - }); 64 - } else { 65 - i++; 66 - } 67 - } 68 - 69 - return segments; 70 - } 71 - 72 - function parseVTTTimestamp(timestamp: string): number { 73 - const parts = timestamp.split(":"); 74 - if (parts.length === 3) { 75 - const hours = Number.parseFloat(parts[0] || "0"); 76 - const minutes = Number.parseFloat(parts[1] || "0"); 77 - const seconds = Number.parseFloat(parts[2] || "0"); 78 - return hours * 3600 + minutes * 60 + seconds; 79 - } 80 - return 0; 81 - } 82 27 83 28 class WordStreamer { 84 29 private queue: string[] = []; ··· 469 414 // Load VTT for completed jobs 470 415 if (update.status === "completed") { 471 416 await this.loadVTTForJob(jobId); 472 - this.setupWordHighlighting(jobId); 473 417 } 474 418 } 475 419 } ··· 540 484 // Fetch VTT for completed jobs 541 485 if (job.status === "completed") { 542 486 await this.loadVTTForJob(job.id); 543 - await this.updateComplete; 544 - this.setupWordHighlighting(job.id); 545 487 } 546 488 } 547 489 // Don't override serviceAvailable - it's set by checkHealth() ··· 563 505 const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`); 564 506 if (response.ok) { 565 507 const vttContent = await response.text(); 566 - const segments = parseVTT(vttContent); 567 508 568 - // Update job with VTT content and segments 509 + // Update job with VTT content 569 510 const job = this.jobs.find((j) => j.id === jobId); 570 511 if (job) { 571 512 job.vttContent = vttContent; 572 - job.vttSegments = segments; 573 513 job.audioUrl = `/api/transcriptions/${jobId}/audio`; 574 514 this.jobs = [...this.jobs]; 575 515 } ··· 579 519 } 580 520 } 581 521 582 - private setupWordHighlighting(jobId: string) { 583 - const job = this.jobs.find((j) => j.id === jobId); 584 - if (!job?.audioUrl || !job.vttSegments) return; 585 - 586 - // Wait for next frame to ensure DOM is updated 587 - requestAnimationFrame(() => { 588 - const audioElement = this.shadowRoot?.querySelector( 589 - `#audio-${jobId}`, 590 - ) as HTMLAudioElement; 591 - const transcriptDiv = this.shadowRoot?.querySelector( 592 - `#transcript-${jobId}`, 593 - ) as HTMLDivElement; 594 - 595 - if (!audioElement || !transcriptDiv) { 596 - console.warn("Could not find audio or transcript elements"); 597 - return; 598 - } 599 - 600 - // Track current segment 601 - let currentSegmentElement: HTMLElement | null = null; 602 - 603 - // Update highlighting on timeupdate 604 - audioElement.addEventListener("timeupdate", () => { 605 - const currentTime = audioElement.currentTime; 606 - const segmentElements = transcriptDiv.querySelectorAll("[data-start]"); 607 - 608 - for (const el of segmentElements) { 609 - const start = Number.parseFloat( 610 - (el as HTMLElement).dataset.start || "0", 611 - ); 612 - const end = Number.parseFloat((el as HTMLElement).dataset.end || "0"); 613 - 614 - if (currentTime >= start && currentTime <= end) { 615 - if (currentSegmentElement !== el) { 616 - currentSegmentElement?.classList.remove("current-segment"); 617 - (el as HTMLElement).classList.add("current-segment"); 618 - currentSegmentElement = el as HTMLElement; 619 - 620 - // Auto-scroll to current segment 621 - el.scrollIntoView({ 622 - behavior: "smooth", 623 - block: "center", 624 - }); 625 - } 626 - break; 627 - } 628 - } 629 - }); 630 - 631 - // Handle segment clicks 632 - transcriptDiv.addEventListener("click", (e) => { 633 - const target = e.target as HTMLElement; 634 - if (target.dataset.start) { 635 - const start = Number.parseFloat(target.dataset.start); 636 - audioElement.currentTime = start; 637 - audioElement.play(); 638 - } 639 - }); 640 - }); 641 - } 522 + 642 523 643 524 private handleDragOver(e: DragEvent) { 644 525 e.preventDefault(); ··· 741 622 return displayed; 742 623 } 743 624 744 - const segments = parseVTT(job.vttContent); 745 - // Group segments by paragraph (extract paragraph number from ID like "Paragraph 1-1" -> "1") 746 - const paragraphGroups = new Map<string, typeof segments>(); 747 - for (const segment of segments) { 748 - const id = (segment.index || '').trim(); 749 - const match = id.match(/^Paragraph\s+(\d+)-/); 750 - const paraNum = match ? match[1] : '0'; 751 - if (!paragraphGroups.has(paraNum)) { 752 - paragraphGroups.set(paraNum, []); 753 - } 754 - paragraphGroups.get(paraNum)!.push(segment); 755 - } 756 - 757 - // Render each paragraph group 758 - const paragraphs = Array.from(paragraphGroups.entries()).map(([paraNum, groupSegments]) => { 759 - // Concatenate all text in the group 760 - const fullText = groupSegments.map(s => s.text || '').join(' '); 761 - // Split into sentences 762 - const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean); 763 - // Calculate word counts for timing 764 - const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length); 765 - const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0)); 766 - 767 - // Overall paragraph timing 768 - const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0)); 769 - const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart)); 770 - 771 - let acc = 0; 772 - const paraDuration = paraEnd - paraStart; 773 - 774 - return html`<div class="paragraph"> 775 - ${sentences.map((sent, si) => { 776 - const startOffset = (acc / totalWords) * paraDuration; 777 - acc += wordCounts[si]; 778 - const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration; 779 - const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart; 780 - const spanStart = paraStart + startOffset; 781 - const spanEnd = paraStart + endOffset; 782 - return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`; 783 - })} 784 - </div>`; 785 - }); 786 - 787 - return html`${paragraphs}`; 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>`; 788 627 } 789 628 790 629 ··· 835 674 } 836 675 837 676 ${ 838 - job.status === "completed" && job.audioUrl && job.vttSegments 677 + job.status === "completed" && job.audioUrl && job.vttContent 839 678 ? html` 840 679 <div class="audio-player"> 841 680 <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio> 842 681 </div> 843 - <div class="job-transcript" id="transcript-${job.id}"> 844 - ${this.renderTranscript(job)} 845 - </div> 682 + ${this.renderTranscript(job)} 846 683 ` 847 684 : this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id) 848 685 ? html`
+268
src/components/vtt-viewer.ts
··· 1 + import { LitElement, html, css } from "lit"; 2 + import { customElement, property } from "lit/decorators.js"; 3 + 4 + interface VTTSegment { 5 + start: number; 6 + end: number; 7 + text: string; 8 + index?: string; 9 + } 10 + 11 + function parseVTT(vttContent: string): VTTSegment[] { 12 + const segments: VTTSegment[] = []; 13 + const lines = vttContent.split("\n"); 14 + 15 + let i = 0; 16 + // Skip WEBVTT header if present 17 + while (i < lines.length && (lines[i] || "").trim() !== "WEBVTT") { 18 + i++; 19 + } 20 + if (i < lines.length) i++; // advance past header if found 21 + 22 + while (i < lines.length) { 23 + let index: string | undefined; 24 + let line = lines[i] || ""; 25 + 26 + // Check for cue ID (line before timestamp) 27 + if (line.trim() && !line.includes("-->")) { 28 + index = line.trim(); 29 + i++; 30 + line = lines[i] || ""; 31 + } 32 + 33 + if (line.includes("-->")) { 34 + const parts = line.split("-->").map((s) => s.trim()); 35 + const start = parseVTTTimestamp(parts[0] ?? ""); 36 + const end = parseVTTTimestamp(parts[1] ?? ""); 37 + 38 + // Collect text lines until empty line 39 + const textLines: string[] = []; 40 + i++; 41 + while (i < lines.length && (lines[i] || "").trim()) { 42 + textLines.push(lines[i] || ""); 43 + i++; 44 + } 45 + 46 + segments.push({ 47 + start, 48 + end, 49 + text: textLines.join(" ").trim(), 50 + index, 51 + }); 52 + } else { 53 + i++; 54 + } 55 + } 56 + 57 + return segments; 58 + } 59 + 60 + function parseVTTTimestamp(timestamp?: string): number { 61 + const parts = (timestamp || "").split(":"); 62 + if (parts.length === 3) { 63 + const hours = Number.parseFloat(parts[0] || "0"); 64 + const minutes = Number.parseFloat(parts[1] || "0"); 65 + const seconds = Number.parseFloat(parts[2] || "0"); 66 + return hours * 3600 + minutes * 60 + seconds; 67 + } 68 + return 0; 69 + } 70 + 71 + @customElement("vtt-viewer") 72 + export class VTTViewer extends LitElement { 73 + @property({ type: String }) vttContent = ""; 74 + @property({ type: String }) audioId = ""; 75 + 76 + static override styles = css` 77 + .transcript { 78 + background: color-mix(in srgb, var(--primary) 5%, transparent); 79 + border-radius: 6px; 80 + padding: 1rem; 81 + font-family: monospace; 82 + font-size: 0.875rem; 83 + color: var(--text); 84 + line-height: 1.6; 85 + word-wrap: break-word; 86 + } 87 + 88 + .segment { 89 + cursor: pointer; 90 + transition: background 0.1s; 91 + display: inline; 92 + } 93 + 94 + .segment:hover { 95 + background: color-mix(in srgb, var(--primary) 15%, transparent); 96 + border-radius: 2px; 97 + } 98 + 99 + .current-segment { 100 + background: color-mix(in srgb, var(--accent) 30%, transparent); 101 + border-radius: 2px; 102 + } 103 + 104 + .paragraph { 105 + display: block; 106 + margin: 0 0 1rem 0; 107 + line-height: 1.6; 108 + } 109 + `; 110 + 111 + private audioElement: HTMLAudioElement | null = null; 112 + private boundTimeUpdate: ((this: HTMLAudioElement, ev: Event) => any) | null = null; 113 + private boundTranscriptClick: ((e: Event) => any) | null = null; 114 + 115 + private _viewerId = `vtt-${Math.random().toString(36).slice(2,9)}`; 116 + 117 + private findAudioElementById(id: string): HTMLAudioElement | null { 118 + let root: any = this.getRootNode(); 119 + let depth = 0; 120 + while (root && depth < 10) { 121 + if (root instanceof ShadowRoot) { 122 + const el = root.querySelector(`#${id}`) as HTMLAudioElement | null; 123 + if (el) return el; 124 + root = (root as ShadowRoot).host?.getRootNode?.(); 125 + } else if (root instanceof Document) { 126 + const byId = root.getElementById(id) as HTMLAudioElement | null; 127 + if (byId) return byId; 128 + break; 129 + } else { 130 + break; 131 + } 132 + depth++; 133 + } 134 + return null; 135 + } 136 + 137 + private setupHighlighting() { 138 + // Detach previous listeners if any 139 + this.detachHighlighting(); 140 + 141 + const audioElement = this.findAudioElementById(this.audioId); 142 + const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null; 143 + if (!audioElement || !transcriptDiv) return; 144 + 145 + // Clear any lingering highlights from prior instances 146 + transcriptDiv.querySelectorAll('.current-segment').forEach((el) => (el as HTMLElement).classList.remove('current-segment')); 147 + 148 + this.audioElement = audioElement; 149 + let currentSegmentElement: HTMLElement | null = null; 150 + 151 + this.boundTimeUpdate = () => { 152 + const currentTime = this.audioElement?.currentTime ?? 0; 153 + const segmentElements = transcriptDiv.querySelectorAll('[data-start]'); 154 + let found = false; 155 + 156 + for (const el of Array.from(segmentElements)) { 157 + const start = Number.parseFloat((el as HTMLElement).dataset.start || '0'); 158 + const end = Number.parseFloat((el as HTMLElement).dataset.end || '0'); 159 + 160 + if (currentTime >= start && currentTime <= end) { 161 + found = true; 162 + if (currentSegmentElement !== el) { 163 + currentSegmentElement?.classList.remove('current-segment'); 164 + (el as HTMLElement).classList.add('current-segment'); 165 + currentSegmentElement = el as HTMLElement; 166 + (el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' }); 167 + } 168 + break; 169 + } 170 + } 171 + 172 + // If no segment matched, clear any existing highlight 173 + if (!found && currentSegmentElement) { 174 + currentSegmentElement.classList.remove('current-segment'); 175 + currentSegmentElement = null; 176 + } 177 + }; 178 + 179 + audioElement.addEventListener('timeupdate', this.boundTimeUpdate as EventListener); 180 + 181 + this.boundTranscriptClick = (e: Event) => { 182 + const target = e.target as HTMLElement; 183 + if (target.dataset.start) { 184 + this.audioElement!.currentTime = Number.parseFloat(target.dataset.start); 185 + this.audioElement!.play(); 186 + } 187 + }; 188 + 189 + transcriptDiv.addEventListener('click', this.boundTranscriptClick); 190 + } 191 + 192 + private detachHighlighting() { 193 + try { 194 + const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null; 195 + if (this.audioElement) { 196 + // Pause playback to avoid audio continuing after the viewer is removed 197 + try { 198 + this.audioElement.pause(); 199 + } catch (e) { 200 + // ignore 201 + } 202 + if (this.boundTimeUpdate) { 203 + this.audioElement.removeEventListener('timeupdate', this.boundTimeUpdate); 204 + } 205 + } 206 + if (transcriptDiv && this.boundTranscriptClick) { 207 + transcriptDiv.removeEventListener('click', this.boundTranscriptClick); 208 + } 209 + } finally { 210 + this.audioElement = null; 211 + this.boundTimeUpdate = null; 212 + this.boundTranscriptClick = null; 213 + } 214 + } 215 + 216 + override disconnectedCallback() { 217 + this.detachHighlighting(); 218 + super.disconnectedCallback && super.disconnectedCallback(); 219 + } 220 + 221 + override updated(changed: Map<string, any>) { 222 + super.updated(changed); 223 + if (changed.has('vttContent') || changed.has('audioId')) { 224 + this.setupHighlighting(); 225 + } 226 + } 227 + 228 + private renderFromVTT() { 229 + if (!this.vttContent) return html``; 230 + const segments = parseVTT(this.vttContent); 231 + const paragraphGroups = new Map<string, VTTSegment[]>(); 232 + 233 + for (const segment of segments) { 234 + const id = (segment.index || "").trim(); 235 + const match = id.match(/^Paragraph\s+(\d+)-/); 236 + const paraNum = match ? match[1] : '0'; 237 + if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []); 238 + paragraphGroups.get(paraNum)!.push(segment); 239 + } 240 + 241 + const paragraphs = Array.from(paragraphGroups.entries()).map(([_, groupSegments]) => { 242 + const fullText = groupSegments.map(s => s.text || '').join(' '); 243 + const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean); 244 + const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length); 245 + const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0)); 246 + const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0)); 247 + const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart)); 248 + let acc = 0; 249 + const paraDuration = paraEnd - paraStart; 250 + 251 + return html`<div class="paragraph">${sentences.map((sent, si) => { 252 + const startOffset = (acc / totalWords) * paraDuration; 253 + acc += wordCounts[si]; 254 + const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration; 255 + const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart; 256 + const spanStart = paraStart + startOffset; 257 + const spanEnd = paraStart + endOffset; 258 + return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`; 259 + })}</div>`; 260 + }); 261 + 262 + return html`${paragraphs}`; 263 + } 264 + 265 + override render() { 266 + return html`<div class="transcript">${this.renderFromVTT()}</div>`; 267 + } 268 + }
+114 -48
src/index.ts
··· 43 43 type TranscriptionUpdate, 44 44 WhisperServiceManager, 45 45 } from "./lib/transcription"; 46 - import { getTranscript, getTranscriptVTT } from "./lib/transcript-storage"; 46 + import { getTranscriptVTT } from "./lib/transcript-storage"; 47 47 import indexHTML from "./pages/index.html"; 48 48 import adminHTML from "./pages/admin.html"; 49 49 import settingsHTML from "./pages/settings.html"; ··· 83 83 } 84 84 85 85 // Periodic sync every 5 minutes as backup (SSE handles real-time updates) 86 - setInterval(async () => { 87 - try { 88 - await whisperService.syncWithWhisper(); 89 - } catch (error) { 90 - console.warn( 91 - "[Sync] Failed to sync with Murmur:", 92 - error instanceof Error ? error.message : "Unknown error", 93 - ); 94 - } 95 - }, 5 * 60 * 1000); 86 + setInterval( 87 + async () => { 88 + try { 89 + await whisperService.syncWithWhisper(); 90 + } catch (error) { 91 + console.warn( 92 + "[Sync] Failed to sync with Murmur:", 93 + error instanceof Error ? error.message : "Unknown error", 94 + ); 95 + } 96 + }, 97 + 5 * 60 * 1000, 98 + ); 96 99 97 100 // Clean up stale files daily 98 101 setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000); ··· 652 655 progress: number; 653 656 }, 654 657 [string] 655 - >( 656 - "SELECT status, progress FROM transcriptions WHERE id = ?", 657 - ) 658 + >("SELECT status, progress FROM transcriptions WHERE id = ?") 658 659 .get(transcriptionId); 659 660 if (current) { 660 - // Load transcript from file if completed 661 - let transcript: string | undefined; 662 - if (current.status === "completed") { 663 - transcript = (await getTranscript(transcriptionId)) || undefined; 664 - } 665 661 sendEvent({ 666 662 status: current.status as TranscriptionUpdate["status"], 667 663 progress: current.progress, 668 - transcript, 669 664 }); 670 665 } 671 666 // If already complete, close immediately ··· 737 732 const user = requireAuth(req); 738 733 const transcriptionId = req.params.id; 739 734 740 - // Verify ownership 735 + // Verify ownership or admin 741 736 const transcription = db 742 737 .query< 743 738 { 744 739 id: string; 745 740 user_id: number; 741 + filename: string; 742 + original_filename: string; 746 743 status: string; 747 - original_filename: string; 744 + progress: number; 745 + created_at: number; 748 746 }, 749 747 [string] 750 748 >( 751 - "SELECT id, user_id, status, original_filename FROM transcriptions WHERE id = ?", 749 + "SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 752 750 ) 753 751 .get(transcriptionId); 754 752 755 - if (!transcription || transcription.user_id !== user.id) { 753 + if (!transcription) { 754 + return Response.json( 755 + { error: "Transcription not found" }, 756 + { status: 404 }, 757 + ); 758 + } 759 + 760 + // Allow access if user owns it or is admin 761 + if (transcription.user_id !== user.id && user.role !== "admin") { 756 762 return Response.json( 757 763 { error: "Transcription not found" }, 758 764 { status: 404 }, ··· 789 795 }); 790 796 } 791 797 792 - // Default: return plain text transcript from file 793 - const transcript = await getTranscript(transcriptionId); 794 - if (!transcript) { 795 - return Response.json( 796 - { error: "Transcript not available" }, 797 - { status: 404 }, 798 - ); 798 + // return info on transcript 799 + 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, 799 805 } 800 - 801 - return new Response(transcript, { 806 + return new Response(JSON.stringify(transcript), { 802 807 headers: { 803 - "Content-Type": "text/plain", 808 + "Content-Type": "application/json", 804 809 }, 805 810 }); 806 811 } catch (error) { ··· 814 819 const user = requireAuth(req); 815 820 const transcriptionId = req.params.id; 816 821 817 - // Verify ownership and get filename 822 + // Verify ownership or admin 818 823 const transcription = db 819 824 .query< 820 825 { ··· 824 829 status: string; 825 830 }, 826 831 [string] 827 - >("SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?") 832 + >( 833 + "SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?", 834 + ) 828 835 .get(transcriptionId); 829 836 830 - if (!transcription || transcription.user_id !== user.id) { 837 + if (!transcription) { 838 + return Response.json( 839 + { error: "Transcription not found" }, 840 + { status: 404 }, 841 + ); 842 + } 843 + 844 + // Allow access if user owns it or is admin 845 + if (transcription.user_id !== user.id && user.role !== "admin") { 831 846 return Response.json( 832 847 { error: "Transcription not found" }, 833 848 { status: 404 }, ··· 846 861 const file = Bun.file(filePath); 847 862 848 863 if (!(await file.exists())) { 849 - return Response.json({ error: "Audio file not found" }, { status: 404 }); 864 + return Response.json( 865 + { error: "Audio file not found" }, 866 + { status: 404 }, 867 + ); 850 868 } 851 869 852 870 const fileSize = file.size; ··· 909 927 // Load transcripts from files for completed jobs 910 928 const jobs = await Promise.all( 911 929 transcriptions.map(async (t) => { 912 - let transcript: string | null = null; 913 - if (t.status === "completed") { 914 - transcript = await getTranscript(t.id); 915 - } 916 930 return { 917 931 id: t.id, 918 932 filename: t.original_filename, 919 933 status: t.status, 920 934 progress: t.progress, 921 - transcript, 922 935 created_at: t.created_at, 923 936 }; 924 937 }), ··· 1101 1114 const sessions = getSessionsForUser(userId); 1102 1115 1103 1116 // Get transcription count 1104 - const transcriptionCount = db 1105 - .query<{ count: number }, [number]>( 1106 - "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1107 - ) 1108 - .get(userId)?.count ?? 0; 1117 + const transcriptionCount = 1118 + db 1119 + .query<{ count: number }, [number]>( 1120 + "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1121 + ) 1122 + .get(userId)?.count ?? 0; 1109 1123 1110 1124 return Response.json({ 1111 1125 id: user.id, ··· 1295 1309 } 1296 1310 1297 1311 return Response.json({ success: true }); 1312 + } catch (error) { 1313 + return handleError(error); 1314 + } 1315 + }, 1316 + }, 1317 + "/api/admin/transcriptions/:id/details": { 1318 + GET: async (req) => { 1319 + try { 1320 + requireAdmin(req); 1321 + const transcriptionId = req.params.id; 1322 + 1323 + const transcription = db 1324 + .query< 1325 + { 1326 + id: string; 1327 + original_filename: string; 1328 + status: string; 1329 + created_at: number; 1330 + updated_at: number; 1331 + error_message: string | null; 1332 + user_id: number; 1333 + }, 1334 + [string] 1335 + >( 1336 + "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 1337 + ) 1338 + .get(transcriptionId); 1339 + 1340 + if (!transcription) { 1341 + return Response.json( 1342 + { error: "Transcription not found" }, 1343 + { status: 404 }, 1344 + ); 1345 + } 1346 + 1347 + const user = db 1348 + .query<{ email: string; name: string | null }, [number]>( 1349 + "SELECT email, name FROM users WHERE id = ?", 1350 + ) 1351 + .get(transcription.user_id); 1352 + 1353 + return Response.json({ 1354 + id: transcription.id, 1355 + original_filename: transcription.original_filename, 1356 + status: transcription.status, 1357 + created_at: transcription.created_at, 1358 + completed_at: transcription.updated_at, 1359 + error_message: transcription.error_message, 1360 + user_id: transcription.user_id, 1361 + user_email: user?.email || "Unknown", 1362 + user_name: user?.name || null, 1363 + }); 1298 1364 } catch (error) { 1299 1365 return handleError(error); 1300 1366 }
-4
src/lib/transcript-storage.test.ts
··· 1 1 import { expect, test } from "bun:test"; 2 2 import { 3 - deleteTranscript, 4 - getTranscript, 5 3 getTranscriptVTT, 6 - hasTranscript, 7 - saveTranscript, 8 4 saveTranscriptVTT, 9 5 } from "./transcript-storage"; 10 6
-50
src/lib/transcript-storage.ts
··· 1 1 // File-based transcript storage to avoid SQLite size limits 2 2 3 - import { unlinkSync } from "node:fs"; 4 3 import { basename } from "node:path"; 5 4 6 5 const TRANSCRIPTS_DIR = "./transcripts"; ··· 23 22 throw new Error("Invalid transcription ID: path traversal detected"); 24 23 } 25 24 return safeName; 26 - } 27 - 28 - /** 29 - * Write transcript to file system 30 - */ 31 - export async function saveTranscript( 32 - transcriptionId: string, 33 - transcript: string, 34 - ): Promise<void> { 35 - const safeId = validateTranscriptionId(transcriptionId); 36 - const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`; 37 - await Bun.write(filePath, transcript); 38 - } 39 - 40 - /** 41 - * Read transcript from file system 42 - */ 43 - export async function getTranscript( 44 - transcriptionId: string, 45 - ): Promise<string | null> { 46 - const safeId = validateTranscriptionId(transcriptionId); 47 - const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`; 48 - try { 49 - return await Bun.file(filePath).text(); 50 - } catch { 51 - return null; 52 - } 53 - } 54 - 55 - /** 56 - * Delete transcript file 57 - */ 58 - export async function deleteTranscript(transcriptionId: string): Promise<void> { 59 - const safeId = validateTranscriptionId(transcriptionId); 60 - const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`; 61 - try { 62 - unlinkSync(filePath); 63 - } catch { 64 - // File doesn't exist or already deleted 65 - } 66 - } 67 - 68 - /** 69 - * Check if transcript exists 70 - */ 71 - export async function hasTranscript(transcriptionId: string): Promise<boolean> { 72 - const safeId = validateTranscriptionId(transcriptionId); 73 - const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`; 74 - return await Bun.file(filePath).exists(); 75 25 } 76 26 77 27 /**
+113 -13
src/pages/admin.html
··· 274 274 cursor: not-allowed; 275 275 } 276 276 277 - tbody tr { 277 + .users-table tbody tr { 278 278 cursor: pointer; 279 279 } 280 280 281 - tbody tr:hover { 281 + .users-table tbody tr:hover { 282 + background: rgba(0, 0, 0, 0.04); 283 + } 284 + 285 + .transcriptions-table tbody tr { 286 + cursor: pointer; 287 + } 288 + 289 + .transcriptions-table tbody tr:hover { 282 290 background: rgba(0, 0, 0, 0.04); 283 291 } 284 292 ··· 370 378 <div id="transcriptions-tab" class="tab-content active"> 371 379 <div class="section"> 372 380 <h2 class="section-title">All Transcriptions</h2> 373 - <div id="transcriptions-table"></div> 381 + <input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." /> 382 + <div id="transcriptions-table" class="transcriptions-table"></div> 374 383 </div> 375 384 </div> 376 385 ··· 378 387 <div class="section"> 379 388 <h2 class="section-title">All Users</h2> 380 389 <input type="text" id="user-search" class="search" placeholder="Search by name or email..." /> 381 - <div id="users-table"></div> 390 + <div id="users-table" class="users-table"></div> 382 391 </div> 383 392 </div> 384 393 </div> 385 394 </main> 386 395 387 396 <user-modal id="user-modal"></user-modal> 397 + <transcript-modal id="transcript-modal"></transcript-modal> 388 398 389 399 <script type="module" src="../components/auth.ts"></script> 390 400 <script type="module" src="../components/user-modal.ts"></script> 401 + <script type="module" src="../components/transcript-view-modal.ts"></script> 391 402 <script type="module"> 392 403 const errorMessage = document.getElementById('error-message'); 393 404 const loading = document.getElementById('loading'); ··· 395 406 const transcriptionsTable = document.getElementById('transcriptions-table'); 396 407 const usersTable = document.getElementById('users-table'); 397 408 const userModal = document.getElementById('user-modal'); 409 + const transcriptModal = document.getElementById('transcript-modal'); 398 410 399 411 let currentUserEmail = null; 400 412 let allUsers = []; 413 + let allTranscriptions = []; 401 414 let userSortKey = 'created_at'; 402 415 let userSortDirection = 'desc'; 403 416 let userSearchTerm = ''; 417 + let transcriptSortKey = 'created_at'; 418 + let transcriptSortDirection = 'desc'; 419 + let transcriptSearchTerm = ''; 404 420 405 421 // Get current user info 406 422 async function getCurrentUser() { ··· 437 453 userModal.userId = null; 438 454 } 439 455 456 + function openTranscriptModal(transcriptId) { 457 + transcriptModal.setAttribute('open', ''); 458 + transcriptModal.transcriptId = transcriptId; 459 + } 460 + 461 + function closeTranscriptModal() { 462 + transcriptModal.removeAttribute('open'); 463 + transcriptModal.transcriptId = null; 464 + } 465 + 440 466 // Listen for modal close and user update events 441 467 userModal.addEventListener('close', closeUserModal); 442 468 userModal.addEventListener('user-updated', () => loadData()); ··· 446 472 } 447 473 }); 448 474 475 + // Listen for transcript modal events 476 + transcriptModal.addEventListener('close', closeTranscriptModal); 477 + transcriptModal.addEventListener('transcript-deleted', () => loadData()); 478 + transcriptModal.addEventListener('click', (e) => { 479 + if (e.target === transcriptModal) { 480 + closeTranscriptModal(); 481 + } 482 + }); 483 + 449 484 450 485 function renderTranscriptions(transcriptions) { 451 - if (transcriptions.length === 0) { 452 - transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>'; 486 + allTranscriptions = transcriptions; 487 + 488 + // Filter transcriptions based on search term 489 + let filteredTranscriptions = transcriptions.filter(t => { 490 + if (!transcriptSearchTerm) return true; 491 + const term = transcriptSearchTerm.toLowerCase(); 492 + const filename = (t.original_filename || '').toLowerCase(); 493 + const userName = (t.user_name || '').toLowerCase(); 494 + const userEmail = (t.user_email || '').toLowerCase(); 495 + return filename.includes(term) || userName.includes(term) || userEmail.includes(term); 496 + }); 497 + 498 + // Sort transcriptions 499 + filteredTranscriptions.sort((a, b) => { 500 + let aVal = a[transcriptSortKey]; 501 + let bVal = b[transcriptSortKey]; 502 + 503 + // Handle null values 504 + if (aVal === null || aVal === undefined) aVal = ''; 505 + if (bVal === null || bVal === undefined) bVal = ''; 506 + 507 + let comparison = 0; 508 + if (typeof aVal === 'string' && typeof bVal === 'string') { 509 + comparison = aVal.localeCompare(bVal); 510 + } else if (typeof aVal === 'number' && typeof bVal === 'number') { 511 + comparison = aVal - bVal; 512 + } else { 513 + comparison = String(aVal).localeCompare(String(bVal)); 514 + } 515 + 516 + return transcriptSortDirection === 'asc' ? comparison : -comparison; 517 + }); 518 + 519 + if (filteredTranscriptions.length === 0) { 520 + transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>'; 453 521 return; 454 522 } 455 523 ··· 460 528 table.innerHTML = ` 461 529 <thead> 462 530 <tr> 463 - <th>File Name</th> 531 + <th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</th> 464 532 <th>User</th> 465 - <th>Status</th> 466 - <th>Created At</th> 467 - <th>Error</th> 533 + <th class="sortable ${transcriptSortKey === 'status' ? transcriptSortDirection : ''}" data-sort="status">Status</th> 534 + <th class="sortable ${transcriptSortKey === 'created_at' ? transcriptSortDirection : ''}" data-sort="created_at">Created At</th> 468 535 <th>Actions</th> 469 536 </tr> 470 537 </thead> 471 538 <tbody> 472 - ${transcriptions.map(t => ` 473 - <tr> 539 + ${filteredTranscriptions.map(t => ` 540 + <tr data-id="${t.id}"> 474 541 <td>${t.original_filename}</td> 475 542 <td> 476 543 <div class="user-info"> ··· 484 551 </td> 485 552 <td><span class="status-badge status-${t.status}">${t.status}</span></td> 486 553 <td class="timestamp">${formatTimestamp(t.created_at)}</td> 487 - <td>${t.error_message || '-'}</td> 488 554 <td> 489 555 <button class="delete-btn" data-id="${t.id}">Delete</button> 490 556 </td> ··· 495 561 transcriptionsTable.innerHTML = ''; 496 562 transcriptionsTable.appendChild(table); 497 563 564 + // Add sort event listeners 565 + table.querySelectorAll('th.sortable').forEach(th => { 566 + th.addEventListener('click', () => { 567 + const sortKey = th.dataset.sort; 568 + if (transcriptSortKey === sortKey) { 569 + transcriptSortDirection = transcriptSortDirection === 'asc' ? 'desc' : 'asc'; 570 + } else { 571 + transcriptSortKey = sortKey; 572 + transcriptSortDirection = 'asc'; 573 + } 574 + renderTranscriptions(allTranscriptions); 575 + }); 576 + }); 577 + 498 578 // Add delete event listeners 499 579 table.querySelectorAll('.delete-btn').forEach(btn => { 500 580 btn.addEventListener('click', async (e) => { 581 + e.stopPropagation(); // Prevent row click 501 582 const button = e.target; 502 583 const id = button.dataset.id; 503 584 ··· 524 605 button.disabled = false; 525 606 button.textContent = 'Delete'; 526 607 } 608 + }); 609 + }); 610 + 611 + // Add click event to table rows to open modal 612 + table.querySelectorAll('tbody tr').forEach(row => { 613 + row.addEventListener('click', (e) => { 614 + // Don't open modal if clicking on delete button 615 + if (e.target.closest('.delete-btn')) { 616 + return; 617 + } 618 + 619 + const transcriptId = row.dataset.id; 620 + openTranscriptModal(transcriptId); 527 621 }); 528 622 }); 529 623 } ··· 785 879 document.getElementById('user-search').addEventListener('input', (e) => { 786 880 userSearchTerm = e.target.value.trim(); 787 881 renderUsers(allUsers); 882 + }); 883 + 884 + // Transcript search 885 + document.getElementById('transcript-search').addEventListener('input', (e) => { 886 + transcriptSearchTerm = e.target.value.trim(); 887 + renderTranscriptions(allTranscriptions); 788 888 }); 789 889 790 890 // Initialize