🪻 distributed transcription service thistle.dunkirk.sh

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

dunkirk.sh 94b32b21 696e350c

verified
+469
src/components/transcript-view-modal.ts
···
+
import { LitElement, html, css } from "lit";
+
import { customElement, property, state } from "lit/decorators.js";
+
import "./vtt-viewer.ts";
+
+
interface TranscriptDetails {
+
id: string;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
completed_at: number | null;
+
error_message: string | null;
+
user_id: string;
+
user_email: string;
+
user_name: string | null;
+
vtt_content: string | null;
+
}
+
+
@customElement("transcript-modal")
+
export class TranscriptViewModal extends LitElement {
+
@property({ type: String }) transcriptId: string | null = null;
+
@state() private transcript: TranscriptDetails | null = null;
+
@state() private loading = false;
+
@state() private error: string | null = null;
+
private wasOpen = false;
+
+
static override styles = css`
+
:host {
+
display: none;
+
position: fixed;
+
top: 0;
+
left: 0;
+
right: 0;
+
bottom: 0;
+
background: rgba(0, 0, 0, 0.5);
+
z-index: 1000;
+
align-items: center;
+
justify-content: center;
+
padding: 2rem;
+
}
+
+
:host([open]) {
+
display: flex;
+
}
+
+
.modal-content {
+
background: var(--background);
+
border-radius: 8px;
+
max-width: 50rem;
+
width: 100%;
+
max-height: 80vh;
+
display: flex;
+
flex-direction: column;
+
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3);
+
}
+
+
.modal-header {
+
padding: 1.5rem;
+
border-bottom: 2px solid var(--secondary);
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
flex-shrink: 0;
+
}
+
+
.modal-title {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
margin: 0;
+
}
+
+
.modal-close {
+
background: transparent;
+
border: none;
+
font-size: 1.5rem;
+
cursor: pointer;
+
color: var(--text);
+
padding: 0;
+
width: 2rem;
+
height: 2rem;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
border-radius: 4px;
+
transition: background 0.2s;
+
}
+
+
.modal-close:hover {
+
background: var(--secondary);
+
}
+
+
.modal-body {
+
padding: 1.5rem;
+
overflow-y: auto;
+
flex: 1;
+
}
+
+
.detail-section {
+
margin-bottom: 2rem;
+
}
+
+
.detail-section:last-child {
+
margin-bottom: 0;
+
}
+
+
.detail-section-title {
+
font-size: 1.125rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
padding-bottom: 0.5rem;
+
border-bottom: 2px solid var(--secondary);
+
}
+
+
.detail-row {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
padding: 0.75rem 0;
+
border-bottom: 1px solid var(--secondary);
+
}
+
+
.detail-row:last-child {
+
border-bottom: none;
+
}
+
+
.detail-label {
+
font-weight: 500;
+
color: var(--text);
+
}
+
+
.detail-value {
+
color: var(--text);
+
opacity: 0.8;
+
}
+
+
.status-badge {
+
display: inline-block;
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
}
+
+
.status-completed {
+
background: #dcfce7;
+
color: #166534;
+
}
+
+
.status-processing,
+
.status-uploading {
+
background: #fef3c7;
+
color: #92400e;
+
}
+
+
.status-failed {
+
background: #fee2e2;
+
color: #991b1b;
+
}
+
+
.status-pending {
+
background: #e0e7ff;
+
color: #3730a3;
+
}
+
+
.user-info {
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.user-avatar {
+
width: 2rem;
+
height: 2rem;
+
border-radius: 50%;
+
}
+
+
.transcript-text {
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
padding: 1rem;
+
font-family: monospace;
+
font-size: 0.875rem;
+
line-height: 1.6;
+
white-space: pre-wrap;
+
color: var(--text);
+
max-height: 30rem;
+
overflow-y: auto;
+
}
+
+
.loading, .error {
+
text-align: center;
+
padding: 2rem;
+
}
+
+
.error {
+
color: #dc2626;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 2rem;
+
color: var(--text);
+
opacity: 0.6;
+
background: rgba(0, 0, 0, 0.02);
+
border-radius: 4px;
+
}
+
+
.btn-danger {
+
background: #dc2626;
+
color: white;
+
padding: 0.5rem 1rem;
+
border: none;
+
border-radius: 4px;
+
cursor: pointer;
+
font-size: 1rem;
+
font-weight: 500;
+
font-family: inherit;
+
transition: all 0.2s;
+
}
+
+
.btn-danger:hover {
+
background: #b91c1c;
+
}
+
+
.btn-danger:disabled {
+
opacity: 0.5;
+
cursor: not-allowed;
+
}
+
+
.modal-footer {
+
padding: 1.5rem;
+
border-top: 2px solid var(--secondary);
+
display: flex;
+
justify-content: flex-end;
+
gap: 0.5rem;
+
flex-shrink: 0;
+
}
+
+
.audio-player {
+
margin-bottom: 1rem;
+
}
+
+
.audio-player audio {
+
width: 100%;
+
}
+
`;
+
+
override connectedCallback() {
+
super.connectedCallback();
+
if (this.transcriptId) {
+
this.loadTranscriptDetails();
+
}
+
}
+
+
override updated(changedProperties: Map<string, unknown>) {
+
if (changedProperties.has("transcriptId") && this.transcriptId) {
+
this.loadTranscriptDetails();
+
}
+
+
// If the host loses the [open] attribute, stop any playback inside the modal
+
const isOpen = this.hasAttribute("open");
+
if (this.wasOpen && !isOpen) {
+
this.stopAudioPlayback();
+
}
+
this.wasOpen = isOpen;
+
}
+
+
private async loadTranscriptDetails() {
+
if (!this.transcriptId) return;
+
+
this.loading = true;
+
this.error = null;
+
+
try {
+
// Fetch transcript details
+
const [detailsRes, vttRes] = await Promise.all([
+
fetch(`/api/admin/transcriptions/${this.transcriptId}/details`),
+
fetch(`/api/transcriptions/${this.transcriptId}?format=vtt`).catch(() => null),
+
]);
+
+
if (!detailsRes.ok) {
+
throw new Error("Failed to load transcript details");
+
}
+
+
const vttContent = vttRes?.ok ? await vttRes.text() : null;
+
+
// Get basic info from database
+
const info = await detailsRes.json();
+
+
this.transcript = {
+
id: this.transcriptId,
+
original_filename: info?.original_filename || "Unknown",
+
status: info?.status || "unknown",
+
created_at: info?.created_at || 0,
+
completed_at: info?.completed_at || null,
+
error_message: info?.error_message || null,
+
user_id: info?.user_id || "",
+
user_email: info?.user_email || "",
+
user_name: info?.user_name || null,
+
vtt_content: vttContent,
+
};
+
} catch (err) {
+
this.error = err instanceof Error ? err.message : "Failed to load transcript details";
+
this.transcript = null;
+
} finally {
+
this.loading = false;
+
}
+
}
+
+
private close() {
+
this.stopAudioPlayback();
+
this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true }));
+
}
+
+
private formatTimestamp(timestamp: number) {
+
const date = new Date(timestamp * 1000);
+
return date.toLocaleString();
+
}
+
+
private stopAudioPlayback() {
+
try {
+
// stop audio inside this modal's shadow root
+
const aud = this.shadowRoot?.querySelector('audio') as HTMLAudioElement | null;
+
if (aud) {
+
aud.pause();
+
try { aud.currentTime = 0; } catch (e) { /* ignore */ }
+
}
+
+
// Also stop any audio elements in light DOM that match the transcript audio id
+
if (this.transcript) {
+
const id = `audio-${this.transcript.id}`;
+
const outside = document.getElementById(id) as HTMLAudioElement | null;
+
if (outside && outside !== aud) {
+
outside.pause();
+
try { outside.currentTime = 0; } catch (e) { /* ignore */ }
+
}
+
}
+
} catch (e) {
+
// ignore
+
}
+
}
+
+
private async handleDelete() {
+
if (!confirm("Are you sure you want to delete this transcription? This cannot be undone.")) {
+
return;
+
}
+
+
try {
+
const res = await fetch(`/api/admin/transcriptions/${this.transcriptId}`, {
+
method: "DELETE",
+
});
+
+
if (!res.ok) {
+
throw new Error("Failed to delete transcription");
+
}
+
+
this.dispatchEvent(new CustomEvent("transcript-deleted", { bubbles: true, composed: true }));
+
this.close();
+
} catch {
+
alert("Failed to delete transcription");
+
}
+
}
+
+
override render() {
+
return html`
+
<div class="modal-content" @click=${(e: Event) => e.stopPropagation()}>
+
<div class="modal-header">
+
<h2 class="modal-title">Transcription Details</h2>
+
<button class="modal-close" @click=${this.close} aria-label="Close">&times;</button>
+
</div>
+
<div class="modal-body">
+
${this.loading ? html`<div class="loading">Loading...</div>` : ""}
+
${this.error ? html`<div class="error">${this.error}</div>` : ""}
+
${this.transcript ? this.renderTranscriptDetails() : ""}
+
</div>
+
${this.transcript
+
? html`
+
<div class="modal-footer">
+
<button class="btn-danger" @click=${this.handleDelete}>Delete Transcription</button>
+
</div>
+
`
+
: ""}
+
</div>
+
`;
+
}
+
+
private renderTranscriptDetails() {
+
if (!this.transcript) return "";
+
+
return html`
+
<div class="detail-section">
+
<h3 class="detail-section-title">File Information</h3>
+
<div class="detail-row">
+
<span class="detail-label">File Name</span>
+
<span class="detail-value">${this.transcript.original_filename}</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Status</span>
+
<span class="status-badge status-${this.transcript.status}">${this.transcript.status}</span>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Created At</span>
+
<span class="detail-value">${this.formatTimestamp(this.transcript.created_at)}</span>
+
</div>
+
${this.transcript.completed_at
+
? html`
+
<div class="detail-row">
+
<span class="detail-label">Completed At</span>
+
<span class="detail-value">${this.formatTimestamp(this.transcript.completed_at)}</span>
+
</div>
+
`
+
: ""}
+
${this.transcript.error_message
+
? html`
+
<div class="detail-row">
+
<span class="detail-label">Error Message</span>
+
<span class="detail-value" style="color: #dc2626;">${this.transcript.error_message}</span>
+
</div>
+
`
+
: ""}
+
</div>
+
+
<div class="detail-section">
+
<h3 class="detail-section-title">User Information</h3>
+
<div class="detail-row">
+
<span class="detail-label">User</span>
+
<div class="user-info">
+
<img
+
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.transcript.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
+
alt="Avatar"
+
class="user-avatar"
+
/>
+
<span>${this.transcript.user_name || this.transcript.user_email}</span>
+
</div>
+
</div>
+
<div class="detail-row">
+
<span class="detail-label">Email</span>
+
<span class="detail-value">${this.transcript.user_email}</span>
+
</div>
+
</div>
+
+
${this.transcript.status === "completed"
+
? html`
+
<div class="detail-section">
+
<h3 class="detail-section-title">Audio</h3>
+
<div class="audio-player">
+
<audio id="audio-${this.transcript.id}" controls src="/api/transcriptions/${this.transcript.id}/audio"></audio>
+
</div>
+
</div>
+
`
+
: ""}
+
+
<div class="detail-section">
+
<h3 class="detail-section-title">Transcript</h3>
+
${this.transcript.status === "completed" && this.transcript.vtt_content
+
? html`<vtt-viewer .vttContent=${this.transcript.vtt_content ?? ""} .audioId=${`audio-${this.transcript.id}`}></vtt-viewer>`
+
: html`<div class="transcript-text">${this.transcript.vtt_content || "No transcript available"}</div>`}
+
</div>
+
`;
+
}
+
}
+
+
declare global {
+
interface HTMLElementTagNameMap {
+
"transcript-modal": TranscriptViewModal;
+
}
+
}
+7 -170
src/components/transcription.ts
···
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
-
import { parseVTT } from "../lib/vtt-cleaner";
+
import "./vtt-viewer.ts";
interface TranscriptionJob {
id: string;
···
-
function parseVTT(vttContent: string): VTTSegment[] {
-
const segments: VTTSegment[] = [];
-
const lines = vttContent.split("\n");
-
let i = 0;
-
// Skip WEBVTT header
-
while (i < lines.length && lines[i]?.trim() !== "WEBVTT") {
-
i++;
-
}
-
i++; // Skip WEBVTT
-
-
while (i < lines.length) {
-
let index: string | undefined;
-
// Check for cue ID (line before timestamp)
-
if (lines[i]?.trim() && !lines[i]?.includes("-->")) {
-
index = lines[i]?.trim();
-
i++;
-
}
-
-
if (i < lines.length && lines[i]?.includes("-->")) {
-
const [startStr, endStr] = lines[i].split("-->").map((s) => s.trim());
-
const start = parseVTTTimestamp(startStr || "");
-
const end = parseVTTTimestamp(endStr || "");
-
-
// Collect text lines until empty line
-
const textLines: string[] = [];
-
i++;
-
while (i < lines.length && lines[i]?.trim()) {
-
textLines.push(lines[i] || "");
-
i++;
-
}
-
-
segments.push({
-
start,
-
end,
-
text: textLines.join(" ").trim(),
-
index,
-
});
-
} else {
-
i++;
-
}
-
}
-
-
return segments;
-
}
-
-
function parseVTTTimestamp(timestamp: string): number {
-
const parts = timestamp.split(":");
-
if (parts.length === 3) {
-
const hours = Number.parseFloat(parts[0] || "0");
-
const minutes = Number.parseFloat(parts[1] || "0");
-
const seconds = Number.parseFloat(parts[2] || "0");
-
return hours * 3600 + minutes * 60 + seconds;
-
}
-
return 0;
-
}
class WordStreamer {
private queue: string[] = [];
···
// Load VTT for completed jobs
if (update.status === "completed") {
await this.loadVTTForJob(jobId);
-
this.setupWordHighlighting(jobId);
}
}
}
···
// Fetch VTT for completed jobs
if (job.status === "completed") {
await this.loadVTTForJob(job.id);
-
await this.updateComplete;
-
this.setupWordHighlighting(job.id);
}
}
// Don't override serviceAvailable - it's set by checkHealth()
···
const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`);
if (response.ok) {
const vttContent = await response.text();
-
const segments = parseVTT(vttContent);
-
// Update job with VTT content and segments
+
// Update job with VTT content
const job = this.jobs.find((j) => j.id === jobId);
if (job) {
job.vttContent = vttContent;
-
job.vttSegments = segments;
job.audioUrl = `/api/transcriptions/${jobId}/audio`;
this.jobs = [...this.jobs];
}
···
}
}
-
private setupWordHighlighting(jobId: string) {
-
const job = this.jobs.find((j) => j.id === jobId);
-
if (!job?.audioUrl || !job.vttSegments) return;
-
-
// Wait for next frame to ensure DOM is updated
-
requestAnimationFrame(() => {
-
const audioElement = this.shadowRoot?.querySelector(
-
`#audio-${jobId}`,
-
) as HTMLAudioElement;
-
const transcriptDiv = this.shadowRoot?.querySelector(
-
`#transcript-${jobId}`,
-
) as HTMLDivElement;
-
-
if (!audioElement || !transcriptDiv) {
-
console.warn("Could not find audio or transcript elements");
-
return;
-
}
-
-
// Track current segment
-
let currentSegmentElement: HTMLElement | null = null;
-
-
// Update highlighting on timeupdate
-
audioElement.addEventListener("timeupdate", () => {
-
const currentTime = audioElement.currentTime;
-
const segmentElements = transcriptDiv.querySelectorAll("[data-start]");
-
-
for (const el of segmentElements) {
-
const start = Number.parseFloat(
-
(el as HTMLElement).dataset.start || "0",
-
);
-
const end = Number.parseFloat((el as HTMLElement).dataset.end || "0");
-
-
if (currentTime >= start && currentTime <= end) {
-
if (currentSegmentElement !== el) {
-
currentSegmentElement?.classList.remove("current-segment");
-
(el as HTMLElement).classList.add("current-segment");
-
currentSegmentElement = el as HTMLElement;
-
-
// Auto-scroll to current segment
-
el.scrollIntoView({
-
behavior: "smooth",
-
block: "center",
-
});
-
}
-
break;
-
}
-
}
-
});
-
-
// Handle segment clicks
-
transcriptDiv.addEventListener("click", (e) => {
-
const target = e.target as HTMLElement;
-
if (target.dataset.start) {
-
const start = Number.parseFloat(target.dataset.start);
-
audioElement.currentTime = start;
-
audioElement.play();
-
}
-
});
-
});
-
}
+
private handleDragOver(e: DragEvent) {
e.preventDefault();
···
return displayed;
}
-
const segments = parseVTT(job.vttContent);
-
// Group segments by paragraph (extract paragraph number from ID like "Paragraph 1-1" -> "1")
-
const paragraphGroups = new Map<string, typeof segments>();
-
for (const segment of segments) {
-
const id = (segment.index || '').trim();
-
const match = id.match(/^Paragraph\s+(\d+)-/);
-
const paraNum = match ? match[1] : '0';
-
if (!paragraphGroups.has(paraNum)) {
-
paragraphGroups.set(paraNum, []);
-
}
-
paragraphGroups.get(paraNum)!.push(segment);
-
}
-
-
// Render each paragraph group
-
const paragraphs = Array.from(paragraphGroups.entries()).map(([paraNum, groupSegments]) => {
-
// Concatenate all text in the group
-
const fullText = groupSegments.map(s => s.text || '').join(' ');
-
// Split into sentences
-
const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean);
-
// Calculate word counts for timing
-
const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length);
-
const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0));
-
-
// Overall paragraph timing
-
const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0));
-
const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart));
-
-
let acc = 0;
-
const paraDuration = paraEnd - paraStart;
-
-
return html`<div class="paragraph">
-
${sentences.map((sent, si) => {
-
const startOffset = (acc / totalWords) * paraDuration;
-
acc += wordCounts[si];
-
const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration;
-
const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart;
-
const spanStart = paraStart + startOffset;
-
const spanEnd = paraStart + endOffset;
-
return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`;
-
})}
-
</div>`;
-
});
-
-
return html`${paragraphs}`;
+
// Delegate VTT rendering and highlighting to the vtt-viewer component
+
return html`<vtt-viewer .vttContent=${job.vttContent ?? ""} .audioId=${`audio-${job.id}`}></vtt-viewer>`;
}
···
}
${
-
job.status === "completed" && job.audioUrl && job.vttSegments
+
job.status === "completed" && job.audioUrl && job.vttContent
? html`
<div class="audio-player">
<audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
</div>
-
<div class="job-transcript" id="transcript-${job.id}">
-
${this.renderTranscript(job)}
-
</div>
+
${this.renderTranscript(job)}
`
: this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
? html`
+268
src/components/vtt-viewer.ts
···
+
import { LitElement, html, css } from "lit";
+
import { customElement, property } from "lit/decorators.js";
+
+
interface VTTSegment {
+
start: number;
+
end: number;
+
text: string;
+
index?: string;
+
}
+
+
function parseVTT(vttContent: string): VTTSegment[] {
+
const segments: VTTSegment[] = [];
+
const lines = vttContent.split("\n");
+
+
let i = 0;
+
// Skip WEBVTT header if present
+
while (i < lines.length && (lines[i] || "").trim() !== "WEBVTT") {
+
i++;
+
}
+
if (i < lines.length) i++; // advance past header if found
+
+
while (i < lines.length) {
+
let index: string | undefined;
+
let line = lines[i] || "";
+
+
// Check for cue ID (line before timestamp)
+
if (line.trim() && !line.includes("-->")) {
+
index = line.trim();
+
i++;
+
line = lines[i] || "";
+
}
+
+
if (line.includes("-->")) {
+
const parts = line.split("-->").map((s) => s.trim());
+
const start = parseVTTTimestamp(parts[0] ?? "");
+
const end = parseVTTTimestamp(parts[1] ?? "");
+
+
// Collect text lines until empty line
+
const textLines: string[] = [];
+
i++;
+
while (i < lines.length && (lines[i] || "").trim()) {
+
textLines.push(lines[i] || "");
+
i++;
+
}
+
+
segments.push({
+
start,
+
end,
+
text: textLines.join(" ").trim(),
+
index,
+
});
+
} else {
+
i++;
+
}
+
}
+
+
return segments;
+
}
+
+
function parseVTTTimestamp(timestamp?: string): number {
+
const parts = (timestamp || "").split(":");
+
if (parts.length === 3) {
+
const hours = Number.parseFloat(parts[0] || "0");
+
const minutes = Number.parseFloat(parts[1] || "0");
+
const seconds = Number.parseFloat(parts[2] || "0");
+
return hours * 3600 + minutes * 60 + seconds;
+
}
+
return 0;
+
}
+
+
@customElement("vtt-viewer")
+
export class VTTViewer extends LitElement {
+
@property({ type: String }) vttContent = "";
+
@property({ type: String }) audioId = "";
+
+
static override styles = css`
+
.transcript {
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
border-radius: 6px;
+
padding: 1rem;
+
font-family: monospace;
+
font-size: 0.875rem;
+
color: var(--text);
+
line-height: 1.6;
+
word-wrap: break-word;
+
}
+
+
.segment {
+
cursor: pointer;
+
transition: background 0.1s;
+
display: inline;
+
}
+
+
.segment:hover {
+
background: color-mix(in srgb, var(--primary) 15%, transparent);
+
border-radius: 2px;
+
}
+
+
.current-segment {
+
background: color-mix(in srgb, var(--accent) 30%, transparent);
+
border-radius: 2px;
+
}
+
+
.paragraph {
+
display: block;
+
margin: 0 0 1rem 0;
+
line-height: 1.6;
+
}
+
`;
+
+
private audioElement: HTMLAudioElement | null = null;
+
private boundTimeUpdate: ((this: HTMLAudioElement, ev: Event) => any) | null = null;
+
private boundTranscriptClick: ((e: Event) => any) | null = null;
+
+
private _viewerId = `vtt-${Math.random().toString(36).slice(2,9)}`;
+
+
private findAudioElementById(id: string): HTMLAudioElement | null {
+
let root: any = this.getRootNode();
+
let depth = 0;
+
while (root && depth < 10) {
+
if (root instanceof ShadowRoot) {
+
const el = root.querySelector(`#${id}`) as HTMLAudioElement | null;
+
if (el) return el;
+
root = (root as ShadowRoot).host?.getRootNode?.();
+
} else if (root instanceof Document) {
+
const byId = root.getElementById(id) as HTMLAudioElement | null;
+
if (byId) return byId;
+
break;
+
} else {
+
break;
+
}
+
depth++;
+
}
+
return null;
+
}
+
+
private setupHighlighting() {
+
// Detach previous listeners if any
+
this.detachHighlighting();
+
+
const audioElement = this.findAudioElementById(this.audioId);
+
const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null;
+
if (!audioElement || !transcriptDiv) return;
+
+
// Clear any lingering highlights from prior instances
+
transcriptDiv.querySelectorAll('.current-segment').forEach((el) => (el as HTMLElement).classList.remove('current-segment'));
+
+
this.audioElement = audioElement;
+
let currentSegmentElement: HTMLElement | null = null;
+
+
this.boundTimeUpdate = () => {
+
const currentTime = this.audioElement?.currentTime ?? 0;
+
const segmentElements = transcriptDiv.querySelectorAll('[data-start]');
+
let found = false;
+
+
for (const el of Array.from(segmentElements)) {
+
const start = Number.parseFloat((el as HTMLElement).dataset.start || '0');
+
const end = Number.parseFloat((el as HTMLElement).dataset.end || '0');
+
+
if (currentTime >= start && currentTime <= end) {
+
found = true;
+
if (currentSegmentElement !== el) {
+
currentSegmentElement?.classList.remove('current-segment');
+
(el as HTMLElement).classList.add('current-segment');
+
currentSegmentElement = el as HTMLElement;
+
(el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
+
}
+
break;
+
}
+
}
+
+
// If no segment matched, clear any existing highlight
+
if (!found && currentSegmentElement) {
+
currentSegmentElement.classList.remove('current-segment');
+
currentSegmentElement = null;
+
}
+
};
+
+
audioElement.addEventListener('timeupdate', this.boundTimeUpdate as EventListener);
+
+
this.boundTranscriptClick = (e: Event) => {
+
const target = e.target as HTMLElement;
+
if (target.dataset.start) {
+
this.audioElement!.currentTime = Number.parseFloat(target.dataset.start);
+
this.audioElement!.play();
+
}
+
};
+
+
transcriptDiv.addEventListener('click', this.boundTranscriptClick);
+
}
+
+
private detachHighlighting() {
+
try {
+
const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null;
+
if (this.audioElement) {
+
// Pause playback to avoid audio continuing after the viewer is removed
+
try {
+
this.audioElement.pause();
+
} catch (e) {
+
// ignore
+
}
+
if (this.boundTimeUpdate) {
+
this.audioElement.removeEventListener('timeupdate', this.boundTimeUpdate);
+
}
+
}
+
if (transcriptDiv && this.boundTranscriptClick) {
+
transcriptDiv.removeEventListener('click', this.boundTranscriptClick);
+
}
+
} finally {
+
this.audioElement = null;
+
this.boundTimeUpdate = null;
+
this.boundTranscriptClick = null;
+
}
+
}
+
+
override disconnectedCallback() {
+
this.detachHighlighting();
+
super.disconnectedCallback && super.disconnectedCallback();
+
}
+
+
override updated(changed: Map<string, any>) {
+
super.updated(changed);
+
if (changed.has('vttContent') || changed.has('audioId')) {
+
this.setupHighlighting();
+
}
+
}
+
+
private renderFromVTT() {
+
if (!this.vttContent) return html``;
+
const segments = parseVTT(this.vttContent);
+
const paragraphGroups = new Map<string, VTTSegment[]>();
+
+
for (const segment of segments) {
+
const id = (segment.index || "").trim();
+
const match = id.match(/^Paragraph\s+(\d+)-/);
+
const paraNum = match ? match[1] : '0';
+
if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []);
+
paragraphGroups.get(paraNum)!.push(segment);
+
}
+
+
const paragraphs = Array.from(paragraphGroups.entries()).map(([_, groupSegments]) => {
+
const fullText = groupSegments.map(s => s.text || '').join(' ');
+
const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean);
+
const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length);
+
const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0));
+
const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0));
+
const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart));
+
let acc = 0;
+
const paraDuration = paraEnd - paraStart;
+
+
return html`<div class="paragraph">${sentences.map((sent, si) => {
+
const startOffset = (acc / totalWords) * paraDuration;
+
acc += wordCounts[si];
+
const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration;
+
const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart;
+
const spanStart = paraStart + startOffset;
+
const spanEnd = paraStart + endOffset;
+
return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`;
+
})}</div>`;
+
});
+
+
return html`${paragraphs}`;
+
}
+
+
override render() {
+
return html`<div class="transcript">${this.renderFromVTT()}</div>`;
+
}
+
}
+114 -48
src/index.ts
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
-
import { getTranscript, getTranscriptVTT } from "./lib/transcript-storage";
+
import { getTranscriptVTT } from "./lib/transcript-storage";
import indexHTML from "./pages/index.html";
import adminHTML from "./pages/admin.html";
import settingsHTML from "./pages/settings.html";
···
}
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
-
setInterval(async () => {
-
try {
-
await whisperService.syncWithWhisper();
-
} catch (error) {
-
console.warn(
-
"[Sync] Failed to sync with Murmur:",
-
error instanceof Error ? error.message : "Unknown error",
-
);
-
}
-
}, 5 * 60 * 1000);
+
setInterval(
+
async () => {
+
try {
+
await whisperService.syncWithWhisper();
+
} catch (error) {
+
console.warn(
+
"[Sync] Failed to sync with Murmur:",
+
error instanceof Error ? error.message : "Unknown error",
+
);
+
}
+
},
+
5 * 60 * 1000,
+
);
// Clean up stale files daily
setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
···
progress: number;
},
[string]
-
>(
-
"SELECT status, progress FROM transcriptions WHERE id = ?",
-
)
+
>("SELECT status, progress FROM transcriptions WHERE id = ?")
.get(transcriptionId);
if (current) {
-
// Load transcript from file if completed
-
let transcript: string | undefined;
-
if (current.status === "completed") {
-
transcript = (await getTranscript(transcriptionId)) || undefined;
-
}
sendEvent({
status: current.status as TranscriptionUpdate["status"],
progress: current.progress,
-
transcript,
});
}
// If already complete, close immediately
···
const user = requireAuth(req);
const transcriptionId = req.params.id;
-
// Verify ownership
+
// Verify ownership or admin
const transcription = db
.query<
{
id: string;
user_id: number;
+
filename: string;
+
original_filename: string;
status: string;
-
original_filename: string;
+
progress: number;
+
created_at: number;
},
[string]
>(
-
"SELECT id, user_id, status, original_filename FROM transcriptions WHERE id = ?",
+
"SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?",
)
.get(transcriptionId);
-
if (!transcription || transcription.user_id !== user.id) {
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
+
// Allow access if user owns it or is admin
+
if (transcription.user_id !== user.id && user.role !== "admin") {
return Response.json(
{ error: "Transcription not found" },
{ status: 404 },
···
});
}
-
// Default: return plain text transcript from file
-
const transcript = await getTranscript(transcriptionId);
-
if (!transcript) {
-
return Response.json(
-
{ error: "Transcript not available" },
-
{ status: 404 },
-
);
+
// return info on transcript
+
const transcript = {
+
id: transcription.id,
+
filename: transcription.original_filename,
+
status: transcription.status,
+
progress: transcription.progress,
+
created_at: transcription.created_at,
}
-
-
return new Response(transcript, {
+
return new Response(JSON.stringify(transcript), {
headers: {
-
"Content-Type": "text/plain",
+
"Content-Type": "application/json",
},
});
} catch (error) {
···
const user = requireAuth(req);
const transcriptionId = req.params.id;
-
// Verify ownership and get filename
+
// Verify ownership or admin
const transcription = db
.query<
{
···
status: string;
},
[string]
-
>("SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?")
+
>(
+
"SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?",
+
)
.get(transcriptionId);
-
if (!transcription || transcription.user_id !== user.id) {
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
+
// Allow access if user owns it or is admin
+
if (transcription.user_id !== user.id && user.role !== "admin") {
return Response.json(
{ error: "Transcription not found" },
{ status: 404 },
···
const file = Bun.file(filePath);
if (!(await file.exists())) {
-
return Response.json({ error: "Audio file not found" }, { status: 404 });
+
return Response.json(
+
{ error: "Audio file not found" },
+
{ status: 404 },
+
);
}
const fileSize = file.size;
···
// Load transcripts from files for completed jobs
const jobs = await Promise.all(
transcriptions.map(async (t) => {
-
let transcript: string | null = null;
-
if (t.status === "completed") {
-
transcript = await getTranscript(t.id);
-
}
return {
id: t.id,
filename: t.original_filename,
status: t.status,
progress: t.progress,
-
transcript,
created_at: t.created_at,
};
}),
···
const sessions = getSessionsForUser(userId);
// Get transcription count
-
const transcriptionCount = db
-
.query<{ count: number }, [number]>(
-
"SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
-
)
-
.get(userId)?.count ?? 0;
+
const transcriptionCount =
+
db
+
.query<{ count: number }, [number]>(
+
"SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
+
)
+
.get(userId)?.count ?? 0;
return Response.json({
id: user.id,
···
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/transcriptions/:id/details": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const transcriptionId = req.params.id;
+
+
const transcription = db
+
.query<
+
{
+
id: string;
+
original_filename: string;
+
status: string;
+
created_at: number;
+
updated_at: number;
+
error_message: string | null;
+
user_id: number;
+
},
+
[string]
+
>(
+
"SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
+
if (!transcription) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
+
const user = db
+
.query<{ email: string; name: string | null }, [number]>(
+
"SELECT email, name FROM users WHERE id = ?",
+
)
+
.get(transcription.user_id);
+
+
return Response.json({
+
id: transcription.id,
+
original_filename: transcription.original_filename,
+
status: transcription.status,
+
created_at: transcription.created_at,
+
completed_at: transcription.updated_at,
+
error_message: transcription.error_message,
+
user_id: transcription.user_id,
+
user_email: user?.email || "Unknown",
+
user_name: user?.name || null,
+
});
} catch (error) {
return handleError(error);
-4
src/lib/transcript-storage.test.ts
···
import { expect, test } from "bun:test";
import {
-
deleteTranscript,
-
getTranscript,
getTranscriptVTT,
-
hasTranscript,
-
saveTranscript,
saveTranscriptVTT,
} from "./transcript-storage";
-50
src/lib/transcript-storage.ts
···
// File-based transcript storage to avoid SQLite size limits
-
import { unlinkSync } from "node:fs";
import { basename } from "node:path";
const TRANSCRIPTS_DIR = "./transcripts";
···
throw new Error("Invalid transcription ID: path traversal detected");
}
return safeName;
-
}
-
-
/**
-
* Write transcript to file system
-
*/
-
export async function saveTranscript(
-
transcriptionId: string,
-
transcript: string,
-
): Promise<void> {
-
const safeId = validateTranscriptionId(transcriptionId);
-
const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`;
-
await Bun.write(filePath, transcript);
-
}
-
-
/**
-
* Read transcript from file system
-
*/
-
export async function getTranscript(
-
transcriptionId: string,
-
): Promise<string | null> {
-
const safeId = validateTranscriptionId(transcriptionId);
-
const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`;
-
try {
-
return await Bun.file(filePath).text();
-
} catch {
-
return null;
-
}
-
}
-
-
/**
-
* Delete transcript file
-
*/
-
export async function deleteTranscript(transcriptionId: string): Promise<void> {
-
const safeId = validateTranscriptionId(transcriptionId);
-
const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`;
-
try {
-
unlinkSync(filePath);
-
} catch {
-
// File doesn't exist or already deleted
-
}
-
}
-
-
/**
-
* Check if transcript exists
-
*/
-
export async function hasTranscript(transcriptionId: string): Promise<boolean> {
-
const safeId = validateTranscriptionId(transcriptionId);
-
const filePath = `${TRANSCRIPTS_DIR}/${safeId}.txt`;
-
return await Bun.file(filePath).exists();
}
/**
+113 -13
src/pages/admin.html
···
cursor: not-allowed;
}
-
tbody tr {
+
.users-table tbody tr {
cursor: pointer;
}
-
tbody tr:hover {
+
.users-table tbody tr:hover {
+
background: rgba(0, 0, 0, 0.04);
+
}
+
+
.transcriptions-table tbody tr {
+
cursor: pointer;
+
}
+
+
.transcriptions-table tbody tr:hover {
background: rgba(0, 0, 0, 0.04);
}
···
<div id="transcriptions-tab" class="tab-content active">
<div class="section">
<h2 class="section-title">All Transcriptions</h2>
-
<div id="transcriptions-table"></div>
+
<input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." />
+
<div id="transcriptions-table" class="transcriptions-table"></div>
</div>
</div>
···
<div class="section">
<h2 class="section-title">All Users</h2>
<input type="text" id="user-search" class="search" placeholder="Search by name or email..." />
-
<div id="users-table"></div>
+
<div id="users-table" class="users-table"></div>
</div>
</div>
</div>
</main>
<user-modal id="user-modal"></user-modal>
+
<transcript-modal id="transcript-modal"></transcript-modal>
<script type="module" src="../components/auth.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
+
<script type="module" src="../components/transcript-view-modal.ts"></script>
<script type="module">
const errorMessage = document.getElementById('error-message');
const loading = document.getElementById('loading');
···
const transcriptionsTable = document.getElementById('transcriptions-table');
const usersTable = document.getElementById('users-table');
const userModal = document.getElementById('user-modal');
+
const transcriptModal = document.getElementById('transcript-modal');
let currentUserEmail = null;
let allUsers = [];
+
let allTranscriptions = [];
let userSortKey = 'created_at';
let userSortDirection = 'desc';
let userSearchTerm = '';
+
let transcriptSortKey = 'created_at';
+
let transcriptSortDirection = 'desc';
+
let transcriptSearchTerm = '';
// Get current user info
async function getCurrentUser() {
···
userModal.userId = null;
}
+
function openTranscriptModal(transcriptId) {
+
transcriptModal.setAttribute('open', '');
+
transcriptModal.transcriptId = transcriptId;
+
}
+
+
function closeTranscriptModal() {
+
transcriptModal.removeAttribute('open');
+
transcriptModal.transcriptId = null;
+
}
+
// Listen for modal close and user update events
userModal.addEventListener('close', closeUserModal);
userModal.addEventListener('user-updated', () => loadData());
···
}
});
+
// Listen for transcript modal events
+
transcriptModal.addEventListener('close', closeTranscriptModal);
+
transcriptModal.addEventListener('transcript-deleted', () => loadData());
+
transcriptModal.addEventListener('click', (e) => {
+
if (e.target === transcriptModal) {
+
closeTranscriptModal();
+
}
+
});
+
function renderTranscriptions(transcriptions) {
-
if (transcriptions.length === 0) {
-
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions yet</div>';
+
allTranscriptions = transcriptions;
+
+
// Filter transcriptions based on search term
+
let filteredTranscriptions = transcriptions.filter(t => {
+
if (!transcriptSearchTerm) return true;
+
const term = transcriptSearchTerm.toLowerCase();
+
const filename = (t.original_filename || '').toLowerCase();
+
const userName = (t.user_name || '').toLowerCase();
+
const userEmail = (t.user_email || '').toLowerCase();
+
return filename.includes(term) || userName.includes(term) || userEmail.includes(term);
+
});
+
+
// Sort transcriptions
+
filteredTranscriptions.sort((a, b) => {
+
let aVal = a[transcriptSortKey];
+
let bVal = b[transcriptSortKey];
+
+
// Handle null values
+
if (aVal === null || aVal === undefined) aVal = '';
+
if (bVal === null || bVal === undefined) bVal = '';
+
+
let comparison = 0;
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
+
comparison = aVal.localeCompare(bVal);
+
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
+
comparison = aVal - bVal;
+
} else {
+
comparison = String(aVal).localeCompare(String(bVal));
+
}
+
+
return transcriptSortDirection === 'asc' ? comparison : -comparison;
+
});
+
+
if (filteredTranscriptions.length === 0) {
+
transcriptionsTable.innerHTML = '<div class="empty-state">No transcriptions found</div>';
return;
}
···
table.innerHTML = `
<thead>
<tr>
-
<th>File Name</th>
+
<th class="sortable ${transcriptSortKey === 'original_filename' ? transcriptSortDirection : ''}" data-sort="original_filename">File Name</th>
<th>User</th>
-
<th>Status</th>
-
<th>Created At</th>
-
<th>Error</th>
+
<th class="sortable ${transcriptSortKey === 'status' ? transcriptSortDirection : ''}" data-sort="status">Status</th>
+
<th class="sortable ${transcriptSortKey === 'created_at' ? transcriptSortDirection : ''}" data-sort="created_at">Created At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
-
${transcriptions.map(t => `
-
<tr>
+
${filteredTranscriptions.map(t => `
+
<tr data-id="${t.id}">
<td>${t.original_filename}</td>
<td>
<div class="user-info">
···
</td>
<td><span class="status-badge status-${t.status}">${t.status}</span></td>
<td class="timestamp">${formatTimestamp(t.created_at)}</td>
-
<td>${t.error_message || '-'}</td>
<td>
<button class="delete-btn" data-id="${t.id}">Delete</button>
</td>
···
transcriptionsTable.innerHTML = '';
transcriptionsTable.appendChild(table);
+
// Add sort event listeners
+
table.querySelectorAll('th.sortable').forEach(th => {
+
th.addEventListener('click', () => {
+
const sortKey = th.dataset.sort;
+
if (transcriptSortKey === sortKey) {
+
transcriptSortDirection = transcriptSortDirection === 'asc' ? 'desc' : 'asc';
+
} else {
+
transcriptSortKey = sortKey;
+
transcriptSortDirection = 'asc';
+
}
+
renderTranscriptions(allTranscriptions);
+
});
+
});
+
// Add delete event listeners
table.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
+
e.stopPropagation(); // Prevent row click
const button = e.target;
const id = button.dataset.id;
···
button.disabled = false;
button.textContent = 'Delete';
}
+
});
+
});
+
+
// Add click event to table rows to open modal
+
table.querySelectorAll('tbody tr').forEach(row => {
+
row.addEventListener('click', (e) => {
+
// Don't open modal if clicking on delete button
+
if (e.target.closest('.delete-btn')) {
+
return;
+
}
+
+
const transcriptId = row.dataset.id;
+
openTranscriptModal(transcriptId);
});
});
}
···
document.getElementById('user-search').addEventListener('input', (e) => {
userSearchTerm = e.target.value.trim();
renderUsers(allUsers);
+
});
+
+
// Transcript search
+
document.getElementById('transcript-search').addEventListener('input', (e) => {
+
transcriptSearchTerm = e.target.value.trim();
+
renderTranscriptions(allTranscriptions);
});
// Initialize