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