🪻 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3
4interface PendingRecording {
5 id: string;
6 original_filename: string;
7 user_id: number;
8 user_name: string | null;
9 user_email: string;
10 class_id: string;
11 class_name: string;
12 course_code: string;
13 meeting_time_id: string | null;
14 meeting_label: string | null;
15 created_at: number;
16 status: string;
17}
18
19interface Class {
20 id: string;
21 name: string;
22 course_code: string;
23}
24
25interface Transcription {
26 id: string;
27 original_filename: string;
28 status: string;
29 meeting_time_id: string | null;
30 created_at: number;
31}
32
33interface MeetingTime {
34 id: string;
35 label: string;
36}
37
38@customElement("admin-pending-recordings")
39export class AdminPendingRecordings extends LitElement {
40 @state() recordings: PendingRecording[] = [];
41 @state() isLoading = true;
42 @state() error: string | null = null;
43
44 static override styles = css`
45 :host {
46 display: block;
47 }
48
49 .error-banner {
50 background: #fecaca;
51 border: 2px solid rgba(220, 38, 38, 0.8);
52 border-radius: 6px;
53 padding: 1rem;
54 margin-bottom: 1.5rem;
55 color: #dc2626;
56 font-weight: 500;
57 }
58
59 .loading,
60 .empty-state {
61 text-align: center;
62 padding: 3rem;
63 color: var(--paynes-gray);
64 }
65
66 .error {
67 background: color-mix(in srgb, red 10%, transparent);
68 border: 1px solid red;
69 color: red;
70 padding: 1rem;
71 border-radius: 4px;
72 margin-bottom: 1rem;
73 }
74
75 .recordings-grid {
76 display: grid;
77 gap: 1.5rem;
78 }
79
80 .recording-card {
81 background: var(--background);
82 border: 2px solid var(--secondary);
83 border-radius: 8px;
84 padding: 1.5rem;
85 transition: border-color 0.2s;
86 }
87
88 .recording-card:hover {
89 border-color: var(--primary);
90 }
91
92 .card-header {
93 display: flex;
94 justify-content: space-between;
95 align-items: flex-start;
96 margin-bottom: 1rem;
97 }
98
99 .file-info {
100 flex: 1;
101 }
102
103 .filename {
104 font-size: 1.125rem;
105 font-weight: 600;
106 color: var(--text);
107 margin-bottom: 0.5rem;
108 }
109
110 .meta-row {
111 display: flex;
112 gap: 2rem;
113 flex-wrap: wrap;
114 margin-bottom: 1rem;
115 }
116
117 .meta-item {
118 display: flex;
119 flex-direction: column;
120 gap: 0.25rem;
121 }
122
123 .meta-label {
124 font-size: 0.75rem;
125 font-weight: 600;
126 text-transform: uppercase;
127 color: var(--paynes-gray);
128 letter-spacing: 0.05em;
129 }
130
131 .meta-value {
132 font-size: 0.875rem;
133 color: var(--text);
134 }
135
136 .class-info {
137 display: flex;
138 flex-direction: column;
139 gap: 0.25rem;
140 }
141
142 .course-code {
143 font-weight: 600;
144 color: var(--accent);
145 font-size: 0.875rem;
146 }
147
148 .class-name {
149 font-size: 0.875rem;
150 color: var(--text);
151 }
152
153 .meeting-label {
154 display: inline-block;
155 background: color-mix(in srgb, var(--primary) 10%, transparent);
156 color: var(--primary);
157 padding: 0.25rem 0.5rem;
158 border-radius: 4px;
159 font-size: 0.875rem;
160 font-weight: 500;
161 }
162
163 .user-info {
164 display: flex;
165 align-items: center;
166 gap: 0.5rem;
167 }
168
169 .user-avatar {
170 width: 1.5rem;
171 height: 1.5rem;
172 border-radius: 50%;
173 }
174
175 .timestamp {
176 color: var(--paynes-gray);
177 font-size: 0.875rem;
178 }
179
180 .audio-player {
181 margin: 1rem 0;
182 }
183
184 .audio-player audio {
185 width: 100%;
186 height: 2.5rem;
187 }
188
189 .actions {
190 display: flex;
191 gap: 0.75rem;
192 margin-top: 1rem;
193 }
194
195 .approve-btn {
196 background: var(--accent);
197 color: var(--white);
198 border: none;
199 padding: 0.75rem 1.5rem;
200 border-radius: 4px;
201 cursor: pointer;
202 font-size: 0.875rem;
203 font-weight: 600;
204 transition: opacity 0.2s;
205 flex: 1;
206 }
207
208 .approve-btn:hover:not(:disabled) {
209 opacity: 0.9;
210 }
211
212 .approve-btn:disabled {
213 opacity: 0.5;
214 cursor: not-allowed;
215 }
216
217 .delete-btn {
218 background: transparent;
219 border: 2px solid #dc2626;
220 color: #dc2626;
221 padding: 0.75rem 1.5rem;
222 border-radius: 4px;
223 cursor: pointer;
224 font-size: 0.875rem;
225 font-weight: 600;
226 transition: all 0.2s;
227 }
228
229 .delete-btn:hover:not(:disabled) {
230 background: #dc2626;
231 color: var(--white);
232 }
233
234 .delete-btn:disabled {
235 opacity: 0.5;
236 cursor: not-allowed;
237 }
238 `;
239
240 override async connectedCallback() {
241 super.connectedCallback();
242 await this.loadRecordings();
243 }
244
245 private async loadRecordings() {
246 this.isLoading = true;
247 this.error = null;
248
249 try {
250 // Get all classes with their transcriptions
251 const response = await fetch("/api/classes");
252 if (!response.ok) {
253 const data = await response.json();
254 throw new Error(data.error || "Failed to load classes");
255 }
256
257 const data = await response.json();
258 const classesGrouped = data.classes || {};
259
260 // Flatten all classes
261 const allClasses: Class[] = [];
262 for (const classes of Object.values(classesGrouped)) {
263 allClasses.push(...(classes as Class[]));
264 }
265
266 // Fetch transcriptions for each class
267 const pendingRecordings: PendingRecording[] = [];
268
269 await Promise.all(
270 allClasses.map(async (cls) => {
271 try {
272 const classResponse = await fetch(`/api/classes/${cls.id}`);
273 if (!classResponse.ok) return;
274
275 const classData = await classResponse.json();
276 const pendingTranscriptions = (
277 classData.transcriptions || []
278 ).filter((t: Transcription) => t.status === "pending");
279
280 for (const transcription of pendingTranscriptions) {
281 // Get user info
282 const userResponse = await fetch(
283 `/api/admin/transcriptions/${transcription.id}/details`,
284 );
285 if (!userResponse.ok) continue;
286
287 const transcriptionDetails = await userResponse.json();
288
289 // Find meeting label
290 const meetingTime = classData.meetingTimes.find(
291 (m: MeetingTime) => m.id === transcription.meeting_time_id,
292 );
293
294 pendingRecordings.push({
295 id: transcription.id,
296 original_filename: transcription.original_filename,
297 user_id: transcriptionDetails.user_id,
298 user_name: transcriptionDetails.user_name,
299 user_email: transcriptionDetails.user_email,
300 class_id: cls.id,
301 class_name: cls.name,
302 course_code: cls.course_code,
303 meeting_time_id: transcription.meeting_time_id,
304 meeting_label: meetingTime?.label || null,
305 created_at: transcription.created_at,
306 status: transcription.status,
307 });
308 }
309 } catch (error) {
310 console.error(`Failed to load class ${cls.id}:`, error);
311 }
312 }),
313 );
314
315 // Sort by created_at descending
316 pendingRecordings.sort((a, b) => b.created_at - a.created_at);
317
318 this.recordings = pendingRecordings;
319 } catch (err) {
320 this.error =
321 err instanceof Error
322 ? err.message
323 : "Failed to load pending recordings. Please try again.";
324 } finally {
325 this.isLoading = false;
326 }
327 }
328
329 private async handleApprove(recordingId: string) {
330 this.error = null;
331 try {
332 const response = await fetch(`/api/transcripts/${recordingId}/select`, {
333 method: "PUT",
334 });
335
336 if (!response.ok) {
337 const data = await response.json();
338 throw new Error(data.error || "Failed to approve recording");
339 }
340
341 // Reload recordings
342 await this.loadRecordings();
343 } catch (err) {
344 this.error =
345 err instanceof Error
346 ? err.message
347 : "Failed to approve recording. Please try again.";
348 }
349 }
350
351 private async handleDelete(recordingId: string) {
352 if (
353 !confirm(
354 "Are you sure you want to delete this recording? This cannot be undone.",
355 )
356 ) {
357 return;
358 }
359
360 this.error = null;
361 try {
362 const response = await fetch(`/api/admin/transcriptions/${recordingId}`, {
363 method: "DELETE",
364 });
365
366 if (!response.ok) {
367 const data = await response.json();
368 throw new Error(data.error || "Failed to delete recording");
369 }
370
371 // Reload recordings
372 await this.loadRecordings();
373 } catch (err) {
374 this.error =
375 err instanceof Error
376 ? err.message
377 : "Failed to delete recording. Please try again.";
378 }
379 }
380
381 private formatTimestamp(timestamp: number): string {
382 const date = new Date(timestamp * 1000);
383 return date.toLocaleString();
384 }
385
386 override render() {
387 if (this.isLoading) {
388 return html`<div class="loading">Loading pending recordings...</div>`;
389 }
390
391 if (this.error) {
392 return html`
393 <div class="error-banner">${this.error}</div>
394 <button @click=${this.loadRecordings}>Retry</button>
395 `;
396 }
397
398 if (this.recordings.length === 0) {
399 return html`
400 <div class="empty-state">
401 <p>No pending recordings</p>
402 </div>
403 `;
404 }
405
406 return html`
407 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""}
408
409 <div class="recordings-grid">
410 ${this.recordings.map(
411 (recording) => html`
412 <div class="recording-card">
413 <div class="card-header">
414 <div class="file-info">
415 <div class="filename">${recording.original_filename}</div>
416 </div>
417 </div>
418
419 <div class="meta-row">
420 <div class="meta-item">
421 <div class="meta-label">Class</div>
422 <div class="meta-value">
423 <div class="class-info">
424 <span class="course-code">${recording.course_code}</span>
425 <span class="class-name">${recording.class_name}</span>
426 </div>
427 </div>
428 </div>
429
430 <div class="meta-item">
431 <div class="meta-label">Meeting Time</div>
432 <div class="meta-value">
433 ${
434 recording.meeting_label
435 ? html`<span class="meeting-label">${recording.meeting_label}</span>`
436 : html`<span style="color: var(--paynes-gray); font-style: italic;">Not specified</span>`
437 }
438 </div>
439 </div>
440
441 <div class="meta-item">
442 <div class="meta-label">Uploaded By</div>
443 <div class="meta-value">
444 <div class="user-info">
445 <img
446 src="https://hostedboringavatars.vercel.app/api/marble?size=24&name=${recording.user_id}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
447 alt="Avatar"
448 class="user-avatar"
449 />
450 <span>${recording.user_name || recording.user_email}</span>
451 </div>
452 </div>
453 </div>
454
455 <div class="meta-item">
456 <div class="meta-label">Uploaded At</div>
457 <div class="meta-value timestamp">
458 ${this.formatTimestamp(recording.created_at)}
459 </div>
460 </div>
461 </div>
462
463 <div class="audio-player">
464 <audio controls preload="metadata" src="/api/transcriptions/${recording.id}/audio"></audio>
465 </div>
466
467 <div class="actions">
468 <button class="approve-btn" @click=${() => this.handleApprove(recording.id)}>
469 ✓ Approve & Transcribe
470 </button>
471 <button class="delete-btn" @click=${() => this.handleDelete(recording.id)}>
472 Delete
473 </button>
474 </div>
475 </div>
476 `,
477 )}
478 </div>
479 `;
480 }
481}