🪻 distributed transcription service thistle.dunkirk.sh

feat: add waitlist system with meeting times

Students can now request classes that don't exist yet:
- Search for classes, if not found, request it via waitlist form
- Waitlist form collects: course code, name, professor, semester, year, meeting times, and optional additional info
- Meeting times field allows multiple entries (e.g., "Monday Lecture", "Wednesday Lab")
- Pressing Enter in meeting time field adds another field instead of submitting

Admin workflow for waitlist management:
- New "Classes" tab in admin panel with two sub-tabs: Classes and Waitlist
- Waitlist shows all pending requests with badge count
- "Approve & Create Class" opens modal pre-filled with student's request
- Admin can edit meeting times before creating the class
- Creates class with meeting times, removes from waitlist, and switches to Classes tab
- Can also delete waitlist requests

Additional changes:
- Removed section field completely (not used, meeting times are more meaningful)
- Database migrations: v3 (waitlist table), v4 (meeting_times column), v5 (drop section)
- Meeting times stored as JSON array in waitlist, created as separate records when approved

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

dunkirk.sh 14510882 0b3565be

verified
Changed files
+1447 -6
src
+836
src/components/admin-classes.ts
···
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
+
interface Class {
+
id: string;
+
course_code: string;
+
name: string;
+
professor: string;
+
semester: string;
+
year: number;
+
archived: boolean;
+
created_at: number;
+
}
+
+
interface WaitlistEntry {
+
id: string;
+
user_id: number;
+
course_code: string;
+
course_name: string;
+
professor: string;
+
semester: string;
+
year: number;
+
additional_info: string | null;
+
meeting_times: string | null;
+
created_at: number;
+
}
+
+
@customElement("admin-classes")
+
export class AdminClasses extends LitElement {
+
@state() classes: Class[] = [];
+
@state() waitlist: WaitlistEntry[] = [];
+
@state() isLoading = true;
+
@state() error = "";
+
@state() searchTerm = "";
+
@state() showCreateModal = false;
+
@state() activeTab: "classes" | "waitlist" = "classes";
+
@state() approvingEntry: WaitlistEntry | null = null;
+
@state() meetingTimes: string[] = [""];
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.header {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 1.5rem;
+
gap: 1rem;
+
}
+
+
.search {
+
flex: 1;
+
max-width: 30rem;
+
padding: 0.5rem 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
}
+
+
.search:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.create-btn {
+
padding: 0.75rem 1.5rem;
+
background: var(--primary);
+
color: white;
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
white-space: nowrap;
+
}
+
+
.create-btn:hover {
+
background: var(--gunmetal);
+
border-color: var(--gunmetal);
+
}
+
+
.classes-grid {
+
display: grid;
+
gap: 1rem;
+
}
+
+
.class-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.25rem;
+
transition: all 0.2s;
+
}
+
+
.class-card:hover {
+
border-color: var(--primary);
+
}
+
+
.class-card.archived {
+
opacity: 0.6;
+
border-style: dashed;
+
}
+
+
.class-header {
+
display: flex;
+
justify-content: space-between;
+
align-items: flex-start;
+
gap: 1rem;
+
margin-bottom: 0.75rem;
+
}
+
+
.class-info {
+
flex: 1;
+
}
+
+
.course-code {
+
font-size: 0.875rem;
+
font-weight: 600;
+
color: var(--accent);
+
text-transform: uppercase;
+
}
+
+
.class-name {
+
font-size: 1.125rem;
+
font-weight: 600;
+
margin: 0.25rem 0;
+
color: var(--text);
+
}
+
+
.class-meta {
+
display: flex;
+
gap: 1rem;
+
font-size: 0.875rem;
+
color: var(--paynes-gray);
+
flex-wrap: wrap;
+
}
+
+
.badge {
+
display: inline-block;
+
padding: 0.25rem 0.5rem;
+
border-radius: 4px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
text-transform: uppercase;
+
}
+
+
.badge.archived {
+
background: var(--secondary);
+
color: var(--text);
+
}
+
+
.actions {
+
display: flex;
+
gap: 0.5rem;
+
flex-wrap: wrap;
+
}
+
+
button {
+
padding: 0.5rem 1rem;
+
border: 2px solid;
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-archive {
+
background: transparent;
+
color: var(--paynes-gray);
+
border-color: var(--secondary);
+
}
+
+
.btn-archive:hover {
+
border-color: var(--paynes-gray);
+
}
+
+
.btn-delete {
+
background: transparent;
+
color: #dc2626;
+
border-color: #dc2626;
+
}
+
+
.btn-delete:hover {
+
background: #dc2626;
+
color: white;
+
}
+
+
button:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 3rem 2rem;
+
color: var(--paynes-gray);
+
}
+
+
.loading {
+
text-align: center;
+
padding: 3rem 2rem;
+
color: var(--paynes-gray);
+
}
+
+
.error-message {
+
background: #fee2e2;
+
color: #991b1b;
+
padding: 1rem;
+
border-radius: 6px;
+
margin-bottom: 1rem;
+
}
+
+
.tabs {
+
display: flex;
+
gap: 1rem;
+
margin-bottom: 2rem;
+
border-bottom: 2px solid var(--secondary);
+
}
+
+
.tab {
+
padding: 0.75rem 1.5rem;
+
background: transparent;
+
border: none;
+
border-radius: 0;
+
color: var(--text);
+
cursor: pointer;
+
font-size: 1rem;
+
font-weight: 500;
+
font-family: inherit;
+
border-bottom: 2px solid transparent;
+
margin-bottom: -2px;
+
transition: all 0.2s;
+
}
+
+
.tab:hover {
+
color: var(--primary);
+
}
+
+
.tab.active {
+
color: var(--primary);
+
border-bottom-color: var(--primary);
+
}
+
+
.tab-badge {
+
display: inline-block;
+
margin-left: 0.5rem;
+
padding: 0.125rem 0.5rem;
+
background: var(--accent);
+
color: white;
+
border-radius: 12px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
}
+
+
.modal-overlay {
+
position: fixed;
+
top: 0;
+
left: 0;
+
right: 0;
+
bottom: 0;
+
background: rgba(0, 0, 0, 0.5);
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
z-index: 1000;
+
padding: 1rem;
+
}
+
+
.modal {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 12px;
+
padding: 2rem;
+
max-width: 32rem;
+
width: 100%;
+
max-height: 90vh;
+
overflow-y: auto;
+
}
+
+
.modal-title {
+
margin: 0 0 1.5rem 0;
+
color: var(--text);
+
font-size: 1.5rem;
+
}
+
+
.form-group {
+
margin-bottom: 1rem;
+
}
+
+
.form-group label {
+
display: block;
+
margin-bottom: 0.5rem;
+
font-weight: 500;
+
color: var(--text);
+
font-size: 0.875rem;
+
}
+
+
.form-group input {
+
width: 100%;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
box-sizing: border-box;
+
}
+
+
.form-group input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.meeting-times-list {
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.meeting-time-row {
+
display: flex;
+
gap: 0.5rem;
+
align-items: center;
+
}
+
+
.meeting-time-row input {
+
flex: 1;
+
}
+
+
.btn-remove {
+
padding: 0.5rem;
+
background: transparent;
+
color: #dc2626;
+
border: 2px solid #dc2626;
+
border-radius: 6px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
transition: all 0.2s;
+
}
+
+
.btn-remove:hover {
+
background: #dc2626;
+
color: white;
+
}
+
+
.btn-add {
+
margin-top: 0.5rem;
+
padding: 0.5rem 1rem;
+
background: transparent;
+
color: var(--primary);
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
transition: all 0.2s;
+
}
+
+
.btn-add:hover {
+
background: var(--primary);
+
color: white;
+
}
+
+
.modal-actions {
+
display: flex;
+
gap: 0.75rem;
+
justify-content: flex-end;
+
margin-top: 1.5rem;
+
}
+
+
.btn-submit {
+
padding: 0.75rem 1.5rem;
+
background: var(--primary);
+
color: white;
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-submit:hover:not(:disabled) {
+
background: var(--gunmetal);
+
border-color: var(--gunmetal);
+
}
+
+
.btn-submit:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
+
.btn-cancel {
+
padding: 0.75rem 1.5rem;
+
background: transparent;
+
color: var(--text);
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-cancel:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
`;
+
+
override async connectedCallback() {
+
super.connectedCallback();
+
await this.loadData();
+
}
+
+
private async loadData() {
+
this.isLoading = true;
+
this.error = "";
+
+
try {
+
const [classesRes, waitlistRes] = await Promise.all([
+
fetch("/api/admin/classes"),
+
fetch("/api/admin/waitlist"),
+
]);
+
+
if (!classesRes.ok || !waitlistRes.ok) {
+
throw new Error("Failed to load data");
+
}
+
+
const classesData = await classesRes.json();
+
const waitlistData = await waitlistRes.json();
+
+
this.classes = classesData.classes || [];
+
this.waitlist = waitlistData.waitlist || [];
+
} catch {
+
this.error = "Failed to load data. Please try again.";
+
} finally {
+
this.isLoading = false;
+
}
+
}
+
+
private handleSearch(e: Event) {
+
this.searchTerm = (e.target as HTMLInputElement).value.toLowerCase();
+
}
+
+
private async handleToggleArchive(classId: string) {
+
try {
+
const response = await fetch(`/api/classes/${classId}/archive`, {
+
method: "PUT",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to update class");
+
}
+
+
await this.loadData();
+
} catch {
+
this.error = "Failed to update class. Please try again.";
+
}
+
}
+
+
private async handleDelete(classId: string, courseName: string) {
+
if (
+
!confirm(
+
`Are you sure you want to delete ${courseName}? This will remove all associated data and cannot be undone.`,
+
)
+
) {
+
return;
+
}
+
+
try {
+
const response = await fetch(`/api/classes/${classId}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to delete class");
+
}
+
+
await this.loadData();
+
} catch {
+
this.error = "Failed to delete class. Please try again.";
+
}
+
}
+
+
private handleCreateClass() {
+
this.showCreateModal = true;
+
}
+
+
private async handleDeleteWaitlist(id: string, courseCode: string) {
+
if (
+
!confirm(
+
`Are you sure you want to delete this waitlist request for ${courseCode}?`,
+
)
+
) {
+
return;
+
}
+
+
try {
+
const response = await fetch(`/api/admin/waitlist/${id}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to delete waitlist entry");
+
}
+
+
await this.loadData();
+
} catch {
+
this.error = "Failed to delete waitlist entry. Please try again.";
+
}
+
}
+
+
private getFilteredClasses() {
+
if (!this.searchTerm) return this.classes;
+
+
return this.classes.filter((cls) => {
+
const searchStr = this.searchTerm;
+
return (
+
cls.course_code.toLowerCase().includes(searchStr) ||
+
cls.name.toLowerCase().includes(searchStr) ||
+
cls.professor.toLowerCase().includes(searchStr)
+
);
+
});
+
}
+
+
override render() {
+
if (this.isLoading) {
+
return html`<div class="loading">Loading...</div>`;
+
}
+
+
const filteredClasses = this.getFilteredClasses();
+
+
return html`
+
${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
+
+
<div class="tabs">
+
<button
+
class="tab ${this.activeTab === "classes" ? "active" : ""}"
+
@click=${() => { this.activeTab = "classes"; }}
+
>
+
Classes
+
</button>
+
<button
+
class="tab ${this.activeTab === "waitlist" ? "active" : ""}"
+
@click=${() => { this.activeTab = "waitlist"; }}
+
>
+
Waitlist
+
${this.waitlist.length > 0 ? html`<span class="tab-badge">${this.waitlist.length}</span>` : ""}
+
</button>
+
</div>
+
+
${
+
this.activeTab === "classes"
+
? this.renderClasses(filteredClasses)
+
: this.renderWaitlist()
+
}
+
+
${this.approvingEntry ? this.renderApprovalModal() : ""}
+
`;
+
}
+
+
private renderClasses(filteredClasses: Class[]) {
+
return html`
+
<div class="header">
+
<input
+
type="text"
+
class="search"
+
placeholder="Search classes..."
+
@input=${this.handleSearch}
+
.value=${this.searchTerm}
+
/>
+
<button class="create-btn" @click=${this.handleCreateClass}>
+
+ Create Class
+
</button>
+
</div>
+
+
${
+
filteredClasses.length === 0
+
? html`
+
<div class="empty-state">
+
${this.searchTerm ? "No classes found matching your search" : "No classes yet"}
+
</div>
+
`
+
: html`
+
<div class="classes-grid">
+
${filteredClasses.map(
+
(cls) => html`
+
<div class="class-card ${cls.archived ? "archived" : ""}">
+
<div class="class-header">
+
<div class="class-info">
+
<div class="course-code">${cls.course_code}</div>
+
<div class="class-name">${cls.name}</div>
+
<div class="class-meta">
+
<span>👤 ${cls.professor}</span>
+
<span>📅 ${cls.semester} ${cls.year}</span>
+
${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
+
</div>
+
</div>
+
<div class="actions">
+
<button
+
class="btn-archive"
+
@click=${() => this.handleToggleArchive(cls.id)}
+
>
+
${cls.archived ? "Unarchive" : "Archive"}
+
</button>
+
<button
+
class="btn-delete"
+
@click=${() => this.handleDelete(cls.id, cls.course_code)}
+
>
+
Delete
+
</button>
+
</div>
+
</div>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
}
+
`;
+
}
+
+
private renderWaitlist() {
+
return html`
+
${
+
this.waitlist.length === 0
+
? html`
+
<div class="empty-state">No waitlist requests yet</div>
+
`
+
: html`
+
<div class="classes-grid">
+
${this.waitlist.map(
+
(entry) => html`
+
<div class="class-card">
+
<div class="class-header">
+
<div class="class-info">
+
<div class="course-code">${entry.course_code}</div>
+
<div class="class-name">${entry.course_name}</div>
+
<div class="class-meta">
+
<span>👤 ${entry.professor}</span>
+
<span>📅 ${entry.semester} ${entry.year}</span>
+
</div>
+
${
+
entry.additional_info
+
? html`
+
<p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--paynes-gray);">
+
${entry.additional_info}
+
</p>
+
`
+
: ""
+
}
+
</div>
+
<div class="actions">
+
<button
+
class="btn-archive"
+
@click=${() => this.handleApproveWaitlist(entry)}
+
>
+
Approve & Create Class
+
</button>
+
<button
+
class="btn-delete"
+
@click=${() => this.handleDeleteWaitlist(entry.id, entry.course_code)}
+
>
+
Delete
+
</button>
+
</div>
+
</div>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
}
+
`;
+
}
+
+
private handleApproveWaitlist(entry: WaitlistEntry) {
+
this.approvingEntry = entry;
+
+
// Parse meeting times from JSON if available, otherwise use empty array
+
if (entry.meeting_times) {
+
try {
+
const parsed = JSON.parse(entry.meeting_times);
+
this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : [""];
+
} catch {
+
this.meetingTimes = [""];
+
}
+
} else {
+
this.meetingTimes = [""];
+
}
+
}
+
+
private addMeetingTime() {
+
this.meetingTimes = [...this.meetingTimes, ""];
+
}
+
+
private removeMeetingTime(index: number) {
+
this.meetingTimes = this.meetingTimes.filter((_, i) => i !== index);
+
}
+
+
private updateMeetingTime(index: number, value: string) {
+
this.meetingTimes = this.meetingTimes.map((time, i) =>
+
i === index ? value : time,
+
);
+
}
+
+
private cancelApproval() {
+
this.approvingEntry = null;
+
this.meetingTimes = [""];
+
}
+
+
private async submitApproval() {
+
if (!this.approvingEntry) return;
+
+
const entry = this.approvingEntry;
+
const times = this.meetingTimes.filter((t) => t.trim() !== "");
+
+
if (times.length === 0) {
+
this.error = "Please add at least one meeting time";
+
return;
+
}
+
+
try {
+
const response = await fetch("/api/classes", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
course_code: entry.course_code,
+
name: entry.course_name,
+
professor: entry.professor,
+
semester: entry.semester,
+
year: entry.year,
+
meeting_times: times,
+
}),
+
});
+
+
if (!response.ok) {
+
throw new Error("Failed to create class");
+
}
+
+
await fetch(`/api/admin/waitlist/${entry.id}`, {
+
method: "DELETE",
+
});
+
+
await this.loadData();
+
+
this.activeTab = "classes";
+
this.approvingEntry = null;
+
this.meetingTimes = [""];
+
} catch {
+
this.error = "Failed to approve waitlist entry. Please try again.";
+
}
+
}
+
+
private renderApprovalModal() {
+
if (!this.approvingEntry) return "";
+
+
const entry = this.approvingEntry;
+
+
return html`
+
<div class="modal-overlay" @click=${this.cancelApproval}>
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
+
<h2 class="modal-title">Add Meeting Times</h2>
+
+
<p style="margin-bottom: 1.5rem; color: var(--paynes-gray);">
+
Creating class: <strong>${entry.course_code} - ${entry.course_name}</strong>
+
</p>
+
+
<div class="form-group">
+
<label>Meeting Times</label>
+
<div class="meeting-times-list">
+
${this.meetingTimes.map(
+
(time, index) => html`
+
<div class="meeting-time-row">
+
<input
+
type="text"
+
placeholder="e.g., Monday Lecture, Wednesday Lab"
+
.value=${time}
+
@input=${(e: Event) =>
+
this.updateMeetingTime(
+
index,
+
(e.target as HTMLInputElement).value,
+
)}
+
/>
+
${
+
this.meetingTimes.length > 1
+
? html`
+
<button
+
class="btn-remove"
+
@click=${() => this.removeMeetingTime(index)}
+
>
+
Remove
+
</button>
+
`
+
: ""
+
}
+
</div>
+
`,
+
)}
+
<button class="btn-add" @click=${this.addMeetingTime}>
+
+ Add Meeting Time
+
</button>
+
</div>
+
</div>
+
+
<div class="modal-actions">
+
<button class="btn-cancel" @click=${this.cancelApproval}>
+
Cancel
+
</button>
+
<button
+
class="btn-submit"
+
@click=${this.submitApproval}
+
?disabled=${this.meetingTimes.every((t) => t.trim() === "")}
+
>
+
Create Class
+
</button>
+
</div>
+
</div>
+
</div>
+
`;
+
}
+
}
+401 -4
src/components/class-registration-modal.ts
···
course_code: string;
name: string;
professor: string;
-
section: string | null;
semester: string;
year: number;
}
···
@state() isJoining = false;
@state() error = "";
@state() hasSearched = false;
static override styles = css`
:host {
···
color: var(--paynes-gray);
}
.loading {
text-align: center;
padding: 2rem;
color: var(--paynes-gray);
}
`;
private handleClose() {
···
this.results = [];
this.error = "";
this.hasSearched = false;
this.dispatchEvent(new CustomEvent("close"));
}
···
}
}
override render() {
if (!this.open) return html``;
···
this.isSearching
? html`<div class="loading">Searching...</div>`
: this.results.length === 0
-
? html`
<div class="empty-state">
-
No classes found matching "${this.searchQuery}"
</div>
`
: html`
···
<div class="class-name">${cls.name}</div>
<div class="class-meta">
<span>👤 ${cls.professor}</span>
-
${cls.section ? html`<span>📍 Section ${cls.section}</span>` : ""}
<span>📅 ${cls.semester} ${cls.year}</span>
</div>
</div>
···
course_code: string;
name: string;
professor: string;
semester: string;
year: number;
}
···
@state() isJoining = false;
@state() error = "";
@state() hasSearched = false;
+
@state() showWaitlistForm = false;
+
@state() waitlistData = {
+
courseCode: "",
+
courseName: "",
+
professor: "",
+
semester: "",
+
year: new Date().getFullYear(),
+
additionalInfo: "",
+
meetingTimes: [""],
+
};
static override styles = css`
:host {
···
color: var(--paynes-gray);
}
+
.empty-state button {
+
margin-top: 1rem;
+
padding: 0.75rem 1.5rem;
+
background: var(--accent);
+
color: white;
+
border: 2px solid var(--accent);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.empty-state button:hover {
+
background: transparent;
+
color: var(--accent);
+
}
+
+
.waitlist-form {
+
margin-top: 1.5rem;
+
}
+
+
.form-grid {
+
display: grid;
+
grid-template-columns: 1fr 1fr;
+
gap: 1rem;
+
margin-bottom: 1rem;
+
}
+
+
.form-group-full {
+
grid-column: 1 / -1;
+
}
+
+
.form-group {
+
display: flex;
+
flex-direction: column;
+
}
+
+
.form-group label {
+
margin-bottom: 0.5rem;
+
}
+
+
.form-group input,
+
.form-group select,
+
.form-group textarea {
+
width: 100%;
+
padding: 0.75rem;
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-family: inherit;
+
background: var(--background);
+
color: var(--text);
+
transition: all 0.2s;
+
box-sizing: border-box;
+
}
+
+
.form-group textarea {
+
min-height: 6rem;
+
resize: vertical;
+
}
+
+
.form-group input:focus,
+
.form-group select:focus,
+
.form-group textarea:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.form-actions {
+
display: flex;
+
gap: 0.75rem;
+
justify-content: flex-end;
+
margin-top: 1.5rem;
+
}
+
+
.btn-submit {
+
padding: 0.75rem 1.5rem;
+
background: var(--primary);
+
color: white;
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-submit:hover:not(:disabled) {
+
background: var(--gunmetal);
+
border-color: var(--gunmetal);
+
}
+
+
.btn-submit:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
+
.btn-cancel {
+
padding: 0.75rem 1.5rem;
+
background: transparent;
+
color: var(--text);
+
border: 2px solid var(--secondary);
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-cancel:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
.loading {
text-align: center;
padding: 2rem;
color: var(--paynes-gray);
}
+
+
.meeting-times-list {
+
display: flex;
+
flex-direction: column;
+
gap: 0.5rem;
+
}
+
+
.meeting-time-row {
+
display: flex;
+
gap: 0.5rem;
+
align-items: center;
+
}
+
+
.meeting-time-row input {
+
flex: 1;
+
}
+
+
.btn-remove {
+
padding: 0.5rem 1rem;
+
background: transparent;
+
color: #dc2626;
+
border: 2px solid #dc2626;
+
border-radius: 6px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-remove:hover {
+
background: #dc2626;
+
color: white;
+
}
+
+
.btn-add {
+
margin-top: 0.5rem;
+
padding: 0.5rem 1rem;
+
background: transparent;
+
color: var(--primary);
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
cursor: pointer;
+
font-size: 0.875rem;
+
font-weight: 500;
+
transition: all 0.2s;
+
font-family: inherit;
+
}
+
+
.btn-add:hover {
+
background: var(--primary);
+
color: white;
+
}
`;
private handleClose() {
···
this.results = [];
this.error = "";
this.hasSearched = false;
+
this.showWaitlistForm = false;
+
this.waitlistData = {
+
courseCode: "",
+
courseName: "",
+
professor: "",
+
semester: "",
+
year: new Date().getFullYear(),
+
additionalInfo: "",
+
meetingTimes: [""],
+
};
this.dispatchEvent(new CustomEvent("close"));
}
···
}
}
+
private handleRequestWaitlist() {
+
this.showWaitlistForm = true;
+
this.waitlistData.courseCode = this.searchQuery;
+
}
+
+
private handleWaitlistInput(field: string, e: Event) {
+
const value = (
+
e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
+
).value;
+
this.waitlistData = { ...this.waitlistData, [field]: value };
+
}
+
+
private async handleSubmitWaitlist(e: Event) {
+
e.preventDefault();
+
this.isJoining = true;
+
this.error = "";
+
+
try {
+
const response = await fetch("/api/classes/waitlist", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify(this.waitlistData),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to submit waitlist request";
+
return;
+
}
+
+
// Success
+
alert(
+
"Your class request has been submitted! An admin will review it soon.",
+
);
+
this.handleClose();
+
} catch {
+
this.error = "Failed to submit request. Please try again.";
+
} finally {
+
this.isJoining = false;
+
}
+
}
+
+
private handleCancelWaitlist() {
+
this.showWaitlistForm = false;
+
}
+
+
private addMeetingTime() {
+
this.waitlistData = {
+
...this.waitlistData,
+
meetingTimes: [...this.waitlistData.meetingTimes, ""],
+
};
+
}
+
+
private removeMeetingTime(index: number) {
+
this.waitlistData = {
+
...this.waitlistData,
+
meetingTimes: this.waitlistData.meetingTimes.filter(
+
(_, i) => i !== index,
+
),
+
};
+
}
+
+
private updateMeetingTime(index: number, value: string) {
+
const newTimes = [...this.waitlistData.meetingTimes];
+
newTimes[index] = value;
+
this.waitlistData = { ...this.waitlistData, meetingTimes: newTimes };
+
}
+
override render() {
if (!this.open) return html``;
···
this.isSearching
? html`<div class="loading">Searching...</div>`
: this.results.length === 0
+
? this.showWaitlistForm
+
? html`
+
<div class="waitlist-form">
+
<p style="margin-bottom: 1.5rem; color: var(--text);">
+
Request this class to be added to Thistle
+
</p>
+
<form @submit=${this.handleSubmitWaitlist}>
+
<div class="form-grid">
+
<div class="form-group">
+
<label>Course Code *</label>
+
<input
+
type="text"
+
required
+
.value=${this.waitlistData.courseCode}
+
@input=${(e: Event) => this.handleWaitlistInput("courseCode", e)}
+
/>
+
</div>
+
<div class="form-group">
+
<label>Course Name *</label>
+
<input
+
type="text"
+
required
+
.value=${this.waitlistData.courseName}
+
@input=${(e: Event) => this.handleWaitlistInput("courseName", e)}
+
/>
+
</div>
+
<div class="form-group">
+
<label>Professor *</label>
+
<input
+
type="text"
+
required
+
.value=${this.waitlistData.professor}
+
@input=${(e: Event) => this.handleWaitlistInput("professor", e)}
+
/>
+
</div>
+
<div class="form-group">
+
<label>Semester *</label>
+
<select
+
required
+
.value=${this.waitlistData.semester}
+
@change=${(e: Event) => this.handleWaitlistInput("semester", e)}
+
>
+
<option value="">Select semester</option>
+
<option value="Spring">Spring</option>
+
<option value="Summer">Summer</option>
+
<option value="Fall">Fall</option>
+
<option value="Winter">Winter</option>
+
</select>
+
</div>
+
<div class="form-group">
+
<label>Year *</label>
+
<input
+
type="number"
+
required
+
min="2020"
+
max="2030"
+
.value=${this.waitlistData.year.toString()}
+
@input=${(e: Event) => this.handleWaitlistInput("year", e)}
+
/>
+
</div>
+
<div class="form-group form-group-full">
+
<label>Meeting Times *</label>
+
<div class="meeting-times-list">
+
${this.waitlistData.meetingTimes.map(
+
(time, index) => html`
+
<div class="meeting-time-row">
+
<input
+
type="text"
+
required
+
placeholder="e.g., Monday Lecture, Wednesday Lecture"
+
.value=${time}
+
@input=${(e: Event) =>
+
this.updateMeetingTime(
+
index,
+
(e.target as HTMLInputElement).value,
+
)}
+
@keydown=${(e: KeyboardEvent) => {
+
if (e.key === "Enter") {
+
e.preventDefault();
+
this.addMeetingTime();
+
}
+
}}
+
/>
+
${
+
this.waitlistData.meetingTimes.length > 1
+
? html`
+
<button
+
type="button"
+
class="btn-remove"
+
@click=${() => this.removeMeetingTime(index)}
+
>
+
Remove
+
</button>
+
`
+
: ""
+
}
+
</div>
+
`,
+
)}
+
<button type="button" class="btn-add" @click=${this.addMeetingTime}>
+
+ Add Meeting Time
+
</button>
+
</div>
+
</div>
+
<div class="form-group form-group-full">
+
<label>Additional Info (optional)</label>
+
<textarea
+
placeholder="Any additional details about this class..."
+
.value=${this.waitlistData.additionalInfo}
+
@input=${(e: Event) => this.handleWaitlistInput("additionalInfo", e)}
+
></textarea>
+
</div>
+
</div>
+
${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
+
<div class="form-actions">
+
<button
+
type="button"
+
class="btn-cancel"
+
@click=${this.handleCancelWaitlist}
+
?disabled=${this.isJoining}
+
>
+
Cancel
+
</button>
+
<button
+
type="submit"
+
class="btn-submit"
+
?disabled=${this.isJoining}
+
>
+
${this.isJoining ? "Submitting..." : "Submit Request"}
+
</button>
+
</div>
+
</form>
+
</div>
+
`
+
: html`
<div class="empty-state">
+
<p>No classes found matching "${this.searchQuery}"</p>
+
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
+
Can't find your class? Request it to be added.
+
</p>
+
<button @click=${this.handleRequestWaitlist}>
+
Request Class
+
</button>
</div>
`
: html`
···
<div class="class-name">${cls.name}</div>
<div class="class-meta">
<span>👤 ${cls.professor}</span>
<span>📅 ${cls.semester} ${cls.year}</span>
</div>
</div>
+38
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code);
`,
},
];
function getCurrentVersion(): number {
···
CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code);
`,
},
+
{
+
version: 3,
+
name: "Add class waitlist table",
+
sql: `
+
CREATE TABLE IF NOT EXISTS class_waitlist (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
course_code TEXT NOT NULL,
+
course_name TEXT NOT NULL,
+
professor TEXT NOT NULL,
+
section TEXT,
+
semester TEXT NOT NULL,
+
year INTEGER NOT NULL,
+
additional_info TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_waitlist_user_id ON class_waitlist(user_id);
+
CREATE INDEX IF NOT EXISTS idx_waitlist_course_code ON class_waitlist(course_code);
+
`,
+
},
+
{
+
version: 4,
+
name: "Add meeting_times to class_waitlist",
+
sql: `
+
ALTER TABLE class_waitlist ADD COLUMN meeting_times TEXT;
+
`,
+
},
+
{
+
version: 5,
+
name: "Remove section columns",
+
sql: `
+
DROP INDEX IF EXISTS idx_classes_section;
+
ALTER TABLE classes DROP COLUMN section;
+
ALTER TABLE class_waitlist DROP COLUMN section;
+
`,
+
},
];
function getCurrentVersion(): number {
+86 -1
src/index.ts
···
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
} from "./lib/classes";
import { handleError, ValidationErrors } from "./lib/errors";
import { requireAdmin, requireAuth } from "./lib/middleware";
···
}
},
},
"/api/admin/transcriptions/:id": {
DELETE: async (req) => {
try {
···
try {
requireAdmin(req);
const body = await req.json();
-
const { course_code, name, professor, semester, year } = body;
if (!course_code || !name || !professor || !semester || !year) {
return Response.json(
···
professor,
semester,
year,
});
return Response.json(newClass);
···
}
return Response.json({ success: true });
} catch (error) {
return handleError(error);
}
···
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
+
addToWaitlist,
+
getAllWaitlistEntries,
+
deleteWaitlistEntry,
} from "./lib/classes";
import { handleError, ValidationErrors } from "./lib/errors";
import { requireAdmin, requireAuth } from "./lib/middleware";
···
}
},
},
+
"/api/admin/classes": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const classes = getClassesForUser(0, true); // Admin sees all classes
+
return Response.json({ classes });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/waitlist": {
+
GET: async (req) => {
+
try {
+
requireAdmin(req);
+
const waitlist = getAllWaitlistEntries();
+
return Response.json({ waitlist });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/admin/waitlist/:id": {
+
DELETE: async (req) => {
+
try {
+
requireAdmin(req);
+
const id = req.params.id;
+
deleteWaitlistEntry(id);
+
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
"/api/admin/transcriptions/:id": {
DELETE: async (req) => {
try {
···
try {
requireAdmin(req);
const body = await req.json();
+
const {
+
course_code,
+
name,
+
professor,
+
semester,
+
year,
+
meeting_times,
+
} = body;
if (!course_code || !name || !professor || !semester || !year) {
return Response.json(
···
professor,
semester,
year,
+
meeting_times,
});
return Response.json(newClass);
···
}
return Response.json({ success: true });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
+
"/api/classes/waitlist": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const body = await req.json();
+
+
const {
+
courseCode,
+
courseName,
+
professor,
+
semester,
+
year,
+
additionalInfo,
+
meetingTimes,
+
} = body;
+
+
if (!courseCode || !courseName || !professor || !semester || !year) {
+
return Response.json(
+
{ error: "Missing required fields" },
+
{ status: 400 },
+
);
+
}
+
+
const id = addToWaitlist(
+
user.id,
+
courseCode,
+
courseName,
+
professor,
+
semester,
+
Number.parseInt(year, 10),
+
additionalInfo || null,
+
meetingTimes || null,
+
);
+
+
return Response.json({ success: true, id });
} catch (error) {
return handleError(error);
}
+77 -1
src/lib/classes.ts
···
course_code: string;
name: string;
professor: string;
-
section: string | null;
semester: string;
year: number;
archived: boolean;
···
professor: string;
semester: string;
year: number;
}): Class {
const id = nanoid();
const now = Math.floor(Date.now() / 1000);
···
now,
],
);
return {
id,
···
return { success: true };
}
···
course_code: string;
name: string;
professor: string;
semester: string;
year: number;
archived: boolean;
···
professor: string;
semester: string;
year: number;
+
meeting_times?: string[];
}): Class {
const id = nanoid();
const now = Math.floor(Date.now() / 1000);
···
now,
],
);
+
+
// Create meeting times if provided
+
if (data.meeting_times && data.meeting_times.length > 0) {
+
for (const label of data.meeting_times) {
+
createMeetingTime(id, label);
+
}
+
}
return {
id,
···
return { success: true };
}
+
/**
+
* Waitlist entry interface
+
*/
+
export interface WaitlistEntry {
+
id: string;
+
user_id: number;
+
course_code: string;
+
course_name: string;
+
professor: string;
+
semester: string;
+
year: number;
+
additional_info: string | null;
+
meeting_times: string | null;
+
created_at: number;
+
}
+
+
/**
+
* Add a class to the waitlist
+
*/
+
export function addToWaitlist(
+
userId: number,
+
courseCode: string,
+
courseName: string,
+
professor: string,
+
semester: string,
+
year: number,
+
additionalInfo: string | null,
+
meetingTimes: string[] | null,
+
): string {
+
const id = nanoid();
+
const meetingTimesJson = meetingTimes ? JSON.stringify(meetingTimes) : null;
+
+
db.query(
+
`INSERT INTO class_waitlist
+
(id, user_id, course_code, course_name, professor, semester, year, additional_info, meeting_times, created_at)
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+
).run(
+
id,
+
userId,
+
courseCode,
+
courseName,
+
professor,
+
semester,
+
year,
+
additionalInfo,
+
meetingTimesJson,
+
Math.floor(Date.now() / 1000),
+
);
+
return id;
+
}
+
+
/**
+
* Get all waitlist entries
+
*/
+
export function getAllWaitlistEntries(): WaitlistEntry[] {
+
return db
+
.query<WaitlistEntry, []>(
+
"SELECT * FROM class_waitlist ORDER BY created_at DESC",
+
)
+
.all();
+
}
+
+
/**
+
* Delete a waitlist entry
+
*/
+
export function deleteWaitlistEntry(id: string): void {
+
db.query("DELETE FROM class_waitlist WHERE id = ?").run(id);
+
}
+
+9
src/pages/admin.html
···
<button class="tab active" data-tab="pending">Pending Recordings</button>
<button class="tab" data-tab="transcriptions">Transcriptions</button>
<button class="tab" data-tab="users">Users</button>
</div>
<div id="pending-tab" class="tab-content active">
···
<admin-users id="users-component"></admin-users>
</div>
</div>
</div>
</main>
···
<script type="module" src="../components/admin-pending-recordings.ts"></script>
<script type="module" src="../components/admin-transcriptions.ts"></script>
<script type="module" src="../components/admin-users.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
<script type="module">
···
<button class="tab active" data-tab="pending">Pending Recordings</button>
<button class="tab" data-tab="transcriptions">Transcriptions</button>
<button class="tab" data-tab="users">Users</button>
+
<button class="tab" data-tab="classes">Classes</button>
</div>
<div id="pending-tab" class="tab-content active">
···
<admin-users id="users-component"></admin-users>
</div>
</div>
+
+
<div id="classes-tab" class="tab-content">
+
<div class="section">
+
<h2 class="section-title">Manage Classes</h2>
+
<admin-classes></admin-classes>
+
</div>
+
</div>
</div>
</main>
···
<script type="module" src="../components/admin-pending-recordings.ts"></script>
<script type="module" src="../components/admin-transcriptions.ts"></script>
<script type="module" src="../components/admin-users.ts"></script>
+
<script type="module" src="../components/admin-classes.ts"></script>
<script type="module" src="../components/user-modal.ts"></script>
<script type="module" src="../components/transcript-view-modal.ts"></script>
<script type="module">