馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import type { MeetingTime } from "./meeting-time-picker";
4import "./meeting-time-picker";
5
6interface ClassResult {
7 id: string;
8 course_code: string;
9 name: string;
10 professor: string;
11 semester: string;
12 year: number;
13 sections?: { id: string; section_number: string }[];
14 is_enrolled?: boolean;
15}
16
17@customElement("class-registration-modal")
18export class ClassRegistrationModal extends LitElement {
19 @property({ type: Boolean }) open = false;
20 @state() searchQuery = "";
21 @state() results: ClassResult[] = [];
22 @state() isSearching = false;
23 @state() isJoining = false;
24 @state() error = "";
25 @state() hasSearched = false;
26 @state() showWaitlistForm = false;
27 @state() selectedSections: Map<string, string> = new Map();
28 @state() waitlistData = {
29 courseCode: "",
30 courseName: "",
31 professor: "",
32 semester: "",
33 year: new Date().getFullYear(),
34 additionalInfo: "",
35 meetingTimes: [] as MeetingTime[],
36 };
37
38 static override styles = css`
39 :host {
40 display: block;
41 }
42
43 .modal-overlay {
44 position: fixed;
45 top: 0;
46 left: 0;
47 right: 0;
48 bottom: 0;
49 background: rgba(0, 0, 0, 0.5);
50 display: flex;
51 align-items: center;
52 justify-content: center;
53 z-index: 1000;
54 padding: 1rem;
55 }
56
57 .modal {
58 background: var(--background);
59 border: 2px solid var(--secondary);
60 border-radius: 12px;
61 padding: 2rem;
62 max-width: 42rem;
63 width: 100%;
64 max-height: 90vh;
65 overflow-y: auto;
66 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
67 }
68
69 .modal-header {
70 display: flex;
71 justify-content: space-between;
72 align-items: center;
73 margin-bottom: 1.5rem;
74 }
75
76 .modal-title {
77 margin: 0;
78 color: var(--text);
79 font-size: 1.5rem;
80 }
81
82 .close-btn {
83 background: transparent;
84 border: none;
85 font-size: 1.5rem;
86 cursor: pointer;
87 color: var(--text);
88 padding: 0;
89 width: 2rem;
90 height: 2rem;
91 display: flex;
92 align-items: center;
93 justify-content: center;
94 border-radius: 4px;
95 transition: all 0.2s;
96 }
97
98 .close-btn:hover {
99 background: var(--secondary);
100 }
101
102 .search-section {
103 margin-bottom: 1.5rem;
104 }
105
106 .search-section > label {
107 margin-bottom: 0.5rem;
108 }
109
110 .search-form {
111 display: flex;
112 gap: 0.75rem;
113 align-items: center;
114 margin-bottom: 0.5rem;
115 }
116
117 .search-input-wrapper {
118 flex: 1;
119 }
120
121 label {
122 display: block;
123 margin-bottom: 0.5rem;
124 font-weight: 500;
125 color: var(--text);
126 font-size: 0.875rem;
127 }
128
129 input {
130 width: 100%;
131 padding: 0.75rem;
132 border: 2px solid var(--secondary);
133 border-radius: 6px;
134 font-size: 1rem;
135 font-family: inherit;
136 background: var(--background);
137 color: var(--text);
138 transition: all 0.2s;
139 box-sizing: border-box;
140 }
141
142 input:focus {
143 outline: none;
144 border-color: var(--primary);
145 }
146
147 .search-btn {
148 padding: 0.75rem 1.5rem;
149 background: var(--primary);
150 color: white;
151 border: 2px solid var(--primary);
152 border-radius: 6px;
153 font-size: 1rem;
154 font-weight: 500;
155 cursor: pointer;
156 transition: all 0.2s;
157 font-family: inherit;
158 }
159
160 .search-btn:hover:not(:disabled) {
161 background: var(--gunmetal);
162 border-color: var(--gunmetal);
163 }
164
165 .search-btn:disabled {
166 opacity: 0.6;
167 cursor: not-allowed;
168 }
169
170 .helper-text {
171 margin-top: 0.5rem;
172 font-size: 0.75rem;
173 color: var(--paynes-gray);
174 }
175
176 .error-message {
177 color: red;
178 font-size: 0.875rem;
179 margin-top: 0.5rem;
180 }
181
182 .results-section {
183 margin-top: 1.5rem;
184 }
185
186 .results-grid {
187 display: grid;
188 gap: 0.75rem;
189 }
190
191 .class-card {
192 background: var(--background);
193 border: 2px solid var(--secondary);
194 border-radius: 8px;
195 padding: 1.25rem;
196 cursor: pointer;
197 transition: all 0.2s;
198 }
199
200 .class-card.enrolled {
201 opacity: 0.6;
202 background: var(--background);
203 cursor: default;
204 }
205
206 .class-card:hover:not(:disabled):not(.enrolled) {
207 border-color: var(--accent);
208 transform: translateX(4px);
209 }
210
211 .class-card:disabled {
212 opacity: 0.6;
213 cursor: not-allowed;
214 }
215
216 .enrolled-badge {
217 display: inline-block;
218 padding: 0.25rem 0.5rem;
219 background: var(--secondary);
220 color: var(--text);
221 border-radius: 4px;
222 font-size: 0.75rem;
223 font-weight: 600;
224 text-transform: uppercase;
225 }
226
227 .class-header {
228 display: flex;
229 justify-content: space-between;
230 align-items: flex-start;
231 gap: 1rem;
232 margin-bottom: 0.5rem;
233 }
234
235 .class-info {
236 flex: 1;
237 }
238
239 .course-code {
240 font-size: 0.875rem;
241 font-weight: 600;
242 color: var(--accent);
243 text-transform: uppercase;
244 }
245
246 .class-name {
247 font-size: 1.125rem;
248 font-weight: 600;
249 margin: 0.25rem 0;
250 color: var(--text);
251 }
252
253 .class-meta {
254 display: flex;
255 gap: 1rem;
256 font-size: 0.875rem;
257 color: var(--paynes-gray);
258 margin-top: 0.5rem;
259 }
260
261 .join-btn {
262 padding: 0.5rem 1rem;
263 background: var(--primary);
264 color: white;
265 border: 2px solid var(--primary);
266 border-radius: 6px;
267 font-size: 0.875rem;
268 font-weight: 500;
269 cursor: pointer;
270 transition: all 0.2s;
271 font-family: inherit;
272 white-space: nowrap;
273 }
274
275 .join-btn:hover:not(:disabled) {
276 background: var(--gunmetal);
277 border-color: var(--gunmetal);
278 }
279
280 .join-btn:disabled {
281 opacity: 0.6;
282 cursor: not-allowed;
283 }
284
285 .empty-state {
286 text-align: center;
287 padding: 3rem 2rem;
288 color: var(--paynes-gray);
289 }
290
291 .empty-state button {
292 margin-top: 1rem;
293 padding: 0.75rem 1.5rem;
294 background: var(--accent);
295 color: white;
296 border: 2px solid var(--accent);
297 border-radius: 6px;
298 font-size: 1rem;
299 font-weight: 500;
300 cursor: pointer;
301 transition: all 0.2s;
302 font-family: inherit;
303 }
304
305 .empty-state button:hover {
306 background: transparent;
307 color: var(--accent);
308 }
309
310 .waitlist-form {
311 margin-top: 1.5rem;
312 }
313
314 .form-grid {
315 display: grid;
316 grid-template-columns: 1fr 1fr;
317 gap: 1rem;
318 margin-bottom: 1rem;
319 }
320
321 .form-group-full {
322 grid-column: 1 / -1;
323 }
324
325 .form-group {
326 display: flex;
327 flex-direction: column;
328 }
329
330 .form-group label {
331 margin-bottom: 0.5rem;
332 }
333
334 .form-group input,
335 .form-group select,
336 .form-group textarea {
337 width: 100%;
338 padding: 0.75rem;
339 border: 2px solid var(--secondary);
340 border-radius: 6px;
341 font-size: 1rem;
342 font-family: inherit;
343 background: var(--background);
344 color: var(--text);
345 transition: all 0.2s;
346 box-sizing: border-box;
347 }
348
349 .form-group textarea {
350 min-height: 6rem;
351 resize: vertical;
352 }
353
354 .form-group input:focus,
355 .form-group select:focus,
356 .form-group textarea:focus {
357 outline: none;
358 border-color: var(--primary);
359 }
360
361 .form-actions {
362 display: flex;
363 gap: 0.75rem;
364 justify-content: flex-end;
365 margin-top: 1.5rem;
366 }
367
368 .btn-submit {
369 padding: 0.75rem 1.5rem;
370 background: var(--primary);
371 color: white;
372 border: 2px solid var(--primary);
373 border-radius: 6px;
374 font-size: 1rem;
375 font-weight: 500;
376 cursor: pointer;
377 transition: all 0.2s;
378 font-family: inherit;
379 }
380
381 .btn-submit:hover:not(:disabled) {
382 background: var(--gunmetal);
383 border-color: var(--gunmetal);
384 }
385
386 .btn-submit:disabled {
387 opacity: 0.6;
388 cursor: not-allowed;
389 }
390
391 .btn-cancel {
392 padding: 0.75rem 1.5rem;
393 background: transparent;
394 color: var(--text);
395 border: 2px solid var(--secondary);
396 border-radius: 6px;
397 font-size: 1rem;
398 font-weight: 500;
399 cursor: pointer;
400 transition: all 0.2s;
401 font-family: inherit;
402 }
403
404 .btn-cancel:hover {
405 border-color: var(--primary);
406 color: var(--primary);
407 }
408
409 .loading {
410 text-align: center;
411 padding: 2rem;
412 color: var(--paynes-gray);
413 }
414 `;
415
416 private handleClose() {
417 this.searchQuery = "";
418 this.results = [];
419 this.error = "";
420 this.hasSearched = false;
421 this.showWaitlistForm = false;
422 this.selectedSections = new Map();
423 this.waitlistData = {
424 courseCode: "",
425 courseName: "",
426 professor: "",
427 semester: "",
428 year: new Date().getFullYear(),
429 additionalInfo: "",
430 meetingTimes: [],
431 };
432 this.dispatchEvent(new CustomEvent("close"));
433 }
434
435 private handleInput(e: Event) {
436 this.searchQuery = (e.target as HTMLInputElement).value;
437 this.error = "";
438 }
439
440 private async handleSearch(e: Event) {
441 e.preventDefault();
442 if (!this.searchQuery.trim()) return;
443
444 this.isSearching = true;
445 this.error = "";
446 this.suggestedQuery = "";
447 this.hasSearched = true;
448
449 // Auto-remove section numbers (e.g., MATH-1720-01 -> MATH-1720)
450 let queryToSearch = this.searchQuery.trim();
451 if (queryToSearch.match(/.*-\d{2,}$/)) {
452 queryToSearch = queryToSearch.replace(/-\d{2,}$/, "");
453 this.searchQuery = queryToSearch;
454 }
455
456 try {
457 const response = await fetch(
458 `/api/classes/search?q=${encodeURIComponent(queryToSearch)}`,
459 );
460
461 if (!response.ok) {
462 throw new Error("Search failed");
463 }
464
465 const data = await response.json();
466 this.results = data.classes || [];
467 } catch {
468 this.error = "Failed to search classes. Please try again.";
469 } finally {
470 this.isSearching = false;
471 }
472 }
473
474 private async handleJoin(
475 classId: string,
476 sections?: { id: string; section_number: string }[],
477 ) {
478 // If class has sections, require section selection
479 const selectedSection = this.selectedSections.get(classId);
480 if (sections && sections.length > 0 && !selectedSection) {
481 this.error = "Please select a section";
482 this.requestUpdate();
483 return;
484 }
485
486 this.isJoining = true;
487 this.error = "";
488
489 try {
490 const response = await fetch("/api/classes/join", {
491 method: "POST",
492 headers: { "Content-Type": "application/json" },
493 body: JSON.stringify({
494 class_id: classId,
495 section_id: selectedSection || null,
496 }),
497 });
498
499 if (!response.ok) {
500 const data = await response.json();
501 this.error = data.error || "Failed to join class";
502 this.isJoining = false;
503 this.requestUpdate();
504 return;
505 }
506
507 // Success - notify parent and close
508 this.dispatchEvent(new CustomEvent("class-joined"));
509 this.handleClose();
510 } catch (error) {
511 console.error("Failed to join class:", error);
512 this.error = "Failed to join class. Please try again.";
513 this.isJoining = false;
514 this.requestUpdate();
515 }
516 }
517
518 private handleRequestWaitlist() {
519 this.showWaitlistForm = true;
520 this.waitlistData.courseCode = this.searchQuery;
521 }
522
523 private handleWaitlistInput(field: string, e: Event) {
524 const value = (
525 e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
526 ).value;
527 this.waitlistData = { ...this.waitlistData, [field]: value };
528 }
529
530 private async handleSubmitWaitlist(e: Event) {
531 e.preventDefault();
532 this.isJoining = true;
533 this.error = "";
534
535 try {
536 const response = await fetch("/api/classes/waitlist", {
537 method: "POST",
538 headers: { "Content-Type": "application/json" },
539 body: JSON.stringify(this.waitlistData),
540 });
541
542 if (!response.ok) {
543 const data = await response.json();
544 this.error = data.error || "Failed to submit waitlist request";
545 return;
546 }
547
548 // Success
549 alert(
550 "Your class request has been submitted! An admin will review it soon.",
551 );
552 this.handleClose();
553 } catch {
554 this.error = "Failed to submit request. Please try again.";
555 } finally {
556 this.isJoining = false;
557 }
558 }
559
560 private handleCancelWaitlist() {
561 this.showWaitlistForm = false;
562 }
563
564 private handleMeetingTimesChange(e: CustomEvent) {
565 this.waitlistData = {
566 ...this.waitlistData,
567 meetingTimes: e.detail,
568 };
569 }
570
571 override render() {
572 if (!this.open) return html``;
573
574 return html`
575 <div class="modal-overlay" @click=${this.handleClose}>
576 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
577 <div class="modal-header">
578 <h2 class="modal-title">Find a Class</h2>
579 <button class="close-btn" @click=${this.handleClose} type="button">脳</button>
580 </div>
581
582 <div class="search-section">
583 <label for="search">Course Code</label>
584 <form class="search-form" @submit=${this.handleSearch}>
585 <div class="search-input-wrapper">
586 <input
587 type="text"
588 id="search"
589 placeholder="CS 101, MATH 220, etc."
590 .value=${this.searchQuery}
591 @input=${this.handleInput}
592 ?disabled=${this.isSearching}
593 />
594 </div>
595 <button
596 type="submit"
597 class="search-btn"
598 ?disabled=${this.isSearching || !this.searchQuery.trim()}
599 >
600 ${this.isSearching ? "Searching..." : "Search"}
601 </button>
602 </form>
603 <div class="helper-text">
604 Search by course code to find available classes
605 </div>
606 ${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
607 </div>
608
609 ${
610 this.hasSearched
611 ? html`
612 <div class="results-section">
613 ${
614 this.isSearching
615 ? html`<div class="loading">Searching...</div>`
616 : this.results.length === 0
617 ? this.showWaitlistForm
618 ? html`
619 <div class="waitlist-form">
620 <p style="margin-bottom: 1.5rem; color: var(--text);">
621 Request this class to be added to Thistle
622 </p>
623 <form @submit=${this.handleSubmitWaitlist}>
624 <div class="form-grid">
625 <div class="form-group">
626 <label>Course Code *</label>
627 <input
628 type="text"
629 required
630 .value=${this.waitlistData.courseCode}
631 @input=${(e: Event) => this.handleWaitlistInput("courseCode", e)}
632 />
633 </div>
634 <div class="form-group">
635 <label>Course Name *</label>
636 <input
637 type="text"
638 required
639 .value=${this.waitlistData.courseName}
640 @input=${(e: Event) => this.handleWaitlistInput("courseName", e)}
641 />
642 </div>
643 <div class="form-group">
644 <label>Professor *</label>
645 <input
646 type="text"
647 required
648 .value=${this.waitlistData.professor}
649 @input=${(e: Event) => this.handleWaitlistInput("professor", e)}
650 />
651 </div>
652 <div class="form-group">
653 <label>Semester *</label>
654 <select
655 required
656 .value=${this.waitlistData.semester}
657 @change=${(e: Event) => this.handleWaitlistInput("semester", e)}
658 >
659 <option value="">Select semester</option>
660 <option value="Spring">Spring</option>
661 <option value="Summer">Summer</option>
662 <option value="Fall">Fall</option>
663 <option value="Winter">Winter</option>
664 </select>
665 </div>
666 <div class="form-group">
667 <label>Year *</label>
668 <input
669 type="number"
670 required
671 min="2020"
672 max="2030"
673 .value=${this.waitlistData.year.toString()}
674 @input=${(e: Event) => this.handleWaitlistInput("year", e)}
675 />
676 </div>
677 <div class="form-group form-group-full">
678 <label>Meeting Times *</label>
679 <meeting-time-picker
680 .value=${this.waitlistData.meetingTimes}
681 @change=${this.handleMeetingTimesChange}
682 ></meeting-time-picker>
683 </div>
684 <div class="form-group form-group-full">
685 <label>Additional Info (optional)</label>
686 <textarea
687 placeholder="Any additional details about this class..."
688 .value=${this.waitlistData.additionalInfo}
689 @input=${(e: Event) => this.handleWaitlistInput("additionalInfo", e)}
690 ></textarea>
691 </div>
692 </div>
693 ${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
694 <div class="form-actions">
695 <button
696 type="button"
697 class="btn-cancel"
698 @click=${this.handleCancelWaitlist}
699 ?disabled=${this.isJoining}
700 >
701 Cancel
702 </button>
703 <button
704 type="submit"
705 class="btn-submit"
706 ?disabled=${this.isJoining}
707 >
708 ${this.isJoining ? "Submitting..." : "Submit Request"}
709 </button>
710 </div>
711 </form>
712 </div>
713 `
714 : html`
715 <div class="empty-state">
716 <p>No classes found matching "${this.searchQuery}"</p>
717 <p style="margin-top: 0.5rem; font-size: 0.875rem;">
718 Can't find your class? Request it to be added.
719 </p>
720 <button @click=${this.handleRequestWaitlist}>
721 Request Class
722 </button>
723 </div>
724 `
725 : html`
726 <div class="results-grid">
727 ${this.results.map(
728 (cls) => html`
729 <div class="class-card ${cls.is_enrolled ? "enrolled" : ""}">
730 <div class="class-header">
731 <div class="class-info">
732 <div class="course-code">
733 ${cls.course_code}
734 ${cls.is_enrolled ? html`<span class="enrolled-badge">Registered</span>` : ""}
735 </div>
736 <div class="class-name">${cls.name}</div>
737 <div class="class-meta">
738 <span>馃懁 ${cls.professor}</span>
739 <span>馃搮 ${cls.semester} ${cls.year}</span>
740 </div>
741 ${
742 !cls.is_enrolled &&
743 cls.sections &&
744 cls.sections.length > 0
745 ? html`
746 <div style="margin-top: 0.75rem;">
747 <label style="font-size: 0.75rem; margin-bottom: 0.25rem;">Select Section *</label>
748 <select
749 style="width: 100%; padding: 0.5rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; background: var(--background); color: var(--text);"
750 @change=${(e: Event) => {
751 const sectionId = (
752 e.target as HTMLSelectElement
753 ).value;
754 if (sectionId) {
755 this.selectedSections.set(cls.id, sectionId);
756 } else {
757 this.selectedSections.delete(cls.id);
758 }
759 this.error = "";
760 this.requestUpdate();
761 }}
762 >
763 <option value="">Choose a section...</option>
764 ${cls.sections.map(
765 (s) =>
766 html`<option value="${s.id}" ?selected=${this.selectedSections.get(cls.id) === s.id}>${s.section_number}</option>`,
767 )}
768 </select>
769 </div>
770 `
771 : ""
772 }
773 </div>
774 ${
775 !cls.is_enrolled
776 ? html`
777 <button
778 class="join-btn"
779 ?disabled=${this.isJoining}
780 @click=${(e: Event) => {
781 e.stopPropagation();
782 console.log('Join button clicked for class:', cls.id, 'sections:', cls.sections);
783 this.handleJoin(cls.id, cls.sections);
784 }}
785 >
786 ${this.isJoining ? "Joining..." : "Join"}
787 </button>
788 `
789 : ""
790 }
791 </div>
792 </div>
793 `,
794 )}
795 </div>
796 `
797 }
798 </div>
799 `
800 : ""
801 }
802 </div>
803 </div>
804 `;
805 }
806}