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