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