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