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