🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: add waitlist system with meeting times

Students can now request classes that don't exist yet:
- Search for classes, if not found, request it via waitlist form
- Waitlist form collects: course code, name, professor, semester, year, meeting times, and optional additional info
- Meeting times field allows multiple entries (e.g., "Monday Lecture", "Wednesday Lab")
- Pressing Enter in meeting time field adds another field instead of submitting

Admin workflow for waitlist management:
- New "Classes" tab in admin panel with two sub-tabs: Classes and Waitlist
- Waitlist shows all pending requests with badge count
- "Approve & Create Class" opens modal pre-filled with student's request
- Admin can edit meeting times before creating the class
- Creates class with meeting times, removes from waitlist, and switches to Classes tab
- Can also delete waitlist requests

Additional changes:
- Removed section field completely (not used, meeting times are more meaningful)
- Database migrations: v3 (waitlist table), v4 (meeting_times column), v5 (drop section)
- Meeting times stored as JSON array in waitlist, created as separate records when approved

💘 Generated with Crush

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

+1447 -6
+836
src/components/admin-classes.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + interface Class { 5 + id: string; 6 + course_code: string; 7 + name: string; 8 + professor: string; 9 + semester: string; 10 + year: number; 11 + archived: boolean; 12 + created_at: number; 13 + } 14 + 15 + interface WaitlistEntry { 16 + id: string; 17 + user_id: number; 18 + course_code: string; 19 + course_name: string; 20 + professor: string; 21 + semester: string; 22 + year: number; 23 + additional_info: string | null; 24 + meeting_times: string | null; 25 + created_at: number; 26 + } 27 + 28 + @customElement("admin-classes") 29 + export class AdminClasses extends LitElement { 30 + @state() classes: Class[] = []; 31 + @state() waitlist: WaitlistEntry[] = []; 32 + @state() isLoading = true; 33 + @state() error = ""; 34 + @state() searchTerm = ""; 35 + @state() showCreateModal = false; 36 + @state() activeTab: "classes" | "waitlist" = "classes"; 37 + @state() approvingEntry: WaitlistEntry | null = null; 38 + @state() meetingTimes: string[] = [""]; 39 + 40 + static override styles = css` 41 + :host { 42 + display: block; 43 + } 44 + 45 + .header { 46 + display: flex; 47 + justify-content: space-between; 48 + align-items: center; 49 + margin-bottom: 1.5rem; 50 + gap: 1rem; 51 + } 52 + 53 + .search { 54 + flex: 1; 55 + max-width: 30rem; 56 + padding: 0.5rem 0.75rem; 57 + border: 2px solid var(--secondary); 58 + border-radius: 4px; 59 + font-size: 1rem; 60 + font-family: inherit; 61 + background: var(--background); 62 + color: var(--text); 63 + } 64 + 65 + .search:focus { 66 + outline: none; 67 + border-color: var(--primary); 68 + } 69 + 70 + .create-btn { 71 + padding: 0.75rem 1.5rem; 72 + background: var(--primary); 73 + color: white; 74 + border: 2px solid var(--primary); 75 + border-radius: 6px; 76 + font-size: 1rem; 77 + font-weight: 500; 78 + cursor: pointer; 79 + transition: all 0.2s; 80 + font-family: inherit; 81 + white-space: nowrap; 82 + } 83 + 84 + .create-btn:hover { 85 + background: var(--gunmetal); 86 + border-color: var(--gunmetal); 87 + } 88 + 89 + .classes-grid { 90 + display: grid; 91 + gap: 1rem; 92 + } 93 + 94 + .class-card { 95 + background: var(--background); 96 + border: 2px solid var(--secondary); 97 + border-radius: 8px; 98 + padding: 1.25rem; 99 + transition: all 0.2s; 100 + } 101 + 102 + .class-card:hover { 103 + border-color: var(--primary); 104 + } 105 + 106 + .class-card.archived { 107 + opacity: 0.6; 108 + border-style: dashed; 109 + } 110 + 111 + .class-header { 112 + display: flex; 113 + justify-content: space-between; 114 + align-items: flex-start; 115 + gap: 1rem; 116 + margin-bottom: 0.75rem; 117 + } 118 + 119 + .class-info { 120 + flex: 1; 121 + } 122 + 123 + .course-code { 124 + font-size: 0.875rem; 125 + font-weight: 600; 126 + color: var(--accent); 127 + text-transform: uppercase; 128 + } 129 + 130 + .class-name { 131 + font-size: 1.125rem; 132 + font-weight: 600; 133 + margin: 0.25rem 0; 134 + color: var(--text); 135 + } 136 + 137 + .class-meta { 138 + display: flex; 139 + gap: 1rem; 140 + font-size: 0.875rem; 141 + color: var(--paynes-gray); 142 + flex-wrap: wrap; 143 + } 144 + 145 + .badge { 146 + display: inline-block; 147 + padding: 0.25rem 0.5rem; 148 + border-radius: 4px; 149 + font-size: 0.75rem; 150 + font-weight: 600; 151 + text-transform: uppercase; 152 + } 153 + 154 + .badge.archived { 155 + background: var(--secondary); 156 + color: var(--text); 157 + } 158 + 159 + .actions { 160 + display: flex; 161 + gap: 0.5rem; 162 + flex-wrap: wrap; 163 + } 164 + 165 + button { 166 + padding: 0.5rem 1rem; 167 + border: 2px solid; 168 + border-radius: 6px; 169 + font-size: 0.875rem; 170 + font-weight: 500; 171 + cursor: pointer; 172 + transition: all 0.2s; 173 + font-family: inherit; 174 + } 175 + 176 + .btn-archive { 177 + background: transparent; 178 + color: var(--paynes-gray); 179 + border-color: var(--secondary); 180 + } 181 + 182 + .btn-archive:hover { 183 + border-color: var(--paynes-gray); 184 + } 185 + 186 + .btn-delete { 187 + background: transparent; 188 + color: #dc2626; 189 + border-color: #dc2626; 190 + } 191 + 192 + .btn-delete:hover { 193 + background: #dc2626; 194 + color: white; 195 + } 196 + 197 + button:disabled { 198 + opacity: 0.6; 199 + cursor: not-allowed; 200 + } 201 + 202 + .empty-state { 203 + text-align: center; 204 + padding: 3rem 2rem; 205 + color: var(--paynes-gray); 206 + } 207 + 208 + .loading { 209 + text-align: center; 210 + padding: 3rem 2rem; 211 + color: var(--paynes-gray); 212 + } 213 + 214 + .error-message { 215 + background: #fee2e2; 216 + color: #991b1b; 217 + padding: 1rem; 218 + border-radius: 6px; 219 + margin-bottom: 1rem; 220 + } 221 + 222 + .tabs { 223 + display: flex; 224 + gap: 1rem; 225 + margin-bottom: 2rem; 226 + border-bottom: 2px solid var(--secondary); 227 + } 228 + 229 + .tab { 230 + padding: 0.75rem 1.5rem; 231 + background: transparent; 232 + border: none; 233 + border-radius: 0; 234 + color: var(--text); 235 + cursor: pointer; 236 + font-size: 1rem; 237 + font-weight: 500; 238 + font-family: inherit; 239 + border-bottom: 2px solid transparent; 240 + margin-bottom: -2px; 241 + transition: all 0.2s; 242 + } 243 + 244 + .tab:hover { 245 + color: var(--primary); 246 + } 247 + 248 + .tab.active { 249 + color: var(--primary); 250 + border-bottom-color: var(--primary); 251 + } 252 + 253 + .tab-badge { 254 + display: inline-block; 255 + margin-left: 0.5rem; 256 + padding: 0.125rem 0.5rem; 257 + background: var(--accent); 258 + color: white; 259 + border-radius: 12px; 260 + font-size: 0.75rem; 261 + font-weight: 600; 262 + } 263 + 264 + .modal-overlay { 265 + position: fixed; 266 + top: 0; 267 + left: 0; 268 + right: 0; 269 + bottom: 0; 270 + background: rgba(0, 0, 0, 0.5); 271 + display: flex; 272 + align-items: center; 273 + justify-content: center; 274 + z-index: 1000; 275 + padding: 1rem; 276 + } 277 + 278 + .modal { 279 + background: var(--background); 280 + border: 2px solid var(--secondary); 281 + border-radius: 12px; 282 + padding: 2rem; 283 + max-width: 32rem; 284 + width: 100%; 285 + max-height: 90vh; 286 + overflow-y: auto; 287 + } 288 + 289 + .modal-title { 290 + margin: 0 0 1.5rem 0; 291 + color: var(--text); 292 + font-size: 1.5rem; 293 + } 294 + 295 + .form-group { 296 + margin-bottom: 1rem; 297 + } 298 + 299 + .form-group label { 300 + display: block; 301 + margin-bottom: 0.5rem; 302 + font-weight: 500; 303 + color: var(--text); 304 + font-size: 0.875rem; 305 + } 306 + 307 + .form-group input { 308 + width: 100%; 309 + padding: 0.75rem; 310 + border: 2px solid var(--secondary); 311 + border-radius: 6px; 312 + font-size: 1rem; 313 + font-family: inherit; 314 + background: var(--background); 315 + color: var(--text); 316 + box-sizing: border-box; 317 + } 318 + 319 + .form-group input:focus { 320 + outline: none; 321 + border-color: var(--primary); 322 + } 323 + 324 + .meeting-times-list { 325 + display: flex; 326 + flex-direction: column; 327 + gap: 0.5rem; 328 + } 329 + 330 + .meeting-time-row { 331 + display: flex; 332 + gap: 0.5rem; 333 + align-items: center; 334 + } 335 + 336 + .meeting-time-row input { 337 + flex: 1; 338 + } 339 + 340 + .btn-remove { 341 + padding: 0.5rem; 342 + background: transparent; 343 + color: #dc2626; 344 + border: 2px solid #dc2626; 345 + border-radius: 6px; 346 + cursor: pointer; 347 + font-size: 0.875rem; 348 + font-weight: 500; 349 + transition: all 0.2s; 350 + } 351 + 352 + .btn-remove:hover { 353 + background: #dc2626; 354 + color: white; 355 + } 356 + 357 + .btn-add { 358 + margin-top: 0.5rem; 359 + padding: 0.5rem 1rem; 360 + background: transparent; 361 + color: var(--primary); 362 + border: 2px solid var(--primary); 363 + border-radius: 6px; 364 + cursor: pointer; 365 + font-size: 0.875rem; 366 + font-weight: 500; 367 + transition: all 0.2s; 368 + } 369 + 370 + .btn-add:hover { 371 + background: var(--primary); 372 + color: white; 373 + } 374 + 375 + .modal-actions { 376 + display: flex; 377 + gap: 0.75rem; 378 + justify-content: flex-end; 379 + margin-top: 1.5rem; 380 + } 381 + 382 + .btn-submit { 383 + padding: 0.75rem 1.5rem; 384 + background: var(--primary); 385 + color: white; 386 + border: 2px solid var(--primary); 387 + border-radius: 6px; 388 + font-size: 1rem; 389 + font-weight: 500; 390 + cursor: pointer; 391 + transition: all 0.2s; 392 + font-family: inherit; 393 + } 394 + 395 + .btn-submit:hover:not(:disabled) { 396 + background: var(--gunmetal); 397 + border-color: var(--gunmetal); 398 + } 399 + 400 + .btn-submit:disabled { 401 + opacity: 0.6; 402 + cursor: not-allowed; 403 + } 404 + 405 + .btn-cancel { 406 + padding: 0.75rem 1.5rem; 407 + background: transparent; 408 + color: var(--text); 409 + border: 2px solid var(--secondary); 410 + border-radius: 6px; 411 + font-size: 1rem; 412 + font-weight: 500; 413 + cursor: pointer; 414 + transition: all 0.2s; 415 + font-family: inherit; 416 + } 417 + 418 + .btn-cancel:hover { 419 + border-color: var(--primary); 420 + color: var(--primary); 421 + } 422 + `; 423 + 424 + override async connectedCallback() { 425 + super.connectedCallback(); 426 + await this.loadData(); 427 + } 428 + 429 + private async loadData() { 430 + this.isLoading = true; 431 + this.error = ""; 432 + 433 + try { 434 + const [classesRes, waitlistRes] = await Promise.all([ 435 + fetch("/api/admin/classes"), 436 + fetch("/api/admin/waitlist"), 437 + ]); 438 + 439 + if (!classesRes.ok || !waitlistRes.ok) { 440 + throw new Error("Failed to load data"); 441 + } 442 + 443 + const classesData = await classesRes.json(); 444 + const waitlistData = await waitlistRes.json(); 445 + 446 + this.classes = classesData.classes || []; 447 + this.waitlist = waitlistData.waitlist || []; 448 + } catch { 449 + this.error = "Failed to load data. Please try again."; 450 + } finally { 451 + this.isLoading = false; 452 + } 453 + } 454 + 455 + private handleSearch(e: Event) { 456 + this.searchTerm = (e.target as HTMLInputElement).value.toLowerCase(); 457 + } 458 + 459 + private async handleToggleArchive(classId: string) { 460 + try { 461 + const response = await fetch(`/api/classes/${classId}/archive`, { 462 + method: "PUT", 463 + }); 464 + 465 + if (!response.ok) { 466 + throw new Error("Failed to update class"); 467 + } 468 + 469 + await this.loadData(); 470 + } catch { 471 + this.error = "Failed to update class. Please try again."; 472 + } 473 + } 474 + 475 + private async handleDelete(classId: string, courseName: string) { 476 + if ( 477 + !confirm( 478 + `Are you sure you want to delete ${courseName}? This will remove all associated data and cannot be undone.`, 479 + ) 480 + ) { 481 + return; 482 + } 483 + 484 + try { 485 + const response = await fetch(`/api/classes/${classId}`, { 486 + method: "DELETE", 487 + }); 488 + 489 + if (!response.ok) { 490 + throw new Error("Failed to delete class"); 491 + } 492 + 493 + await this.loadData(); 494 + } catch { 495 + this.error = "Failed to delete class. Please try again."; 496 + } 497 + } 498 + 499 + private handleCreateClass() { 500 + this.showCreateModal = true; 501 + } 502 + 503 + private async handleDeleteWaitlist(id: string, courseCode: string) { 504 + if ( 505 + !confirm( 506 + `Are you sure you want to delete this waitlist request for ${courseCode}?`, 507 + ) 508 + ) { 509 + return; 510 + } 511 + 512 + try { 513 + const response = await fetch(`/api/admin/waitlist/${id}`, { 514 + method: "DELETE", 515 + }); 516 + 517 + if (!response.ok) { 518 + throw new Error("Failed to delete waitlist entry"); 519 + } 520 + 521 + await this.loadData(); 522 + } catch { 523 + this.error = "Failed to delete waitlist entry. Please try again."; 524 + } 525 + } 526 + 527 + private getFilteredClasses() { 528 + if (!this.searchTerm) return this.classes; 529 + 530 + return this.classes.filter((cls) => { 531 + const searchStr = this.searchTerm; 532 + return ( 533 + cls.course_code.toLowerCase().includes(searchStr) || 534 + cls.name.toLowerCase().includes(searchStr) || 535 + cls.professor.toLowerCase().includes(searchStr) 536 + ); 537 + }); 538 + } 539 + 540 + override render() { 541 + if (this.isLoading) { 542 + return html`<div class="loading">Loading...</div>`; 543 + } 544 + 545 + const filteredClasses = this.getFilteredClasses(); 546 + 547 + return html` 548 + ${this.error ? html`<div class="error-message">${this.error}</div>` : ""} 549 + 550 + <div class="tabs"> 551 + <button 552 + class="tab ${this.activeTab === "classes" ? "active" : ""}" 553 + @click=${() => { this.activeTab = "classes"; }} 554 + > 555 + Classes 556 + </button> 557 + <button 558 + class="tab ${this.activeTab === "waitlist" ? "active" : ""}" 559 + @click=${() => { this.activeTab = "waitlist"; }} 560 + > 561 + Waitlist 562 + ${this.waitlist.length > 0 ? html`<span class="tab-badge">${this.waitlist.length}</span>` : ""} 563 + </button> 564 + </div> 565 + 566 + ${ 567 + this.activeTab === "classes" 568 + ? this.renderClasses(filteredClasses) 569 + : this.renderWaitlist() 570 + } 571 + 572 + ${this.approvingEntry ? this.renderApprovalModal() : ""} 573 + `; 574 + } 575 + 576 + private renderClasses(filteredClasses: Class[]) { 577 + return html` 578 + <div class="header"> 579 + <input 580 + type="text" 581 + class="search" 582 + placeholder="Search classes..." 583 + @input=${this.handleSearch} 584 + .value=${this.searchTerm} 585 + /> 586 + <button class="create-btn" @click=${this.handleCreateClass}> 587 + + Create Class 588 + </button> 589 + </div> 590 + 591 + ${ 592 + filteredClasses.length === 0 593 + ? html` 594 + <div class="empty-state"> 595 + ${this.searchTerm ? "No classes found matching your search" : "No classes yet"} 596 + </div> 597 + ` 598 + : html` 599 + <div class="classes-grid"> 600 + ${filteredClasses.map( 601 + (cls) => html` 602 + <div class="class-card ${cls.archived ? "archived" : ""}"> 603 + <div class="class-header"> 604 + <div class="class-info"> 605 + <div class="course-code">${cls.course_code}</div> 606 + <div class="class-name">${cls.name}</div> 607 + <div class="class-meta"> 608 + <span>👤 ${cls.professor}</span> 609 + <span>📅 ${cls.semester} ${cls.year}</span> 610 + ${cls.archived ? html`<span class="badge archived">Archived</span>` : ""} 611 + </div> 612 + </div> 613 + <div class="actions"> 614 + <button 615 + class="btn-archive" 616 + @click=${() => this.handleToggleArchive(cls.id)} 617 + > 618 + ${cls.archived ? "Unarchive" : "Archive"} 619 + </button> 620 + <button 621 + class="btn-delete" 622 + @click=${() => this.handleDelete(cls.id, cls.course_code)} 623 + > 624 + Delete 625 + </button> 626 + </div> 627 + </div> 628 + </div> 629 + `, 630 + )} 631 + </div> 632 + ` 633 + } 634 + `; 635 + } 636 + 637 + private renderWaitlist() { 638 + return html` 639 + ${ 640 + this.waitlist.length === 0 641 + ? html` 642 + <div class="empty-state">No waitlist requests yet</div> 643 + ` 644 + : html` 645 + <div class="classes-grid"> 646 + ${this.waitlist.map( 647 + (entry) => html` 648 + <div class="class-card"> 649 + <div class="class-header"> 650 + <div class="class-info"> 651 + <div class="course-code">${entry.course_code}</div> 652 + <div class="class-name">${entry.course_name}</div> 653 + <div class="class-meta"> 654 + <span>👤 ${entry.professor}</span> 655 + <span>📅 ${entry.semester} ${entry.year}</span> 656 + </div> 657 + ${ 658 + entry.additional_info 659 + ? html` 660 + <p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--paynes-gray);"> 661 + ${entry.additional_info} 662 + </p> 663 + ` 664 + : "" 665 + } 666 + </div> 667 + <div class="actions"> 668 + <button 669 + class="btn-archive" 670 + @click=${() => this.handleApproveWaitlist(entry)} 671 + > 672 + Approve & Create Class 673 + </button> 674 + <button 675 + class="btn-delete" 676 + @click=${() => this.handleDeleteWaitlist(entry.id, entry.course_code)} 677 + > 678 + Delete 679 + </button> 680 + </div> 681 + </div> 682 + </div> 683 + `, 684 + )} 685 + </div> 686 + ` 687 + } 688 + `; 689 + } 690 + 691 + private handleApproveWaitlist(entry: WaitlistEntry) { 692 + this.approvingEntry = entry; 693 + 694 + // Parse meeting times from JSON if available, otherwise use empty array 695 + if (entry.meeting_times) { 696 + try { 697 + const parsed = JSON.parse(entry.meeting_times); 698 + this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : [""]; 699 + } catch { 700 + this.meetingTimes = [""]; 701 + } 702 + } else { 703 + this.meetingTimes = [""]; 704 + } 705 + } 706 + 707 + private addMeetingTime() { 708 + this.meetingTimes = [...this.meetingTimes, ""]; 709 + } 710 + 711 + private removeMeetingTime(index: number) { 712 + this.meetingTimes = this.meetingTimes.filter((_, i) => i !== index); 713 + } 714 + 715 + private updateMeetingTime(index: number, value: string) { 716 + this.meetingTimes = this.meetingTimes.map((time, i) => 717 + i === index ? value : time, 718 + ); 719 + } 720 + 721 + private cancelApproval() { 722 + this.approvingEntry = null; 723 + this.meetingTimes = [""]; 724 + } 725 + 726 + private async submitApproval() { 727 + if (!this.approvingEntry) return; 728 + 729 + const entry = this.approvingEntry; 730 + const times = this.meetingTimes.filter((t) => t.trim() !== ""); 731 + 732 + if (times.length === 0) { 733 + this.error = "Please add at least one meeting time"; 734 + return; 735 + } 736 + 737 + try { 738 + const response = await fetch("/api/classes", { 739 + method: "POST", 740 + headers: { "Content-Type": "application/json" }, 741 + body: JSON.stringify({ 742 + course_code: entry.course_code, 743 + name: entry.course_name, 744 + professor: entry.professor, 745 + semester: entry.semester, 746 + year: entry.year, 747 + meeting_times: times, 748 + }), 749 + }); 750 + 751 + if (!response.ok) { 752 + throw new Error("Failed to create class"); 753 + } 754 + 755 + await fetch(`/api/admin/waitlist/${entry.id}`, { 756 + method: "DELETE", 757 + }); 758 + 759 + await this.loadData(); 760 + 761 + this.activeTab = "classes"; 762 + this.approvingEntry = null; 763 + this.meetingTimes = [""]; 764 + } catch { 765 + this.error = "Failed to approve waitlist entry. Please try again."; 766 + } 767 + } 768 + 769 + private renderApprovalModal() { 770 + if (!this.approvingEntry) return ""; 771 + 772 + const entry = this.approvingEntry; 773 + 774 + return html` 775 + <div class="modal-overlay" @click=${this.cancelApproval}> 776 + <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 777 + <h2 class="modal-title">Add Meeting Times</h2> 778 + 779 + <p style="margin-bottom: 1.5rem; color: var(--paynes-gray);"> 780 + Creating class: <strong>${entry.course_code} - ${entry.course_name}</strong> 781 + </p> 782 + 783 + <div class="form-group"> 784 + <label>Meeting Times</label> 785 + <div class="meeting-times-list"> 786 + ${this.meetingTimes.map( 787 + (time, index) => html` 788 + <div class="meeting-time-row"> 789 + <input 790 + type="text" 791 + placeholder="e.g., Monday Lecture, Wednesday Lab" 792 + .value=${time} 793 + @input=${(e: Event) => 794 + this.updateMeetingTime( 795 + index, 796 + (e.target as HTMLInputElement).value, 797 + )} 798 + /> 799 + ${ 800 + this.meetingTimes.length > 1 801 + ? html` 802 + <button 803 + class="btn-remove" 804 + @click=${() => this.removeMeetingTime(index)} 805 + > 806 + Remove 807 + </button> 808 + ` 809 + : "" 810 + } 811 + </div> 812 + `, 813 + )} 814 + <button class="btn-add" @click=${this.addMeetingTime}> 815 + + Add Meeting Time 816 + </button> 817 + </div> 818 + </div> 819 + 820 + <div class="modal-actions"> 821 + <button class="btn-cancel" @click=${this.cancelApproval}> 822 + Cancel 823 + </button> 824 + <button 825 + class="btn-submit" 826 + @click=${this.submitApproval} 827 + ?disabled=${this.meetingTimes.every((t) => t.trim() === "")} 828 + > 829 + Create Class 830 + </button> 831 + </div> 832 + </div> 833 + </div> 834 + `; 835 + } 836 + }
+401 -4
src/components/class-registration-modal.ts
··· 6 6 course_code: string; 7 7 name: string; 8 8 professor: string; 9 - section: string | null; 10 9 semester: string; 11 10 year: number; 12 11 } ··· 20 19 @state() isJoining = false; 21 20 @state() error = ""; 22 21 @state() hasSearched = false; 22 + @state() showWaitlistForm = false; 23 + @state() waitlistData = { 24 + courseCode: "", 25 + courseName: "", 26 + professor: "", 27 + semester: "", 28 + year: new Date().getFullYear(), 29 + additionalInfo: "", 30 + meetingTimes: [""], 31 + }; 23 32 24 33 static override styles = css` 25 34 :host { ··· 257 266 color: var(--paynes-gray); 258 267 } 259 268 269 + .empty-state button { 270 + margin-top: 1rem; 271 + padding: 0.75rem 1.5rem; 272 + background: var(--accent); 273 + color: white; 274 + border: 2px solid var(--accent); 275 + border-radius: 6px; 276 + font-size: 1rem; 277 + font-weight: 500; 278 + cursor: pointer; 279 + transition: all 0.2s; 280 + font-family: inherit; 281 + } 282 + 283 + .empty-state button:hover { 284 + background: transparent; 285 + color: var(--accent); 286 + } 287 + 288 + .waitlist-form { 289 + margin-top: 1.5rem; 290 + } 291 + 292 + .form-grid { 293 + display: grid; 294 + grid-template-columns: 1fr 1fr; 295 + gap: 1rem; 296 + margin-bottom: 1rem; 297 + } 298 + 299 + .form-group-full { 300 + grid-column: 1 / -1; 301 + } 302 + 303 + .form-group { 304 + display: flex; 305 + flex-direction: column; 306 + } 307 + 308 + .form-group label { 309 + margin-bottom: 0.5rem; 310 + } 311 + 312 + .form-group input, 313 + .form-group select, 314 + .form-group textarea { 315 + width: 100%; 316 + padding: 0.75rem; 317 + border: 2px solid var(--secondary); 318 + border-radius: 6px; 319 + font-size: 1rem; 320 + font-family: inherit; 321 + background: var(--background); 322 + color: var(--text); 323 + transition: all 0.2s; 324 + box-sizing: border-box; 325 + } 326 + 327 + .form-group textarea { 328 + min-height: 6rem; 329 + resize: vertical; 330 + } 331 + 332 + .form-group input:focus, 333 + .form-group select:focus, 334 + .form-group textarea:focus { 335 + outline: none; 336 + border-color: var(--primary); 337 + } 338 + 339 + .form-actions { 340 + display: flex; 341 + gap: 0.75rem; 342 + justify-content: flex-end; 343 + margin-top: 1.5rem; 344 + } 345 + 346 + .btn-submit { 347 + padding: 0.75rem 1.5rem; 348 + background: var(--primary); 349 + color: white; 350 + border: 2px solid var(--primary); 351 + border-radius: 6px; 352 + font-size: 1rem; 353 + font-weight: 500; 354 + cursor: pointer; 355 + transition: all 0.2s; 356 + font-family: inherit; 357 + } 358 + 359 + .btn-submit:hover:not(:disabled) { 360 + background: var(--gunmetal); 361 + border-color: var(--gunmetal); 362 + } 363 + 364 + .btn-submit:disabled { 365 + opacity: 0.6; 366 + cursor: not-allowed; 367 + } 368 + 369 + .btn-cancel { 370 + padding: 0.75rem 1.5rem; 371 + background: transparent; 372 + color: var(--text); 373 + border: 2px solid var(--secondary); 374 + border-radius: 6px; 375 + font-size: 1rem; 376 + font-weight: 500; 377 + cursor: pointer; 378 + transition: all 0.2s; 379 + font-family: inherit; 380 + } 381 + 382 + .btn-cancel:hover { 383 + border-color: var(--primary); 384 + color: var(--primary); 385 + } 386 + 260 387 .loading { 261 388 text-align: center; 262 389 padding: 2rem; 263 390 color: var(--paynes-gray); 264 391 } 392 + 393 + .meeting-times-list { 394 + display: flex; 395 + flex-direction: column; 396 + gap: 0.5rem; 397 + } 398 + 399 + .meeting-time-row { 400 + display: flex; 401 + gap: 0.5rem; 402 + align-items: center; 403 + } 404 + 405 + .meeting-time-row input { 406 + flex: 1; 407 + } 408 + 409 + .btn-remove { 410 + padding: 0.5rem 1rem; 411 + background: transparent; 412 + color: #dc2626; 413 + border: 2px solid #dc2626; 414 + border-radius: 6px; 415 + cursor: pointer; 416 + font-size: 0.875rem; 417 + font-weight: 500; 418 + transition: all 0.2s; 419 + font-family: inherit; 420 + } 421 + 422 + .btn-remove:hover { 423 + background: #dc2626; 424 + color: white; 425 + } 426 + 427 + .btn-add { 428 + margin-top: 0.5rem; 429 + padding: 0.5rem 1rem; 430 + background: transparent; 431 + color: var(--primary); 432 + border: 2px solid var(--primary); 433 + border-radius: 6px; 434 + cursor: pointer; 435 + font-size: 0.875rem; 436 + font-weight: 500; 437 + transition: all 0.2s; 438 + font-family: inherit; 439 + } 440 + 441 + .btn-add:hover { 442 + background: var(--primary); 443 + color: white; 444 + } 265 445 `; 266 446 267 447 private handleClose() { ··· 269 449 this.results = []; 270 450 this.error = ""; 271 451 this.hasSearched = false; 452 + this.showWaitlistForm = false; 453 + this.waitlistData = { 454 + courseCode: "", 455 + courseName: "", 456 + professor: "", 457 + semester: "", 458 + year: new Date().getFullYear(), 459 + additionalInfo: "", 460 + meetingTimes: [""], 461 + }; 272 462 this.dispatchEvent(new CustomEvent("close")); 273 463 } 274 464 ··· 330 520 } 331 521 } 332 522 523 + private handleRequestWaitlist() { 524 + this.showWaitlistForm = true; 525 + this.waitlistData.courseCode = this.searchQuery; 526 + } 527 + 528 + private handleWaitlistInput(field: string, e: Event) { 529 + const value = ( 530 + e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement 531 + ).value; 532 + this.waitlistData = { ...this.waitlistData, [field]: value }; 533 + } 534 + 535 + private async handleSubmitWaitlist(e: Event) { 536 + e.preventDefault(); 537 + this.isJoining = true; 538 + this.error = ""; 539 + 540 + try { 541 + const response = await fetch("/api/classes/waitlist", { 542 + method: "POST", 543 + headers: { "Content-Type": "application/json" }, 544 + body: JSON.stringify(this.waitlistData), 545 + }); 546 + 547 + if (!response.ok) { 548 + const data = await response.json(); 549 + this.error = data.error || "Failed to submit waitlist request"; 550 + return; 551 + } 552 + 553 + // Success 554 + alert( 555 + "Your class request has been submitted! An admin will review it soon.", 556 + ); 557 + this.handleClose(); 558 + } catch { 559 + this.error = "Failed to submit request. Please try again."; 560 + } finally { 561 + this.isJoining = false; 562 + } 563 + } 564 + 565 + private handleCancelWaitlist() { 566 + this.showWaitlistForm = false; 567 + } 568 + 569 + private addMeetingTime() { 570 + this.waitlistData = { 571 + ...this.waitlistData, 572 + meetingTimes: [...this.waitlistData.meetingTimes, ""], 573 + }; 574 + } 575 + 576 + private removeMeetingTime(index: number) { 577 + this.waitlistData = { 578 + ...this.waitlistData, 579 + meetingTimes: this.waitlistData.meetingTimes.filter( 580 + (_, i) => i !== index, 581 + ), 582 + }; 583 + } 584 + 585 + private updateMeetingTime(index: number, value: string) { 586 + const newTimes = [...this.waitlistData.meetingTimes]; 587 + newTimes[index] = value; 588 + this.waitlistData = { ...this.waitlistData, meetingTimes: newTimes }; 589 + } 590 + 333 591 override render() { 334 592 if (!this.open) return html``; 335 593 ··· 376 634 this.isSearching 377 635 ? html`<div class="loading">Searching...</div>` 378 636 : this.results.length === 0 379 - ? html` 637 + ? this.showWaitlistForm 638 + ? html` 639 + <div class="waitlist-form"> 640 + <p style="margin-bottom: 1.5rem; color: var(--text);"> 641 + Request this class to be added to Thistle 642 + </p> 643 + <form @submit=${this.handleSubmitWaitlist}> 644 + <div class="form-grid"> 645 + <div class="form-group"> 646 + <label>Course Code *</label> 647 + <input 648 + type="text" 649 + required 650 + .value=${this.waitlistData.courseCode} 651 + @input=${(e: Event) => this.handleWaitlistInput("courseCode", e)} 652 + /> 653 + </div> 654 + <div class="form-group"> 655 + <label>Course Name *</label> 656 + <input 657 + type="text" 658 + required 659 + .value=${this.waitlistData.courseName} 660 + @input=${(e: Event) => this.handleWaitlistInput("courseName", e)} 661 + /> 662 + </div> 663 + <div class="form-group"> 664 + <label>Professor *</label> 665 + <input 666 + type="text" 667 + required 668 + .value=${this.waitlistData.professor} 669 + @input=${(e: Event) => this.handleWaitlistInput("professor", e)} 670 + /> 671 + </div> 672 + <div class="form-group"> 673 + <label>Semester *</label> 674 + <select 675 + required 676 + .value=${this.waitlistData.semester} 677 + @change=${(e: Event) => this.handleWaitlistInput("semester", e)} 678 + > 679 + <option value="">Select semester</option> 680 + <option value="Spring">Spring</option> 681 + <option value="Summer">Summer</option> 682 + <option value="Fall">Fall</option> 683 + <option value="Winter">Winter</option> 684 + </select> 685 + </div> 686 + <div class="form-group"> 687 + <label>Year *</label> 688 + <input 689 + type="number" 690 + required 691 + min="2020" 692 + max="2030" 693 + .value=${this.waitlistData.year.toString()} 694 + @input=${(e: Event) => this.handleWaitlistInput("year", e)} 695 + /> 696 + </div> 697 + <div class="form-group form-group-full"> 698 + <label>Meeting Times *</label> 699 + <div class="meeting-times-list"> 700 + ${this.waitlistData.meetingTimes.map( 701 + (time, index) => html` 702 + <div class="meeting-time-row"> 703 + <input 704 + type="text" 705 + required 706 + placeholder="e.g., Monday Lecture, Wednesday Lecture" 707 + .value=${time} 708 + @input=${(e: Event) => 709 + this.updateMeetingTime( 710 + index, 711 + (e.target as HTMLInputElement).value, 712 + )} 713 + @keydown=${(e: KeyboardEvent) => { 714 + if (e.key === "Enter") { 715 + e.preventDefault(); 716 + this.addMeetingTime(); 717 + } 718 + }} 719 + /> 720 + ${ 721 + this.waitlistData.meetingTimes.length > 1 722 + ? html` 723 + <button 724 + type="button" 725 + class="btn-remove" 726 + @click=${() => this.removeMeetingTime(index)} 727 + > 728 + Remove 729 + </button> 730 + ` 731 + : "" 732 + } 733 + </div> 734 + `, 735 + )} 736 + <button type="button" class="btn-add" @click=${this.addMeetingTime}> 737 + + Add Meeting Time 738 + </button> 739 + </div> 740 + </div> 741 + <div class="form-group form-group-full"> 742 + <label>Additional Info (optional)</label> 743 + <textarea 744 + placeholder="Any additional details about this class..." 745 + .value=${this.waitlistData.additionalInfo} 746 + @input=${(e: Event) => this.handleWaitlistInput("additionalInfo", e)} 747 + ></textarea> 748 + </div> 749 + </div> 750 + ${this.error ? html`<div class="error-message">${this.error}</div>` : ""} 751 + <div class="form-actions"> 752 + <button 753 + type="button" 754 + class="btn-cancel" 755 + @click=${this.handleCancelWaitlist} 756 + ?disabled=${this.isJoining} 757 + > 758 + Cancel 759 + </button> 760 + <button 761 + type="submit" 762 + class="btn-submit" 763 + ?disabled=${this.isJoining} 764 + > 765 + ${this.isJoining ? "Submitting..." : "Submit Request"} 766 + </button> 767 + </div> 768 + </form> 769 + </div> 770 + ` 771 + : html` 380 772 <div class="empty-state"> 381 - No classes found matching "${this.searchQuery}" 773 + <p>No classes found matching "${this.searchQuery}"</p> 774 + <p style="margin-top: 0.5rem; font-size: 0.875rem;"> 775 + Can't find your class? Request it to be added. 776 + </p> 777 + <button @click=${this.handleRequestWaitlist}> 778 + Request Class 779 + </button> 382 780 </div> 383 781 ` 384 782 : html` ··· 396 794 <div class="class-name">${cls.name}</div> 397 795 <div class="class-meta"> 398 796 <span>👤 ${cls.professor}</span> 399 - ${cls.section ? html`<span>📍 Section ${cls.section}</span>` : ""} 400 797 <span>📅 ${cls.semester} ${cls.year}</span> 401 798 </div> 402 799 </div>
+38
src/db/schema.ts
··· 142 142 CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code); 143 143 `, 144 144 }, 145 + { 146 + version: 3, 147 + name: "Add class waitlist table", 148 + sql: ` 149 + CREATE TABLE IF NOT EXISTS class_waitlist ( 150 + id TEXT PRIMARY KEY, 151 + user_id INTEGER NOT NULL, 152 + course_code TEXT NOT NULL, 153 + course_name TEXT NOT NULL, 154 + professor TEXT NOT NULL, 155 + section TEXT, 156 + semester TEXT NOT NULL, 157 + year INTEGER NOT NULL, 158 + additional_info TEXT, 159 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 160 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 161 + ); 162 + 163 + CREATE INDEX IF NOT EXISTS idx_waitlist_user_id ON class_waitlist(user_id); 164 + CREATE INDEX IF NOT EXISTS idx_waitlist_course_code ON class_waitlist(course_code); 165 + `, 166 + }, 167 + { 168 + version: 4, 169 + name: "Add meeting_times to class_waitlist", 170 + sql: ` 171 + ALTER TABLE class_waitlist ADD COLUMN meeting_times TEXT; 172 + `, 173 + }, 174 + { 175 + version: 5, 176 + name: "Remove section columns", 177 + sql: ` 178 + DROP INDEX IF EXISTS idx_classes_section; 179 + ALTER TABLE classes DROP COLUMN section; 180 + ALTER TABLE class_waitlist DROP COLUMN section; 181 + `, 182 + }, 145 183 ]; 146 184 147 185 function getCurrentVersion(): number {
+86 -1
src/index.ts
··· 42 42 searchClassesByCourseCode, 43 43 toggleClassArchive, 44 44 updateMeetingTime, 45 + addToWaitlist, 46 + getAllWaitlistEntries, 47 + deleteWaitlistEntry, 45 48 } from "./lib/classes"; 46 49 import { handleError, ValidationErrors } from "./lib/errors"; 47 50 import { requireAdmin, requireAuth } from "./lib/middleware"; ··· 1088 1091 } 1089 1092 }, 1090 1093 }, 1094 + "/api/admin/classes": { 1095 + GET: async (req) => { 1096 + try { 1097 + requireAdmin(req); 1098 + const classes = getClassesForUser(0, true); // Admin sees all classes 1099 + return Response.json({ classes }); 1100 + } catch (error) { 1101 + return handleError(error); 1102 + } 1103 + }, 1104 + }, 1105 + "/api/admin/waitlist": { 1106 + GET: async (req) => { 1107 + try { 1108 + requireAdmin(req); 1109 + const waitlist = getAllWaitlistEntries(); 1110 + return Response.json({ waitlist }); 1111 + } catch (error) { 1112 + return handleError(error); 1113 + } 1114 + }, 1115 + }, 1116 + "/api/admin/waitlist/:id": { 1117 + DELETE: async (req) => { 1118 + try { 1119 + requireAdmin(req); 1120 + const id = req.params.id; 1121 + deleteWaitlistEntry(id); 1122 + return Response.json({ success: true }); 1123 + } catch (error) { 1124 + return handleError(error); 1125 + } 1126 + }, 1127 + }, 1091 1128 "/api/admin/transcriptions/:id": { 1092 1129 DELETE: async (req) => { 1093 1130 try { ··· 1473 1510 try { 1474 1511 requireAdmin(req); 1475 1512 const body = await req.json(); 1476 - const { course_code, name, professor, semester, year } = body; 1513 + const { 1514 + course_code, 1515 + name, 1516 + professor, 1517 + semester, 1518 + year, 1519 + meeting_times, 1520 + } = body; 1477 1521 1478 1522 if (!course_code || !name || !professor || !semester || !year) { 1479 1523 return Response.json( ··· 1488 1532 professor, 1489 1533 semester, 1490 1534 year, 1535 + meeting_times, 1491 1536 }); 1492 1537 1493 1538 return Response.json(newClass); ··· 1535 1580 } 1536 1581 1537 1582 return Response.json({ success: true }); 1583 + } catch (error) { 1584 + return handleError(error); 1585 + } 1586 + }, 1587 + }, 1588 + "/api/classes/waitlist": { 1589 + POST: async (req) => { 1590 + try { 1591 + const user = requireAuth(req); 1592 + const body = await req.json(); 1593 + 1594 + const { 1595 + courseCode, 1596 + courseName, 1597 + professor, 1598 + semester, 1599 + year, 1600 + additionalInfo, 1601 + meetingTimes, 1602 + } = body; 1603 + 1604 + if (!courseCode || !courseName || !professor || !semester || !year) { 1605 + return Response.json( 1606 + { error: "Missing required fields" }, 1607 + { status: 400 }, 1608 + ); 1609 + } 1610 + 1611 + const id = addToWaitlist( 1612 + user.id, 1613 + courseCode, 1614 + courseName, 1615 + professor, 1616 + semester, 1617 + Number.parseInt(year, 10), 1618 + additionalInfo || null, 1619 + meetingTimes || null, 1620 + ); 1621 + 1622 + return Response.json({ success: true, id }); 1538 1623 } catch (error) { 1539 1624 return handleError(error); 1540 1625 }
+77 -1
src/lib/classes.ts
··· 6 6 course_code: string; 7 7 name: string; 8 8 professor: string; 9 - section: string | null; 10 9 semester: string; 11 10 year: number; 12 11 archived: boolean; ··· 85 84 professor: string; 86 85 semester: string; 87 86 year: number; 87 + meeting_times?: string[]; 88 88 }): Class { 89 89 const id = nanoid(); 90 90 const now = Math.floor(Date.now() / 1000); ··· 101 101 now, 102 102 ], 103 103 ); 104 + 105 + // Create meeting times if provided 106 + if (data.meeting_times && data.meeting_times.length > 0) { 107 + for (const label of data.meeting_times) { 108 + createMeetingTime(id, label); 109 + } 110 + } 104 111 105 112 return { 106 113 id, ··· 302 309 return { success: true }; 303 310 } 304 311 312 + /** 313 + * Waitlist entry interface 314 + */ 315 + export interface WaitlistEntry { 316 + id: string; 317 + user_id: number; 318 + course_code: string; 319 + course_name: string; 320 + professor: string; 321 + semester: string; 322 + year: number; 323 + additional_info: string | null; 324 + meeting_times: string | null; 325 + created_at: number; 326 + } 327 + 328 + /** 329 + * Add a class to the waitlist 330 + */ 331 + export function addToWaitlist( 332 + userId: number, 333 + courseCode: string, 334 + courseName: string, 335 + professor: string, 336 + semester: string, 337 + year: number, 338 + additionalInfo: string | null, 339 + meetingTimes: string[] | null, 340 + ): string { 341 + const id = nanoid(); 342 + const meetingTimesJson = meetingTimes ? JSON.stringify(meetingTimes) : null; 343 + 344 + db.query( 345 + `INSERT INTO class_waitlist 346 + (id, user_id, course_code, course_name, professor, semester, year, additional_info, meeting_times, created_at) 347 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 348 + ).run( 349 + id, 350 + userId, 351 + courseCode, 352 + courseName, 353 + professor, 354 + semester, 355 + year, 356 + additionalInfo, 357 + meetingTimesJson, 358 + Math.floor(Date.now() / 1000), 359 + ); 360 + return id; 361 + } 362 + 363 + /** 364 + * Get all waitlist entries 365 + */ 366 + export function getAllWaitlistEntries(): WaitlistEntry[] { 367 + return db 368 + .query<WaitlistEntry, []>( 369 + "SELECT * FROM class_waitlist ORDER BY created_at DESC", 370 + ) 371 + .all(); 372 + } 373 + 374 + /** 375 + * Delete a waitlist entry 376 + */ 377 + export function deleteWaitlistEntry(id: string): void { 378 + db.query("DELETE FROM class_waitlist WHERE id = ?").run(id); 379 + } 380 +
+9
src/pages/admin.html
··· 166 166 <button class="tab active" data-tab="pending">Pending Recordings</button> 167 167 <button class="tab" data-tab="transcriptions">Transcriptions</button> 168 168 <button class="tab" data-tab="users">Users</button> 169 + <button class="tab" data-tab="classes">Classes</button> 169 170 </div> 170 171 171 172 <div id="pending-tab" class="tab-content active"> ··· 188 189 <admin-users id="users-component"></admin-users> 189 190 </div> 190 191 </div> 192 + 193 + <div id="classes-tab" class="tab-content"> 194 + <div class="section"> 195 + <h2 class="section-title">Manage Classes</h2> 196 + <admin-classes></admin-classes> 197 + </div> 198 + </div> 191 199 </div> 192 200 </main> 193 201 ··· 198 206 <script type="module" src="../components/admin-pending-recordings.ts"></script> 199 207 <script type="module" src="../components/admin-transcriptions.ts"></script> 200 208 <script type="module" src="../components/admin-users.ts"></script> 209 + <script type="module" src="../components/admin-classes.ts"></script> 201 210 <script type="module" src="../components/user-modal.ts"></script> 202 211 <script type="module" src="../components/transcript-view-modal.ts"></script> 203 212 <script type="module">