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