馃 distributed transcription service
thistle.dunkirk.sh
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">×</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}