馃 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 await this.loadData(); 469 } 470 471 private async loadData() { 472 this.isLoading = true; 473 this.error = ""; 474 475 try { 476 const [classesRes, waitlistRes] = await Promise.all([ 477 fetch("/api/admin/classes"), 478 fetch("/api/admin/waitlist"), 479 ]); 480 481 if (!classesRes.ok || !waitlistRes.ok) { 482 throw new Error("Failed to load data"); 483 } 484 485 const classesData = await classesRes.json(); 486 const waitlistData = await waitlistRes.json(); 487 488 this.classes = classesData.classes || []; 489 this.waitlist = waitlistData.waitlist || []; 490 } catch { 491 this.error = "Failed to load data. Please try again."; 492 } finally { 493 this.isLoading = false; 494 } 495 } 496 497 private handleSearch(e: Event) { 498 this.searchTerm = (e.target as HTMLInputElement).value.toLowerCase(); 499 } 500 501 private async handleToggleArchive(classId: string) { 502 try { 503 // Find the class to toggle its archived state 504 const classToToggle = this.classes.find(c => c.id === classId); 505 if (!classToToggle) return; 506 507 const response = await fetch(`/api/classes/${classId}/archive`, { 508 method: "PUT", 509 headers: { "Content-Type": "application/json" }, 510 body: JSON.stringify({ archived: !classToToggle.archived }), 511 }); 512 513 if (!response.ok) { 514 throw new Error("Failed to update class"); 515 } 516 517 // Update local state instead of reloading 518 this.classes = this.classes.map(c => 519 c.id === classId ? { ...c, archived: !c.archived } : c 520 ); 521 } catch { 522 this.error = "Failed to update class. Please try again."; 523 } 524 } 525 526 private handleDeleteClick(id: string, type: "class" | "waitlist") { 527 // If this is a different item or timeout expired, reset 528 if ( 529 !this.deleteState || 530 this.deleteState.id !== id || 531 this.deleteState.type !== type 532 ) { 533 // Clear any existing timeout 534 if (this.deleteState?.timeout) { 535 clearTimeout(this.deleteState.timeout); 536 } 537 538 // Set first click 539 const timeout = window.setTimeout(() => { 540 this.deleteState = null; 541 }, 1000); 542 543 this.deleteState = { id, type, clicks: 1, timeout }; 544 return; 545 } 546 547 // Increment clicks 548 const newClicks = this.deleteState.clicks + 1; 549 550 // Clear existing timeout 551 if (this.deleteState.timeout) { 552 clearTimeout(this.deleteState.timeout); 553 } 554 555 // Third click - actually delete 556 if (newClicks === 3) { 557 this.deleteState = null; 558 if (type === "class") { 559 this.performDeleteClass(id); 560 } else { 561 this.performDeleteWaitlist(id); 562 } 563 return; 564 } 565 566 // Second click - reset timeout 567 const timeout = window.setTimeout(() => { 568 this.deleteState = null; 569 }, 1000); 570 571 this.deleteState = { id, type, clicks: newClicks, timeout }; 572 } 573 574 private getDeleteButtonText(id: string, type: "class" | "waitlist"): string { 575 if ( 576 !this.deleteState || 577 this.deleteState.id !== id || 578 this.deleteState.type !== type 579 ) { 580 return "Delete"; 581 } 582 583 if (this.deleteState.clicks === 1) { 584 return "Are you sure?"; 585 } 586 587 if (this.deleteState.clicks === 2) { 588 return "Final warning!"; 589 } 590 591 return "Delete"; 592 } 593 594 private async performDeleteClass(classId: string) { 595 try { 596 const response = await fetch(`/api/classes/${classId}`, { 597 method: "DELETE", 598 }); 599 600 if (!response.ok) { 601 throw new Error("Failed to delete class"); 602 } 603 604 await this.loadData(); 605 } catch { 606 this.error = "Failed to delete class. Please try again."; 607 } 608 } 609 610 private async performDeleteWaitlist(id: string) { 611 try { 612 const response = await fetch(`/api/admin/waitlist/${id}`, { 613 method: "DELETE", 614 }); 615 616 if (!response.ok) { 617 throw new Error("Failed to delete waitlist entry"); 618 } 619 620 await this.loadData(); 621 } catch { 622 this.error = "Failed to delete waitlist entry. Please try again."; 623 } 624 } 625 626 private handleCreateClass() { 627 // Set empty form for creating new class 628 this.approvingEntry = null; 629 this.editingClass = { 630 courseCode: "", 631 courseName: "", 632 professor: "", 633 semester: "", 634 year: new Date().getFullYear(), 635 }; 636 this.meetingTimes = []; 637 this.showModal = true; 638 } 639 640 private getFilteredClasses() { 641 if (!this.searchTerm) return this.classes; 642 643 return this.classes.filter((cls) => { 644 const searchStr = this.searchTerm; 645 return ( 646 cls.course_code.toLowerCase().includes(searchStr) || 647 cls.name.toLowerCase().includes(searchStr) || 648 cls.professor.toLowerCase().includes(searchStr) 649 ); 650 }); 651 } 652 653 override render() { 654 if (this.isLoading) { 655 return html`<div class="loading">Loading...</div>`; 656 } 657 658 const filteredClasses = this.getFilteredClasses(); 659 660 return html` 661 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 662 663 <div class="tabs"> 664 <button 665 class="tab ${this.activeTab === "classes" ? "active" : ""}" 666 @click=${() => { 667 this.activeTab = "classes"; 668 }} 669 > 670 Classes 671 </button> 672 <button 673 class="tab ${this.activeTab === "waitlist" ? "active" : ""}" 674 @click=${() => { 675 this.activeTab = "waitlist"; 676 }} 677 > 678 Waitlist 679 ${this.waitlist.length > 0 ? html`<span class="tab-badge">${this.waitlist.length}</span>` : ""} 680 </button> 681 </div> 682 683 ${ 684 this.activeTab === "classes" 685 ? this.renderClasses(filteredClasses) 686 : this.renderWaitlist() 687 } 688 689 ${this.showModal ? this.renderApprovalModal() : ""} 690 `; 691 } 692 693 private renderClasses(filteredClasses: Class[]) { 694 return html` 695 <div class="header"> 696 <input 697 type="text" 698 class="search" 699 placeholder="Search classes..." 700 @input=${this.handleSearch} 701 .value=${this.searchTerm} 702 /> 703 <button class="create-btn" @click=${this.handleCreateClass}> 704 + Create Class 705 </button> 706 </div> 707 708 ${ 709 filteredClasses.length === 0 710 ? html` 711 <div class="empty-state"> 712 ${this.searchTerm ? "No classes found matching your search" : "No classes yet"} 713 </div> 714 ` 715 : html` 716 <div class="classes-grid"> 717 ${filteredClasses.map( 718 (cls) => html` 719 <div class="class-card ${cls.archived ? "archived" : ""}"> 720 <div class="class-header"> 721 <div class="class-info"> 722 <div class="course-code">${cls.course_code}</div> 723 <div class="class-name">${cls.name}</div> 724 <div class="class-meta"> 725 <span>馃懁 ${cls.professor}</span> 726 <span>馃搮 ${cls.semester} ${cls.year}</span> 727 ${cls.archived ? html`<span class="badge archived">Archived</span>` : ""} 728 </div> 729 </div> 730 <div class="actions"> 731 <button 732 class="btn-archive" 733 @click=${() => this.handleToggleArchive(cls.id)} 734 > 735 ${cls.archived ? "Unarchive" : "Archive"} 736 </button> 737 <button 738 class="btn-delete" 739 @click=${() => this.handleDeleteClick(cls.id, "class")} 740 > 741 ${this.getDeleteButtonText(cls.id, "class")} 742 </button> 743 </div> 744 </div> 745 </div> 746 `, 747 )} 748 </div> 749 ` 750 } 751 `; 752 } 753 754 private renderWaitlist() { 755 return html` 756 ${ 757 this.waitlist.length === 0 758 ? html` 759 <div class="empty-state">No waitlist requests yet</div> 760 ` 761 : html` 762 <div class="classes-grid"> 763 ${this.waitlist.map( 764 (entry) => html` 765 <div class="class-card"> 766 <div class="class-header"> 767 <div class="class-info"> 768 <div class="course-code">${entry.course_code}</div> 769 <div class="class-name">${entry.course_name}</div> 770 <div class="class-meta"> 771 <span>馃懁 ${entry.professor}</span> 772 <span>馃搮 ${entry.semester} ${entry.year}</span> 773 </div> 774 ${ 775 entry.additional_info 776 ? html` 777 <p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--paynes-gray);"> 778 ${entry.additional_info} 779 </p> 780 ` 781 : "" 782 } 783 </div> 784 <div class="actions"> 785 <button 786 class="btn-archive" 787 @click=${() => this.handleApproveWaitlist(entry)} 788 > 789 Approve & Create Class 790 </button> 791 <button 792 class="btn-delete" 793 @click=${() => this.handleDeleteClick(entry.id, "waitlist")} 794 > 795 ${this.getDeleteButtonText(entry.id, "waitlist")} 796 </button> 797 </div> 798 </div> 799 </div> 800 `, 801 )} 802 </div> 803 ` 804 } 805 `; 806 } 807 808 private handleApproveWaitlist(entry: WaitlistEntry) { 809 this.approvingEntry = entry; 810 811 // Pre-fill form with waitlist data 812 this.editingClass = { 813 courseCode: entry.course_code, 814 courseName: entry.course_name, 815 professor: entry.professor, 816 semester: entry.semester, 817 year: entry.year, 818 }; 819 820 // Parse meeting times from JSON if available, otherwise use empty array 821 if (entry.meeting_times) { 822 try { 823 const parsed = JSON.parse(entry.meeting_times); 824 this.meetingTimes = 825 Array.isArray(parsed) && parsed.length > 0 ? parsed : []; 826 } catch { 827 this.meetingTimes = []; 828 } 829 } else { 830 this.meetingTimes = []; 831 } 832 this.showModal = true; 833 } 834 835 private handleMeetingTimesChange(e: CustomEvent) { 836 this.meetingTimes = e.detail; 837 } 838 839 private handleClassFieldInput(field: string, e: Event) { 840 const value = (e.target as HTMLInputElement | HTMLSelectElement).value; 841 this.editingClass = { ...this.editingClass, [field]: value }; 842 } 843 844 private cancelApproval() { 845 this.showModal = false; 846 this.approvingEntry = null; 847 this.meetingTimes = []; 848 this.editingClass = { 849 courseCode: "", 850 courseName: "", 851 professor: "", 852 semester: "", 853 year: new Date().getFullYear(), 854 }; 855 } 856 857 private async submitApproval() { 858 if (this.meetingTimes.length === 0) { 859 this.error = "Please add at least one meeting time"; 860 return; 861 } 862 863 // Convert MeetingTime objects to label strings 864 const labels = this.meetingTimes.map((t) => t.label); 865 866 try { 867 const response = await fetch("/api/classes", { 868 method: "POST", 869 headers: { "Content-Type": "application/json" }, 870 body: JSON.stringify({ 871 course_code: this.editingClass.courseCode, 872 name: this.editingClass.courseName, 873 professor: this.editingClass.professor, 874 semester: this.editingClass.semester, 875 year: this.editingClass.year, 876 meeting_times: labels, 877 }), 878 }); 879 880 if (!response.ok) { 881 const data = await response.json(); 882 console.error("Failed to create class:", data); 883 throw new Error(data.error || "Failed to create class"); 884 } 885 886 // If approving from waitlist, delete the waitlist entry 887 if (this.approvingEntry) { 888 await fetch(`/api/admin/waitlist/${this.approvingEntry.id}`, { 889 method: "DELETE", 890 }); 891 } 892 893 await this.loadData(); 894 895 this.activeTab = "classes"; 896 this.showModal = false; 897 this.approvingEntry = null; 898 this.meetingTimes = []; 899 this.editingClass = { 900 courseCode: "", 901 courseName: "", 902 professor: "", 903 semester: "", 904 year: new Date().getFullYear(), 905 }; 906 } catch (error) { 907 console.error("Error in submitApproval:", error); 908 this.error = 909 error instanceof Error 910 ? error.message 911 : "Failed to create class. Please try again."; 912 } 913 } 914 915 private renderApprovalModal() { 916 const isApproving = !!this.approvingEntry; 917 const title = isApproving ? "Review & Create Class" : "Create New Class"; 918 const description = isApproving 919 ? "Review the class details and make any edits before creating" 920 : "Enter the class details below"; 921 922 return html` 923 <div class="modal-overlay" @click=${this.cancelApproval}> 924 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 925 <h2 class="modal-title">${title}</h2> 926 927 <p style="margin-bottom: 1.5rem; color: var(--paynes-gray);"> 928 ${description} 929 </p> 930 931 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""} 932 933 <div class="form-grid"> 934 <div class="form-group"> 935 <label>Course Code *</label> 936 <input 937 type="text" 938 required 939 .value=${this.editingClass.courseCode} 940 @input=${(e: Event) => this.handleClassFieldInput("courseCode", e)} 941 /> 942 </div> 943 <div class="form-group"> 944 <label>Course Name *</label> 945 <input 946 type="text" 947 required 948 .value=${this.editingClass.courseName} 949 @input=${(e: Event) => this.handleClassFieldInput("courseName", e)} 950 /> 951 </div> 952 <div class="form-group"> 953 <label>Professor *</label> 954 <input 955 type="text" 956 required 957 .value=${this.editingClass.professor} 958 @input=${(e: Event) => this.handleClassFieldInput("professor", e)} 959 /> 960 </div> 961 <div class="form-group"> 962 <label>Semester *</label> 963 <select 964 required 965 .value=${this.editingClass.semester} 966 @change=${(e: Event) => this.handleClassFieldInput("semester", e)} 967 > 968 <option value="">Select semester</option> 969 <option value="Spring">Spring</option> 970 <option value="Summer">Summer</option> 971 <option value="Fall">Fall</option> 972 <option value="Winter">Winter</option> 973 </select> 974 </div> 975 <div class="form-group"> 976 <label>Year *</label> 977 <input 978 type="number" 979 required 980 min="2020" 981 max="2030" 982 .value=${this.editingClass.year.toString()} 983 @input=${(e: Event) => this.handleClassFieldInput("year", e)} 984 /> 985 </div> 986 <div class="form-group form-group-full"> 987 <label>Meeting Times *</label> 988 <meeting-time-picker 989 .value=${this.meetingTimes} 990 @change=${this.handleMeetingTimesChange} 991 ></meeting-time-picker> 992 </div> 993 </div> 994 995 <div class="modal-actions"> 996 <button class="btn-cancel" @click=${this.cancelApproval}> 997 Cancel 998 </button> 999 <button 1000 class="btn-submit" 1001 @click=${this.submitApproval} 1002 ?disabled=${this.meetingTimes.length === 0} 1003 > 1004 Create Class 1005 </button> 1006 </div> 1007 </div> 1008 </div> 1009 `; 1010 } 1011}