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