🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add admin pending recordings tab

- Create admin-pending-recordings component
- Display all pending recordings across all classes
- Show class info (course code, name), meeting time, uploader, upload date
- Approve & Transcribe button to select recordings for transcription
- Delete button to remove unwanted recordings
- Add new "Pending Recordings" tab as first tab in admin panel
- Fetch recordings from all classes and aggregate them
- Call PUT /api/transcripts/:id/select to approve recordings
- Reload data after approve/delete actions

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

+393 -2
+382
src/components/admin-pending-recordings.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + interface 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") 20 + export 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 + }
+11 -2
src/pages/admin.html
··· 371 371 </div> 372 372 373 373 <div class="tabs"> 374 - <button class="tab active" data-tab="transcriptions">Transcriptions</button> 374 + <button class="tab active" data-tab="pending">Pending Recordings</button> 375 + <button class="tab" data-tab="transcriptions">Transcriptions</button> 375 376 <button class="tab" data-tab="users">Users</button> 376 377 </div> 377 378 378 - <div id="transcriptions-tab" class="tab-content active"> 379 + <div id="pending-tab" class="tab-content active"> 380 + <div class="section"> 381 + <h2 class="section-title">Pending Recordings</h2> 382 + <admin-pending-recordings></admin-pending-recordings> 383 + </div> 384 + </div> 385 + 386 + <div id="transcriptions-tab" class="tab-content"> 379 387 <div class="section"> 380 388 <h2 class="section-title">All Transcriptions</h2> 381 389 <input type="text" id="transcript-search" class="search" placeholder="Search by filename or user..." /> ··· 397 405 <transcript-modal id="transcript-modal"></transcript-modal> 398 406 399 407 <script type="module" src="../components/auth.ts"></script> 408 + <script type="module" src="../components/admin-pending-recordings.ts"></script> 400 409 <script type="module" src="../components/user-modal.ts"></script> 401 410 <script type="module" src="../components/transcript-view-modal.ts"></script> 402 411 <script type="module">