🪻 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}