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