🪻 distributed transcription service thistle.dunkirk.sh

feat: redesign class registration with search and selection

Students now search for classes and select from results:
- Added section column to classes table (migration v2)
- Search by course code returns all matching classes
- Results show professor, section, semester, year
- Students pick specific section/professor when joining
- Updated Class interface to include section field
- Replaced joinClassByCode with searchClassesByCourseCode + joinClass
- New GET /api/classes/search?q=<query> endpoint
- Updated POST /api/classes/join to accept class_id
- Redesigned modal with search form and clickable result cards

Better UX for multi-section courses with different professors.

💘 Generated with Crush

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

dunkirk.sh 0b3565be b8fed938

verified
Changed files
+305 -81
src
+4
CRUSH.md
···
This is a Bun-based transcription service using the [Bun fullstack pattern](https://bun.com/docs/bundler/fullstack) for routing and bundled HTML.
+
## Workflow
+
+
**IMPORTANT**: Do NOT commit changes until the user explicitly asks you to commit. Always wait for user verification that changes are working correctly before making commits.
+
## Project Info
- Name: Thistle
+240 -64
src/components/class-registration-modal.ts
···
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
+
interface ClassResult {
+
id: string;
+
course_code: string;
+
name: string;
+
professor: string;
+
section: string | null;
+
semester: string;
+
year: number;
+
}
+
@customElement("class-registration-modal")
export class ClassRegistrationModal extends LitElement {
@property({ type: Boolean }) open = false;
-
@state() classCode = "";
-
@state() isSubmitting = false;
+
@state() searchQuery = "";
+
@state() results: ClassResult[] = [];
+
@state() isSearching = false;
+
@state() isJoining = false;
@state() error = "";
+
@state() hasSearched = false;
static override styles = css`
:host {
···
border: 2px solid var(--secondary);
border-radius: 12px;
padding: 2rem;
-
max-width: 28rem;
+
max-width: 42rem;
width: 100%;
+
max-height: 90vh;
+
overflow-y: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
···
background: var(--secondary);
}
-
.form-group {
+
.search-section {
margin-bottom: 1.5rem;
+
}
+
+
.search-section > label {
+
margin-bottom: 0.5rem;
+
}
+
+
.search-form {
+
display: flex;
+
gap: 0.75rem;
+
align-items: center;
+
margin-bottom: 0.5rem;
+
}
+
+
.search-input-wrapper {
+
flex: 1;
}
label {
···
color: var(--text);
transition: all 0.2s;
box-sizing: border-box;
-
text-transform: uppercase;
}
input:focus {
···
border-color: var(--primary);
}
+
.search-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;
+
}
+
+
.search-btn:hover:not(:disabled) {
+
background: var(--gunmetal);
+
border-color: var(--gunmetal);
+
}
+
+
.search-btn:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
.helper-text {
margin-top: 0.5rem;
font-size: 0.75rem;
···
margin-top: 0.5rem;
}
-
.modal-actions {
-
display: flex;
-
gap: 0.75rem;
+
.results-section {
margin-top: 1.5rem;
}
-
button {
-
padding: 0.75rem 1.5rem;
-
border: 2px solid var(--primary);
-
border-radius: 6px;
-
font-size: 1rem;
-
font-weight: 500;
+
.results-grid {
+
display: grid;
+
gap: 0.75rem;
+
}
+
+
.class-card {
+
background: var(--background);
+
border: 2px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.25rem;
cursor: pointer;
transition: all 0.2s;
-
font-family: inherit;
+
}
+
+
.class-card:hover:not(:disabled) {
+
border-color: var(--accent);
+
transform: translateX(4px);
}
-
button:disabled {
+
.class-card:disabled {
opacity: 0.6;
cursor: not-allowed;
}
-
.btn-primary {
+
.class-header {
+
display: flex;
+
justify-content: space-between;
+
align-items: flex-start;
+
gap: 1rem;
+
margin-bottom: 0.5rem;
+
}
+
+
.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);
+
margin-top: 0.5rem;
+
}
+
+
.join-btn {
+
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
-
flex: 1;
+
border: 2px solid var(--primary);
+
border-radius: 6px;
+
font-size: 0.875rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
white-space: nowrap;
}
-
.btn-primary:hover:not(:disabled) {
+
.join-btn:hover:not(:disabled) {
background: var(--gunmetal);
border-color: var(--gunmetal);
}
-
.btn-secondary {
-
background: transparent;
-
color: var(--text);
-
border-color: var(--secondary);
+
.join-btn:disabled {
+
opacity: 0.6;
+
cursor: not-allowed;
}
-
.btn-secondary:hover:not(:disabled) {
-
border-color: var(--primary);
-
color: var(--primary);
+
.empty-state {
+
text-align: center;
+
padding: 3rem 2rem;
+
color: var(--paynes-gray);
+
}
+
+
.loading {
+
text-align: center;
+
padding: 2rem;
+
color: var(--paynes-gray);
}
`;
private handleClose() {
-
this.classCode = "";
+
this.searchQuery = "";
+
this.results = [];
this.error = "";
+
this.hasSearched = false;
this.dispatchEvent(new CustomEvent("close"));
}
private handleInput(e: Event) {
-
this.classCode = (e.target as HTMLInputElement).value.toUpperCase();
+
this.searchQuery = (e.target as HTMLInputElement).value;
this.error = "";
}
-
private async handleSubmit(e: Event) {
+
private async handleSearch(e: Event) {
e.preventDefault();
+
if (!this.searchQuery.trim()) return;
+
+
this.isSearching = true;
this.error = "";
-
this.isSubmitting = true;
+
this.hasSearched = true;
+
+
try {
+
const response = await fetch(
+
`/api/classes/search?q=${encodeURIComponent(this.searchQuery.trim())}`,
+
);
+
+
if (!response.ok) {
+
throw new Error("Search failed");
+
}
+
+
const data = await response.json();
+
this.results = data.classes || [];
+
} catch {
+
this.error = "Failed to search classes. Please try again.";
+
} finally {
+
this.isSearching = false;
+
}
+
}
+
+
private async handleJoin(classId: string) {
+
this.isJoining = true;
+
this.error = "";
try {
const response = await fetch("/api/classes/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ class_code: this.classCode.trim() }),
+
body: JSON.stringify({ class_id: classId }),
});
if (!response.ok) {
···
} catch {
this.error = "Failed to join class. Please try again.";
} finally {
-
this.isSubmitting = false;
+
this.isJoining = false;
}
}
···
<div class="modal-overlay" @click=${this.handleClose}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<div class="modal-header">
-
<h2 class="modal-title">Register for Class</h2>
+
<h2 class="modal-title">Find a Class</h2>
<button class="close-btn" @click=${this.handleClose} type="button">×</button>
</div>
-
<form @submit=${this.handleSubmit}>
-
<div class="form-group">
-
<label for="class-code">Class Code</label>
-
<input
-
type="text"
-
id="class-code"
-
placeholder="ABC123"
-
.value=${this.classCode}
-
@input=${this.handleInput}
-
required
-
?disabled=${this.isSubmitting}
-
maxlength="20"
-
/>
-
<div class="helper-text">
-
Enter the class code provided by your instructor
+
<div class="search-section">
+
<label for="search">Course Code</label>
+
<form class="search-form" @submit=${this.handleSearch}>
+
<div class="search-input-wrapper">
+
<input
+
type="text"
+
id="search"
+
placeholder="CS 101, MATH 220, etc."
+
.value=${this.searchQuery}
+
@input=${this.handleInput}
+
?disabled=${this.isSearching}
+
/>
</div>
-
${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
-
</div>
-
-
<div class="modal-actions">
<button
type="submit"
-
class="btn-primary"
-
?disabled=${this.isSubmitting || !this.classCode.trim()}
+
class="search-btn"
+
?disabled=${this.isSearching || !this.searchQuery.trim()}
>
-
${this.isSubmitting ? "Joining..." : "Join Class"}
+
${this.isSearching ? "Searching..." : "Search"}
</button>
-
<button
-
type="button"
-
class="btn-secondary"
-
@click=${this.handleClose}
-
?disabled=${this.isSubmitting}
-
>
-
Cancel
-
</button>
+
</form>
+
<div class="helper-text">
+
Search by course code to find available classes
</div>
-
</form>
+
${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
+
</div>
+
+
${
+
this.hasSearched
+
? html`
+
<div class="results-section">
+
${
+
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="results-grid">
+
${this.results.map(
+
(cls) => html`
+
<button
+
class="class-card"
+
@click=${() => this.handleJoin(cls.id)}
+
?disabled=${this.isJoining}
+
>
+
<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>
+
${cls.section ? html`<span>📍 Section ${cls.section}</span>` : ""}
+
<span>📅 ${cls.semester} ${cls.year}</span>
+
</div>
+
</div>
+
<button
+
class="join-btn"
+
?disabled=${this.isJoining}
+
@click=${(e: Event) => {
+
e.stopPropagation();
+
this.handleJoin(cls.id);
+
}}
+
>
+
${this.isJoining ? "Joining..." : "Join"}
+
</button>
+
</div>
+
</button>
+
`,
+
)}
+
</div>
+
`
+
}
+
</div>
+
`
+
: ""
+
}
</div>
</div>
`;
+8
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);
`,
},
+
{
+
version: 2,
+
name: "Add section column to classes table",
+
sql: `
+
ALTER TABLE classes ADD COLUMN section TEXT;
+
CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code);
+
`,
+
},
];
function getCurrentVersion(): number {
+25 -6
src/index.ts
···
getMeetingTimesForClass,
getTranscriptionsForClass,
isUserEnrolledInClass,
-
joinClassByCode,
+
joinClass,
removeUserFromClass,
+
searchClassesByCourseCode,
toggleClassArchive,
updateMeetingTime,
} from "./lib/classes";
···
},
},
+
"/api/classes/search": {
+
GET: async (req) => {
+
try {
+
requireAuth(req);
+
const url = new URL(req.url);
+
const query = url.searchParams.get("q");
+
+
if (!query) {
+
return Response.json({ classes: [] });
+
}
+
+
const classes = searchClassesByCourseCode(query);
+
return Response.json({ classes });
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
"/api/classes/join": {
POST: async (req) => {
try {
const user = requireAuth(req);
const body = await req.json();
-
const classCode = body.class_code;
+
const classId = body.class_id;
-
if (!classCode || typeof classCode !== "string") {
+
if (!classId || typeof classId !== "string") {
return Response.json(
-
{ error: "Class code required" },
+
{ error: "Class ID required" },
{ status: 400 },
);
-
const result = joinClassByCode(classCode.trim(), user.id);
+
const result = joinClass(classId, user.id);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
-
return Response.json({ success: true, class_id: result.classId });
+
return Response.json({ success: true });
} catch (error) {
return handleError(error);
+28 -11
src/lib/classes.ts
···
course_code: string;
name: string;
professor: string;
+
section: string | null;
semester: string;
year: number;
archived: boolean;
···
}
/**
-
* Join a class by class code
+
* Search for classes by course code
*/
-
export function joinClassByCode(
-
classCode: string,
-
userId: number,
-
): { success: boolean; classId?: string; error?: string } {
-
// Find class by code (case-insensitive)
-
const cls = db
+
export function searchClassesByCourseCode(courseCode: string): Class[] {
+
return db
.query<Class, [string]>(
-
"SELECT * FROM classes WHERE UPPER(id) = UPPER(?) AND archived = 0",
+
`SELECT * FROM classes
+
WHERE UPPER(course_code) LIKE UPPER(?)
+
AND archived = 0
+
ORDER BY year DESC, semester DESC, professor ASC, section ASC`,
)
-
.get(classCode);
+
.all(`%${courseCode}%`);
+
}
+
+
/**
+
* Join a class by class ID
+
*/
+
export function joinClass(
+
classId: string,
+
userId: number,
+
): { success: boolean; error?: string } {
+
// Find class by ID
+
const cls = db
+
.query<Class, [string]>("SELECT * FROM classes WHERE id = ?")
+
.get(classId);
if (!cls) {
-
return { success: false, error: "Class not found or is archived" };
+
return { success: false, error: "Class not found" };
+
}
+
+
if (cls.archived) {
+
return { success: false, error: "This class is archived" };
}
// Check if already enrolled
···
"INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)",
).run(cls.id, userId, Math.floor(Date.now() / 1000));
-
return { success: true, classId: cls.id };
+
return { success: true };
}