+24
-10
.env.example
+24
-10
.env.example
······+DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nPASTE_YOUR_DKIM_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
+154
-1
CRUSH.md
+154
-1
CRUSH.md
···**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.+**CRITICAL**: Always use `process.env.ORIGIN` for generating URLs in emails and links, NOT hardcoded domains.+- `ORIGIN` - The public URL of the application (e.g., `https://thistle.app` or `http://localhost:3000`)+**Never hardcode domain names** like `https://thistle.app` in code - always use `process.env.ORIGIN`.···+The application uses [Murmur](https://github.com/taciturnaxolotl/murmur) as the transcription backend.+The `TranscriptionService` runs periodic syncs to reconcile state between our database and Murmur:+- **Cleans up finished jobs** - After successful completion or failure, jobs are deleted from Murmur+- **Cleans up orphaned jobs** - Jobs found in Murmur but not in our database are automatically deleted+- **Completed jobs**: After fetching transcript and saving to storage, the job is deleted from Murmur+- This prevents Murmur's database from accumulating stale jobs (Murmur doesn't have automatic cleanup)+4. Job completes → fetch VTT, clean with LLM, save transcript, update to `status='completed'`, **delete from Murmur**+5. If job fails in Murmur → update to `status='failed'` with error message, **delete from Murmur**+This project uses [Tangled](https://tangled.org) for issue tracking via the `tangled-cli` tool.+tangled-cli issue create --repo "thistle" --title "Issue title" --label "bug" --label "priority:high" --body "Issue description"+**Note:** The repo name for this project is `thistle` (resolves to `dunkirk.sh/thistle` in Tangled). Labels are supported but need to be created in the repository first.+- The CLI may have decoding issues with some API responses (missing `createdAt` field). If `tangled-cli issue list` fails, you can access issues via the web interface at https://tangled.org/dunkirk.sh/thistle
-264
docs/CLASS_SYSTEM_SPEC.md
-264
docs/CLASS_SYSTEM_SPEC.md
···-Restructure Thistle from individual transcript management to class-based transcript organization. Users will manage transcripts grouped by classes, with scheduled meeting times and selective transcription.
+28
-28
package.json
+28
-28
package.json
···
+19
-1
public/favicon/site.webmanifest
+19
-1
public/favicon/site.webmanifest
···-{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+17
scripts/clear-rate-limits.ts
+17
scripts/clear-rate-limits.ts
···
+39
scripts/remove-from-classes.ts
+39
scripts/remove-from-classes.ts
···
+70
scripts/send-test-emails.ts
+70
scripts/send-test-emails.ts
···
+260
-29
src/components/admin-classes.ts
+260
-29
src/components/admin-classes.ts
··········································+<h2 class="modal-title">${this.editingClassInfo.course_code} - ${this.editingClassInfo.name}</h2>+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;"+style="flex: 1; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; background: var(--background); color: var(--text);"+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;"+? html`<p style="color: var(--paynes-gray); text-align: center; padding: 2rem;">No sections yet. Add one above.</p>`+<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;">+style="padding: 0.5rem 1rem; background: transparent; color: red; border: 2px solid red; border-radius: 4px; font-size: 0.875rem; cursor: pointer;"+<div style="display: flex; gap: 0.75rem; justify-content: space-between; padding-top: 1.5rem; border-top: 2px solid var(--secondary);">+style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"+style="padding: 0.75rem 1.5rem; background: transparent; color: red; border: 2px solid red; border-radius: 6px; font-weight: 500; cursor: pointer;"+style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"+${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>` : ""}··················+<div class="help-text">Comma-separated list of section numbers. Leave blank if no sections.</div>
+36
-13
src/components/admin-pending-recordings.ts
+36
-13
src/components/admin-pending-recordings.ts
··················
+30
-10
src/components/admin-transcriptions.ts
+30
-10
src/components/admin-transcriptions.ts
··················
+154
-62
src/components/admin-users.ts
+154
-62
src/components/admin-users.ts
·····················-private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {···-private async performRevokeSubscription(userId: number, email: string, subscriptionId: string) {···-alert(`Failed to revoke subscription: ${error instanceof Error ? error.message : "Unknown error"}`);·····················+<div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>······-? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`+? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`···-@change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)}-?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}-${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")}+? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`+@change=${(e: Event) => this.handleRoleChange(u.id, u.email, (e.target as HTMLSelectElement).value as "user" | "admin", u.role, e)}+?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}+${this.revokingSubscriptions.has(u.id) ? "Revoking..." : this.getDeleteButtonText(u.id, "revoke")}
+273
-4
src/components/auth.ts
+273
-4
src/components/auth.ts
·····················+${this.needsEmailVerification ? "Verify Email" : this.needsRegistration ? "Create Account" : "Sign In"}···
+60
-11
src/components/class-registration-modal.ts
+60
-11
src/components/class-registration-modal.ts
·····················+style="width: 100%; padding: 0.5rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; background: var(--background); color: var(--text);"+html`<option value="${s.id}" ?selected=${this.selectedSections.get(cls.id) === s.id}>${s.section_number}</option>`,······
+123
-13
src/components/class-view.ts
+123
-13
src/components/class-view.ts
·····················<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin: 2rem 0; text-align: center;"><p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p><a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a>+style="padding: 0.5rem 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"+html`<option value=${s.id} ?selected=${s.id === this.selectedSectionFilter}>${s.section_number}</option>`,······<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>···
+21
-7
src/components/classes-overview.ts
+21
-7
src/components/classes-overview.ts
············
+436
src/components/pending-recordings-view.ts
+436
src/components/pending-recordings-view.ts
···+Vote for the best quality recording. The winner will be automatically transcribed when 40% of class votes or after 30 minutes.+<div class="recording-card ${this.userVote === recording.id ? "voted" : ""} ${this.winningRecordingId === recording.id ? "winning" : ""}">
+335
src/components/reset-password-form.ts
+335
src/components/reset-password-form.ts
···
+10
-5
src/components/transcription.ts
+10
-5
src/components/transcription.ts
······<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;"><h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3><p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p><a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a><div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}"
+360
-66
src/components/upload-recording-modal.ts
+360
-66
src/components/upload-recording-modal.ts
······+formData.append("recording_date", Math.floor(this.selectedFile.lastModified / 1000).toString());···+style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"+class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}"+<option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option>+<div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;">+<div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;">+<div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div>+<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
+44
-31
src/components/user-modal.ts
+44
-31
src/components/user-modal.ts
···············-"Are you sure you want to change this user's password? This will log them out of all devices.",+"Password reset email sent successfully. The user will receive a link to set a new password.",···<input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>+<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;">+<p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p>-<input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
+340
-44
src/components/user-settings.ts
+340
-44
src/components/user-settings.ts
·············································+${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ""}·································Opens the customer portal where you can update payment methods, view invoices, and manage your subscription.·····················
+128
-39
src/db/schema.ts
+128
-39
src/db/schema.ts
···············CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);+CREATE INDEX IF NOT EXISTS idx_transcriptions_meeting_time_id ON transcriptions(meeting_time_id);······+CREATE INDEX IF NOT EXISTS idx_verification_tokens_user_id ON email_verification_tokens(user_id);+CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);+CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id);+CREATE INDEX IF NOT EXISTS idx_transcriptions_recording_date ON transcriptions(recording_date);
+4
-2
src/index.test.README.md
+4
-2
src/index.test.README.md
······
+353
-747
src/index.test.ts
+353
-747
src/index.test.ts
···-`\n⚠️ Test server not running on port ${TEST_PORT}. Start it with:\n PORT=${TEST_PORT} bun run src/index.ts\n Then run tests in another terminal.\n`,+// Dummy env vars to pass startup validation (won't be used due to SKIP_EMAILS/SKIP_POLAR_SYNC)···-"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",-"DELETE FROM passkeys WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",-"DELETE FROM transcriptions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",-"DELETE FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%'",·············································································································································
+2123
-506
src/index.ts
+2123
-506
src/index.ts
·····················+`INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)+`[Sync] Linked ${currentSubscriptions.length} current subscription(s) to user ${userId} (${email})`,············-"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,·········+"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,+"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,···+"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",-"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",···················································-"SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?",+"SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?",···············-"SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",·········-"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",+"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",···+return Response.json(result.data); // Return just the array for now, can add pagination UI later···+return Response.json(result.data); // Return just the array for now, can add pagination UI later············-`INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)-`[Admin] Synced ${subscriptions.result.items.length} subscription(s) for user ${userId} (${user.email})`,··············································································+"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://hostedboringavatars.vercel.app; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none';",
+136
src/lib/api-response-format.test.ts
+136
src/lib/api-response-format.test.ts
···
+55
src/lib/audio-metadata.integration.test.ts
+55
src/lib/audio-metadata.integration.test.ts
···+await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -metadata creation_time=${creationTime} -y ${testAudioPath}`.quiet();
+128
src/lib/audio-metadata.test.ts
+128
src/lib/audio-metadata.test.ts
···
+144
src/lib/audio-metadata.ts
+144
src/lib/audio-metadata.ts
···+`[AudioMetadata] Extracted creation date from metadata: ${date.toISOString()} from ${filePath}`,+`[AudioMetadata] No meeting time found matching ${dayName} in available options: ${meetingTimes.map((mt) => mt.label).join(", ")}`,
+34
src/lib/auth.test.ts
+34
src/lib/auth.test.ts
···
+448
-67
src/lib/auth.ts
+448
-67
src/lib/auth.ts
······"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",···+"SELECT id, status, cancel_at_period_end FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",+`[User Delete] Skipping cancellation for subscription ${subscription.id} (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})`,···+"INSERT INTO email_change_tokens (id, user_id, new_email, token, expires_at) VALUES (?, ?, ?, ?, ?)",······-LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')+LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')+LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
+7
-7
src/lib/classes.test.ts
+7
-7
src/lib/classes.test.ts
···
+248
-22
src/lib/classes.ts
+248
-22
src/lib/classes.ts
··················+"INSERT OR IGNORE INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",·········-`SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at+`SELECT id, user_id, meeting_time_id, section_id, filename, original_filename, status, progress, error_message, created_at, updated_at······
-1
src/lib/client-auth.ts
-1
src/lib/client-auth.ts
+1
-2
src/lib/crypto-fallback.ts
+1
-2
src/lib/crypto-fallback.ts
···
+117
src/lib/cursor.test.ts
+117
src/lib/cursor.test.ts
···
+92
src/lib/cursor.ts
+92
src/lib/cursor.ts
···
+116
src/lib/email-change.test.ts
+116
src/lib/email-change.test.ts
···
+297
src/lib/email-templates.ts
+297
src/lib/email-templates.ts
···+This code will expire in 24 hours. Enter it in the verification dialog after you login, or click the button below:+<a href="${verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email</a>+<p>We received a request to reset your password. Click the button below to create a new password.</p>+<a href="${options.resetLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 1rem 0; border: 2px solid #ef8354;">Reset Password</a>+<a href="${options.resetLink}" style="color: #4f5d75; word-break: break-all;">${options.resetLink}</a>+<a href="${options.transcriptLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">View Transcript</a>+<a href="${options.verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email Change</a>+<a href="${options.verifyLink}" style="color: #4f5d75; word-break: break-all;">${options.verifyLink}</a>+<p>If you didn't request this change, please ignore this email and your email address will remain unchanged.</p>
+161
src/lib/email-verification.test.ts
+161
src/lib/email-verification.test.ts
···
+97
src/lib/email.ts
+97
src/lib/email.ts
···+`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`,
+326
src/lib/pagination.test.ts
+326
src/lib/pagination.test.ts
···+"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",+"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",+"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",+"INSERT INTO transcriptions (id, user_id, filename, original_filename, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",+"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",+"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
+20
src/lib/rate-limit.ts
+20
src/lib/rate-limit.ts
···
+2
-2
src/lib/subscription-routes.test.ts
+2
-2
src/lib/subscription-routes.test.ts
······
+44
-4
src/lib/transcription.ts
+44
-4
src/lib/transcription.ts
···············
+118
src/lib/validation.test.ts
+118
src/lib/validation.test.ts
···
+223
src/lib/validation.ts
+223
src/lib/validation.ts
···+/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+227
src/lib/voting.ts
+227
src/lib/voting.ts
···+let query = `SELECT id, user_id, filename, original_filename, vote_count, created_at, section_id+`[Voting] Auto-submitting ${topRecording.id} - reached ${topRecording.vote_count}/${voteThreshold} votes (40% threshold)`,
+10
src/lib/vtt-cleaner.test.ts
+10
src/lib/vtt-cleaner.test.ts
···
+8
-8
src/lib/vtt-cleaner.ts
+8
-8
src/lib/vtt-cleaner.ts
···-"[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT",
+4
-250
src/pages/admin.html
+4
-250
src/pages/admin.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">······
+151
src/pages/admin.ts
+151
src/pages/admin.ts
···
+2
-85
src/pages/index.html
+2
-85
src/pages/index.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">···
+14
src/pages/index.ts
+14
src/pages/index.ts
···
+36
src/pages/reset-password.html
+36
src/pages/reset-password.html
···
+10
src/pages/reset-password.ts
+10
src/pages/reset-password.ts
···
+1
-6
src/pages/settings.html
+1
-6
src/pages/settings.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
+3
-21
src/pages/transcribe.html
+3
-21
src/pages/transcribe.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">···-<a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;">
+120
src/styles/admin.css
+120
src/styles/admin.css
···
+71
src/styles/index.css
+71
src/styles/index.css
···
+6
src/styles/reset-password.css
+6
src/styles/reset-password.css
+27
src/styles/transcribe.css
+27
src/styles/transcribe.css
···