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