馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import type { MeetingTime } from "./meeting-time-picker"; 4import "./meeting-time-picker"; 5 6interface Class { 7 id: string; 8 course_code: string; 9 name: string; 10 professor: string; 11 semester: string; 12 year: number; 13 archived: boolean; 14 created_at: number; 15 student_count?: number; 16 transcript_count?: number; 17} 18 19interface WaitlistEntry { 20 id: string; 21 user_id: number; 22 course_code: string; 23 course_name: string; 24 professor: string; 25 semester: string; 26 year: number; 27 additional_info: string | null; 28 meeting_times: string | null; 29 created_at: number; 30} 31 32@customElement("admin-classes") 33export class AdminClasses extends LitElement { 34 @state() classes: Class[] = []; 35 @state() waitlist: WaitlistEntry[] = []; 36 @state() isLoading = true; 37 @state() error = ""; 38 @state() searchTerm = ""; 39 40 @state() activeTab: "classes" | "waitlist" = "classes"; 41 @state() approvingEntry: WaitlistEntry | null = null; 42 @state() showModal = false; 43 @state() meetingTimes: MeetingTime[] = []; 44 @state() editingClass = { 45 courseCode: "", 46 courseName: "", 47 professor: "", 48 semester: "", 49 year: new Date().getFullYear(), 50 }; 51 @state() deleteState: { 52 id: string; 53 type: "class" | "waitlist"; 54 clicks: number; 55 timeout: number | null; 56 } | null = null; 57 58 static override styles = css` 59 :host { 60 display: block; 61 } 62 63 .header { 64 display: flex; 65 justify-content: space-between; 66 align-items: center; 67 margin-bottom: 1.5rem; 68 gap: 1rem; 69 } 70 71 .search { 72 flex: 1; 73 max-width: 30rem; 74 padding: 0.5rem 0.75rem; 75 border: 2px solid var(--secondary); 76 border-radius: 4px; 77 font-size: 1rem; 78 font-family: inherit; 79 background: var(--background); 80 color: var(--text); 81 } 82 83 .search:focus { 84 outline: none; 85 border-color: var(--primary); 86 } 87 88 .create-btn { 89 padding: 0.75rem 1.5rem; 90 background: var(--primary); 91 color: white; 92 border: 2px solid var(--primary); 93 border-radius: 6px; 94 font-size: 1rem; 95 font-weight: 500; 96 cursor: pointer; 97 transition: all 0.2s; 98 font-family: inherit; 99 white-space: nowrap; 100 } 101 102 .create-btn:hover { 103 background: var(--gunmetal); 104 border-color: var(--gunmetal); 105 } 106 107 .classes-grid { 108 display: grid; 109 gap: 1rem; 110 } 111 112 .class-card { 113 background: var(--background); 114 border: 2px solid var(--secondary); 115 border-radius: 8px; 116 padding: 1.25rem; 117 transition: all 0.2s; 118 } 119 120 .class-card:hover { 121 border-color: var(--primary); 122 } 123 124 .class-card.archived { 125 opacity: 0.6; 126 border-style: dashed; 127 } 128 129 .class-header { 130 display: flex; 131 justify-content: space-between; 132 align-items: flex-start; 133 gap: 1rem; 134 margin-bottom: 0.75rem; 135 } 136 137 .class-info { 138 flex: 1; 139 } 140 141 .course-code { 142 font-size: 0.875rem; 143 font-weight: 600; 144 color: var(--accent); 145 text-transform: uppercase; 146 } 147 148 .class-name { 149 font-size: 1.125rem; 150 font-weight: 600; 151 margin: 0.25rem 0; 152 color: var(--text); 153 } 154 155 .class-meta { 156 display: flex; 157 gap: 1rem; 158 font-size: 0.875rem; 159 color: var(--paynes-gray); 160 flex-wrap: wrap; 161 } 162 163 .badge { 164 display: inline-block; 165 padding: 0.25rem 0.5rem; 166 border-radius: 4px; 167 font-size: 0.75rem; 168 font-weight: 600; 169 text-transform: uppercase; 170 } 171 172 .badge.archived { 173 background: var(--secondary); 174 color: var(--text); 175 } 176 177 .actions { 178 display: flex; 179 gap: 0.5rem; 180 flex-wrap: wrap; 181 } 182 183 button { 184 padding: 0.5rem 1rem; 185 border: 2px solid; 186 border-radius: 6px; 187 font-size: 0.875rem; 188 font-weight: 500; 189 cursor: pointer; 190 transition: all 0.2s; 191 font-family: inherit; 192 } 193 194 .btn-archive { 195 background: transparent; 196 color: var(--paynes-gray); 197 border-color: var(--secondary); 198 } 199 200 .btn-archive:hover { 201 border-color: var(--paynes-gray); 202 } 203 204 .btn-delete { 205 background: transparent; 206 color: #dc2626; 207 border-color: #dc2626; 208 } 209 210 .btn-delete:hover { 211 background: #dc2626; 212 color: white; 213 } 214 215 button:disabled { 216 opacity: 0.6; 217 cursor: not-allowed; 218 } 219 220 .empty-state { 221 text-align: center; 222 padding: 3rem 2rem; 223 color: var(--paynes-gray); 224 } 225 226 .loading { 227 text-align: center; 228 padding: 3rem 2rem; 229 color: var(--paynes-gray); 230 } 231 232 .error-banner { 233 background: #fecaca; 234 border: 2px solid rgba(220, 38, 38, 0.8); 235 border-radius: 6px; 236 padding: 1rem; 237 margin-bottom: 1.5rem; 238 color: #dc2626; 239 font-weight: 500; 240 } 241 242 .tabs { 243 display: flex; 244 gap: 1rem; 245 margin-bottom: 2rem; 246 border-bottom: 2px solid var(--secondary); 247 } 248 249 .tab { 250 padding: 0.75rem 1.5rem; 251 background: transparent; 252 border: none; 253 border-radius: 0; 254 color: var(--text); 255 cursor: pointer; 256 font-size: 1rem; 257 font-weight: 500; 258 font-family: inherit; 259 border-bottom: 2px solid transparent; 260 margin-bottom: -2px; 261 transition: all 0.2s; 262 } 263 264 .tab:hover { 265 color: var(--primary); 266 } 267 268 .tab.active { 269 color: var(--primary); 270 border-bottom-color: var(--primary); 271 } 272 273 .tab-badge { 274 display: inline-block; 275 margin-left: 0.5rem; 276 padding: 0.125rem 0.5rem; 277 background: var(--accent); 278 color: white; 279 border-radius: 12px; 280 font-size: 0.75rem; 281 font-weight: 600; 282 } 283 284 .modal-overlay { 285 position: fixed; 286 top: 0; 287 left: 0; 288 right: 0; 289 bottom: 0; 290 background: rgba(0, 0, 0, 0.5); 291 display: flex; 292 align-items: center; 293 justify-content: center; 294 z-index: 1000; 295 padding: 1rem; 296 } 297 298 .modal { 299 background: var(--background); 300 border: 2px solid var(--secondary); 301 border-radius: 12px; 302 padding: 2rem; 303 max-width: 32rem; 304 width: 100%; 305 max-height: 90vh; 306 overflow-y: auto; 307 } 308 309 .modal-title { 310 margin: 0 0 1.5rem 0; 311 color: var(--text); 312 font-size: 1.5rem; 313 } 314 315 .form-group { 316 margin-bottom: 1rem; 317 } 318 319 .form-group label { 320 display: block; 321 margin-bottom: 0.5rem; 322 font-weight: 500; 323 color: var(--text); 324 font-size: 0.875rem; 325 } 326 327 .form-group input { 328 width: 100%; 329 padding: 0.75rem; 330 border: 2px solid var(--secondary); 331 border-radius: 6px; 332 font-size: 1rem; 333 font-family: inherit; 334 background: var(--background); 335 color: var(--text); 336 box-sizing: border-box; 337 } 338 339 .form-group input:focus, 340 .form-group select:focus { 341 outline: none; 342 border-color: var(--primary); 343 } 344 345 .form-group select { 346 width: 100%; 347 padding: 0.75rem; 348 border: 2px solid var(--secondary); 349 border-radius: 6px; 350 font-size: 1rem; 351 font-family: inherit; 352 background: var(--background); 353 color: var(--text); 354 box-sizing: border-box; 355 } 356 357 .form-grid { 358 display: grid; 359 grid-template-columns: 1fr 1fr; 360 gap: 1rem; 361 margin-bottom: 1rem; 362 } 363 364 .form-group-full { 365 grid-column: 1 / -1; 366 } 367 368 .meeting-times-list { 369 display: flex; 370 flex-direction: column; 371 gap: 0.5rem; 372 } 373 374 .meeting-time-row { 375 display: flex; 376 gap: 0.5rem; 377 align-items: center; 378 } 379 380 .meeting-time-row input { 381 flex: 1; 382 } 383 384 .btn-remove { 385 padding: 0.5rem; 386 background: transparent; 387 color: #dc2626; 388 border: 2px solid #dc2626; 389 border-radius: 6px; 390 cursor: pointer; 391 font-size: 0.875rem; 392 font-weight: 500; 393 transition: all 0.2s; 394 } 395 396 .btn-remove:hover { 397 background: #dc2626; 398 color: white; 399 } 400 401 .btn-add { 402 margin-top: 0.5rem; 403 padding: 0.5rem 1rem; 404 background: transparent; 405 color: var(--primary); 406 border: 2px solid var(--primary); 407 border-radius: 6px; 408 cursor: pointer; 409 font-size: 0.875rem; 410 font-weight: 500; 411 transition: all 0.2s; 412 } 413 414 .btn-add:hover { 415 background: var(--primary); 416 color: white; 417 } 418 419 .modal-actions { 420 display: flex; 421 gap: 0.75rem; 422 justify-content: flex-end; 423 margin-top: 1.5rem; 424 } 425 426 .btn-submit { 427 padding: 0.75rem 1.5rem; 428 background: var(--primary); 429 color: white; 430 border: 2px solid var(--primary); 431 border-radius: 6px; 432 font-size: 1rem; 433 font-weight: 500; 434 cursor: pointer; 435 transition: all 0.2s; 436 font-family: inherit; 437 } 438 439 .btn-submit:hover:not(:disabled) { 440 background: var(--gunmetal); 441 border-color: var(--gunmetal); 442 } 443 444 .btn-submit:disabled { 445 opacity: 0.6; 446 cursor: not-allowed; 447 } 448 449 .btn-cancel { 450 padding: 0.75rem 1.5rem; 451 background: transparent; 452 color: var(--text); 453 border: 2px solid var(--secondary); 454 border-radius: 6px; 455 font-size: 1rem; 456 font-weight: 500; 457 cursor: pointer; 458 transition: all 0.2s; 459 font-family: inherit; 460 } 461 462 .btn-cancel:hover { 463 border-color: var(--primary); 464 color: var(--primary); 465 } 466 `; 467 468 override async connectedCallback() { 469 super.connectedCallback(); 470 471 // Check for subtab query parameter 472 const params = new URLSearchParams(window.location.search); 473 const subtab = params.get("subtab"); 474 if (subtab && this.isValidSubtab(subtab)) { 475 this.activeTab = subtab as "classes" | "waitlist"; 476 } else { 477 // Set default subtab in URL if on classes tab 478 this.setActiveTab(this.activeTab); 479 } 480 481 await this.loadData(); 482 } 483 484 private isValidSubtab(subtab: string): boolean { 485 return ["classes", "waitlist"].includes(subtab); 486 } 487 488 private setActiveTab(tab: "classes" | "waitlist") { 489 this.activeTab = tab; 490 // Update URL without reloading page 491 const url = new URL(window.location.href); 492 url.searchParams.set("subtab", tab); 493 window.history.pushState({}, "", url); 494 } 495 496 private async loadData() { 497 this.isLoading = true; 498 this.error = ""; 499 500 try { 501 const [classesRes, waitlistRes] = await Promise.all([ 502 fetch("/api/admin/classes"), 503 fetch("/api/admin/waitlist"), 504 ]); 505 506 if (!classesRes.ok || !waitlistRes.ok) { 507 throw new Error("Failed to load data"); 508 } 509 510 const classesData = await classesRes.json(); 511 const waitlistData = await waitlistRes.json(); 512 513 this.classes = classesData.classes || []; 514 this.waitlist = waitlistData.waitlist || []; 515 } catch { 516 this.error = "Failed to load data. Please try again."; 517 } finally { 518 this.isLoading = false; 519 } 520 } 521 522 private handleSearch(e: Event) { 523 this.searchTerm = (e.target as HTMLInputElement).value.toLowerCase(); 524 } 525 526 private async handleToggleArchive(classId: string) { 527 try { 528 // Find the class to toggle its archived state 529 const classToToggle = this.classes.find(c => c.id === classId); 530 if (!classToToggle) return; 531 532 const response = await fetch(`/api/classes/${classId}/archive`, { 533 method: "PUT", 534 headers: { "Content-Type": "application/json" }, 535 body: JSON.stringify({ archived: !classToToggle.archived }), 536 }); 537 538 if (!response.ok) { 539 throw new Error("Failed to update class"); 540 } 541 542 // Update local state instead of reloading 543 this.classes = this.classes.map(c => 544 c.id === classId ? { ...c, archived: !c.archived } : c 545 ); 546 } catch { 547 this.error = "Failed to update class. Please try again."; 548 } 549 } 550 551 private handleDeleteClick(id: string, type: "class" | "waitlist") { 552 // If this is a different item or timeout expired, reset 553 if ( 554 !this.deleteState || 555 this.deleteState.id !== id || 556 this.deleteState.type !== type 557 ) { 558 // Clear any existing timeout 559 if (this.deleteState?.timeout) { 560 clearTimeout(this.deleteState.timeout); 561 } 562 563 // Set first click 564 const timeout = window.setTimeout(() => { 565 this.deleteState = null; 566 }, 1000); 567 568 this.deleteState = { id, type, clicks: 1, timeout }; 569 return; 570 } 571 572 // Increment clicks 573 const newClicks = this.deleteState.clicks + 1; 574 575 // Clear existing timeout 576 if (this.deleteState.timeout) { 577 clearTimeout(this.deleteState.timeout); 578 } 579 580 // Third click - actually delete 581 if (newClicks === 3) { 582 this.deleteState = null; 583 if (type === "class") { 584 this.performDeleteClass(id); 585 } else { 586 this.performDeleteWaitlist(id); 587 } 588 return; 589 } 590 591 // Second click - reset timeout 592 const timeout = window.setTimeout(() => { 593 this.deleteState = null; 594 }, 1000); 595 596 this.deleteState = { id, type, clicks: newClicks, timeout }; 597 } 598 599 private getDeleteButtonText(id: string, type: "class" | "waitlist"): string { 600 if ( 601 !this.deleteState || 602 this.deleteState.id !== id || 603 this.deleteState.type !== type 604 ) { 605 return "Delete"; 606 } 607 608 if (this.deleteState.clicks === 1) { 609 return "Are you sure?"; 610 } 611 612 if (this.deleteState.clicks === 2) { 613 return "Final warning!"; 614 } 615 616 return "Delete"; 617 } 618 619 private async performDeleteClass(classId: string) { 620 try { 621 const response = await fetch(`/api/classes/${classId}`, { 622 method: "DELETE", 623 }); 624 625 if (!response.ok) { 626 throw new Error("Failed to delete class"); 627 } 628 629 await this.loadData(); 630 } catch { 631 this.error = "Failed to delete class. Please try again."; 632 } 633 } 634 635 private async performDeleteWaitlist(id: string) { 636 try { 637 const response = await fetch(`/api/admin/waitlist/${id}`, { 638 method: "DELETE", 639 }); 640 641 if (!response.ok) { 642 throw new Error("Failed to delete waitlist entry"); 643 } 644 645 await this.loadData(); 646 } catch { 647 this.error = "Failed to delete waitlist entry. Please try again."; 648 } 649 } 650 651 private handleCreateClass() { 652 // Set empty form for creating new class 653 this.approvingEntry = null; 654 this.editingClass = { 655 courseCode: "", 656 courseName: "", 657 professor: "", 658 semester: "", 659 year: new Date().getFullYear(), 660 }; 661 this.meetingTimes = []; 662 this.showModal = true; 663 } 664 665 private getFilteredClasses() { 666 if (!this.searchTerm) return this.classes; 667 668 return this.classes.filter((cls) => { 669 const searchStr = this.searchTerm; 670 return ( 671 cls.course_code.toLowerCase().includes(searchStr) || 672 cls.name.toLowerCase().includes(searchStr) || 673 cls.professor.toLowerCase().includes(searchStr) 674 ); 675 }); 676 } 677 678 override render() { 679 if (this.isLoading) { 680 return html`<div class="loading">Loading...</div>`; 681 } 682 683 const filteredClasses = this.getFilteredClasses(); 684 685 return html` 686 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 687 688 <div class="tabs"> 689 <button 690 class="tab ${this.activeTab === "classes" ? "active" : ""}" 691 @click=${() => { 692 this.setActiveTab("classes"); 693 }} 694 > 695 Classes 696 </button> 697 <button 698 class="tab ${this.activeTab === "waitlist" ? "active" : ""}" 699 @click=${() => { 700 this.setActiveTab("waitlist"); 701 }} 702 > 703 Waitlist 704 ${this.waitlist.length > 0 ? html`<span class="tab-badge">${this.waitlist.length}</span>` : ""} 705 </button> 706 </div> 707 708 ${ 709 this.activeTab === "classes" 710 ? this.renderClasses(filteredClasses) 711 : this.renderWaitlist() 712 } 713 714 ${this.showModal ? this.renderApprovalModal() : ""} 715 `; 716 } 717 718 private renderClasses(filteredClasses: Class[]) { 719 return html` 720 <div class="header"> 721 <input 722 type="text" 723 class="search" 724 placeholder="Search classes..." 725 @input=${this.handleSearch} 726 .value=${this.searchTerm} 727 /> 728 <button class="create-btn" @click=${this.handleCreateClass}> 729 + Create Class 730 </button> 731 </div> 732 733 ${ 734 filteredClasses.length === 0 735 ? html` 736 <div class="empty-state"> 737 ${this.searchTerm ? "No classes found matching your search" : "No classes yet"} 738 </div> 739 ` 740 : html` 741 <div class="classes-grid"> 742 ${filteredClasses.map( 743 (cls) => html` 744 <div class="class-card ${cls.archived ? "archived" : ""}"> 745 <div class="class-header"> 746 <div class="class-info"> 747 <div class="course-code">${cls.course_code}</div> 748 <div class="class-name">${cls.name}</div> 749 <div class="class-meta"> 750 <span>馃懁 ${cls.professor}</span> 751 <span>馃搮 ${cls.semester} ${cls.year}</span> 752 <span>馃懃 ${cls.student_count || 0} students</span> 753 <span>馃搫 ${cls.transcript_count || 0} transcripts</span> 754 ${cls.archived ? html`<span class="badge archived">Archived</span>` : ""} 755 </div> 756 </div> 757 <div class="actions"> 758 <button 759 class="btn-archive" 760 @click=${() => this.handleToggleArchive(cls.id)} 761 > 762 ${cls.archived ? "Unarchive" : "Archive"} 763 </button> 764 <button 765 class="btn-delete" 766 @click=${() => this.handleDeleteClick(cls.id, "class")} 767 > 768 ${this.getDeleteButtonText(cls.id, "class")} 769 </button> 770 </div> 771 </div> 772 </div> 773 `, 774 )} 775 </div> 776 ` 777 } 778 `; 779 } 780 781 private renderWaitlist() { 782 return html` 783 ${ 784 this.waitlist.length === 0 785 ? html` 786 <div class="empty-state">No waitlist requests yet</div> 787 ` 788 : html` 789 <div class="classes-grid"> 790 ${this.waitlist.map( 791 (entry) => html` 792 <div class="class-card"> 793 <div class="class-header"> 794 <div class="class-info"> 795 <div class="course-code">${entry.course_code}</div> 796 <div class="class-name">${entry.course_name}</div> 797 <div class="class-meta"> 798 <span>馃懁 ${entry.professor}</span> 799 <span>馃搮 ${entry.semester} ${entry.year}</span> 800 </div> 801 ${ 802 entry.additional_info 803 ? html` 804 <p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--paynes-gray);"> 805 ${entry.additional_info} 806 </p> 807 ` 808 : "" 809 } 810 </div> 811 <div class="actions"> 812 <button 813 class="btn-archive" 814 @click=${() => this.handleApproveWaitlist(entry)} 815 > 816 Approve & Create Class 817 </button> 818 <button 819 class="btn-delete" 820 @click=${() => this.handleDeleteClick(entry.id, "waitlist")} 821 > 822 ${this.getDeleteButtonText(entry.id, "waitlist")} 823 </button> 824 </div> 825 </div> 826 </div> 827 `, 828 )} 829 </div> 830 ` 831 } 832 `; 833 } 834 835 private handleApproveWaitlist(entry: WaitlistEntry) { 836 this.approvingEntry = entry; 837 838 // Pre-fill form with waitlist data 839 this.editingClass = { 840 courseCode: entry.course_code, 841 courseName: entry.course_name, 842 professor: entry.professor, 843 semester: entry.semester, 844 year: entry.year, 845 }; 846 847 // Parse meeting times from JSON if available, otherwise use empty array 848 if (entry.meeting_times) { 849 try { 850 const parsed = JSON.parse(entry.meeting_times); 851 this.meetingTimes = 852 Array.isArray(parsed) && parsed.length > 0 ? parsed : []; 853 } catch { 854 this.meetingTimes = []; 855 } 856 } else { 857 this.meetingTimes = []; 858 } 859 this.showModal = true; 860 } 861 862 private handleMeetingTimesChange(e: CustomEvent) { 863 this.meetingTimes = e.detail; 864 } 865 866 private handleClassFieldInput(field: string, e: Event) { 867 const value = (e.target as HTMLInputElement | HTMLSelectElement).value; 868 this.editingClass = { ...this.editingClass, [field]: value }; 869 } 870 871 private cancelApproval() { 872 this.showModal = false; 873 this.approvingEntry = null; 874 this.meetingTimes = []; 875 this.editingClass = { 876 courseCode: "", 877 courseName: "", 878 professor: "", 879 semester: "", 880 year: new Date().getFullYear(), 881 }; 882 } 883 884 private async submitApproval() { 885 if (this.meetingTimes.length === 0) { 886 this.error = "Please add at least one meeting time"; 887 return; 888 } 889 890 // Convert MeetingTime objects to label strings 891 const labels = this.meetingTimes.map((t) => t.label); 892 893 try { 894 const response = await fetch("/api/classes", { 895 method: "POST", 896 headers: { "Content-Type": "application/json" }, 897 body: JSON.stringify({ 898 course_code: this.editingClass.courseCode, 899 name: this.editingClass.courseName, 900 professor: this.editingClass.professor, 901 semester: this.editingClass.semester, 902 year: this.editingClass.year, 903 meeting_times: labels, 904 }), 905 }); 906 907 if (!response.ok) { 908 const data = await response.json(); 909 console.error("Failed to create class:", data); 910 throw new Error(data.error || "Failed to create class"); 911 } 912 913 // If approving from waitlist, delete the waitlist entry 914 if (this.approvingEntry) { 915 await fetch(`/api/admin/waitlist/${this.approvingEntry.id}`, { 916 method: "DELETE", 917 }); 918 } 919 920 await this.loadData(); 921 922 this.setActiveTab("classes"); 923 this.showModal = false; 924 this.approvingEntry = null; 925 this.meetingTimes = []; 926 this.editingClass = { 927 courseCode: "", 928 courseName: "", 929 professor: "", 930 semester: "", 931 year: new Date().getFullYear(), 932 }; 933 } catch (error) { 934 console.error("Error in submitApproval:", error); 935 this.error = 936 error instanceof Error 937 ? error.message 938 : "Failed to create class. Please try again."; 939 } 940 } 941 942 private renderApprovalModal() { 943 const isApproving = !!this.approvingEntry; 944 const title = isApproving ? "Review & Create Class" : "Create New Class"; 945 const description = isApproving 946 ? "Review the class details and make any edits before creating" 947 : "Enter the class details below"; 948 949 return html` 950 <div class="modal-overlay" @click=${this.cancelApproval}> 951 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 952 <h2 class="modal-title">${title}</h2> 953 954 <p style="margin-bottom: 1.5rem; color: var(--paynes-gray);"> 955 ${description} 956 </p> 957 958 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 959 960 <div class="form-grid"> 961 <div class="form-group"> 962 <label>Course Code *</label> 963 <input 964 type="text" 965 required 966 .value=${this.editingClass.courseCode} 967 @input=${(e: Event) => this.handleClassFieldInput("courseCode", e)} 968 /> 969 </div> 970 <div class="form-group"> 971 <label>Course Name *</label> 972 <input 973 type="text" 974 required 975 .value=${this.editingClass.courseName} 976 @input=${(e: Event) => this.handleClassFieldInput("courseName", e)} 977 /> 978 </div> 979 <div class="form-group"> 980 <label>Professor *</label> 981 <input 982 type="text" 983 required 984 .value=${this.editingClass.professor} 985 @input=${(e: Event) => this.handleClassFieldInput("professor", e)} 986 /> 987 </div> 988 <div class="form-group"> 989 <label>Semester *</label> 990 <select 991 required 992 .value=${this.editingClass.semester} 993 @change=${(e: Event) => this.handleClassFieldInput("semester", e)} 994 > 995 <option value="">Select semester</option> 996 <option value="Spring">Spring</option> 997 <option value="Summer">Summer</option> 998 <option value="Fall">Fall</option> 999 <option value="Winter">Winter</option> 1000 </select> 1001 </div> 1002 <div class="form-group"> 1003 <label>Year *</label> 1004 <input 1005 type="number" 1006 required 1007 min="2020" 1008 max="2030" 1009 .value=${this.editingClass.year.toString()} 1010 @input=${(e: Event) => this.handleClassFieldInput("year", e)} 1011 /> 1012 </div> 1013 <div class="form-group form-group-full"> 1014 <label>Meeting Times *</label> 1015 <meeting-time-picker 1016 .value=${this.meetingTimes} 1017 @change=${this.handleMeetingTimesChange} 1018 ></meeting-time-picker> 1019 </div> 1020 </div> 1021 1022 <div class="modal-actions"> 1023 <button class="btn-cancel" @click=${this.cancelApproval}> 1024 Cancel 1025 </button> 1026 <button 1027 class="btn-submit" 1028 @click=${this.submitApproval} 1029 ?disabled=${this.meetingTimes.length === 0} 1030 > 1031 Create Class 1032 </button> 1033 </div> 1034 </div> 1035 </div> 1036 `; 1037 } 1038}