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