🪻 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import "../components/vtt-viewer.ts";
4
5interface TranscriptionJob {
6 id: string;
7 filename: string;
8 class_name?: string;
9 status: "uploading" | "processing" | "transcribing" | "completed" | "failed";
10 progress: number;
11 created_at: number;
12 audioUrl?: string;
13 vttContent?: string;
14}
15
16@customElement("class-view")
17export class ClassView extends LitElement {
18 @state() override className = "";
19 @state() jobs: TranscriptionJob[] = [];
20 @state() searchQuery = "";
21 @state() isLoading = true;
22 private eventSources: Map<string, EventSource> = new Map();
23
24 static override styles = css`
25 :host {
26 display: block;
27 }
28
29 .header {
30 display: flex;
31 justify-content: space-between;
32 align-items: center;
33 margin-bottom: 2rem;
34 }
35
36 .back-link {
37 color: var(--paynes-gray);
38 text-decoration: none;
39 font-size: 0.875rem;
40 display: flex;
41 align-items: center;
42 gap: 0.25rem;
43 margin-bottom: 0.5rem;
44 }
45
46 .back-link:hover {
47 color: var(--accent);
48 }
49
50 h1 {
51 color: var(--text);
52 margin: 0;
53 }
54
55 .search-box {
56 padding: 0.5rem 0.75rem;
57 border: 1px solid var(--secondary);
58 border-radius: 4px;
59 font-size: 0.875rem;
60 color: var(--text);
61 background: var(--background);
62 width: 20rem;
63 }
64
65 .search-box:focus {
66 outline: none;
67 border-color: var(--primary);
68 }
69
70 .job-card {
71 background: var(--background);
72 border: 1px solid var(--secondary);
73 border-radius: 8px;
74 padding: 1.5rem;
75 margin-bottom: 1rem;
76 }
77
78 .job-header {
79 display: flex;
80 align-items: center;
81 justify-content: space-between;
82 margin-bottom: 1rem;
83 }
84
85 .job-filename {
86 font-weight: 500;
87 color: var(--text);
88 }
89
90 .job-date {
91 font-size: 0.875rem;
92 color: var(--paynes-gray);
93 }
94
95 .job-status {
96 padding: 0.25rem 0.75rem;
97 border-radius: 4px;
98 font-size: 0.75rem;
99 font-weight: 600;
100 text-transform: uppercase;
101 }
102
103 .status-completed {
104 background: color-mix(in srgb, green 10%, transparent);
105 color: green;
106 }
107
108 .status-failed {
109 background: color-mix(in srgb, var(--text) 10%, transparent);
110 color: var(--text);
111 }
112
113 .status-processing, .status-transcribing, .status-uploading {
114 background: color-mix(in srgb, var(--accent) 10%, transparent);
115 color: var(--accent);
116 }
117
118 .audio-player audio {
119 width: 100%;
120 height: 2.5rem;
121 }
122
123 .empty-state {
124 text-align: center;
125 padding: 4rem 2rem;
126 color: var(--paynes-gray);
127 }
128
129 .empty-state h2 {
130 color: var(--text);
131 margin-bottom: 1rem;
132 }
133
134 .progress-bar {
135 width: 100%;
136 height: 4px;
137 background: var(--secondary);
138 border-radius: 2px;
139 margin-bottom: 1rem;
140 overflow: hidden;
141 position: relative;
142 }
143
144 .progress-fill {
145 height: 100%;
146 background: var(--primary);
147 border-radius: 2px;
148 transition: width 0.3s;
149 }
150
151 .progress-fill.indeterminate {
152 width: 30%;
153 background: var(--primary);
154 animation: progress-slide 1.5s ease-in-out infinite;
155 }
156
157 @keyframes progress-slide {
158 0% {
159 transform: translateX(-100%);
160 }
161 100% {
162 transform: translateX(333%);
163 }
164 }
165 `;
166
167 override async connectedCallback() {
168 super.connectedCallback();
169 this.extractClassName();
170 await this.loadJobs();
171 this.connectToJobStreams();
172
173 window.addEventListener("auth-changed", this.handleAuthChange);
174 }
175
176 override disconnectedCallback() {
177 super.disconnectedCallback();
178 window.removeEventListener("auth-changed", this.handleAuthChange);
179 }
180
181 private handleAuthChange = async () => {
182 await this.loadJobs();
183 };
184
185 private extractClassName() {
186 const path = window.location.pathname;
187 const match = path.match(/^\/class\/(.+)$/);
188 if (match) {
189 this.className = decodeURIComponent(match[1] ?? "");
190 }
191 }
192
193 private async loadJobs() {
194 this.isLoading = true;
195 try {
196 const response = await fetch("/api/transcriptions");
197 if (!response.ok) {
198 if (response.status === 401) {
199 this.jobs = [];
200 return;
201 }
202 throw new Error("Failed to load transcriptions");
203 }
204
205 const data = await response.json();
206 const allJobs = data.jobs || [];
207
208 // Filter by class
209 if (this.className === "uncategorized") {
210 this.jobs = allJobs.filter((job: TranscriptionJob) => !job.class_name);
211 } else {
212 this.jobs = allJobs.filter(
213 (job: TranscriptionJob) => job.class_name === this.className,
214 );
215 }
216
217 // Load VTT for completed jobs
218 await this.loadVTTForCompletedJobs();
219 } catch (error) {
220 console.error("Failed to load jobs:", error);
221 } finally {
222 this.isLoading = false;
223 }
224 }
225
226 private async loadVTTForCompletedJobs() {
227 const completedJobs = this.jobs.filter((job) => job.status === "completed");
228
229 await Promise.all(
230 completedJobs.map(async (job) => {
231 try {
232 const response = await fetch(
233 `/api/transcriptions/${job.id}?format=vtt`,
234 );
235 if (response.ok) {
236 const vttContent = await response.text();
237 job.vttContent = vttContent;
238 job.audioUrl = `/api/transcriptions/${job.id}/audio`;
239 this.requestUpdate();
240 }
241 } catch (error) {
242 console.error(`Failed to load VTT for job ${job.id}:`, error);
243 }
244 }),
245 );
246 }
247
248 private connectToJobStreams() {
249 // For active jobs, connect to SSE streams
250 for (const job of this.jobs) {
251 if (
252 job.status === "processing" ||
253 job.status === "transcribing" ||
254 job.status === "uploading"
255 ) {
256 this.connectToJobStream(job.id);
257 }
258 }
259 }
260
261 private connectToJobStream(jobId: string) {
262 if (this.eventSources.has(jobId)) {
263 return;
264 }
265
266 const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
267
268 eventSource.addEventListener("update", async (event) => {
269 const update = JSON.parse(event.data);
270
271 const job = this.jobs.find((j) => j.id === jobId);
272 if (job) {
273 if (update.status !== undefined) job.status = update.status;
274 if (update.progress !== undefined) job.progress = update.progress;
275
276 if (update.status === "completed") {
277 await this.loadVTTForCompletedJobs();
278 eventSource.close();
279 this.eventSources.delete(jobId);
280 }
281
282 this.requestUpdate();
283 }
284 });
285
286 eventSource.onerror = () => {
287 eventSource.close();
288 this.eventSources.delete(jobId);
289 };
290
291 this.eventSources.set(jobId, eventSource);
292 }
293
294 private get filteredJobs(): TranscriptionJob[] {
295 if (!this.searchQuery) {
296 return this.jobs;
297 }
298
299 const query = this.searchQuery.toLowerCase();
300 return this.jobs.filter((job) =>
301 job.filename.toLowerCase().includes(query),
302 );
303 }
304
305 private formatDate(timestamp: number): string {
306 const date = new Date(timestamp * 1000);
307 return date.toLocaleDateString(undefined, {
308 year: "numeric",
309 month: "short",
310 day: "numeric",
311 hour: "2-digit",
312 minute: "2-digit",
313 });
314 }
315
316 private getStatusClass(status: string): string {
317 return `status-${status}`;
318 }
319
320 override render() {
321 const displayName =
322 this.className === "uncategorized" ? "Uncategorized" : this.className;
323
324 return html`
325 <div>
326 <a href="/classes" class="back-link">← Back to all classes</a>
327
328 <div class="header">
329 <h1>${displayName}</h1>
330 <input
331 type="text"
332 class="search-box"
333 placeholder="Search transcriptions..."
334 .value=${this.searchQuery}
335 @input=${(e: Event) => {
336 this.searchQuery = (e.target as HTMLInputElement).value;
337 }}
338 />
339 </div>
340
341 ${
342 this.filteredJobs.length === 0 && !this.isLoading
343 ? html`
344 <div class="empty-state">
345 <h2>${this.searchQuery ? "No matching transcriptions" : "No transcriptions yet"}</h2>
346 <p>${this.searchQuery ? "Try a different search term" : "Upload an audio file to get started!"}</p>
347 </div>
348 `
349 : html`
350 ${this.filteredJobs.map(
351 (job) => html`
352 <div class="job-card">
353 <div class="job-header">
354 <div>
355 <div class="job-filename">${job.filename}</div>
356 <div class="job-date">${this.formatDate(job.created_at)}</div>
357 </div>
358 <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span>
359 </div>
360
361 ${
362 job.status === "uploading" ||
363 job.status === "processing" ||
364 job.status === "transcribing"
365 ? html`
366 <div class="progress-bar">
367 <div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}"
368 style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div>
369 </div>
370 `
371 : ""
372 }
373
374 ${
375 job.status === "completed" && job.audioUrl && job.vttContent
376 ? html`
377 <div class="audio-player">
378 <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
379 </div>
380 <vtt-viewer .vttContent=${job.vttContent} .audioId=${`audio-${job.id}`}></vtt-viewer>
381 `
382 : ""
383 }
384 </div>
385 `,
386 )}
387 `
388 }
389 </div>
390 `;
391 }
392}