馃 distributed transcription service thistle.dunkirk.sh
1import { LitElement, html, css } 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(() => 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 465declare global { 466 interface HTMLElementTagNameMap { 467 "transcript-modal": TranscriptViewModal; 468 } 469}