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