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