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