馃 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}