馃 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}