🪻 distributed transcription service thistle.dunkirk.sh
at main 11 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, property, state } from "lit/decorators.js"; 3 4interface PendingRecording { 5 id: string; 6 user_id: number; 7 filename: string; 8 original_filename: string; 9 vote_count: number; 10 created_at: number; 11} 12 13interface RecordingsData { 14 recordings: PendingRecording[]; 15 total_users: number; 16 user_vote: string | null; 17 vote_threshold: number; 18 winning_recording_id: string | null; 19} 20 21@customElement("pending-recordings-view") 22export class PendingRecordingsView extends LitElement { 23 @property({ type: String }) classId = ""; 24 @property({ type: String }) meetingTimeId = ""; 25 @property({ type: String }) meetingTimeLabel = ""; 26 @property({ type: String }) sectionId: string | null = null; 27 28 @state() private recordings: PendingRecording[] = []; 29 @state() private userVote: string | null = null; 30 @state() private voteThreshold = 0; 31 @state() private winningRecordingId: string | null = null; 32 @state() private error: string | null = null; 33 @state() private timeRemaining = ""; 34 35 private refreshInterval?: number; 36 private loadingInProgress = false; 37 38 static override styles = css` 39 :host { 40 display: block; 41 padding: 1rem; 42 } 43 44 .container { 45 max-width: 56rem; 46 margin: 0 auto; 47 } 48 49 h2 { 50 color: var(--text); 51 margin-bottom: 0.5rem; 52 } 53 54 .info { 55 color: var(--paynes-gray); 56 font-size: 0.875rem; 57 margin-bottom: 1.5rem; 58 } 59 60 .stats { 61 display: flex; 62 gap: 2rem; 63 margin-bottom: 1.5rem; 64 padding: 1rem; 65 background: color-mix(in srgb, var(--primary) 5%, transparent); 66 border-radius: 8px; 67 } 68 69 .stat { 70 display: flex; 71 flex-direction: column; 72 gap: 0.25rem; 73 } 74 75 .stat-label { 76 font-size: 0.75rem; 77 color: var(--paynes-gray); 78 text-transform: uppercase; 79 letter-spacing: 0.05em; 80 } 81 82 .stat-value { 83 font-size: 1.5rem; 84 font-weight: 600; 85 color: var(--text); 86 } 87 88 .recordings-list { 89 display: flex; 90 flex-direction: column; 91 gap: 1rem; 92 } 93 94 .recording-card { 95 border: 2px solid var(--secondary); 96 border-radius: 8px; 97 padding: 1rem; 98 transition: all 0.2s; 99 } 100 101 .recording-card.voted { 102 border-color: var(--accent); 103 background: color-mix(in srgb, var(--accent) 5%, transparent); 104 } 105 106 .recording-card.winning { 107 border-color: var(--accent); 108 background: color-mix(in srgb, var(--accent) 10%, transparent); 109 } 110 111 .recording-header { 112 display: flex; 113 justify-content: space-between; 114 align-items: center; 115 margin-bottom: 0.75rem; 116 } 117 118 .recording-info { 119 flex: 1; 120 } 121 122 .recording-name { 123 font-weight: 600; 124 color: var(--text); 125 margin-bottom: 0.25rem; 126 } 127 128 .recording-meta { 129 font-size: 0.75rem; 130 color: var(--paynes-gray); 131 } 132 133 .vote-section { 134 display: flex; 135 align-items: center; 136 gap: 1rem; 137 } 138 139 .vote-count { 140 font-size: 1.25rem; 141 font-weight: 600; 142 color: var(--accent); 143 min-width: 3rem; 144 text-align: center; 145 } 146 147 .vote-button { 148 padding: 0.5rem 1rem; 149 border-radius: 6px; 150 font-size: 0.875rem; 151 font-weight: 500; 152 cursor: pointer; 153 transition: all 0.2s; 154 border: 2px solid var(--secondary); 155 background: var(--background); 156 color: var(--text); 157 } 158 159 .vote-button:hover:not(:disabled) { 160 border-color: var(--accent); 161 background: color-mix(in srgb, var(--accent) 10%, transparent); 162 } 163 164 .vote-button.voted { 165 border-color: var(--accent); 166 background: var(--accent); 167 color: var(--white); 168 } 169 170 .vote-button:disabled { 171 opacity: 0.5; 172 cursor: not-allowed; 173 } 174 175 .delete-button { 176 padding: 0.5rem; 177 border: none; 178 background: transparent; 179 color: var(--paynes-gray); 180 cursor: pointer; 181 border-radius: 4px; 182 transition: all 0.2s; 183 } 184 185 .delete-button:hover { 186 background: color-mix(in srgb, red 10%, transparent); 187 color: red; 188 } 189 190 .winning-badge { 191 background: var(--accent); 192 color: var(--white); 193 padding: 0.25rem 0.75rem; 194 border-radius: 12px; 195 font-size: 0.75rem; 196 font-weight: 600; 197 } 198 199 .error { 200 background: color-mix(in srgb, red 10%, transparent); 201 border: 1px solid red; 202 color: red; 203 padding: 0.75rem; 204 border-radius: 4px; 205 margin-bottom: 1rem; 206 font-size: 0.875rem; 207 } 208 209 .empty-state { 210 text-align: center; 211 padding: 3rem 1rem; 212 color: var(--paynes-gray); 213 } 214 215 .audio-player { 216 margin-top: 0.75rem; 217 } 218 219 audio { 220 width: 100%; 221 height: 2.5rem; 222 } 223 `; 224 225 override connectedCallback() { 226 super.connectedCallback(); 227 this.loadRecordings(); 228 // Refresh every 10 seconds 229 this.refreshInterval = setInterval(() => this.loadRecordings(), 10000); 230 } 231 232 override disconnectedCallback() { 233 super.disconnectedCallback(); 234 if (this.refreshInterval) { 235 clearInterval(this.refreshInterval); 236 } 237 } 238 239 private async loadRecordings() { 240 if (this.loadingInProgress) return; 241 242 this.loadingInProgress = true; 243 244 try { 245 // Build URL with optional section_id parameter 246 const url = new URL( 247 `/api/classes/${this.classId}/meetings/${this.meetingTimeId}/recordings`, 248 window.location.origin, 249 ); 250 if (this.sectionId !== null) { 251 url.searchParams.set("section_id", this.sectionId); 252 } 253 254 const response = await fetch(url.toString()); 255 256 if (!response.ok) { 257 throw new Error("Failed to load recordings"); 258 } 259 260 const data: RecordingsData = await response.json(); 261 this.recordings = data.recordings; 262 this.userVote = data.user_vote; 263 this.voteThreshold = data.vote_threshold; 264 this.winningRecordingId = data.winning_recording_id; 265 266 // Calculate time remaining for first recording 267 if (this.recordings.length > 0 && this.recordings[0]) { 268 const uploadedAt = this.recordings[0].created_at; 269 const now = Date.now() / 1000; 270 const elapsed = now - uploadedAt; 271 const remaining = 30 * 60 - elapsed; // 30 minutes 272 273 if (remaining > 0) { 274 const minutes = Math.floor(remaining / 60); 275 const seconds = Math.floor(remaining % 60); 276 this.timeRemaining = `${minutes}:${seconds.toString().padStart(2, "0")}`; 277 } else { 278 this.timeRemaining = "Auto-submitting..."; 279 } 280 } 281 282 this.error = null; 283 } catch (error) { 284 this.error = 285 error instanceof Error ? error.message : "Failed to load recordings"; 286 } finally { 287 this.loadingInProgress = false; 288 } 289 } 290 291 private async handleVote(recordingId: string) { 292 try { 293 const response = await fetch(`/api/recordings/${recordingId}/vote`, { 294 method: "POST", 295 }); 296 297 if (!response.ok) { 298 throw new Error("Failed to vote"); 299 } 300 301 const data = await response.json(); 302 303 // If a winner was selected, reload the page to show it in transcriptions 304 if (data.winning_recording_id) { 305 window.location.reload(); 306 } else { 307 // Just reload recordings to show updated votes 308 await this.loadRecordings(); 309 } 310 } catch (error) { 311 this.error = 312 error instanceof Error ? error.message : "Failed to vote"; 313 } 314 } 315 316 private async handleDelete(recordingId: string) { 317 if (!confirm("Delete this recording?")) { 318 return; 319 } 320 321 try { 322 const response = await fetch(`/api/recordings/${recordingId}`, { 323 method: "DELETE", 324 }); 325 326 if (!response.ok) { 327 throw new Error("Failed to delete recording"); 328 } 329 330 await this.loadRecordings(); 331 } catch (error) { 332 this.error = 333 error instanceof Error ? error.message : "Failed to delete recording"; 334 } 335 } 336 337 private formatTimeAgo(timestamp: number): string { 338 const now = Date.now() / 1000; 339 const diff = now - timestamp; 340 341 if (diff < 60) return "just now"; 342 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 343 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 344 return `${Math.floor(diff / 86400)}d ago`; 345 } 346 347 override render() { 348 return html` 349 <div class="container"> 350 <h2>Pending Recordings - ${this.meetingTimeLabel}</h2> 351 <p class="info"> 352 Vote for the best quality recording. The winner will be automatically transcribed when 40% of class votes or after 30 minutes. 353 </p> 354 355 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 356 357 ${ 358 this.recordings.length > 0 359 ? html` 360 <div class="stats"> 361 <div class="stat"> 362 <div class="stat-label">Recordings</div> 363 <div class="stat-value">${this.recordings.length}</div> 364 </div> 365 <div class="stat"> 366 <div class="stat-label">Vote Threshold</div> 367 <div class="stat-value">${this.voteThreshold} votes</div> 368 </div> 369 <div class="stat"> 370 <div class="stat-label">Time Remaining</div> 371 <div class="stat-value">${this.timeRemaining}</div> 372 </div> 373 </div> 374 375 <div class="recordings-list"> 376 ${this.recordings.map( 377 (recording) => html` 378 <div class="recording-card ${this.userVote === recording.id ? "voted" : ""} ${this.winningRecordingId === recording.id ? "winning" : ""}"> 379 <div class="recording-header"> 380 <div class="recording-info"> 381 <div class="recording-name">${recording.original_filename}</div> 382 <div class="recording-meta"> 383 Uploaded ${this.formatTimeAgo(recording.created_at)} 384 </div> 385 </div> 386 387 <div class="vote-section"> 388 ${ 389 this.winningRecordingId === recording.id 390 ? html`<span class="winning-badge">✨ Selected</span>` 391 : "" 392 } 393 394 <div class="vote-count"> 395 ${recording.vote_count} ${recording.vote_count === 1 ? "vote" : "votes"} 396 </div> 397 398 <button 399 class="vote-button ${this.userVote === recording.id ? "voted" : ""}" 400 @click=${() => this.handleVote(recording.id)} 401 ?disabled=${this.winningRecordingId !== null} 402 > 403 ${this.userVote === recording.id ? "✓ Voted" : "Vote"} 404 </button> 405 406 <button 407 class="delete-button" 408 @click=${() => this.handleDelete(recording.id)} 409 title="Delete recording" 410 > 411 🗑️ 412 </button> 413 </div> 414 </div> 415 416 <div class="audio-player"> 417 <audio controls preload="none"> 418 <source src="/api/transcriptions/${recording.id}/audio" type="audio/mpeg"> 419 </audio> 420 </div> 421 </div> 422 `, 423 )} 424 </div> 425 ` 426 : html` 427 <div class="empty-state"> 428 <p>No recordings uploaded yet for this meeting time.</p> 429 <p>Upload a recording to get started!</p> 430 </div> 431 ` 432 } 433 </div> 434 `; 435 } 436}