🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: implement class system frontend (classes overview & detail)

- Rewrite classes-overview component to fetch from /api/classes
- Group classes by semester/year with section headers
- Display class cards with course code, name, professor, archived badge
- Add register for class card (placeholder)
- Rewrite class-view component to fetch from /api/classes/:id
- Display class info, meeting times, and recordings
- Show recording status badges (pending/selected/processing/completed/failed)
- Real-time transcription progress via SSE
- VTT viewer for completed transcriptions
- Archive banner and upload restrictions for archived classes
- Search functionality for recordings
- Update routing from /class/:className to /classes/* (wildcard for HTML routes)

💘 Generated with Crush

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

+494 -291
+340 -165
src/components/class-view.ts
··· 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import "../components/vtt-viewer.ts"; 4 4 5 - interface TranscriptionJob { 5 + interface Class { 6 6 id: string; 7 + course_code: string; 8 + name: string; 9 + professor: string; 10 + semester: string; 11 + year: number; 12 + archived: boolean; 13 + } 14 + 15 + interface MeetingTime { 16 + id: string; 17 + class_id: string; 18 + label: string; 19 + created_at: number; 20 + } 21 + 22 + interface Transcription { 23 + id: string; 24 + user_id: number; 25 + meeting_time_id: string | null; 7 26 filename: string; 8 - class_name?: string; 9 - status: "uploading" | "processing" | "transcribing" | "completed" | "failed"; 27 + original_filename: string; 28 + status: "pending" | "selected" | "uploading" | "processing" | "transcribing" | "completed" | "failed"; 10 29 progress: number; 30 + error_message: string | null; 11 31 created_at: number; 12 - audioUrl?: string; 13 - vttContent?: string; 32 + updated_at: number; 14 33 } 15 34 16 35 @customElement("class-view") 17 36 export class ClassView extends LitElement { 18 - @state() override className = ""; 19 - @state() jobs: TranscriptionJob[] = []; 20 - @state() searchQuery = ""; 37 + @state() classId = ""; 38 + @state() classInfo: Class | null = null; 39 + @state() meetingTimes: MeetingTime[] = []; 40 + @state() transcriptions: Transcription[] = []; 21 41 @state() isLoading = true; 42 + @state() error: string | null = null; 43 + @state() searchQuery = ""; 22 44 private eventSources: Map<string, EventSource> = new Map(); 23 45 24 46 static override styles = css` ··· 27 49 } 28 50 29 51 .header { 30 - display: flex; 31 - justify-content: space-between; 32 - align-items: center; 33 52 margin-bottom: 2rem; 34 53 } 35 54 ··· 47 66 color: var(--accent); 48 67 } 49 68 50 - h1 { 69 + .class-header { 70 + display: flex; 71 + justify-content: space-between; 72 + align-items: flex-start; 73 + margin-bottom: 1rem; 74 + } 75 + 76 + .class-info h1 { 51 77 color: var(--text); 52 - margin: 0; 78 + margin: 0 0 0.5rem 0; 79 + } 80 + 81 + .course-code { 82 + font-size: 1rem; 83 + color: var(--accent); 84 + font-weight: 600; 85 + text-transform: uppercase; 86 + } 87 + 88 + .professor { 89 + color: var(--paynes-gray); 90 + font-size: 0.875rem; 91 + margin-top: 0.25rem; 92 + } 93 + 94 + .semester { 95 + color: var(--paynes-gray); 96 + font-size: 0.875rem; 97 + } 98 + 99 + .archived-banner { 100 + background: var(--paynes-gray); 101 + color: var(--white); 102 + padding: 0.5rem 1rem; 103 + border-radius: 4px; 104 + font-weight: 600; 105 + margin-bottom: 1rem; 106 + } 107 + 108 + .search-upload { 109 + display: flex; 110 + gap: 1rem; 111 + align-items: center; 112 + margin-bottom: 2rem; 53 113 } 54 114 55 115 .search-box { 116 + flex: 1; 56 117 padding: 0.5rem 0.75rem; 57 118 border: 1px solid var(--secondary); 58 119 border-radius: 4px; 59 120 font-size: 0.875rem; 60 121 color: var(--text); 61 122 background: var(--background); 62 - width: 20rem; 63 123 } 64 124 65 125 .search-box:focus { ··· 67 127 border-color: var(--primary); 68 128 } 69 129 70 - .job-card { 130 + .upload-button { 131 + background: var(--accent); 132 + color: var(--white); 133 + border: none; 134 + padding: 0.5rem 1rem; 135 + border-radius: 4px; 136 + font-size: 0.875rem; 137 + font-weight: 600; 138 + cursor: pointer; 139 + transition: opacity 0.2s; 140 + } 141 + 142 + .upload-button:hover:not(:disabled) { 143 + opacity: 0.9; 144 + } 145 + 146 + .upload-button:disabled { 147 + opacity: 0.5; 148 + cursor: not-allowed; 149 + } 150 + 151 + .meetings-section { 152 + margin-bottom: 2rem; 153 + } 154 + 155 + .meetings-section h2 { 156 + font-size: 1.25rem; 157 + color: var(--text); 158 + margin-bottom: 1rem; 159 + } 160 + 161 + .meetings-list { 162 + display: flex; 163 + gap: 0.75rem; 164 + flex-wrap: wrap; 165 + } 166 + 167 + .meeting-tag { 168 + background: color-mix(in srgb, var(--primary) 10%, transparent); 169 + color: var(--primary); 170 + padding: 0.5rem 1rem; 171 + border-radius: 4px; 172 + font-size: 0.875rem; 173 + font-weight: 500; 174 + } 175 + 176 + .transcription-card { 71 177 background: var(--background); 72 178 border: 1px solid var(--secondary); 73 179 border-radius: 8px; ··· 75 181 margin-bottom: 1rem; 76 182 } 77 183 78 - .job-header { 184 + .transcription-header { 79 185 display: flex; 80 186 align-items: center; 81 187 justify-content: space-between; 82 188 margin-bottom: 1rem; 83 189 } 84 190 85 - .job-filename { 191 + .transcription-filename { 86 192 font-weight: 500; 87 193 color: var(--text); 88 194 } 89 195 90 - .job-date { 196 + .transcription-date { 91 197 font-size: 0.875rem; 92 198 color: var(--paynes-gray); 93 199 } 94 200 95 - .job-status { 201 + .transcription-status { 96 202 padding: 0.25rem 0.75rem; 97 203 border-radius: 4px; 98 204 font-size: 0.75rem; ··· 100 206 text-transform: uppercase; 101 207 } 102 208 103 - .status-completed { 104 - background: color-mix(in srgb, green 10%, transparent); 105 - color: green; 106 - } 107 - 108 - .status-failed { 109 - background: color-mix(in srgb, var(--text) 10%, transparent); 110 - color: var(--text); 209 + .status-pending { 210 + background: color-mix(in srgb, var(--paynes-gray) 10%, transparent); 211 + color: var(--paynes-gray); 111 212 } 112 213 113 - .status-processing, .status-transcribing, .status-uploading { 214 + .status-selected, .status-uploading, .status-processing, .status-transcribing { 114 215 background: color-mix(in srgb, var(--accent) 10%, transparent); 115 216 color: var(--accent); 116 217 } 117 218 118 - .audio-player audio { 119 - width: 100%; 120 - height: 2.5rem; 121 - } 122 - 123 - .empty-state { 124 - text-align: center; 125 - padding: 4rem 2rem; 126 - color: var(--paynes-gray); 219 + .status-completed { 220 + background: color-mix(in srgb, green 10%, transparent); 221 + color: green; 127 222 } 128 223 129 - .empty-state h2 { 130 - color: var(--text); 131 - margin-bottom: 1rem; 224 + .status-failed { 225 + background: color-mix(in srgb, red 10%, transparent); 226 + color: red; 132 227 } 133 228 134 229 .progress-bar { ··· 138 233 border-radius: 2px; 139 234 margin-bottom: 1rem; 140 235 overflow: hidden; 141 - position: relative; 142 236 } 143 237 144 238 .progress-fill { ··· 150 244 151 245 .progress-fill.indeterminate { 152 246 width: 30%; 153 - background: var(--primary); 154 247 animation: progress-slide 1.5s ease-in-out infinite; 155 248 } 156 249 157 250 @keyframes progress-slide { 158 - 0% { 159 - transform: translateX(-100%); 160 - } 161 - 100% { 162 - transform: translateX(333%); 163 - } 251 + 0% { transform: translateX(-100%); } 252 + 100% { transform: translateX(333%); } 253 + } 254 + 255 + .audio-player audio { 256 + width: 100%; 257 + height: 2.5rem; 258 + } 259 + 260 + .empty-state { 261 + text-align: center; 262 + padding: 4rem 2rem; 263 + color: var(--paynes-gray); 264 + } 265 + 266 + .empty-state h2 { 267 + color: var(--text); 268 + margin-bottom: 1rem; 269 + } 270 + 271 + .loading { 272 + text-align: center; 273 + padding: 4rem 2rem; 274 + color: var(--paynes-gray); 275 + } 276 + 277 + .error { 278 + background: color-mix(in srgb, red 10%, transparent); 279 + border: 1px solid red; 280 + color: red; 281 + padding: 1rem; 282 + border-radius: 4px; 283 + margin-bottom: 2rem; 164 284 } 165 285 `; 166 286 167 287 override async connectedCallback() { 168 288 super.connectedCallback(); 169 - this.extractClassName(); 170 - await this.loadJobs(); 171 - this.connectToJobStreams(); 289 + this.extractClassId(); 290 + await this.loadClass(); 291 + this.connectToTranscriptionStreams(); 172 292 173 293 window.addEventListener("auth-changed", this.handleAuthChange); 174 294 } ··· 176 296 override disconnectedCallback() { 177 297 super.disconnectedCallback(); 178 298 window.removeEventListener("auth-changed", this.handleAuthChange); 299 + // Close all event sources 300 + for (const eventSource of this.eventSources.values()) { 301 + eventSource.close(); 302 + } 303 + this.eventSources.clear(); 179 304 } 180 305 181 306 private handleAuthChange = async () => { 182 - await this.loadJobs(); 307 + await this.loadClass(); 183 308 }; 184 309 185 - private extractClassName() { 310 + private extractClassId() { 186 311 const path = window.location.pathname; 187 - const match = path.match(/^\/class\/(.+)$/); 188 - if (match) { 189 - this.className = decodeURIComponent(match[1] ?? ""); 312 + const match = path.match(/^\/classes\/(.+)$/); 313 + if (match && match[1]) { 314 + this.classId = match[1]; 190 315 } 191 316 } 192 317 193 - private async loadJobs() { 318 + private async loadClass() { 194 319 this.isLoading = true; 320 + this.error = null; 321 + 195 322 try { 196 - const response = await fetch("/api/transcriptions"); 323 + const response = await fetch(`/api/classes/${this.classId}`); 197 324 if (!response.ok) { 198 325 if (response.status === 401) { 199 - this.jobs = []; 326 + window.location.href = "/"; 327 + return; 328 + } 329 + if (response.status === 403) { 330 + this.error = "You don't have access to this class."; 200 331 return; 201 332 } 202 - throw new Error("Failed to load transcriptions"); 333 + throw new Error("Failed to load class"); 203 334 } 204 335 205 336 const data = await response.json(); 206 - const allJobs = data.jobs || []; 207 - 208 - // Filter by class 209 - if (this.className === "uncategorized") { 210 - this.jobs = allJobs.filter((job: TranscriptionJob) => !job.class_name); 211 - } else { 212 - this.jobs = allJobs.filter( 213 - (job: TranscriptionJob) => job.class_name === this.className, 214 - ); 215 - } 337 + this.classInfo = data.class; 338 + this.meetingTimes = data.meetingTimes || []; 339 + this.transcriptions = data.transcriptions || []; 216 340 217 - // Load VTT for completed jobs 218 - await this.loadVTTForCompletedJobs(); 341 + // Load VTT for completed transcriptions 342 + await this.loadVTTForCompleted(); 219 343 } catch (error) { 220 - console.error("Failed to load jobs:", error); 344 + console.error("Failed to load class:", error); 345 + this.error = "Failed to load class. Please try again."; 221 346 } finally { 222 347 this.isLoading = false; 223 348 } 224 349 } 225 350 226 - private async loadVTTForCompletedJobs() { 227 - const completedJobs = this.jobs.filter((job) => job.status === "completed"); 351 + private async loadVTTForCompleted() { 352 + const completed = this.transcriptions.filter((t) => t.status === "completed"); 228 353 229 354 await Promise.all( 230 - completedJobs.map(async (job) => { 355 + completed.map(async (transcription) => { 231 356 try { 232 - const response = await fetch( 233 - `/api/transcriptions/${job.id}?format=vtt`, 234 - ); 357 + const response = await fetch(`/api/transcriptions/${transcription.id}?format=vtt`); 235 358 if (response.ok) { 236 359 const vttContent = await response.text(); 237 - job.vttContent = vttContent; 238 - job.audioUrl = `/api/transcriptions/${job.id}/audio`; 360 + (transcription as any).vttContent = vttContent; 361 + (transcription as any).audioUrl = `/api/transcriptions/${transcription.id}/audio`; 239 362 this.requestUpdate(); 240 363 } 241 364 } catch (error) { 242 - console.error(`Failed to load VTT for job ${job.id}:`, error); 365 + console.error(`Failed to load VTT for ${transcription.id}:`, error); 243 366 } 244 367 }), 245 368 ); 246 369 } 247 370 248 - private connectToJobStreams() { 249 - // For active jobs, connect to SSE streams 250 - for (const job of this.jobs) { 251 - if ( 252 - job.status === "processing" || 253 - job.status === "transcribing" || 254 - job.status === "uploading" 255 - ) { 256 - this.connectToJobStream(job.id); 371 + private connectToTranscriptionStreams() { 372 + const activeStatuses = ["selected", "uploading", "processing", "transcribing"]; 373 + for (const transcription of this.transcriptions) { 374 + if (activeStatuses.includes(transcription.status)) { 375 + this.connectToStream(transcription.id); 257 376 } 258 377 } 259 378 } 260 379 261 - private connectToJobStream(jobId: string) { 262 - if (this.eventSources.has(jobId)) { 263 - return; 264 - } 380 + private connectToStream(transcriptionId: string) { 381 + if (this.eventSources.has(transcriptionId)) return; 265 382 266 - const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`); 383 + const eventSource = new EventSource(`/api/transcriptions/${transcriptionId}/stream`); 267 384 268 385 eventSource.addEventListener("update", async (event) => { 269 386 const update = JSON.parse(event.data); 387 + const transcription = this.transcriptions.find((t) => t.id === transcriptionId); 270 388 271 - const job = this.jobs.find((j) => j.id === jobId); 272 - if (job) { 273 - if (update.status !== undefined) job.status = update.status; 274 - if (update.progress !== undefined) job.progress = update.progress; 389 + if (transcription) { 390 + if (update.status !== undefined) transcription.status = update.status; 391 + if (update.progress !== undefined) transcription.progress = update.progress; 275 392 276 393 if (update.status === "completed") { 277 - await this.loadVTTForCompletedJobs(); 394 + await this.loadVTTForCompleted(); 278 395 eventSource.close(); 279 - this.eventSources.delete(jobId); 396 + this.eventSources.delete(transcriptionId); 280 397 } 281 398 282 399 this.requestUpdate(); ··· 285 402 286 403 eventSource.onerror = () => { 287 404 eventSource.close(); 288 - this.eventSources.delete(jobId); 405 + this.eventSources.delete(transcriptionId); 289 406 }; 290 407 291 - this.eventSources.set(jobId, eventSource); 408 + this.eventSources.set(transcriptionId, eventSource); 292 409 } 293 410 294 - private get filteredJobs(): TranscriptionJob[] { 295 - if (!this.searchQuery) { 296 - return this.jobs; 297 - } 411 + private get filteredTranscriptions() { 412 + if (!this.searchQuery) return this.transcriptions; 298 413 299 414 const query = this.searchQuery.toLowerCase(); 300 - return this.jobs.filter((job) => 301 - job.filename.toLowerCase().includes(query), 415 + return this.transcriptions.filter((t) => 416 + t.original_filename.toLowerCase().includes(query), 302 417 ); 303 418 } 304 419 ··· 313 428 }); 314 429 } 315 430 316 - private getStatusClass(status: string): string { 317 - return `status-${status}`; 431 + private getMeetingLabel(meetingTimeId: string | null): string { 432 + if (!meetingTimeId) return ""; 433 + const meeting = this.meetingTimes.find((m) => m.id === meetingTimeId); 434 + return meeting ? meeting.label : ""; 435 + } 436 + 437 + private handleUploadClick() { 438 + // TODO: Open upload modal 439 + alert("Upload modal coming soon!"); 318 440 } 319 441 320 442 override render() { 321 - const displayName = 322 - this.className === "uncategorized" ? "Uncategorized" : this.className; 443 + if (this.isLoading) { 444 + return html`<div class="loading">Loading class...</div>`; 445 + } 446 + 447 + if (this.error) { 448 + return html` 449 + <div class="error">${this.error}</div> 450 + <a href="/classes">← Back to classes</a> 451 + `; 452 + } 453 + 454 + if (!this.classInfo) { 455 + return html` 456 + <div class="error">Class not found</div> 457 + <a href="/classes">← Back to classes</a> 458 + `; 459 + } 323 460 324 461 return html` 325 - <div> 462 + <div class="header"> 326 463 <a href="/classes" class="back-link">← Back to all classes</a> 327 - 328 - <div class="header"> 329 - <h1>${displayName}</h1> 464 + 465 + ${this.classInfo.archived ? html`<div class="archived-banner">⚠️ This class is archived - no new recordings can be uploaded</div>` : ""} 466 + 467 + <div class="class-header"> 468 + <div class="class-info"> 469 + <div class="course-code">${this.classInfo.course_code}</div> 470 + <h1>${this.classInfo.name}</h1> 471 + <div class="professor">Professor: ${this.classInfo.professor}</div> 472 + <div class="semester">${this.classInfo.semester} ${this.classInfo.year}</div> 473 + </div> 474 + </div> 475 + 476 + ${ 477 + this.meetingTimes.length > 0 478 + ? html` 479 + <div class="meetings-section"> 480 + <h2>Meeting Times</h2> 481 + <div class="meetings-list"> 482 + ${this.meetingTimes.map((meeting) => html`<div class="meeting-tag">${meeting.label}</div>`)} 483 + </div> 484 + </div> 485 + ` 486 + : "" 487 + } 488 + 489 + <div class="search-upload"> 330 490 <input 331 491 type="text" 332 492 class="search-box" 333 - placeholder="Search transcriptions..." 493 + placeholder="Search recordings..." 334 494 .value=${this.searchQuery} 335 495 @input=${(e: Event) => { 336 496 this.searchQuery = (e.target as HTMLInputElement).value; 337 497 }} 338 498 /> 499 + <button 500 + class="upload-button" 501 + ?disabled=${this.classInfo.archived} 502 + @click=${this.handleUploadClick} 503 + > 504 + 📤 Upload Recording 505 + </button> 339 506 </div> 507 + </div> 340 508 341 - ${ 342 - this.filteredJobs.length === 0 && !this.isLoading 343 - ? html` 344 - <div class="empty-state"> 345 - <h2>${this.searchQuery ? "No matching transcriptions" : "No transcriptions yet"}</h2> 346 - <p>${this.searchQuery ? "Try a different search term" : "Upload an audio file to get started!"}</p> 347 - </div> 348 - ` 349 - : html` 350 - ${this.filteredJobs.map( 351 - (job) => html` 352 - <div class="job-card"> 353 - <div class="job-header"> 354 - <div> 355 - <div class="job-filename">${job.filename}</div> 356 - <div class="job-date">${this.formatDate(job.created_at)}</div> 357 - </div> 358 - <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span> 509 + ${ 510 + this.filteredTranscriptions.length === 0 511 + ? html` 512 + <div class="empty-state"> 513 + <h2>${this.searchQuery ? "No matching recordings" : "No recordings yet"}</h2> 514 + <p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p> 515 + </div> 516 + ` 517 + : html` 518 + ${this.filteredTranscriptions.map( 519 + (t) => html` 520 + <div class="transcription-card"> 521 + <div class="transcription-header"> 522 + <div> 523 + <div class="transcription-filename">${t.original_filename}</div> 524 + ${ 525 + t.meeting_time_id 526 + ? html`<div class="transcription-date">${this.getMeetingLabel(t.meeting_time_id)} • ${this.formatDate(t.created_at)}</div>` 527 + : html`<div class="transcription-date">${this.formatDate(t.created_at)}</div>` 528 + } 359 529 </div> 530 + <span class="transcription-status status-${t.status}">${t.status}</span> 531 + </div> 360 532 361 - ${ 362 - job.status === "uploading" || 363 - job.status === "processing" || 364 - job.status === "transcribing" 365 - ? html` 366 - <div class="progress-bar"> 367 - <div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}" 368 - style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div> 369 - </div> 370 - ` 371 - : "" 372 - } 533 + ${ 534 + ["uploading", "processing", "transcribing", "selected"].includes( 535 + t.status, 536 + ) 537 + ? html` 538 + <div class="progress-bar"> 539 + <div 540 + class="progress-fill ${t.status === "processing" ? "indeterminate" : ""}" 541 + style="${t.status === "processing" ? "" : `width: ${t.progress}%`}" 542 + ></div> 543 + </div> 544 + ` 545 + : "" 546 + } 547 + 548 + ${ 549 + t.status === "completed" && (t as any).audioUrl && (t as any).vttContent 550 + ? html` 551 + <div class="audio-player"> 552 + <audio id="audio-${t.id}" preload="metadata" controls src="${(t as any).audioUrl}"></audio> 553 + </div> 554 + <vtt-viewer .vttContent=${(t as any).vttContent} .audioId=${`audio-${t.id}`}></vtt-viewer> 555 + ` 556 + : "" 557 + } 373 558 374 - ${ 375 - job.status === "completed" && job.audioUrl && job.vttContent 376 - ? html` 377 - <div class="audio-player"> 378 - <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio> 379 - </div> 380 - <vtt-viewer .vttContent=${job.vttContent} .audioId=${`audio-${job.id}`}></vtt-viewer> 381 - ` 382 - : "" 383 - } 384 - </div> 385 - `, 386 - )} 387 - ` 388 - } 389 - </div> 559 + ${t.error_message ? html`<div class="error">${t.error_message}</div>` : ""} 560 + </div> 561 + `, 562 + )} 563 + ` 564 + } 390 565 `; 391 566 } 392 567 }
+153 -125
src/components/classes-overview.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 4 - interface ClassStats { 4 + interface Class { 5 + id: string; 6 + course_code: string; 5 7 name: string; 6 - count: number; 7 - lastUpdated: number; 8 + professor: string; 9 + semester: string; 10 + year: number; 11 + archived: boolean; 12 + } 13 + 14 + interface ClassesGrouped { 15 + [semesterYear: string]: Class[]; 8 16 } 9 17 10 18 @customElement("classes-overview") 11 19 export class ClassesOverview extends LitElement { 12 - @state() classes: ClassStats[] = []; 13 - @state() uncategorizedCount = 0; 20 + @state() classes: ClassesGrouped = {}; 21 + @state() isLoading = true; 22 + @state() error: string | null = null; 14 23 15 24 static override styles = css` 16 25 :host { ··· 22 31 margin-bottom: 2rem; 23 32 } 24 33 34 + .semester-section { 35 + margin-bottom: 3rem; 36 + } 37 + 38 + .semester-title { 39 + font-size: 1.5rem; 40 + font-weight: 600; 41 + color: var(--primary); 42 + margin-bottom: 1.5rem; 43 + padding-bottom: 0.5rem; 44 + border-bottom: 2px solid var(--secondary); 45 + } 46 + 25 47 .classes-grid { 26 48 display: grid; 27 49 grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); 28 50 gap: 1.5rem; 29 - margin-top: 2rem; 30 51 } 31 52 32 53 .class-card { ··· 39 60 text-decoration: none; 40 61 color: var(--text); 41 62 display: block; 63 + position: relative; 42 64 } 43 65 44 66 .class-card:hover { ··· 47 69 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 48 70 } 49 71 72 + .class-card.archived { 73 + opacity: 0.6; 74 + border-style: dashed; 75 + } 76 + 77 + .course-code { 78 + font-size: 0.875rem; 79 + font-weight: 600; 80 + color: var(--accent); 81 + text-transform: uppercase; 82 + margin-bottom: 0.5rem; 83 + } 84 + 50 85 .class-name { 51 - font-size: 1.25rem; 86 + font-size: 1.125rem; 52 87 font-weight: 600; 53 88 margin-bottom: 0.5rem; 54 89 color: var(--text); 55 90 } 56 91 57 - .class-stats { 92 + .professor { 58 93 font-size: 0.875rem; 59 94 color: var(--paynes-gray); 95 + margin-bottom: 0.25rem; 60 96 } 61 97 62 - .class-count { 63 - font-weight: 500; 64 - color: var(--accent); 98 + .archived-badge { 99 + position: absolute; 100 + top: 0.75rem; 101 + right: 0.75rem; 102 + background: var(--paynes-gray); 103 + color: var(--white); 104 + padding: 0.25rem 0.5rem; 105 + border-radius: 4px; 106 + font-size: 0.75rem; 107 + font-weight: 600; 108 + text-transform: uppercase; 65 109 } 66 110 67 - .upload-section { 111 + .register-card { 68 112 background: color-mix(in srgb, var(--accent) 10%, transparent); 69 113 border: 2px dashed var(--accent); 70 114 border-radius: 8px; 71 - padding: 2rem; 72 - margin-bottom: 2rem; 73 - text-align: center; 115 + padding: 1.5rem; 116 + cursor: pointer; 117 + transition: all 0.2s; 118 + display: flex; 119 + flex-direction: column; 120 + align-items: center; 121 + justify-content: center; 122 + min-height: 10rem; 123 + color: var(--accent); 74 124 } 75 125 76 - .upload-button { 77 - background: var(--accent); 78 - color: var(--white); 79 - border: none; 80 - padding: 0.75rem 1.5rem; 81 - border-radius: 4px; 82 - font-size: 1rem; 83 - font-weight: 600; 84 - cursor: pointer; 85 - transition: opacity 0.2s; 126 + .register-card:hover { 127 + background: color-mix(in srgb, var(--accent) 20%, transparent); 128 + transform: translateY(-2px); 129 + } 130 + 131 + .register-icon { 132 + font-size: 3rem; 133 + margin-bottom: 0.5rem; 86 134 } 87 135 88 - .upload-button:hover { 89 - opacity: 0.9; 136 + .register-text { 137 + font-weight: 600; 138 + font-size: 1rem; 90 139 } 91 140 92 141 .empty-state { ··· 99 148 color: var(--text); 100 149 margin-bottom: 1rem; 101 150 } 151 + 152 + .loading { 153 + text-align: center; 154 + padding: 4rem 2rem; 155 + color: var(--paynes-gray); 156 + } 157 + 158 + .error { 159 + background: color-mix(in srgb, red 10%, transparent); 160 + border: 1px solid red; 161 + color: red; 162 + padding: 1rem; 163 + border-radius: 4px; 164 + margin-bottom: 2rem; 165 + } 102 166 `; 103 167 104 168 override async connectedCallback() { 105 169 super.connectedCallback(); 106 170 await this.loadClasses(); 107 - 108 171 window.addEventListener("auth-changed", this.handleAuthChange); 109 172 } 110 173 ··· 118 181 }; 119 182 120 183 private async loadClasses() { 184 + this.isLoading = true; 185 + this.error = null; 186 + 121 187 try { 122 - const response = await fetch("/api/transcriptions"); 188 + const response = await fetch("/api/classes"); 123 189 if (!response.ok) { 124 190 if (response.status === 401) { 125 - this.classes = []; 126 - this.uncategorizedCount = 0; 191 + this.classes = {}; 127 192 return; 128 193 } 129 194 throw new Error("Failed to load classes"); 130 195 } 131 196 132 197 const data = await response.json(); 133 - const jobs = data.jobs || []; 134 - 135 - // Group by class and count 136 - const classMap = new Map< 137 - string, 138 - { count: number; lastUpdated: number } 139 - >(); 140 - let uncategorized = 0; 141 - 142 - for (const job of jobs) { 143 - const className = job.class_name; 144 - if (!className) { 145 - uncategorized++; 146 - } else { 147 - const existing = classMap.get(className); 148 - if (existing) { 149 - existing.count++; 150 - existing.lastUpdated = Math.max( 151 - existing.lastUpdated, 152 - job.created_at, 153 - ); 154 - } else { 155 - classMap.set(className, { 156 - count: 1, 157 - lastUpdated: job.created_at, 158 - }); 159 - } 160 - } 161 - } 162 - 163 - this.uncategorizedCount = uncategorized; 164 - this.classes = Array.from(classMap.entries()) 165 - .map(([name, stats]) => ({ 166 - name, 167 - count: stats.count, 168 - lastUpdated: stats.lastUpdated, 169 - })) 170 - .sort((a, b) => b.lastUpdated - a.lastUpdated); 198 + this.classes = data.classes || {}; 171 199 } catch (error) { 172 200 console.error("Failed to load classes:", error); 201 + this.error = "Failed to load classes. Please try again."; 202 + } finally { 203 + this.isLoading = false; 173 204 } 174 205 } 175 206 176 - private navigateToUpload() { 177 - window.location.href = "/transcribe"; 207 + private handleRegisterClick() { 208 + // TODO: Open registration modal/form 209 + alert("Class registration coming soon!"); 178 210 } 179 211 180 - private formatDate(timestamp: number): string { 181 - const date = new Date(timestamp * 1000); 182 - const now = new Date(); 183 - const diffMs = now.getTime() - date.getTime(); 184 - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); 212 + override render() { 213 + if (this.isLoading) { 214 + return html`<div class="loading">Loading classes...</div>`; 215 + } 185 216 186 - if (diffDays === 0) { 187 - return "Today"; 217 + if (this.error) { 218 + return html` 219 + <div class="error">${this.error}</div> 220 + <button @click=${this.loadClasses}>Retry</button> 221 + `; 188 222 } 189 - if (diffDays === 1) { 190 - return "Yesterday"; 191 - } 192 - if (diffDays < 7) { 193 - return `${diffDays} days ago`; 194 - } 195 - return date.toLocaleDateString(); 196 - } 197 223 198 - override render() { 199 - const hasClasses = this.classes.length > 0 || this.uncategorizedCount > 0; 224 + const semesterKeys = Object.keys(this.classes); 225 + const hasClasses = semesterKeys.length > 0; 200 226 201 227 return html` 202 228 <h1>Your Classes</h1> 203 229 204 - <div class="upload-section"> 205 - <button class="upload-button" @click=${this.navigateToUpload}> 206 - 📤 Upload New Transcription 207 - </button> 208 - </div> 209 - 210 230 ${ 211 231 hasClasses 212 232 ? html` 213 - <div class="classes-grid"> 214 - ${this.classes.map( 215 - (classInfo) => html` 216 - <a class="class-card" href="/class/${encodeURIComponent(classInfo.name)}"> 217 - <div class="class-name">${classInfo.name}</div> 218 - <div class="class-stats"> 219 - <span class="class-count">${classInfo.count}</span> 220 - ${classInfo.count === 1 ? "transcription" : "transcriptions"} 221 - • ${this.formatDate(classInfo.lastUpdated)} 233 + ${semesterKeys.map( 234 + (semesterYear) => html` 235 + <div class="semester-section"> 236 + <h2 class="semester-title">${semesterYear}</h2> 237 + <div class="classes-grid"> 238 + ${this.classes[semesterYear]?.map( 239 + (cls) => html` 240 + <a class="class-card ${cls.archived ? "archived" : ""}" href="/classes/${cls.id}"> 241 + ${cls.archived ? html`<div class="archived-badge">Archived</div>` : ""} 242 + <div class="course-code">${cls.course_code}</div> 243 + <div class="class-name">${cls.name}</div> 244 + <div class="professor">${cls.professor}</div> 245 + </a> 246 + `, 247 + )} 248 + 249 + ${ 250 + semesterKeys.indexOf(semesterYear) === 0 251 + ? html` 252 + <div class="register-card" @click=${this.handleRegisterClick}> 253 + <div class="register-icon">+</div> 254 + <div class="register-text">Register for Class</div> 255 + </div> 256 + ` 257 + : "" 258 + } 222 259 </div> 223 - </a> 260 + </div> 224 261 `, 225 262 )} 226 - 227 - ${ 228 - this.uncategorizedCount > 0 229 - ? html` 230 - <a class="class-card" href="/class/uncategorized"> 231 - <div class="class-name">Uncategorized</div> 232 - <div class="class-stats"> 233 - <span class="class-count">${this.uncategorizedCount}</span> 234 - ${this.uncategorizedCount === 1 ? "transcription" : "transcriptions"} 235 - </div> 236 - </a> 237 - ` 238 - : "" 239 - } 240 - </div> 241 - ` 263 + ` 242 264 : html` 243 - <div class="empty-state"> 244 - <h2>No transcriptions yet</h2> 245 - <p>Upload your first audio file to get started!</p> 246 - </div> 247 - ` 265 + <div class="empty-state"> 266 + <h2>No classes yet</h2> 267 + <p>You haven't been enrolled in any classes.</p> 268 + </div> 269 + <div class="classes-grid"> 270 + <div class="register-card" @click=${this.handleRegisterClick}> 271 + <div class="register-icon">+</div> 272 + <div class="register-text">Register for Class</div> 273 + </div> 274 + </div> 275 + ` 248 276 } 249 277 `; 250 278 }
+1 -1
src/index.ts
··· 127 127 "/settings": settingsHTML, 128 128 "/transcribe": transcribeHTML, 129 129 "/classes": classesHTML, 130 - "/class/:className": classHTML, 130 + "/classes/*": classHTML, 131 131 "/api/auth/register": { 132 132 POST: async (req) => { 133 133 try {