🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

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>

+305 -81
+4
CRUSH.md
··· 2 2 3 3 This is a Bun-based transcription service using the [Bun fullstack pattern](https://bun.com/docs/bundler/fullstack) for routing and bundled HTML. 4 4 5 + ## Workflow 6 + 7 + **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. 8 + 5 9 ## Project Info 6 10 7 11 - Name: Thistle
+240 -64
src/components/class-registration-modal.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, property, state } from "lit/decorators.js"; 3 3 4 + interface ClassResult { 5 + id: string; 6 + course_code: string; 7 + name: string; 8 + professor: string; 9 + section: string | null; 10 + semester: string; 11 + year: number; 12 + } 13 + 4 14 @customElement("class-registration-modal") 5 15 export class ClassRegistrationModal extends LitElement { 6 16 @property({ type: Boolean }) open = false; 7 - @state() classCode = ""; 8 - @state() isSubmitting = false; 17 + @state() searchQuery = ""; 18 + @state() results: ClassResult[] = []; 19 + @state() isSearching = false; 20 + @state() isJoining = false; 9 21 @state() error = ""; 22 + @state() hasSearched = false; 10 23 11 24 static override styles = css` 12 25 :host { ··· 32 45 border: 2px solid var(--secondary); 33 46 border-radius: 12px; 34 47 padding: 2rem; 35 - max-width: 28rem; 48 + max-width: 42rem; 36 49 width: 100%; 50 + max-height: 90vh; 51 + overflow-y: auto; 37 52 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 38 53 } 39 54 ··· 70 85 background: var(--secondary); 71 86 } 72 87 73 - .form-group { 88 + .search-section { 74 89 margin-bottom: 1.5rem; 90 + } 91 + 92 + .search-section > label { 93 + margin-bottom: 0.5rem; 94 + } 95 + 96 + .search-form { 97 + display: flex; 98 + gap: 0.75rem; 99 + align-items: center; 100 + margin-bottom: 0.5rem; 101 + } 102 + 103 + .search-input-wrapper { 104 + flex: 1; 75 105 } 76 106 77 107 label { ··· 93 123 color: var(--text); 94 124 transition: all 0.2s; 95 125 box-sizing: border-box; 96 - text-transform: uppercase; 97 126 } 98 127 99 128 input:focus { ··· 101 130 border-color: var(--primary); 102 131 } 103 132 133 + .search-btn { 134 + padding: 0.75rem 1.5rem; 135 + background: var(--primary); 136 + color: white; 137 + border: 2px solid var(--primary); 138 + border-radius: 6px; 139 + font-size: 1rem; 140 + font-weight: 500; 141 + cursor: pointer; 142 + transition: all 0.2s; 143 + font-family: inherit; 144 + } 145 + 146 + .search-btn:hover:not(:disabled) { 147 + background: var(--gunmetal); 148 + border-color: var(--gunmetal); 149 + } 150 + 151 + .search-btn:disabled { 152 + opacity: 0.6; 153 + cursor: not-allowed; 154 + } 155 + 104 156 .helper-text { 105 157 margin-top: 0.5rem; 106 158 font-size: 0.75rem; ··· 113 165 margin-top: 0.5rem; 114 166 } 115 167 116 - .modal-actions { 117 - display: flex; 118 - gap: 0.75rem; 168 + .results-section { 119 169 margin-top: 1.5rem; 120 170 } 121 171 122 - button { 123 - padding: 0.75rem 1.5rem; 124 - border: 2px solid var(--primary); 125 - border-radius: 6px; 126 - font-size: 1rem; 127 - font-weight: 500; 172 + .results-grid { 173 + display: grid; 174 + gap: 0.75rem; 175 + } 176 + 177 + .class-card { 178 + background: var(--background); 179 + border: 2px solid var(--secondary); 180 + border-radius: 8px; 181 + padding: 1.25rem; 128 182 cursor: pointer; 129 183 transition: all 0.2s; 130 - font-family: inherit; 184 + } 185 + 186 + .class-card:hover:not(:disabled) { 187 + border-color: var(--accent); 188 + transform: translateX(4px); 131 189 } 132 190 133 - button:disabled { 191 + .class-card:disabled { 134 192 opacity: 0.6; 135 193 cursor: not-allowed; 136 194 } 137 195 138 - .btn-primary { 196 + .class-header { 197 + display: flex; 198 + justify-content: space-between; 199 + align-items: flex-start; 200 + gap: 1rem; 201 + margin-bottom: 0.5rem; 202 + } 203 + 204 + .class-info { 205 + flex: 1; 206 + } 207 + 208 + .course-code { 209 + font-size: 0.875rem; 210 + font-weight: 600; 211 + color: var(--accent); 212 + text-transform: uppercase; 213 + } 214 + 215 + .class-name { 216 + font-size: 1.125rem; 217 + font-weight: 600; 218 + margin: 0.25rem 0; 219 + color: var(--text); 220 + } 221 + 222 + .class-meta { 223 + display: flex; 224 + gap: 1rem; 225 + font-size: 0.875rem; 226 + color: var(--paynes-gray); 227 + margin-top: 0.5rem; 228 + } 229 + 230 + .join-btn { 231 + padding: 0.5rem 1rem; 139 232 background: var(--primary); 140 233 color: white; 141 - flex: 1; 234 + border: 2px solid var(--primary); 235 + border-radius: 6px; 236 + font-size: 0.875rem; 237 + font-weight: 500; 238 + cursor: pointer; 239 + transition: all 0.2s; 240 + font-family: inherit; 241 + white-space: nowrap; 142 242 } 143 243 144 - .btn-primary:hover:not(:disabled) { 244 + .join-btn:hover:not(:disabled) { 145 245 background: var(--gunmetal); 146 246 border-color: var(--gunmetal); 147 247 } 148 248 149 - .btn-secondary { 150 - background: transparent; 151 - color: var(--text); 152 - border-color: var(--secondary); 249 + .join-btn:disabled { 250 + opacity: 0.6; 251 + cursor: not-allowed; 153 252 } 154 253 155 - .btn-secondary:hover:not(:disabled) { 156 - border-color: var(--primary); 157 - color: var(--primary); 254 + .empty-state { 255 + text-align: center; 256 + padding: 3rem 2rem; 257 + color: var(--paynes-gray); 258 + } 259 + 260 + .loading { 261 + text-align: center; 262 + padding: 2rem; 263 + color: var(--paynes-gray); 158 264 } 159 265 `; 160 266 161 267 private handleClose() { 162 - this.classCode = ""; 268 + this.searchQuery = ""; 269 + this.results = []; 163 270 this.error = ""; 271 + this.hasSearched = false; 164 272 this.dispatchEvent(new CustomEvent("close")); 165 273 } 166 274 167 275 private handleInput(e: Event) { 168 - this.classCode = (e.target as HTMLInputElement).value.toUpperCase(); 276 + this.searchQuery = (e.target as HTMLInputElement).value; 169 277 this.error = ""; 170 278 } 171 279 172 - private async handleSubmit(e: Event) { 280 + private async handleSearch(e: Event) { 173 281 e.preventDefault(); 282 + if (!this.searchQuery.trim()) return; 283 + 284 + this.isSearching = true; 174 285 this.error = ""; 175 - this.isSubmitting = true; 286 + this.hasSearched = true; 287 + 288 + try { 289 + const response = await fetch( 290 + `/api/classes/search?q=${encodeURIComponent(this.searchQuery.trim())}`, 291 + ); 292 + 293 + if (!response.ok) { 294 + throw new Error("Search failed"); 295 + } 296 + 297 + const data = await response.json(); 298 + this.results = data.classes || []; 299 + } catch { 300 + this.error = "Failed to search classes. Please try again."; 301 + } finally { 302 + this.isSearching = false; 303 + } 304 + } 305 + 306 + private async handleJoin(classId: string) { 307 + this.isJoining = true; 308 + this.error = ""; 176 309 177 310 try { 178 311 const response = await fetch("/api/classes/join", { 179 312 method: "POST", 180 313 headers: { "Content-Type": "application/json" }, 181 - body: JSON.stringify({ class_code: this.classCode.trim() }), 314 + body: JSON.stringify({ class_id: classId }), 182 315 }); 183 316 184 317 if (!response.ok) { ··· 193 326 } catch { 194 327 this.error = "Failed to join class. Please try again."; 195 328 } finally { 196 - this.isSubmitting = false; 329 + this.isJoining = false; 197 330 } 198 331 } 199 332 ··· 204 337 <div class="modal-overlay" @click=${this.handleClose}> 205 338 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 206 339 <div class="modal-header"> 207 - <h2 class="modal-title">Register for Class</h2> 340 + <h2 class="modal-title">Find a Class</h2> 208 341 <button class="close-btn" @click=${this.handleClose} type="button">×</button> 209 342 </div> 210 343 211 - <form @submit=${this.handleSubmit}> 212 - <div class="form-group"> 213 - <label for="class-code">Class Code</label> 214 - <input 215 - type="text" 216 - id="class-code" 217 - placeholder="ABC123" 218 - .value=${this.classCode} 219 - @input=${this.handleInput} 220 - required 221 - ?disabled=${this.isSubmitting} 222 - maxlength="20" 223 - /> 224 - <div class="helper-text"> 225 - Enter the class code provided by your instructor 344 + <div class="search-section"> 345 + <label for="search">Course Code</label> 346 + <form class="search-form" @submit=${this.handleSearch}> 347 + <div class="search-input-wrapper"> 348 + <input 349 + type="text" 350 + id="search" 351 + placeholder="CS 101, MATH 220, etc." 352 + .value=${this.searchQuery} 353 + @input=${this.handleInput} 354 + ?disabled=${this.isSearching} 355 + /> 226 356 </div> 227 - ${this.error ? html`<div class="error-message">${this.error}</div>` : ""} 228 - </div> 229 - 230 - <div class="modal-actions"> 231 357 <button 232 358 type="submit" 233 - class="btn-primary" 234 - ?disabled=${this.isSubmitting || !this.classCode.trim()} 359 + class="search-btn" 360 + ?disabled=${this.isSearching || !this.searchQuery.trim()} 235 361 > 236 - ${this.isSubmitting ? "Joining..." : "Join Class"} 362 + ${this.isSearching ? "Searching..." : "Search"} 237 363 </button> 238 - <button 239 - type="button" 240 - class="btn-secondary" 241 - @click=${this.handleClose} 242 - ?disabled=${this.isSubmitting} 243 - > 244 - Cancel 245 - </button> 364 + </form> 365 + <div class="helper-text"> 366 + Search by course code to find available classes 246 367 </div> 247 - </form> 368 + ${this.error ? html`<div class="error-message">${this.error}</div>` : ""} 369 + </div> 370 + 371 + ${ 372 + this.hasSearched 373 + ? html` 374 + <div class="results-section"> 375 + ${ 376 + this.isSearching 377 + ? html`<div class="loading">Searching...</div>` 378 + : this.results.length === 0 379 + ? html` 380 + <div class="empty-state"> 381 + No classes found matching "${this.searchQuery}" 382 + </div> 383 + ` 384 + : html` 385 + <div class="results-grid"> 386 + ${this.results.map( 387 + (cls) => html` 388 + <button 389 + class="class-card" 390 + @click=${() => this.handleJoin(cls.id)} 391 + ?disabled=${this.isJoining} 392 + > 393 + <div class="class-header"> 394 + <div class="class-info"> 395 + <div class="course-code">${cls.course_code}</div> 396 + <div class="class-name">${cls.name}</div> 397 + <div class="class-meta"> 398 + <span>👤 ${cls.professor}</span> 399 + ${cls.section ? html`<span>📍 Section ${cls.section}</span>` : ""} 400 + <span>📅 ${cls.semester} ${cls.year}</span> 401 + </div> 402 + </div> 403 + <button 404 + class="join-btn" 405 + ?disabled=${this.isJoining} 406 + @click=${(e: Event) => { 407 + e.stopPropagation(); 408 + this.handleJoin(cls.id); 409 + }} 410 + > 411 + ${this.isJoining ? "Joining..." : "Join"} 412 + </button> 413 + </div> 414 + </button> 415 + `, 416 + )} 417 + </div> 418 + ` 419 + } 420 + </div> 421 + ` 422 + : "" 423 + } 248 424 </div> 249 425 </div> 250 426 `;
+8
src/db/schema.ts
··· 134 134 CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id); 135 135 `, 136 136 }, 137 + { 138 + version: 2, 139 + name: "Add section column to classes table", 140 + sql: ` 141 + ALTER TABLE classes ADD COLUMN section TEXT; 142 + CREATE INDEX IF NOT EXISTS idx_classes_course_code ON classes(course_code); 143 + `, 144 + }, 137 145 ]; 138 146 139 147 function getCurrentVersion(): number {
+25 -6
src/index.ts
··· 37 37 getMeetingTimesForClass, 38 38 getTranscriptionsForClass, 39 39 isUserEnrolledInClass, 40 - joinClassByCode, 40 + joinClass, 41 41 removeUserFromClass, 42 + searchClassesByCourseCode, 42 43 toggleClassArchive, 43 44 updateMeetingTime, 44 45 } from "./lib/classes"; ··· 1495 1496 } 1496 1497 }, 1497 1498 }, 1499 + "/api/classes/search": { 1500 + GET: async (req) => { 1501 + try { 1502 + requireAuth(req); 1503 + const url = new URL(req.url); 1504 + const query = url.searchParams.get("q"); 1505 + 1506 + if (!query) { 1507 + return Response.json({ classes: [] }); 1508 + } 1509 + 1510 + const classes = searchClassesByCourseCode(query); 1511 + return Response.json({ classes }); 1512 + } catch (error) { 1513 + return handleError(error); 1514 + } 1515 + }, 1516 + }, 1498 1517 "/api/classes/join": { 1499 1518 POST: async (req) => { 1500 1519 try { 1501 1520 const user = requireAuth(req); 1502 1521 const body = await req.json(); 1503 - const classCode = body.class_code; 1522 + const classId = body.class_id; 1504 1523 1505 - if (!classCode || typeof classCode !== "string") { 1524 + if (!classId || typeof classId !== "string") { 1506 1525 return Response.json( 1507 - { error: "Class code required" }, 1526 + { error: "Class ID required" }, 1508 1527 { status: 400 }, 1509 1528 ); 1510 1529 } 1511 1530 1512 - const result = joinClassByCode(classCode.trim(), user.id); 1531 + const result = joinClass(classId, user.id); 1513 1532 1514 1533 if (!result.success) { 1515 1534 return Response.json({ error: result.error }, { status: 400 }); 1516 1535 } 1517 1536 1518 - return Response.json({ success: true, class_id: result.classId }); 1537 + return Response.json({ success: true }); 1519 1538 } catch (error) { 1520 1539 return handleError(error); 1521 1540 }
+28 -11
src/lib/classes.ts
··· 6 6 course_code: string; 7 7 name: string; 8 8 professor: string; 9 + section: string | null; 9 10 semester: string; 10 11 year: number; 11 12 archived: boolean; ··· 249 250 } 250 251 251 252 /** 252 - * Join a class by class code 253 + * Search for classes by course code 253 254 */ 254 - export function joinClassByCode( 255 - classCode: string, 256 - userId: number, 257 - ): { success: boolean; classId?: string; error?: string } { 258 - // Find class by code (case-insensitive) 259 - const cls = db 255 + export function searchClassesByCourseCode(courseCode: string): Class[] { 256 + return db 260 257 .query<Class, [string]>( 261 - "SELECT * FROM classes WHERE UPPER(id) = UPPER(?) AND archived = 0", 258 + `SELECT * FROM classes 259 + WHERE UPPER(course_code) LIKE UPPER(?) 260 + AND archived = 0 261 + ORDER BY year DESC, semester DESC, professor ASC, section ASC`, 262 262 ) 263 - .get(classCode); 263 + .all(`%${courseCode}%`); 264 + } 265 + 266 + /** 267 + * Join a class by class ID 268 + */ 269 + export function joinClass( 270 + classId: string, 271 + userId: number, 272 + ): { success: boolean; error?: string } { 273 + // Find class by ID 274 + const cls = db 275 + .query<Class, [string]>("SELECT * FROM classes WHERE id = ?") 276 + .get(classId); 264 277 265 278 if (!cls) { 266 - return { success: false, error: "Class not found or is archived" }; 279 + return { success: false, error: "Class not found" }; 280 + } 281 + 282 + if (cls.archived) { 283 + return { success: false, error: "This class is archived" }; 267 284 } 268 285 269 286 // Check if already enrolled ··· 282 299 "INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 283 300 ).run(cls.id, userId, Math.floor(Date.now() / 1000)); 284 301 285 - return { success: true, classId: cls.id }; 302 + return { success: true }; 286 303 } 287 304