🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sections

+617 -47
+39
scripts/remove-from-classes.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import db from "../src/db/schema"; 4 + 5 + const email = process.argv[2]; 6 + 7 + if (!email) { 8 + console.error("Usage: bun scripts/remove-from-classes.ts <email>"); 9 + console.error(" Removes a user from all their enrolled classes"); 10 + process.exit(1); 11 + } 12 + 13 + const user = db 14 + .query<{ id: number; email: string }, [string]>( 15 + "SELECT id, email FROM users WHERE email = ?", 16 + ) 17 + .get(email); 18 + 19 + if (!user) { 20 + console.error(`User with email ${email} not found`); 21 + process.exit(1); 22 + } 23 + 24 + // Get current enrollments 25 + const enrollments = db 26 + .query<{ class_id: string }, [number]>( 27 + "SELECT class_id FROM class_members WHERE user_id = ?", 28 + ) 29 + .all(user.id); 30 + 31 + if (enrollments.length === 0) { 32 + console.log(`User ${email} is not enrolled in any classes`); 33 + process.exit(0); 34 + } 35 + 36 + // Remove from all classes 37 + db.run("DELETE FROM class_members WHERE user_id = ?", [user.id]); 38 + 39 + console.log(`✅ Successfully removed ${email} from ${enrollments.length} class(es)`);
+215 -15
src/components/admin-classes.ts
··· 40 40 @state() activeTab: "classes" | "waitlist" = "classes"; 41 41 @state() approvingEntry: WaitlistEntry | null = null; 42 42 @state() showModal = false; 43 + @state() showClassSettingsModal = false; 44 + @state() editingClassId: string | null = null; 45 + @state() editingClassInfo: Class | null = null; 46 + @state() editingClassSections: { id: string; section_number: string }[] = []; 47 + @state() newSectionNumber = ""; 43 48 @state() meetingTimes: MeetingTime[] = []; 49 + @state() sections: string[] = []; 44 50 @state() editingClass = { 45 51 courseCode: "", 46 52 courseName: "", ··· 664 670 this.showModal = true; 665 671 } 666 672 673 + private async handleEditSections(classId: string) { 674 + try { 675 + const response = await fetch(`/api/classes/${classId}`); 676 + if (!response.ok) throw new Error("Failed to load class"); 677 + 678 + const data = await response.json(); 679 + this.editingClassId = classId; 680 + this.editingClassInfo = data.class; 681 + this.editingClassSections = data.sections || []; 682 + this.newSectionNumber = ""; 683 + this.showClassSettingsModal = true; 684 + } catch { 685 + this.error = "Failed to load class details"; 686 + } 687 + } 688 + 689 + private async handleAddSection() { 690 + if (!this.newSectionNumber.trim() || !this.editingClassId) return; 691 + 692 + try { 693 + const response = await fetch(`/api/classes/${this.editingClassId}/sections`, { 694 + method: "POST", 695 + headers: { "Content-Type": "application/json" }, 696 + body: JSON.stringify({ section_number: this.newSectionNumber.trim() }), 697 + }); 698 + 699 + if (!response.ok) { 700 + const data = await response.json(); 701 + this.error = data.error || "Failed to add section"; 702 + return; 703 + } 704 + 705 + const newSection = await response.json(); 706 + this.editingClassSections = [...this.editingClassSections, newSection]; 707 + this.newSectionNumber = ""; 708 + } catch { 709 + this.error = "Failed to add section"; 710 + } 711 + } 712 + 713 + private async handleDeleteSection(sectionId: string) { 714 + if (!this.editingClassId) return; 715 + 716 + try { 717 + const response = await fetch(`/api/classes/${this.editingClassId}/sections/${sectionId}`, { 718 + method: "DELETE", 719 + }); 720 + 721 + if (!response.ok) { 722 + const data = await response.json(); 723 + this.error = data.error || "Failed to delete section"; 724 + return; 725 + } 726 + 727 + this.editingClassSections = this.editingClassSections.filter(s => s.id !== sectionId); 728 + } catch { 729 + this.error = "Failed to delete section"; 730 + } 731 + } 732 + 733 + private handleCloseSectionsModal() { 734 + this.showClassSettingsModal = false; 735 + this.editingClassId = null; 736 + this.editingClassInfo = null; 737 + this.editingClassSections = []; 738 + this.newSectionNumber = ""; 739 + this.loadData(); 740 + } 741 + 742 + 667 743 private getFilteredClasses() { 668 744 if (!this.searchTerm) return this.classes; 669 745 ··· 714 790 } 715 791 716 792 ${this.showModal ? this.renderApprovalModal() : ""} 793 + ${this.showClassSettingsModal ? this.renderClassSettingsModal() : ""} 717 794 `; 718 795 } 719 796 ··· 743 820 <div class="classes-grid"> 744 821 ${filteredClasses.map( 745 822 (cls) => html` 746 - <div class="class-card ${cls.archived ? "archived" : ""}"> 823 + <div 824 + class="class-card ${cls.archived ? "archived" : ""}" 825 + @click=${() => this.handleEditSections(cls.id)} 826 + style="cursor: pointer;" 827 + > 747 828 <div class="class-header"> 748 829 <div class="class-info"> 749 830 <div class="course-code">${cls.course_code}</div> ··· 756 837 ${cls.archived ? html`<span class="badge archived">Archived</span>` : ""} 757 838 </div> 758 839 </div> 759 - <div class="actions"> 760 - <button 761 - class="btn-archive" 762 - @click=${() => this.handleToggleArchive(cls.id)} 763 - > 764 - ${cls.archived ? "Unarchive" : "Archive"} 765 - </button> 766 - <button 767 - class="btn-delete" 768 - @click=${() => this.handleDeleteClick(cls.id, "class")} 769 - > 770 - ${this.getDeleteButtonText(cls.id, "class")} 771 - </button> 772 - </div> 773 840 </div> 774 841 </div> 775 842 `, ··· 780 847 `; 781 848 } 782 849 850 + private renderClassSettingsModal() { 851 + if (!this.showClassSettingsModal || !this.editingClassInfo) return html``; 852 + 853 + return html` 854 + <div class="modal-overlay" @click=${this.handleCloseSectionsModal}> 855 + <div class="modal" @click=${(e: Event) => e.stopPropagation()} style="max-width: 48rem;"> 856 + <div class="modal-header"> 857 + <h2 class="modal-title">${this.editingClassInfo.course_code} - ${this.editingClassInfo.name}</h2> 858 + <button class="close-btn" @click=${this.handleCloseSectionsModal} type="button">×</button> 859 + </div> 860 + 861 + <div class="tabs" style="margin-bottom: 1.5rem;"> 862 + <div style="display: flex; gap: 0.5rem; border-bottom: 2px solid var(--secondary);"> 863 + <button 864 + style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: none; border-bottom: 2px solid var(--primary); font-weight: 600; cursor: pointer; margin-bottom: -2px;" 865 + > 866 + Sections 867 + </button> 868 + </div> 869 + </div> 870 + 871 + <!-- Sections Tab --> 872 + <div style="margin-bottom: 1.5rem;"> 873 + <h3 style="margin-bottom: 1rem; color: var(--text);">Manage Sections</h3> 874 + 875 + <div style="display: flex; gap: 0.75rem; margin-bottom: 1rem;"> 876 + <input 877 + type="text" 878 + placeholder="Section number (e.g., 01, 02, A, B)" 879 + .value=${this.newSectionNumber} 880 + @input=${(e: Event) => { 881 + this.newSectionNumber = (e.target as HTMLInputElement).value; 882 + }} 883 + @keypress=${(e: KeyboardEvent) => { 884 + if (e.key === "Enter") { 885 + e.preventDefault(); 886 + this.handleAddSection(); 887 + } 888 + }} 889 + style="flex: 1; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; background: var(--background); color: var(--text);" 890 + /> 891 + <button 892 + @click=${this.handleAddSection} 893 + ?disabled=${!this.newSectionNumber.trim()} 894 + style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer; white-space: nowrap;" 895 + > 896 + Add Section 897 + </button> 898 + </div> 899 + 900 + ${ 901 + this.editingClassSections.length === 0 902 + ? html`<p style="color: var(--paynes-gray); text-align: center; padding: 2rem;">No sections yet. Add one above.</p>` 903 + : html` 904 + <div style="display: flex; flex-direction: column; gap: 0.5rem;"> 905 + ${this.editingClassSections.map( 906 + (section) => html` 907 + <div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: color-mix(in srgb, var(--secondary) 30%, transparent); border-radius: 6px;"> 908 + <span style="font-weight: 500;">Section ${section.section_number}</span> 909 + <button 910 + @click=${(e: Event) => { 911 + e.stopPropagation(); 912 + this.handleDeleteSection(section.id); 913 + }} 914 + style="padding: 0.5rem 1rem; background: transparent; color: red; border: 2px solid red; border-radius: 4px; font-size: 0.875rem; cursor: pointer;" 915 + > 916 + Delete 917 + </button> 918 + </div> 919 + `, 920 + )} 921 + </div> 922 + ` 923 + } 924 + </div> 925 + 926 + <!-- Actions --> 927 + <div style="display: flex; gap: 0.75rem; justify-content: space-between; padding-top: 1.5rem; border-top: 2px solid var(--secondary);"> 928 + <div style="display: flex; gap: 0.75rem;"> 929 + <button 930 + @click=${(e: Event) => { 931 + e.stopPropagation(); 932 + this.handleToggleArchive(this.editingClassId!); 933 + this.handleCloseSectionsModal(); 934 + }} 935 + style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;" 936 + > 937 + ${this.editingClassInfo.archived ? "Unarchive" : "Archive"} Class 938 + </button> 939 + <button 940 + @click=${(e: Event) => { 941 + e.stopPropagation(); 942 + this.handleDeleteClick(this.editingClassId!, "class"); 943 + }} 944 + style="padding: 0.75rem 1.5rem; background: transparent; color: red; border: 2px solid red; border-radius: 6px; font-weight: 500; cursor: pointer;" 945 + > 946 + ${this.getDeleteButtonText(this.editingClassId!, "class")} 947 + </button> 948 + </div> 949 + <button 950 + @click=${this.handleCloseSectionsModal} 951 + style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;" 952 + > 953 + Done 954 + </button> 955 + </div> 956 + 957 + ${this.error ? html`<div style="color: red; margin-top: 1rem; padding: 0.75rem; background: color-mix(in srgb, red 10%, transparent); border-radius: 6px;">${this.error}</div>` : ""} 958 + </div> 959 + </div> 960 + `; 961 + } 962 + 783 963 private renderWaitlist() { 784 964 return html` 785 965 ${ ··· 865 1045 this.meetingTimes = e.detail; 866 1046 } 867 1047 1048 + private handleSectionsChange(e: Event) { 1049 + const value = (e.target as HTMLInputElement).value; 1050 + this.sections = value 1051 + .split(",") 1052 + .map((s) => s.trim()) 1053 + .filter((s) => s); 1054 + } 1055 + 868 1056 private handleClassFieldInput(field: string, e: Event) { 869 1057 const value = (e.target as HTMLInputElement | HTMLSelectElement).value; 870 1058 this.editingClass = { ...this.editingClass, [field]: value }; ··· 874 1062 this.showModal = false; 875 1063 this.approvingEntry = null; 876 1064 this.meetingTimes = []; 1065 + this.sections = []; 877 1066 this.editingClass = { 878 1067 courseCode: "", 879 1068 courseName: "", ··· 903 1092 semester: this.editingClass.semester, 904 1093 year: this.editingClass.year, 905 1094 meeting_times: labels, 1095 + sections: this.sections.length > 0 ? this.sections : undefined, 906 1096 }), 907 1097 }); 908 1098 ··· 1018 1208 .value=${this.meetingTimes} 1019 1209 @change=${this.handleMeetingTimesChange} 1020 1210 ></meeting-time-picker> 1211 + </div> 1212 + <div class="form-group form-group-full"> 1213 + <label>Sections (optional)</label> 1214 + <input 1215 + type="text" 1216 + placeholder="e.g., 01, 02, 03 or A, B, C" 1217 + .value=${this.sections.join(", ")} 1218 + @input=${this.handleSectionsChange} 1219 + /> 1220 + <div class="help-text">Comma-separated list of section numbers. Leave blank if no sections.</div> 1021 1221 </div> 1022 1222 </div> 1023 1223
+60 -11
src/components/class-registration-modal.ts
··· 10 10 professor: string; 11 11 semester: string; 12 12 year: number; 13 + sections?: { id: string; section_number: string }[]; 13 14 is_enrolled?: boolean; 14 15 } 15 16 ··· 23 24 @state() error = ""; 24 25 @state() hasSearched = false; 25 26 @state() showWaitlistForm = false; 27 + @state() selectedSections: Map<string, string> = new Map(); 26 28 @state() waitlistData = { 27 29 courseCode: "", 28 30 courseName: "", ··· 417 419 this.error = ""; 418 420 this.hasSearched = false; 419 421 this.showWaitlistForm = false; 422 + this.selectedSections = new Map(); 420 423 this.waitlistData = { 421 424 courseCode: "", 422 425 courseName: "", ··· 468 471 } 469 472 } 470 473 471 - private async handleJoin(classId: string) { 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 + 472 486 this.isJoining = true; 473 487 this.error = ""; 474 488 ··· 476 490 const response = await fetch("/api/classes/join", { 477 491 method: "POST", 478 492 headers: { "Content-Type": "application/json" }, 479 - body: JSON.stringify({ class_id: classId }), 493 + body: JSON.stringify({ 494 + class_id: classId, 495 + section_id: selectedSection || null, 496 + }), 480 497 }); 481 498 482 499 if (!response.ok) { 483 500 const data = await response.json(); 484 501 this.error = data.error || "Failed to join class"; 502 + this.isJoining = false; 503 + this.requestUpdate(); 485 504 return; 486 505 } 487 506 488 507 // Success - notify parent and close 489 508 this.dispatchEvent(new CustomEvent("class-joined")); 490 509 this.handleClose(); 491 - } catch { 510 + } catch (error) { 511 + console.error("Failed to join class:", error); 492 512 this.error = "Failed to join class. Please try again."; 493 - } finally { 494 513 this.isJoining = false; 514 + this.requestUpdate(); 495 515 } 496 516 } 497 517 ··· 706 726 <div class="results-grid"> 707 727 ${this.results.map( 708 728 (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 - > 729 + <div class="class-card ${cls.is_enrolled ? "enrolled" : ""}"> 714 730 <div class="class-header"> 715 731 <div class="class-info"> 716 732 <div class="course-code"> ··· 722 738 <span>👤 ${cls.professor}</span> 723 739 <span>📅 ${cls.semester} ${cls.year}</span> 724 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 + } 725 773 </div> 726 774 ${ 727 775 !cls.is_enrolled ··· 731 779 ?disabled=${this.isJoining} 732 780 @click=${(e: Event) => { 733 781 e.stopPropagation(); 734 - this.handleJoin(cls.id); 782 + console.log('Join button clicked for class:', cls.id, 'sections:', cls.sections); 783 + this.handleJoin(cls.id, cls.sections); 735 784 }} 736 785 > 737 786 ${this.isJoining ? "Joining..." : "Join"} ··· 740 789 : "" 741 790 } 742 791 </div> 743 - </button> 792 + </div> 744 793 `, 745 794 )} 746 795 </div>
+71 -6
src/components/class-view.ts
··· 24 24 id: string; 25 25 user_id: number; 26 26 meeting_time_id: string | null; 27 + section_id: string | null; 27 28 filename: string; 28 29 original_filename: string; 29 30 status: ··· 42 43 audioUrl?: string; 43 44 } 44 45 46 + interface ClassSection { 47 + id: string; 48 + section_number: string; 49 + } 50 + 45 51 @customElement("class-view") 46 52 export class ClassView extends LitElement { 47 53 @state() classId = ""; 48 54 @state() classInfo: Class | null = null; 49 55 @state() meetingTimes: MeetingTime[] = []; 56 + @state() sections: ClassSection[] = []; 57 + @state() userSection: string | null = null; 58 + @state() selectedSectionFilter: string | null = null; 50 59 @state() transcriptions: Transcription[] = []; 51 60 @state() isLoading = true; 52 61 @state() error: string | null = null; ··· 363 372 const data = await response.json(); 364 373 this.classInfo = data.class; 365 374 this.meetingTimes = data.meetingTimes || []; 375 + this.sections = data.sections || []; 376 + this.userSection = data.userSection || null; 366 377 this.transcriptions = data.transcriptions || []; 378 + 379 + // Default to user's section for filtering 380 + if (this.userSection && !this.selectedSectionFilter) { 381 + this.selectedSectionFilter = this.userSection; 382 + } 367 383 368 384 // Load VTT for completed transcriptions 369 385 await this.loadVTTForCompleted(); ··· 450 466 } 451 467 452 468 private get filteredTranscriptions() { 453 - if (!this.searchQuery) return this.transcriptions; 469 + let filtered = this.transcriptions; 470 + 471 + // Filter by selected section (or user's section by default) 472 + const sectionFilter = this.selectedSectionFilter || this.userSection; 473 + 474 + // Only filter by section if: 475 + // 1. There are sections in the class 476 + // 2. User has a section OR has selected one 477 + if (this.sections.length > 0 && sectionFilter) { 478 + // For admins: show all transcriptions 479 + // For users: show their section + transcriptions with no section (legacy/unassigned) 480 + if (!this.isAdmin) { 481 + filtered = filtered.filter( 482 + (t) => t.section_id === sectionFilter || t.section_id === null, 483 + ); 484 + } 485 + } 486 + 487 + // Filter by search query 488 + if (this.searchQuery) { 489 + const query = this.searchQuery.toLowerCase(); 490 + filtered = filtered.filter((t) => 491 + t.original_filename.toLowerCase().includes(query), 492 + ); 493 + } 454 494 455 - const query = this.searchQuery.toLowerCase(); 456 - return this.transcriptions.filter((t) => 457 - t.original_filename.toLowerCase().includes(query), 458 - ); 495 + return filtered; 459 496 } 460 497 461 498 private formatDate(timestamp: number): string { ··· 521 558 <div class="course-code">${this.classInfo.course_code}</div> 522 559 <h1>${this.classInfo.name}</h1> 523 560 <div class="professor">Professor: ${this.classInfo.professor}</div> 524 - <div class="semester">${this.classInfo.semester} ${this.classInfo.year}</div> 561 + <div class="semester"> 562 + ${this.classInfo.semester} ${this.classInfo.year} 563 + ${ 564 + this.userSection 565 + ? ` • Section ${this.sections.find((s) => s.id === this.userSection)?.section_number || ""}` 566 + : "" 567 + } 568 + </div> 525 569 </div> 526 570 </div> 527 571 ··· 549 593 ` 550 594 : html` 551 595 <div class="search-upload"> 596 + ${ 597 + this.sections.length > 1 598 + ? html` 599 + <select 600 + style="padding: 0.5rem 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);" 601 + @change=${(e: Event) => { 602 + this.selectedSectionFilter = 603 + (e.target as HTMLSelectElement).value || null; 604 + }} 605 + .value=${this.selectedSectionFilter || ""} 606 + > 607 + ${this.sections.map( 608 + (s) => 609 + html`<option value=${s.id} ?selected=${s.id === this.selectedSectionFilter}>${s.section_number}</option>`, 610 + )} 611 + </select> 612 + ` 613 + : "" 614 + } 552 615 <input 553 616 type="text" 554 617 class="search-box" ··· 631 694 ?open=${this.uploadModalOpen} 632 695 .classId=${this.classId} 633 696 .meetingTimes=${this.meetingTimes.map((m) => ({ id: m.id, label: m.label }))} 697 + .sections=${this.sections} 698 + .userSection=${this.userSection} 634 699 @close=${this.handleModalClose} 635 700 @upload-success=${this.handleUploadSuccess} 636 701 ></upload-recording-modal>
+1
src/components/classes-overview.ts
··· 232 232 } 233 233 234 234 private async handleClassJoined() { 235 + this.showRegistrationModal = false; 235 236 await this.loadClasses(); 236 237 } 237 238
+45
src/components/upload-recording-modal.ts
··· 6 6 label: string; 7 7 } 8 8 9 + interface ClassSection { 10 + id: string; 11 + section_number: string; 12 + } 13 + 9 14 @customElement("upload-recording-modal") 10 15 export class UploadRecordingModal extends LitElement { 11 16 @property({ type: Boolean }) open = false; 12 17 @property({ type: String }) classId = ""; 13 18 @property({ type: Array }) meetingTimes: MeetingTime[] = []; 19 + @property({ type: Array }) sections: ClassSection[] = []; 20 + @property({ type: String }) userSection: string | null = null; 14 21 15 22 @state() private selectedFile: File | null = null; 16 23 @state() private selectedMeetingTimeId: string | null = null; 24 + @state() private selectedSectionId: string | null = null; 17 25 @state() private uploading = false; 18 26 @state() private error: string | null = null; 19 27 ··· 228 236 this.selectedMeetingTimeId = select.value || null; 229 237 } 230 238 239 + private handleSectionChange(e: Event) { 240 + const select = e.target as HTMLSelectElement; 241 + this.selectedSectionId = select.value || null; 242 + } 243 + 231 244 private handleClose() { 232 245 if (this.uploading) return; 233 246 this.open = false; 234 247 this.selectedFile = null; 235 248 this.selectedMeetingTimeId = null; 249 + this.selectedSectionId = null; 236 250 this.error = null; 237 251 this.dispatchEvent(new CustomEvent("close")); 238 252 } ··· 256 270 formData.append("audio", this.selectedFile); 257 271 formData.append("class_id", this.classId); 258 272 formData.append("meeting_time_id", this.selectedMeetingTimeId); 273 + 274 + // Use user's section by default, or allow override 275 + const sectionToUse = this.selectedSectionId || this.userSection; 276 + if (sectionToUse) { 277 + formData.append("section_id", sectionToUse); 278 + } 259 279 260 280 const response = await fetch("/api/transcriptions", { 261 281 method: "POST", ··· 341 361 Select which meeting this recording is for 342 362 </div> 343 363 </div> 364 + 365 + ${ 366 + this.sections.length > 1 367 + ? html` 368 + <div class="form-group"> 369 + <label for="section">Section (optional)</label> 370 + <select 371 + id="section" 372 + @change=${this.handleSectionChange} 373 + ?disabled=${this.uploading} 374 + > 375 + <option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option> 376 + ${this.sections.map( 377 + (section) => html` 378 + <option value=${section.id}>${section.section_number}</option> 379 + `, 380 + )} 381 + </select> 382 + <div class="help-text"> 383 + Override which section this recording is for 384 + </div> 385 + </div> 386 + ` 387 + : "" 388 + } 344 389 </form> 345 390 346 391 <div class="modal-footer">
+28
src/db/schema.ts
··· 225 225 VALUES (0, 'ghosty@thistle.internal', NULL, 'Ghosty', '👻', 'user', strftime('%s', 'now')); 226 226 `, 227 227 }, 228 + { 229 + version: 2, 230 + name: "Add sections support to classes and class members", 231 + sql: ` 232 + -- Add section_number to classes (nullable for existing classes) 233 + ALTER TABLE classes ADD COLUMN section_number TEXT; 234 + 235 + -- Add section_id to class_members (nullable - NULL means default section) 236 + ALTER TABLE class_members ADD COLUMN section_id TEXT; 237 + 238 + -- Create sections table to track all available sections for a class 239 + CREATE TABLE IF NOT EXISTS class_sections ( 240 + id TEXT PRIMARY KEY, 241 + class_id TEXT NOT NULL, 242 + section_number TEXT NOT NULL, 243 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 244 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 245 + UNIQUE(class_id, section_number) 246 + ); 247 + 248 + CREATE INDEX IF NOT EXISTS idx_class_sections_class_id ON class_sections(class_id); 249 + 250 + -- Add section_id to transcriptions to track which section uploaded it 251 + ALTER TABLE transcriptions ADD COLUMN section_id TEXT; 252 + 253 + CREATE INDEX IF NOT EXISTS idx_transcriptions_section_id ON transcriptions(section_id); 254 + `, 255 + }, 228 256 ]; 229 257 230 258 function getCurrentVersion(): number {
+62 -9
src/index.ts
··· 48 48 getClassById, 49 49 getClassesForUser, 50 50 getClassMembers, 51 + getClassSections, 51 52 getMeetingById, 52 53 getMeetingTimesForClass, 53 54 getTranscriptionsForClass, 55 + getUserSection, 54 56 isUserEnrolledInClass, 55 57 joinClass, 56 58 removeUserFromClass, 57 59 searchClassesByCourseCode, 58 60 toggleClassArchive, 59 61 updateMeetingTime, 62 + createClassSection, 60 63 } from "./lib/classes"; 61 64 import { sendEmail } from "./lib/email"; 62 65 import { ··· 2224 2227 const meetingTimeId = formData.get("meeting_time_id") as 2225 2228 | string 2226 2229 | null; 2230 + const sectionId = formData.get("section_id") as string | null; 2227 2231 2228 2232 if (!file) throw ValidationErrors.missingField("audio"); 2229 2233 ··· 2292 2296 2293 2297 // Create database record 2294 2298 db.run( 2295 - "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 2299 + "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 2296 2300 [ 2297 2301 transcriptionId, 2298 2302 user.id, 2299 2303 classId, 2300 2304 meetingTimeId, 2305 + sectionId, 2301 2306 filename, 2302 2307 file.name, 2303 2308 "pending", ··· 2915 2920 cursor, 2916 2921 ); 2917 2922 2918 - // For admin, return flat array. For users, group by semester/year 2919 - if (user.role === "admin") { 2920 - return Response.json(result.data); 2921 - } 2922 - 2923 - // Group by semester/year for regular users 2923 + // Group by semester/year for all users 2924 2924 const grouped: Record< 2925 2925 string, 2926 2926 Array<{ ··· 3019 3019 semester, 3020 3020 year, 3021 3021 meeting_times, 3022 + sections: body.sections, 3022 3023 }); 3023 3024 3024 3025 return Response.json(newClass, { status: 201 }); ··· 3048 3049 .all(user.id) 3049 3050 .map((row) => row.class_id); 3050 3051 3051 - // Add is_enrolled flag to each class 3052 + // Add is_enrolled flag and sections to each class 3052 3053 const classesWithEnrollment = classes.map((cls) => ({ 3053 3054 ...cls, 3054 3055 is_enrolled: enrolledClassIds.includes(cls.id), 3056 + sections: getClassSections(cls.id), 3055 3057 })); 3056 3058 3057 3059 return Response.json({ classes: classesWithEnrollment }); ··· 3066 3068 const user = requireAuth(req); 3067 3069 const body = await req.json(); 3068 3070 const classId = body.class_id; 3071 + const sectionId = body.section_id || null; 3069 3072 3070 3073 const classIdValidation = validateClassId(classId); 3071 3074 if (!classIdValidation.valid) { ··· 3075 3078 ); 3076 3079 } 3077 3080 3078 - const result = joinClass(classId, user.id); 3081 + const result = joinClass(classId, user.id, sectionId); 3079 3082 3080 3083 if (!result.success) { 3081 3084 return Response.json({ error: result.error }, { status: 400 }); ··· 3184 3187 } 3185 3188 3186 3189 const meetingTimes = getMeetingTimesForClass(classId); 3190 + const sections = getClassSections(classId); 3187 3191 const transcriptions = getTranscriptionsForClass(classId); 3192 + const userSection = getUserSection(user.id, classId); 3188 3193 3189 3194 return Response.json({ 3190 3195 class: classInfo, 3191 3196 meetingTimes, 3197 + sections, 3198 + userSection, 3192 3199 transcriptions, 3193 3200 }); 3194 3201 } catch (error) { ··· 3346 3353 3347 3354 const meetingTime = createMeetingTime(classId, label); 3348 3355 return Response.json(meetingTime, { status: 201 }); 3356 + } catch (error) { 3357 + return handleError(error); 3358 + } 3359 + }, 3360 + }, 3361 + "/api/classes/:id/sections": { 3362 + POST: async (req) => { 3363 + try { 3364 + requireAdmin(req); 3365 + const classId = req.params.id; 3366 + const body = await req.json(); 3367 + const { section_number } = body; 3368 + 3369 + if (!section_number) { 3370 + return Response.json({ error: "Section number required" }, { status: 400 }); 3371 + } 3372 + 3373 + const section = createClassSection(classId, section_number); 3374 + return Response.json(section); 3375 + } catch (error) { 3376 + return handleError(error); 3377 + } 3378 + }, 3379 + }, 3380 + "/api/classes/:classId/sections/:sectionId": { 3381 + DELETE: async (req) => { 3382 + try { 3383 + requireAdmin(req); 3384 + const sectionId = req.params.sectionId; 3385 + 3386 + // Check if any students are in this section 3387 + const studentsInSection = db 3388 + .query<{ count: number }, [string]>( 3389 + "SELECT COUNT(*) as count FROM class_members WHERE section_id = ?", 3390 + ) 3391 + .get(sectionId); 3392 + 3393 + if (studentsInSection && studentsInSection.count > 0) { 3394 + return Response.json( 3395 + { error: "Cannot delete section with enrolled students" }, 3396 + { status: 400 }, 3397 + ); 3398 + } 3399 + 3400 + db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]); 3401 + return new Response(null, { status: 204 }); 3349 3402 } catch (error) { 3350 3403 return handleError(error); 3351 3404 }
+96 -6
src/lib/classes.ts
··· 9 9 semester: string; 10 10 year: number; 11 11 archived: boolean; 12 + section_number?: string | null; 13 + created_at: number; 14 + } 15 + 16 + export interface ClassSection { 17 + id: string; 18 + class_id: string; 19 + section_number: string; 12 20 created_at: number; 13 21 } 14 22 ··· 22 30 export interface ClassMember { 23 31 class_id: string; 24 32 user_id: number; 33 + section_id: string | null; 25 34 enrolled_at: number; 26 35 } 27 36 ··· 204 213 semester: string; 205 214 year: number; 206 215 meeting_times?: string[]; 216 + sections?: string[]; 207 217 }): Class { 208 218 const id = nanoid(); 209 219 const now = Math.floor(Date.now() / 1000); ··· 225 235 if (data.meeting_times && data.meeting_times.length > 0) { 226 236 for (const label of data.meeting_times) { 227 237 createMeetingTime(id, label); 238 + } 239 + } 240 + 241 + // Create sections if provided 242 + if (data.sections && data.sections.length > 0) { 243 + for (const sectionNumber of data.sections) { 244 + createClassSection(id, sectionNumber); 228 245 } 229 246 } 230 247 ··· 264 281 /** 265 282 * Enroll a user in a class 266 283 */ 267 - export function enrollUserInClass(userId: number, classId: string): void { 284 + export function enrollUserInClass( 285 + userId: number, 286 + classId: string, 287 + sectionId?: string | null, 288 + ): void { 268 289 const now = Math.floor(Date.now() / 1000); 269 290 db.run( 270 - "INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 271 - [classId, userId, now], 291 + "INSERT OR IGNORE INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)", 292 + [classId, userId, sectionId ?? null, now], 272 293 ); 273 294 } 274 295 ··· 371 392 id: string; 372 393 user_id: number; 373 394 meeting_time_id: string | null; 395 + section_id: string | null; 374 396 filename: string; 375 397 original_filename: string; 376 398 status: string; ··· 381 403 }, 382 404 [string] 383 405 >( 384 - `SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at 406 + `SELECT id, user_id, meeting_time_id, section_id, filename, original_filename, status, progress, error_message, created_at, updated_at 385 407 FROM transcriptions 386 408 WHERE class_id = ? 387 409 ORDER BY created_at DESC`, ··· 409 431 export function joinClass( 410 432 classId: string, 411 433 userId: number, 434 + sectionId?: string | null, 412 435 ): { success: boolean; error?: string } { 413 436 // Find class by ID 414 437 const cls = db ··· 434 457 return { success: false, error: "Already enrolled in this class" }; 435 458 } 436 459 460 + // Check if class has sections and require one to be selected 461 + const sections = getClassSections(classId); 462 + if (sections.length > 0 && !sectionId) { 463 + return { success: false, error: "Please select a section" }; 464 + } 465 + 466 + // If section provided, validate it exists and belongs to this class 467 + if (sectionId) { 468 + const section = sections.find((s) => s.id === sectionId); 469 + if (!section) { 470 + return { success: false, error: "Invalid section selected" }; 471 + } 472 + } 473 + 437 474 // Enroll user 438 475 db.query( 439 - "INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 440 - ).run(cls.id, userId, Math.floor(Date.now() / 1000)); 476 + "INSERT INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)", 477 + ).run(cls.id, userId, sectionId ?? null, Math.floor(Date.now() / 1000)); 441 478 442 479 return { success: true }; 480 + } 481 + 482 + /** 483 + * Create a section for a class 484 + */ 485 + export function createClassSection( 486 + classId: string, 487 + sectionNumber: string, 488 + ): ClassSection { 489 + const id = nanoid(); 490 + const now = Math.floor(Date.now() / 1000); 491 + 492 + db.run( 493 + "INSERT INTO class_sections (id, class_id, section_number, created_at) VALUES (?, ?, ?, ?)", 494 + [id, classId, sectionNumber, now], 495 + ); 496 + 497 + return { 498 + id, 499 + class_id: classId, 500 + section_number: sectionNumber, 501 + created_at: now, 502 + }; 503 + } 504 + 505 + /** 506 + * Get all sections for a class 507 + */ 508 + export function getClassSections(classId: string): ClassSection[] { 509 + return db 510 + .query<ClassSection, [string]>( 511 + "SELECT * FROM class_sections WHERE class_id = ? ORDER BY section_number ASC", 512 + ) 513 + .all(classId); 514 + } 515 + 516 + /** 517 + * Delete a class section 518 + */ 519 + export function deleteClassSection(sectionId: string): void { 520 + db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]); 521 + } 522 + 523 + /** 524 + * Get user's enrolled section for a class 525 + */ 526 + export function getUserSection(userId: number, classId: string): string | null { 527 + const result = db 528 + .query<{ section_id: string | null }, [string, number]>( 529 + "SELECT section_id FROM class_members WHERE class_id = ? AND user_id = ?", 530 + ) 531 + .get(classId, userId); 532 + return result?.section_id ?? null; 443 533 } 444 534 445 535 /**