+10
LICENSE.md
+10
LICENSE.md
···+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.+No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2
-1
README.md
+2
-1
README.md
······-<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>+<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=O'Saasy&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
+39
scripts/remove-from-classes.ts
+39
scripts/remove-from-classes.ts
···
+215
-15
src/components/admin-classes.ts
+215
-15
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>
+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>`,······
+112
-6
src/components/class-view.ts
+112
-6
src/components/class-view.ts
·····················+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>`,······
+1
src/components/classes-overview.ts
+1
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" : ""}">
+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}>
+70
-1
src/db/schema.ts
+70
-1
src/db/schema.ts
······+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);
+213
-667
src/index.test.ts
+213
-667
src/index.test.ts
································································································
+814
-374
src/index.ts
+814
-374
src/index.ts
··················-"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",··········································-"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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",··································································+"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';",
+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
···
+22
src/lib/auth.ts
+22
src/lib/auth.ts
······
+107
-7
src/lib/classes.ts
+107
-7
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······
+3
-1
src/lib/email.ts
+3
-1
src/lib/email.ts
···-console.log(`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`);+`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`,
+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)`,
+3
-3
src/lib/vtt-cleaner.test.ts
+3
-3
src/lib/vtt-cleaner.test.ts
···
-1
src/pages/admin.html
-1
src/pages/admin.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/checkout.html
-1
src/pages/checkout.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/class.html
-1
src/pages/class.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/classes.html
-1
src/pages/classes.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/index.html
-1
src/pages/index.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/reset-password.html
-1
src/pages/reset-password.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/settings.html
-1
src/pages/settings.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">
-1
src/pages/transcribe.html
-1
src/pages/transcribe.html
···-<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'">