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