馃 distributed transcription service thistle.dunkirk.sh
at main 12 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, property, state } from "lit/decorators.js"; 3import "./vtt-viewer.ts"; 4 5interface 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") 19export 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( 281 () => null, 282 ), 283 ]); 284 285 if (!detailsRes.ok) { 286 throw new Error("Failed to load transcript details"); 287 } 288 289 const vttContent = vttRes?.ok ? await vttRes.text() : null; 290 291 // Get basic info from database 292 const info = await detailsRes.json(); 293 294 this.transcript = { 295 id: this.transcriptId, 296 original_filename: info?.original_filename || "Unknown", 297 status: info?.status || "unknown", 298 created_at: info?.created_at || 0, 299 completed_at: info?.completed_at || null, 300 error_message: info?.error_message || null, 301 user_id: info?.user_id || "", 302 user_email: info?.user_email || "", 303 user_name: info?.user_name || null, 304 vtt_content: vttContent, 305 }; 306 } catch (err) { 307 this.error = 308 err instanceof Error 309 ? err.message 310 : "Failed to load transcript details"; 311 this.transcript = null; 312 } finally { 313 this.loading = false; 314 } 315 } 316 317 private close() { 318 this.stopAudioPlayback(); 319 this.dispatchEvent( 320 new CustomEvent("close", { bubbles: true, composed: true }), 321 ); 322 } 323 324 private formatTimestamp(timestamp: number) { 325 const date = new Date(timestamp * 1000); 326 return date.toLocaleString(); 327 } 328 329 private stopAudioPlayback() { 330 try { 331 // stop audio inside this modal's shadow root 332 const aud = this.shadowRoot?.querySelector( 333 "audio", 334 ) as HTMLAudioElement | null; 335 if (aud) { 336 aud.pause(); 337 try { 338 aud.currentTime = 0; 339 } catch (_e) { 340 /* ignore */ 341 } 342 } 343 344 // Also stop any audio elements in light DOM that match the transcript audio id 345 if (this.transcript) { 346 const id = `audio-${this.transcript.id}`; 347 const outside = document.getElementById(id) as HTMLAudioElement | null; 348 if (outside && outside !== aud) { 349 outside.pause(); 350 try { 351 outside.currentTime = 0; 352 } catch (_e) { 353 /* ignore */ 354 } 355 } 356 } 357 } catch (_e) { 358 // ignore 359 } 360 } 361 362 private async handleDelete() { 363 if ( 364 !confirm( 365 "Are you sure you want to delete this transcription? This cannot be undone.", 366 ) 367 ) { 368 return; 369 } 370 371 try { 372 const res = await fetch( 373 `/api/admin/transcriptions/${this.transcriptId}`, 374 { 375 method: "DELETE", 376 }, 377 ); 378 379 if (!res.ok) { 380 throw new Error("Failed to delete transcription"); 381 } 382 383 this.dispatchEvent( 384 new CustomEvent("transcript-deleted", { 385 bubbles: true, 386 composed: true, 387 }), 388 ); 389 this.close(); 390 } catch { 391 alert("Failed to delete transcription"); 392 } 393 } 394 395 override render() { 396 return html` 397 <div class="modal-content" @click=${(e: Event) => e.stopPropagation()}> 398 <div class="modal-header"> 399 <h2 class="modal-title">Transcription Details</h2> 400 <button class="modal-close" @click=${this.close} aria-label="Close">&times;</button> 401 </div> 402 <div class="modal-body"> 403 ${this.loading ? html`<div class="loading">Loading...</div>` : ""} 404 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 405 ${this.transcript ? this.renderTranscriptDetails() : ""} 406 </div> 407 ${ 408 this.transcript 409 ? html` 410 <div class="modal-footer"> 411 <button class="btn-danger" @click=${this.handleDelete}>Delete Transcription</button> 412 </div> 413 ` 414 : "" 415 } 416 </div> 417 `; 418 } 419 420 private renderTranscriptDetails() { 421 if (!this.transcript) return ""; 422 423 return html` 424 <div class="detail-section"> 425 <h3 class="detail-section-title">File Information</h3> 426 <div class="detail-row"> 427 <span class="detail-label">File Name</span> 428 <span class="detail-value">${this.transcript.original_filename}</span> 429 </div> 430 <div class="detail-row"> 431 <span class="detail-label">Status</span> 432 <span class="status-badge status-${this.transcript.status}">${this.transcript.status}</span> 433 </div> 434 <div class="detail-row"> 435 <span class="detail-label">Created At</span> 436 <span class="detail-value">${this.formatTimestamp(this.transcript.created_at)}</span> 437 </div> 438 ${ 439 this.transcript.completed_at 440 ? html` 441 <div class="detail-row"> 442 <span class="detail-label">Completed At</span> 443 <span class="detail-value">${this.formatTimestamp(this.transcript.completed_at)}</span> 444 </div> 445 ` 446 : "" 447 } 448 ${ 449 this.transcript.error_message 450 ? html` 451 <div class="detail-row"> 452 <span class="detail-label">Error Message</span> 453 <span class="detail-value" style="color: #dc2626;">${this.transcript.error_message}</span> 454 </div> 455 ` 456 : "" 457 } 458 </div> 459 460 <div class="detail-section"> 461 <h3 class="detail-section-title">User Information</h3> 462 <div class="detail-row"> 463 <span class="detail-label">User</span> 464 <div class="user-info"> 465 <img 466 src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.transcript.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 467 alt="Avatar" 468 class="user-avatar" 469 /> 470 <span>${this.transcript.user_name || this.transcript.user_email}</span> 471 </div> 472 </div> 473 <div class="detail-row"> 474 <span class="detail-label">Email</span> 475 <span class="detail-value">${this.transcript.user_email}</span> 476 </div> 477 </div> 478 479 ${ 480 this.transcript.status === "completed" 481 ? html` 482 <div class="detail-section"> 483 <h3 class="detail-section-title">Audio</h3> 484 <div class="audio-player"> 485 <audio id="audio-${this.transcript.id}" controls src="/api/transcriptions/${this.transcript.id}/audio"></audio> 486 </div> 487 </div> 488 ` 489 : "" 490 } 491 492 <div class="detail-section"> 493 <h3 class="detail-section-title">Transcript</h3> 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 } 499 </div> 500 `; 501 } 502} 503 504declare global { 505 interface HTMLElementTagNameMap { 506 "transcript-modal": TranscriptViewModal; 507 } 508}