🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: add class registration via class code

Students can now join classes by entering a class code:
- Created class-registration-modal component with form
- Added joinClassByCode() function in classes.ts
- Added POST /api/classes/join API endpoint
- Integrated modal into classes-overview component
- Classes are joined by their ID (used as the class code)
- Validates class exists, not archived, and user not already enrolled

UI shows "+ Register for Class" card that opens the modal.

💘 Generated with Crush

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

+334 -2
+252
src/components/class-registration-modal.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, property, state } from "lit/decorators.js"; 3 + 4 + @customElement("class-registration-modal") 5 + export class ClassRegistrationModal extends LitElement { 6 + @property({ type: Boolean }) open = false; 7 + @state() classCode = ""; 8 + @state() isSubmitting = false; 9 + @state() error = ""; 10 + 11 + static override styles = css` 12 + :host { 13 + display: block; 14 + } 15 + 16 + .modal-overlay { 17 + position: fixed; 18 + top: 0; 19 + left: 0; 20 + right: 0; 21 + bottom: 0; 22 + background: rgba(0, 0, 0, 0.5); 23 + display: flex; 24 + align-items: center; 25 + justify-content: center; 26 + z-index: 1000; 27 + padding: 1rem; 28 + } 29 + 30 + .modal { 31 + background: var(--background); 32 + border: 2px solid var(--secondary); 33 + border-radius: 12px; 34 + padding: 2rem; 35 + max-width: 28rem; 36 + width: 100%; 37 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 38 + } 39 + 40 + .modal-header { 41 + display: flex; 42 + justify-content: space-between; 43 + align-items: center; 44 + margin-bottom: 1.5rem; 45 + } 46 + 47 + .modal-title { 48 + margin: 0; 49 + color: var(--text); 50 + font-size: 1.5rem; 51 + } 52 + 53 + .close-btn { 54 + background: transparent; 55 + border: none; 56 + font-size: 1.5rem; 57 + cursor: pointer; 58 + color: var(--text); 59 + padding: 0; 60 + width: 2rem; 61 + height: 2rem; 62 + display: flex; 63 + align-items: center; 64 + justify-content: center; 65 + border-radius: 4px; 66 + transition: all 0.2s; 67 + } 68 + 69 + .close-btn:hover { 70 + background: var(--secondary); 71 + } 72 + 73 + .form-group { 74 + margin-bottom: 1.5rem; 75 + } 76 + 77 + label { 78 + display: block; 79 + margin-bottom: 0.5rem; 80 + font-weight: 500; 81 + color: var(--text); 82 + font-size: 0.875rem; 83 + } 84 + 85 + input { 86 + width: 100%; 87 + padding: 0.75rem; 88 + border: 2px solid var(--secondary); 89 + border-radius: 6px; 90 + font-size: 1rem; 91 + font-family: inherit; 92 + background: var(--background); 93 + color: var(--text); 94 + transition: all 0.2s; 95 + box-sizing: border-box; 96 + text-transform: uppercase; 97 + } 98 + 99 + input:focus { 100 + outline: none; 101 + border-color: var(--primary); 102 + } 103 + 104 + .helper-text { 105 + margin-top: 0.5rem; 106 + font-size: 0.75rem; 107 + color: var(--paynes-gray); 108 + } 109 + 110 + .error-message { 111 + color: red; 112 + font-size: 0.875rem; 113 + margin-top: 0.5rem; 114 + } 115 + 116 + .modal-actions { 117 + display: flex; 118 + gap: 0.75rem; 119 + margin-top: 1.5rem; 120 + } 121 + 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; 128 + cursor: pointer; 129 + transition: all 0.2s; 130 + font-family: inherit; 131 + } 132 + 133 + button:disabled { 134 + opacity: 0.6; 135 + cursor: not-allowed; 136 + } 137 + 138 + .btn-primary { 139 + background: var(--primary); 140 + color: white; 141 + flex: 1; 142 + } 143 + 144 + .btn-primary:hover:not(:disabled) { 145 + background: var(--gunmetal); 146 + border-color: var(--gunmetal); 147 + } 148 + 149 + .btn-secondary { 150 + background: transparent; 151 + color: var(--text); 152 + border-color: var(--secondary); 153 + } 154 + 155 + .btn-secondary:hover:not(:disabled) { 156 + border-color: var(--primary); 157 + color: var(--primary); 158 + } 159 + `; 160 + 161 + private handleClose() { 162 + this.classCode = ""; 163 + this.error = ""; 164 + this.dispatchEvent(new CustomEvent("close")); 165 + } 166 + 167 + private handleInput(e: Event) { 168 + this.classCode = (e.target as HTMLInputElement).value.toUpperCase(); 169 + this.error = ""; 170 + } 171 + 172 + private async handleSubmit(e: Event) { 173 + e.preventDefault(); 174 + this.error = ""; 175 + this.isSubmitting = true; 176 + 177 + try { 178 + const response = await fetch("/api/classes/join", { 179 + method: "POST", 180 + headers: { "Content-Type": "application/json" }, 181 + body: JSON.stringify({ class_code: this.classCode.trim() }), 182 + }); 183 + 184 + if (!response.ok) { 185 + const data = await response.json(); 186 + this.error = data.error || "Failed to join class"; 187 + return; 188 + } 189 + 190 + // Success - notify parent and close 191 + this.dispatchEvent(new CustomEvent("class-joined")); 192 + this.handleClose(); 193 + } catch { 194 + this.error = "Failed to join class. Please try again."; 195 + } finally { 196 + this.isSubmitting = false; 197 + } 198 + } 199 + 200 + override render() { 201 + if (!this.open) return html``; 202 + 203 + return html` 204 + <div class="modal-overlay" @click=${this.handleClose}> 205 + <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 206 + <div class="modal-header"> 207 + <h2 class="modal-title">Register for Class</h2> 208 + <button class="close-btn" @click=${this.handleClose} type="button">×</button> 209 + </div> 210 + 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 226 + </div> 227 + ${this.error ? html`<div class="error-message">${this.error}</div>` : ""} 228 + </div> 229 + 230 + <div class="modal-actions"> 231 + <button 232 + type="submit" 233 + class="btn-primary" 234 + ?disabled=${this.isSubmitting || !this.classCode.trim()} 235 + > 236 + ${this.isSubmitting ? "Joining..." : "Join Class"} 237 + </button> 238 + <button 239 + type="button" 240 + class="btn-secondary" 241 + @click=${this.handleClose} 242 + ?disabled=${this.isSubmitting} 243 + > 244 + Cancel 245 + </button> 246 + </div> 247 + </form> 248 + </div> 249 + </div> 250 + `; 251 + } 252 + }
+17 -2
src/components/classes-overview.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 + import "./class-registration-modal"; 3 4 4 5 interface Class { 5 6 id: string; ··· 20 21 @state() classes: ClassesGrouped = {}; 21 22 @state() isLoading = true; 22 23 @state() error: string | null = null; 24 + @state() showRegistrationModal = false; 23 25 24 26 static override styles = css` 25 27 :host { ··· 205 207 } 206 208 207 209 private handleRegisterClick() { 208 - // TODO: Open registration modal/form 209 - alert("Class registration coming soon!"); 210 + this.showRegistrationModal = true; 211 + } 212 + 213 + private handleModalClose() { 214 + this.showRegistrationModal = false; 215 + } 216 + 217 + private async handleClassJoined() { 218 + await this.loadClasses(); 210 219 } 211 220 212 221 override render() { ··· 274 283 </div> 275 284 ` 276 285 } 286 + 287 + <class-registration-modal 288 + ?open=${this.showRegistrationModal} 289 + @close=${this.handleModalClose} 290 + @class-joined=${this.handleClassJoined} 291 + ></class-registration-modal> 277 292 `; 278 293 } 279 294 }
+27
src/index.ts
··· 37 37 getMeetingTimesForClass, 38 38 getTranscriptionsForClass, 39 39 isUserEnrolledInClass, 40 + joinClassByCode, 40 41 removeUserFromClass, 41 42 toggleClassArchive, 42 43 updateMeetingTime, ··· 1489 1490 }); 1490 1491 1491 1492 return Response.json(newClass); 1493 + } catch (error) { 1494 + return handleError(error); 1495 + } 1496 + }, 1497 + }, 1498 + "/api/classes/join": { 1499 + POST: async (req) => { 1500 + try { 1501 + const user = requireAuth(req); 1502 + const body = await req.json(); 1503 + const classCode = body.class_code; 1504 + 1505 + if (!classCode || typeof classCode !== "string") { 1506 + return Response.json( 1507 + { error: "Class code required" }, 1508 + { status: 400 }, 1509 + ); 1510 + } 1511 + 1512 + const result = joinClassByCode(classCode.trim(), user.id); 1513 + 1514 + if (!result.success) { 1515 + return Response.json({ error: result.error }, { status: 400 }); 1516 + } 1517 + 1518 + return Response.json({ success: true, class_id: result.classId }); 1492 1519 } catch (error) { 1493 1520 return handleError(error); 1494 1521 }
+38
src/lib/classes.ts
··· 247 247 ) 248 248 .all(classId); 249 249 } 250 + 251 + /** 252 + * Join a class by class code 253 + */ 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 260 + .query<Class, [string]>( 261 + "SELECT * FROM classes WHERE UPPER(id) = UPPER(?) AND archived = 0", 262 + ) 263 + .get(classCode); 264 + 265 + if (!cls) { 266 + return { success: false, error: "Class not found or is archived" }; 267 + } 268 + 269 + // Check if already enrolled 270 + const existing = db 271 + .query<ClassMember, [string, number]>( 272 + "SELECT * FROM class_members WHERE class_id = ? AND user_id = ?", 273 + ) 274 + .get(cls.id, userId); 275 + 276 + if (existing) { 277 + return { success: false, error: "Already enrolled in this class" }; 278 + } 279 + 280 + // Enroll user 281 + db.query( 282 + "INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 283 + ).run(cls.id, userId, Math.floor(Date.now() / 1000)); 284 + 285 + return { success: true, classId: cls.id }; 286 + } 287 +