馃 distributed transcription service thistle.dunkirk.sh
at main 23 kB view raw
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}