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