🪻 distributed transcription service thistle.dunkirk.sh

feat: organize transcripts by class

dunkirk.sh 2cedf8bf 17206359

verified
+2 -2
src/components/admin-data-table.ts
···
-
import { LitElement, html, css } from "lit";
+
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export interface TableColumn {
···
@property({ type: Array }) data: unknown[] = [];
@property({ type: String }) searchPlaceholder = "Search...";
@property({ type: String }) emptyMessage = "No data available";
-
@property({ type: Boolean}) loading = false;
+
@property({ type: Boolean }) loading = false;
@property({ type: String }) private searchTerm = "";
@property({ type: String }) private sortKey = "";
+6 -3
src/components/auth.ts
···
<button
type="submit"
class="btn-primary"
-
?disabled=${this.isSubmitting ||
-
(this.passwordStrength?.isChecking ?? false) ||
-
(this.needsRegistration && !(this.passwordStrength?.isValid ?? false))}
+
?disabled=${
+
this.isSubmitting ||
+
(this.passwordStrength?.isChecking ?? false) ||
+
(this.needsRegistration &&
+
!(this.passwordStrength?.isValid ?? false))
+
}
>
${
this.isSubmitting
+392
src/components/class-view.ts
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
import "../components/vtt-viewer.ts";
+
+
interface TranscriptionJob {
+
id: string;
+
filename: string;
+
class_name?: string;
+
status: "uploading" | "processing" | "transcribing" | "completed" | "failed";
+
progress: number;
+
created_at: number;
+
audioUrl?: string;
+
vttContent?: string;
+
}
+
+
@customElement("class-view")
+
export class ClassView extends LitElement {
+
@state() override className = "";
+
@state() jobs: TranscriptionJob[] = [];
+
@state() searchQuery = "";
+
@state() isLoading = true;
+
private eventSources: Map<string, EventSource> = new Map();
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.header {
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
margin-bottom: 2rem;
+
}
+
+
.back-link {
+
color: var(--paynes-gray);
+
text-decoration: none;
+
font-size: 0.875rem;
+
display: flex;
+
align-items: center;
+
gap: 0.25rem;
+
margin-bottom: 0.5rem;
+
}
+
+
.back-link:hover {
+
color: var(--accent);
+
}
+
+
h1 {
+
color: var(--text);
+
margin: 0;
+
}
+
+
.search-box {
+
padding: 0.5rem 0.75rem;
+
border: 1px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 0.875rem;
+
color: var(--text);
+
background: var(--background);
+
width: 20rem;
+
}
+
+
.search-box:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.job-card {
+
background: var(--background);
+
border: 1px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.5rem;
+
margin-bottom: 1rem;
+
}
+
+
.job-header {
+
display: flex;
+
align-items: center;
+
justify-content: space-between;
+
margin-bottom: 1rem;
+
}
+
+
.job-filename {
+
font-weight: 500;
+
color: var(--text);
+
}
+
+
.job-date {
+
font-size: 0.875rem;
+
color: var(--paynes-gray);
+
}
+
+
.job-status {
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
text-transform: uppercase;
+
}
+
+
.status-completed {
+
background: color-mix(in srgb, green 10%, transparent);
+
color: green;
+
}
+
+
.status-failed {
+
background: color-mix(in srgb, var(--text) 10%, transparent);
+
color: var(--text);
+
}
+
+
.status-processing, .status-transcribing, .status-uploading {
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
color: var(--accent);
+
}
+
+
.audio-player audio {
+
width: 100%;
+
height: 2.5rem;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 4rem 2rem;
+
color: var(--paynes-gray);
+
}
+
+
.empty-state h2 {
+
color: var(--text);
+
margin-bottom: 1rem;
+
}
+
+
.progress-bar {
+
width: 100%;
+
height: 4px;
+
background: var(--secondary);
+
border-radius: 2px;
+
margin-bottom: 1rem;
+
overflow: hidden;
+
position: relative;
+
}
+
+
.progress-fill {
+
height: 100%;
+
background: var(--primary);
+
border-radius: 2px;
+
transition: width 0.3s;
+
}
+
+
.progress-fill.indeterminate {
+
width: 30%;
+
background: var(--primary);
+
animation: progress-slide 1.5s ease-in-out infinite;
+
}
+
+
@keyframes progress-slide {
+
0% {
+
transform: translateX(-100%);
+
}
+
100% {
+
transform: translateX(333%);
+
}
+
}
+
`;
+
+
override async connectedCallback() {
+
super.connectedCallback();
+
this.extractClassName();
+
await this.loadJobs();
+
this.connectToJobStreams();
+
+
window.addEventListener("auth-changed", this.handleAuthChange);
+
}
+
+
override disconnectedCallback() {
+
super.disconnectedCallback();
+
window.removeEventListener("auth-changed", this.handleAuthChange);
+
}
+
+
private handleAuthChange = async () => {
+
await this.loadJobs();
+
};
+
+
private extractClassName() {
+
const path = window.location.pathname;
+
const match = path.match(/^\/class\/(.+)$/);
+
if (match) {
+
this.className = decodeURIComponent(match[1] ?? "");
+
}
+
}
+
+
private async loadJobs() {
+
this.isLoading = true;
+
try {
+
const response = await fetch("/api/transcriptions");
+
if (!response.ok) {
+
if (response.status === 401) {
+
this.jobs = [];
+
return;
+
}
+
throw new Error("Failed to load transcriptions");
+
}
+
+
const data = await response.json();
+
const allJobs = data.jobs || [];
+
+
// Filter by class
+
if (this.className === "uncategorized") {
+
this.jobs = allJobs.filter((job: TranscriptionJob) => !job.class_name);
+
} else {
+
this.jobs = allJobs.filter(
+
(job: TranscriptionJob) => job.class_name === this.className,
+
);
+
}
+
+
// Load VTT for completed jobs
+
await this.loadVTTForCompletedJobs();
+
} catch (error) {
+
console.error("Failed to load jobs:", error);
+
} finally {
+
this.isLoading = false;
+
}
+
}
+
+
private async loadVTTForCompletedJobs() {
+
const completedJobs = this.jobs.filter((job) => job.status === "completed");
+
+
await Promise.all(
+
completedJobs.map(async (job) => {
+
try {
+
const response = await fetch(
+
`/api/transcriptions/${job.id}?format=vtt`,
+
);
+
if (response.ok) {
+
const vttContent = await response.text();
+
job.vttContent = vttContent;
+
job.audioUrl = `/api/transcriptions/${job.id}/audio`;
+
this.requestUpdate();
+
}
+
} catch (error) {
+
console.error(`Failed to load VTT for job ${job.id}:`, error);
+
}
+
}),
+
);
+
}
+
+
private connectToJobStreams() {
+
// For active jobs, connect to SSE streams
+
for (const job of this.jobs) {
+
if (
+
job.status === "processing" ||
+
job.status === "transcribing" ||
+
job.status === "uploading"
+
) {
+
this.connectToJobStream(job.id);
+
}
+
}
+
}
+
+
private connectToJobStream(jobId: string) {
+
if (this.eventSources.has(jobId)) {
+
return;
+
}
+
+
const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
+
+
eventSource.addEventListener("update", async (event) => {
+
const update = JSON.parse(event.data);
+
+
const job = this.jobs.find((j) => j.id === jobId);
+
if (job) {
+
if (update.status !== undefined) job.status = update.status;
+
if (update.progress !== undefined) job.progress = update.progress;
+
+
if (update.status === "completed") {
+
await this.loadVTTForCompletedJobs();
+
eventSource.close();
+
this.eventSources.delete(jobId);
+
}
+
+
this.requestUpdate();
+
}
+
});
+
+
eventSource.onerror = () => {
+
eventSource.close();
+
this.eventSources.delete(jobId);
+
};
+
+
this.eventSources.set(jobId, eventSource);
+
}
+
+
private get filteredJobs(): TranscriptionJob[] {
+
if (!this.searchQuery) {
+
return this.jobs;
+
}
+
+
const query = this.searchQuery.toLowerCase();
+
return this.jobs.filter((job) =>
+
job.filename.toLowerCase().includes(query),
+
);
+
}
+
+
private formatDate(timestamp: number): string {
+
const date = new Date(timestamp * 1000);
+
return date.toLocaleDateString(undefined, {
+
year: "numeric",
+
month: "short",
+
day: "numeric",
+
hour: "2-digit",
+
minute: "2-digit",
+
});
+
}
+
+
private getStatusClass(status: string): string {
+
return `status-${status}`;
+
}
+
+
override render() {
+
const displayName =
+
this.className === "uncategorized" ? "Uncategorized" : this.className;
+
+
return html`
+
<div>
+
<a href="/classes" class="back-link">← Back to all classes</a>
+
+
<div class="header">
+
<h1>${displayName}</h1>
+
<input
+
type="text"
+
class="search-box"
+
placeholder="Search transcriptions..."
+
.value=${this.searchQuery}
+
@input=${(e: Event) => {
+
this.searchQuery = (e.target as HTMLInputElement).value;
+
}}
+
/>
+
</div>
+
+
${
+
this.filteredJobs.length === 0 && !this.isLoading
+
? html`
+
<div class="empty-state">
+
<h2>${this.searchQuery ? "No matching transcriptions" : "No transcriptions yet"}</h2>
+
<p>${this.searchQuery ? "Try a different search term" : "Upload an audio file to get started!"}</p>
+
</div>
+
`
+
: html`
+
${this.filteredJobs.map(
+
(job) => html`
+
<div class="job-card">
+
<div class="job-header">
+
<div>
+
<div class="job-filename">${job.filename}</div>
+
<div class="job-date">${this.formatDate(job.created_at)}</div>
+
</div>
+
<span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span>
+
</div>
+
+
${
+
job.status === "uploading" ||
+
job.status === "processing" ||
+
job.status === "transcribing"
+
? html`
+
<div class="progress-bar">
+
<div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}"
+
style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div>
+
</div>
+
`
+
: ""
+
}
+
+
${
+
job.status === "completed" && job.audioUrl && job.vttContent
+
? html`
+
<div class="audio-player">
+
<audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
+
</div>
+
<vtt-viewer .vttContent=${job.vttContent} .audioId=${`audio-${job.id}`}></vtt-viewer>
+
`
+
: ""
+
}
+
</div>
+
`,
+
)}
+
`
+
}
+
</div>
+
`;
+
}
+
}
+251
src/components/classes-overview.ts
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
+
interface ClassStats {
+
name: string;
+
count: number;
+
lastUpdated: number;
+
}
+
+
@customElement("classes-overview")
+
export class ClassesOverview extends LitElement {
+
@state() classes: ClassStats[] = [];
+
@state() uncategorizedCount = 0;
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
h1 {
+
color: var(--text);
+
margin-bottom: 2rem;
+
}
+
+
.classes-grid {
+
display: grid;
+
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
+
gap: 1.5rem;
+
margin-top: 2rem;
+
}
+
+
.class-card {
+
background: var(--background);
+
border: 1px solid var(--secondary);
+
border-radius: 8px;
+
padding: 1.5rem;
+
cursor: pointer;
+
transition: all 0.2s;
+
text-decoration: none;
+
color: var(--text);
+
display: block;
+
}
+
+
.class-card:hover {
+
border-color: var(--accent);
+
transform: translateY(-2px);
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+
}
+
+
.class-name {
+
font-size: 1.25rem;
+
font-weight: 600;
+
margin-bottom: 0.5rem;
+
color: var(--text);
+
}
+
+
.class-stats {
+
font-size: 0.875rem;
+
color: var(--paynes-gray);
+
}
+
+
.class-count {
+
font-weight: 500;
+
color: var(--accent);
+
}
+
+
.upload-section {
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
border: 2px dashed var(--accent);
+
border-radius: 8px;
+
padding: 2rem;
+
margin-bottom: 2rem;
+
text-align: center;
+
}
+
+
.upload-button {
+
background: var(--accent);
+
color: var(--white);
+
border: none;
+
padding: 0.75rem 1.5rem;
+
border-radius: 4px;
+
font-size: 1rem;
+
font-weight: 600;
+
cursor: pointer;
+
transition: opacity 0.2s;
+
}
+
+
.upload-button:hover {
+
opacity: 0.9;
+
}
+
+
.empty-state {
+
text-align: center;
+
padding: 4rem 2rem;
+
color: var(--paynes-gray);
+
}
+
+
.empty-state h2 {
+
color: var(--text);
+
margin-bottom: 1rem;
+
}
+
`;
+
+
override async connectedCallback() {
+
super.connectedCallback();
+
await this.loadClasses();
+
+
window.addEventListener("auth-changed", this.handleAuthChange);
+
}
+
+
override disconnectedCallback() {
+
super.disconnectedCallback();
+
window.removeEventListener("auth-changed", this.handleAuthChange);
+
}
+
+
private handleAuthChange = async () => {
+
await this.loadClasses();
+
};
+
+
private async loadClasses() {
+
try {
+
const response = await fetch("/api/transcriptions");
+
if (!response.ok) {
+
if (response.status === 401) {
+
this.classes = [];
+
this.uncategorizedCount = 0;
+
return;
+
}
+
throw new Error("Failed to load classes");
+
}
+
+
const data = await response.json();
+
const jobs = data.jobs || [];
+
+
// Group by class and count
+
const classMap = new Map<
+
string,
+
{ count: number; lastUpdated: number }
+
>();
+
let uncategorized = 0;
+
+
for (const job of jobs) {
+
const className = job.class_name;
+
if (!className) {
+
uncategorized++;
+
} else {
+
const existing = classMap.get(className);
+
if (existing) {
+
existing.count++;
+
existing.lastUpdated = Math.max(
+
existing.lastUpdated,
+
job.created_at,
+
);
+
} else {
+
classMap.set(className, {
+
count: 1,
+
lastUpdated: job.created_at,
+
});
+
}
+
}
+
}
+
+
this.uncategorizedCount = uncategorized;
+
this.classes = Array.from(classMap.entries())
+
.map(([name, stats]) => ({
+
name,
+
count: stats.count,
+
lastUpdated: stats.lastUpdated,
+
}))
+
.sort((a, b) => b.lastUpdated - a.lastUpdated);
+
} catch (error) {
+
console.error("Failed to load classes:", error);
+
}
+
}
+
+
private navigateToUpload() {
+
window.location.href = "/transcribe";
+
}
+
+
private formatDate(timestamp: number): string {
+
const date = new Date(timestamp * 1000);
+
const now = new Date();
+
const diffMs = now.getTime() - date.getTime();
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+
if (diffDays === 0) {
+
return "Today";
+
}
+
if (diffDays === 1) {
+
return "Yesterday";
+
}
+
if (diffDays < 7) {
+
return `${diffDays} days ago`;
+
}
+
return date.toLocaleDateString();
+
}
+
+
override render() {
+
const hasClasses = this.classes.length > 0 || this.uncategorizedCount > 0;
+
+
return html`
+
<h1>Your Classes</h1>
+
+
<div class="upload-section">
+
<button class="upload-button" @click=${this.navigateToUpload}>
+
📤 Upload New Transcription
+
</button>
+
</div>
+
+
${
+
hasClasses
+
? html`
+
<div class="classes-grid">
+
${this.classes.map(
+
(classInfo) => html`
+
<a class="class-card" href="/class/${encodeURIComponent(classInfo.name)}">
+
<div class="class-name">${classInfo.name}</div>
+
<div class="class-stats">
+
<span class="class-count">${classInfo.count}</span>
+
${classInfo.count === 1 ? "transcription" : "transcriptions"}
+
• ${this.formatDate(classInfo.lastUpdated)}
+
</div>
+
</a>
+
`,
+
)}
+
+
${
+
this.uncategorizedCount > 0
+
? html`
+
<a class="class-card" href="/class/uncategorized">
+
<div class="class-name">Uncategorized</div>
+
<div class="class-stats">
+
<span class="class-count">${this.uncategorizedCount}</span>
+
${this.uncategorizedCount === 1 ? "transcription" : "transcriptions"}
+
</div>
+
</a>
+
`
+
: ""
+
}
+
</div>
+
`
+
: html`
+
<div class="empty-state">
+
<h2>No transcriptions yet</h2>
+
<p>Upload your first audio file to get started!</p>
+
</div>
+
`
+
}
+
`;
+
}
+
}
+67 -28
src/components/transcript-view-modal.ts
···
-
import { LitElement, html, css } from "lit";
+
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "./vtt-viewer.ts";
···
// Fetch transcript details
const [detailsRes, vttRes] = await Promise.all([
fetch(`/api/admin/transcriptions/${this.transcriptId}/details`),
-
fetch(`/api/transcriptions/${this.transcriptId}?format=vtt`).catch(() => null),
+
fetch(`/api/transcriptions/${this.transcriptId}?format=vtt`).catch(
+
() => null,
+
),
]);
if (!detailsRes.ok) {
···
vtt_content: vttContent,
};
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load transcript details";
+
this.error =
+
err instanceof Error
+
? err.message
+
: "Failed to load transcript details";
this.transcript = null;
} finally {
this.loading = false;
···
private close() {
this.stopAudioPlayback();
-
this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true }));
+
this.dispatchEvent(
+
new CustomEvent("close", { bubbles: true, composed: true }),
+
);
}
private formatTimestamp(timestamp: number) {
···
private stopAudioPlayback() {
try {
// stop audio inside this modal's shadow root
-
const aud = this.shadowRoot?.querySelector('audio') as HTMLAudioElement | null;
+
const aud = this.shadowRoot?.querySelector(
+
"audio",
+
) as HTMLAudioElement | null;
if (aud) {
aud.pause();
-
try { aud.currentTime = 0; } catch (e) { /* ignore */ }
+
try {
+
aud.currentTime = 0;
+
} catch (_e) {
+
/* ignore */
+
}
}
// Also stop any audio elements in light DOM that match the transcript audio id
···
const outside = document.getElementById(id) as HTMLAudioElement | null;
if (outside && outside !== aud) {
outside.pause();
-
try { outside.currentTime = 0; } catch (e) { /* ignore */ }
+
try {
+
outside.currentTime = 0;
+
} catch (_e) {
+
/* ignore */
+
}
}
}
-
} catch (e) {
+
} catch (_e) {
// ignore
}
}
private async handleDelete() {
-
if (!confirm("Are you sure you want to delete this transcription? This cannot be undone.")) {
+
if (
+
!confirm(
+
"Are you sure you want to delete this transcription? This cannot be undone.",
+
)
+
) {
return;
}
try {
-
const res = await fetch(`/api/admin/transcriptions/${this.transcriptId}`, {
-
method: "DELETE",
-
});
+
const res = await fetch(
+
`/api/admin/transcriptions/${this.transcriptId}`,
+
{
+
method: "DELETE",
+
},
+
);
if (!res.ok) {
throw new Error("Failed to delete transcription");
}
-
this.dispatchEvent(new CustomEvent("transcript-deleted", { bubbles: true, composed: true }));
+
this.dispatchEvent(
+
new CustomEvent("transcript-deleted", {
+
bubbles: true,
+
composed: true,
+
}),
+
);
this.close();
} catch {
alert("Failed to delete transcription");
···
${this.error ? html`<div class="error">${this.error}</div>` : ""}
${this.transcript ? this.renderTranscriptDetails() : ""}
</div>
-
${this.transcript
-
? html`
+
${
+
this.transcript
+
? html`
<div class="modal-footer">
<button class="btn-danger" @click=${this.handleDelete}>Delete Transcription</button>
</div>
`
-
: ""}
+
: ""
+
}
</div>
`;
}
···
<span class="detail-label">Created At</span>
<span class="detail-value">${this.formatTimestamp(this.transcript.created_at)}</span>
</div>
-
${this.transcript.completed_at
-
? html`
+
${
+
this.transcript.completed_at
+
? html`
<div class="detail-row">
<span class="detail-label">Completed At</span>
<span class="detail-value">${this.formatTimestamp(this.transcript.completed_at)}</span>
</div>
`
-
: ""}
-
${this.transcript.error_message
-
? html`
+
: ""
+
}
+
${
+
this.transcript.error_message
+
? html`
<div class="detail-row">
<span class="detail-label">Error Message</span>
<span class="detail-value" style="color: #dc2626;">${this.transcript.error_message}</span>
</div>
`
-
: ""}
+
: ""
+
}
</div>
<div class="detail-section">
···
</div>
</div>
-
${this.transcript.status === "completed"
-
? html`
+
${
+
this.transcript.status === "completed"
+
? html`
<div class="detail-section">
<h3 class="detail-section-title">Audio</h3>
<div class="audio-player">
···
</div>
</div>
`
-
: ""}
+
: ""
+
}
<div class="detail-section">
<h3 class="detail-section-title">Transcript</h3>
-
${this.transcript.status === "completed" && this.transcript.vtt_content
-
? html`<vtt-viewer .vttContent=${this.transcript.vtt_content ?? ""} .audioId=${`audio-${this.transcript.id}`}></vtt-viewer>`
-
: html`<div class="transcript-text">${this.transcript.vtt_content || "No transcript available"}</div>`}
+
${
+
this.transcript.status === "completed" && this.transcript.vtt_content
+
? html`<vtt-viewer .vttContent=${this.transcript.vtt_content ?? ""} .audioId=${`audio-${this.transcript.id}`}></vtt-viewer>`
+
: html`<div class="transcript-text">${this.transcript.vtt_content || "No transcript available"}</div>`
+
}
</div>
`;
}
+208 -80
src/components/transcription.ts
···
interface TranscriptionJob {
id: string;
filename: string;
+
class_name?: string;
status: "uploading" | "processing" | "transcribing" | "completed" | "failed";
progress: number;
transcript?: string;
···
text: string;
index?: string;
}
-
-
-
-
class WordStreamer {
private queue: string[] = [];
···
this.isProcessing = true;
while (this.queue.length > 0) {
-
const word = this.queue.shift()!;
+
const word = this.queue.shift();
+
if (!word) break;
this.onWord(word);
await new Promise((resolve) => setTimeout(resolve, this.wordDelay));
}
···
showAll() {
// Drain entire queue immediately
while (this.queue.length > 0) {
-
const word = this.queue.shift()!;
+
const word = this.queue.shift();
+
if (!word) break;
this.onWord(word);
}
this.isProcessing = false;
···
@state() isUploading = false;
@state() dragOver = false;
@state() serviceAvailable = true;
+
@state() existingClasses: string[] = [];
+
@state() showNewClassInput = false;
// Word streamers for each job
private wordStreamers = new Map<string, WordStreamer>();
// Displayed transcripts
···
.file-input {
display: none;
}
+
+
.upload-form {
+
margin-top: 1rem;
+
display: flex;
+
flex-direction: column;
+
gap: 0.75rem;
+
}
+
+
.class-input {
+
padding: 0.5rem 0.75rem;
+
border: 1px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 0.875rem;
+
color: var(--text);
+
background: var(--background);
+
}
+
+
.class-input:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.class-input::placeholder {
+
color: var(--paynes-gray);
+
opacity: 0.6;
+
}
+
+
.class-select {
+
width: 100%;
+
padding: 0.5rem 0.75rem;
+
border: 1px solid var(--secondary);
+
border-radius: 4px;
+
font-size: 0.875rem;
+
color: var(--text);
+
background: var(--background);
+
cursor: pointer;
+
}
+
+
.class-select:focus {
+
outline: none;
+
border-color: var(--primary);
+
}
+
+
.class-select option {
+
padding: 0.5rem;
+
}
+
+
.class-group {
+
margin-bottom: 2rem;
+
}
+
+
.class-header {
+
font-size: 1.25rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
padding-bottom: 0.5rem;
+
border-bottom: 2px solid var(--accent);
+
}
+
+
.no-class-header {
+
border-bottom-color: var(--secondary);
+
}
`;
private eventSources: Map<string, EventSource> = new Map();
private handleAuthChange = async () => {
await this.checkHealth();
await this.loadJobs();
+
await this.loadExistingClasses();
this.connectToJobStreams();
};
+
private async loadExistingClasses() {
+
try {
+
const response = await fetch("/api/transcriptions");
+
if (!response.ok) {
+
this.existingClasses = [];
+
return;
+
}
+
+
const data = await response.json();
+
const jobs = data.jobs || [];
+
+
// Extract unique class names
+
const classSet = new Set<string>();
+
for (const job of jobs) {
+
if (job.class_name) {
+
classSet.add(job.class_name);
+
}
+
}
+
+
this.existingClasses = Array.from(classSet).sort();
+
} catch (error) {
+
console.error("Failed to load classes:", error);
+
this.existingClasses = [];
+
}
+
}
+
override async connectedCallback() {
super.connectedCallback();
await this.checkHealth();
await this.loadJobs();
+
await this.loadExistingClasses();
this.connectToJobStreams();
// Listen for auth changes to reload jobs
···
es.close();
}
this.eventSources.clear();
-
+
for (const streamer of this.wordStreamers.values()) {
streamer.clear();
}
this.wordStreamers.clear();
this.displayedTranscripts.clear();
this.lastTranscripts.clear();
-
+
window.removeEventListener("auth-changed", this.handleAuthChange);
}
···
if (update.progress !== undefined) job.progress = update.progress;
if (update.transcript !== undefined) {
job.transcript = update.transcript;
-
+
// Get or create word streamer for this job
if (!this.wordStreamers.has(jobId)) {
const streamer = new WordStreamer(50, (word) => {
···
});
this.wordStreamers.set(jobId, streamer);
}
-
-
const streamer = this.wordStreamers.get(jobId)!;
+
+
const streamer = this.wordStreamers.get(jobId);
+
if (!streamer) return;
const lastTranscript = this.lastTranscripts.get(jobId) || "";
const newTranscript = update.transcript;
-
+
// Check if this is new content we haven't seen
if (newTranscript !== lastTranscript) {
// If new transcript starts with last transcript, it's cumulative - add diff
···
}
this.lastTranscripts.set(jobId, newTranscript);
}
-
+
// On completion, show everything immediately
if (update.status === "completed") {
streamer.showAll();
···
if (update.status === "completed" || update.status === "failed") {
eventSource.close();
this.eventSources.delete(jobId);
-
+
// Clean up streamer
const streamer = this.wordStreamers.get(jobId);
if (streamer) {
···
this.wordStreamers.delete(jobId);
}
this.lastTranscripts.delete(jobId);
-
+
// Load VTT for completed jobs
if (update.status === "completed") {
await this.loadVTTForJob(jobId);
···
if (response.ok) {
const data = await response.json();
this.jobs = data.jobs;
-
+
// Initialize displayedTranscripts for completed/failed jobs
for (const job of this.jobs) {
-
if ((job.status === "completed" || job.status === "failed") && job.transcript) {
+
if (
+
(job.status === "completed" || job.status === "failed") &&
+
job.transcript
+
) {
this.displayedTranscripts.set(job.id, job.transcript);
}
-
+
// Fetch VTT for completed jobs
if (job.status === "completed") {
await this.loadVTTForJob(job.id);
···
}
}
-
-
private handleDragOver(e: DragEvent) {
e.preventDefault();
this.dragOver = true;
···
}
}
+
private handleClassSelectChange(e: Event) {
+
const select = e.target as HTMLSelectElement;
+
this.showNewClassInput = select.value === "__new__";
+
}
+
private async uploadFile(file: File) {
const allowedTypes = [
"audio/mpeg", // MP3
···
this.isUploading = true;
try {
+
// Get class name from dropdown or input
+
let className = "";
+
+
if (this.showNewClassInput) {
+
const classInput = this.shadowRoot?.querySelector(
+
"#class-name-input",
+
) as HTMLInputElement;
+
className = classInput?.value?.trim() || "";
+
} else {
+
const classSelect = this.shadowRoot?.querySelector(
+
"#class-select",
+
) as HTMLSelectElement;
+
const selectedValue = classSelect?.value;
+
if (
+
selectedValue &&
+
selectedValue !== "__new__" &&
+
selectedValue !== ""
+
) {
+
className = selectedValue;
+
}
+
}
+
const formData = new FormData();
formData.append("audio", file);
+
if (className) {
+
formData.append("class_name", className);
+
}
const response = await fetch("/api/transcriptions", {
method: "POST",
···
"Upload failed - transcription service may be unavailable",
);
} else {
-
const result = await response.json();
-
await this.loadJobs();
-
// Connect to SSE stream for this new job
-
this.connectToJobStream(result.id);
+
await response.json();
+
// Redirect to class page after successful upload
+
let className = "";
+
+
if (this.showNewClassInput) {
+
const classInput = this.shadowRoot?.querySelector(
+
"#class-name-input",
+
) as HTMLInputElement;
+
className = classInput?.value?.trim() || "";
+
} else {
+
const classSelect = this.shadowRoot?.querySelector(
+
"#class-select",
+
) as HTMLSelectElement;
+
const selectedValue = classSelect?.value;
+
if (
+
selectedValue &&
+
selectedValue !== "__new__" &&
+
selectedValue !== ""
+
) {
+
className = selectedValue;
+
}
+
}
+
+
if (className) {
+
window.location.href = `/class/${encodeURIComponent(className)}`;
+
} else {
+
window.location.href = "/class/uncategorized";
+
}
}
} catch {
alert("Upload failed - transcription service may be unavailable");
···
}
}
-
private getStatusClass(status: string) {
-
return `status-${status}`;
-
}
-
-
private renderTranscript(job: TranscriptionJob) {
-
if (!job.vttContent) {
-
const displayed = this.displayedTranscripts.get(job.id) || "";
-
return displayed;
-
}
-
-
// Delegate VTT rendering and highlighting to the vtt-viewer component
-
return html`<vtt-viewer .vttContent=${job.vttContent ?? ""} .audioId=${`audio-${job.id}`}></vtt-viewer>`;
-
}
-
-
-
override render() {
return html`
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}"
···
<input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!this.serviceAvailable ? "disabled" : ""} />
</div>
-
<div class="jobs-section ${this.jobs.length === 0 ? "hidden" : ""}">
-
<h3 class="jobs-title">Your Transcriptions</h3>
-
${this.jobs.map(
-
(job) => html`
-
<div class="job-card">
-
<div class="job-header">
-
<span class="job-filename">${job.filename}</span>
-
<span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span>
-
</div>
-
-
${
-
job.status === "uploading" ||
-
job.status === "processing" ||
-
job.status === "transcribing"
-
? html`
-
<div class="progress-bar">
-
<div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}" style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div>
-
</div>
-
`
-
: ""
-
}
-
-
${
-
job.status === "completed" && job.audioUrl && job.vttContent
-
? html`
-
<div class="audio-player">
-
<audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
-
</div>
-
${this.renderTranscript(job)}
-
`
-
: this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
-
? html`
-
<div class="job-transcript">${this.renderTranscript(job)}</div>
-
`
-
: ""
-
}
-
</div>
-
`,
-
)}
-
</div>
+
${
+
this.serviceAvailable
+
? html`
+
<div class="upload-form">
+
<select
+
id="class-select"
+
class="class-select"
+
?disabled=${this.isUploading}
+
@change=${this.handleClassSelectChange}
+
>
+
<option value="">Select a class (optional)</option>
+
${this.existingClasses.map(
+
(className) => html`
+
<option value=${className}>${className}</option>
+
`,
+
)}
+
<option value="__new__">+ Add new class</option>
+
</select>
+
+
${
+
this.showNewClassInput
+
? html`
+
<input
+
type="text"
+
id="class-name-input"
+
class="class-input"
+
placeholder="Enter new class name"
+
?disabled=${this.isUploading}
+
/>
+
`
+
: ""
+
}
+
</div>
+
`
+
: ""
+
}
`;
}
}
+56 -19
src/components/user-modal.ts
···
-
import { LitElement, html, css } from "lit";
+
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
interface Session {
···
this.user = await res.json();
} catch (err) {
-
this.error = err instanceof Error ? err.message : "Failed to load user details";
+
this.error =
+
err instanceof Error ? err.message : "Failed to load user details";
this.user = null;
} finally {
this.loading = false;
···
}
private close() {
-
this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true }));
+
this.dispatchEvent(
+
new CustomEvent("close", { bubbles: true, composed: true }),
+
);
}
private formatTimestamp(timestamp: number) {
···
return;
}
-
const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+
const submitBtn = form.querySelector(
+
'button[type="submit"]',
+
) as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "Updating...";
···
alert("Name updated successfully");
await this.loadUserDetails();
-
this.dispatchEvent(new CustomEvent("user-updated", { bubbles: true, composed: true }));
+
this.dispatchEvent(
+
new CustomEvent("user-updated", { bubbles: true, composed: true }),
+
);
} catch {
alert("Failed to update name");
} finally {
···
return;
}
-
const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+
const submitBtn = form.querySelector(
+
'button[type="submit"]',
+
) as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "Updating...";
···
alert("Email updated successfully");
await this.loadUserDetails();
-
this.dispatchEvent(new CustomEvent("user-updated", { bubbles: true, composed: true }));
+
this.dispatchEvent(
+
new CustomEvent("user-updated", { bubbles: true, composed: true }),
+
);
} catch (error) {
alert(error instanceof Error ? error.message : "Failed to update email");
} finally {
···
return;
}
-
if (!confirm("Are you sure you want to change this user's password? This will log them out of all devices.")) {
+
if (
+
!confirm(
+
"Are you sure you want to change this user's password? This will log them out of all devices.",
+
)
+
) {
return;
}
-
const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+
const submitBtn = form.querySelector(
+
'button[type="submit"]',
+
) as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = "Updating...";
···
throw new Error("Failed to update password");
}
-
alert("Password updated successfully. User has been logged out of all devices.");
+
alert(
+
"Password updated successfully. User has been logged out of all devices.",
+
);
input.value = "";
await this.loadUserDetails();
} catch {
···
}
private async handleLogoutAll() {
-
if (!confirm("Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.")) {
+
if (
+
!confirm(
+
"Are you sure you want to logout this user from ALL devices? This will terminate all active sessions.",
+
)
+
) {
return;
}
···
}
private async handleRevokeSession(sessionId: string) {
-
if (!confirm("Revoke this session? The user will be logged out of this device.")) {
+
if (
+
!confirm(
+
"Revoke this session? The user will be logged out of this device.",
+
)
+
) {
return;
}
try {
-
const res = await fetch(`/api/admin/users/${this.userId}/sessions/${sessionId}`, {
-
method: "DELETE",
-
});
+
const res = await fetch(
+
`/api/admin/users/${this.userId}/sessions/${sessionId}`,
+
{
+
method: "DELETE",
+
},
+
);
if (!res.ok) {
throw new Error("Failed to revoke session");
···
}
private async handleRevokePasskey(passkeyId: string) {
-
if (!confirm("Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.")) {
+
if (
+
!confirm(
+
"Are you sure you want to revoke this passkey? The user will no longer be able to use it to sign in.",
+
)
+
) {
return;
}
try {
-
const res = await fetch(`/api/admin/users/${this.userId}/passkeys/${passkeyId}`, {
-
method: "DELETE",
-
});
+
const res = await fetch(
+
`/api/admin/users/${this.userId}/passkeys/${passkeyId}`,
+
{
+
method: "DELETE",
+
},
+
);
if (!res.ok) {
throw new Error("Failed to revoke passkey");
+5 -8
src/components/user-settings.ts
···
import { customElement, state } from "lit/decorators.js";
import { UAParser } from "ua-parser-js";
import { hashPasswordClient } from "../lib/client-auth";
-
import {
-
isPasskeySupported,
-
registerPasskey,
-
} from "../lib/client-passkey";
+
import { isPasskeySupported, registerPasskey } from "../lib/client-passkey";
interface User {
email: string;
···
</div>
<div style="margin-top: 1rem;">
${
-
session.is_current
-
? html`
+
session.is_current
+
? html`
<button
class="btn btn-rejection"
@click=${this.handleLogout}
···
Logout
</button>
-
: html`
+
: html`
<button
class="btn btn-rejection"
@click=${() => this.handleKillSession(session.id)}
···
Kill Session
</button>
-
}
+
}
</div>
</div>
`,
+102 -62
src/components/vtt-viewer.ts
···
-
import { LitElement, html, css } from "lit";
+
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
interface VTTSegment {
···
`;
private audioElement: HTMLAudioElement | null = null;
-
private boundTimeUpdate: ((this: HTMLAudioElement, ev: Event) => any) | null = null;
-
private boundTranscriptClick: ((e: Event) => any) | null = null;
-
-
private _viewerId = `vtt-${Math.random().toString(36).slice(2,9)}`;
+
private boundTimeUpdate:
+
| ((this: HTMLAudioElement, ev: Event) => void)
+
| null = null;
+
private boundTranscriptClick: ((e: Event) => void) | null = null;
private findAudioElementById(id: string): HTMLAudioElement | null {
-
let root: any = this.getRootNode();
+
let root: Node | Document = this.getRootNode();
let depth = 0;
while (root && depth < 10) {
if (root instanceof ShadowRoot) {
···
this.detachHighlighting();
const audioElement = this.findAudioElementById(this.audioId);
-
const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null;
+
const transcriptDiv = this.shadowRoot?.querySelector(
+
".transcript",
+
) as HTMLDivElement | null;
if (!audioElement || !transcriptDiv) return;
// Clear any lingering highlights from prior instances
-
transcriptDiv.querySelectorAll('.current-segment').forEach((el) => { (el as HTMLElement).classList.remove('current-segment'); });
+
transcriptDiv.querySelectorAll(".current-segment").forEach((el) => {
+
(el as HTMLElement).classList.remove("current-segment");
+
});
this.audioElement = audioElement;
let currentSegmentElement: HTMLElement | null = null;
this.boundTimeUpdate = () => {
const currentTime = this.audioElement?.currentTime ?? 0;
-
const segmentElements = transcriptDiv.querySelectorAll('[data-start]');
+
const segmentElements = transcriptDiv.querySelectorAll("[data-start]");
let found = false;
for (const el of Array.from(segmentElements)) {
-
const start = Number.parseFloat((el as HTMLElement).dataset.start || '0');
-
const end = Number.parseFloat((el as HTMLElement).dataset.end || '0');
+
const start = Number.parseFloat(
+
(el as HTMLElement).dataset.start || "0",
+
);
+
const end = Number.parseFloat((el as HTMLElement).dataset.end || "0");
if (currentTime >= start && currentTime <= end) {
found = true;
if (currentSegmentElement !== el) {
-
currentSegmentElement?.classList.remove('current-segment');
-
(el as HTMLElement).classList.add('current-segment');
+
currentSegmentElement?.classList.remove("current-segment");
+
(el as HTMLElement).classList.add("current-segment");
currentSegmentElement = el as HTMLElement;
-
(el as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
+
(el as HTMLElement).scrollIntoView({
+
behavior: "smooth",
+
block: "center",
+
});
}
break;
}
···
// If no segment matched, clear any existing highlight
if (!found && currentSegmentElement) {
-
currentSegmentElement.classList.remove('current-segment');
+
currentSegmentElement.classList.remove("current-segment");
currentSegmentElement = null;
}
};
-
audioElement.addEventListener('timeupdate', this.boundTimeUpdate as EventListener);
+
audioElement.addEventListener(
+
"timeupdate",
+
this.boundTimeUpdate as EventListener,
+
);
this.boundTranscriptClick = (e: Event) => {
const target = e.target as HTMLElement;
-
if (target.dataset.start) {
-
this.audioElement!.currentTime = Number.parseFloat(target.dataset.start);
-
this.audioElement!.play();
+
if (target.dataset.start && this.audioElement) {
+
this.audioElement.currentTime = Number.parseFloat(target.dataset.start);
+
this.audioElement.play();
}
};
-
transcriptDiv.addEventListener('click', this.boundTranscriptClick);
+
transcriptDiv.addEventListener("click", this.boundTranscriptClick);
}
private detachHighlighting() {
try {
-
const transcriptDiv = this.shadowRoot?.querySelector('.transcript') as HTMLDivElement | null;
+
const transcriptDiv = this.shadowRoot?.querySelector(
+
".transcript",
+
) as HTMLDivElement | null;
if (this.audioElement) {
// Pause playback to avoid audio continuing after the viewer is removed
try {
this.audioElement.pause();
-
} catch (e) {
+
} catch (_e) {
// ignore
}
if (this.boundTimeUpdate) {
-
this.audioElement.removeEventListener('timeupdate', this.boundTimeUpdate);
+
this.audioElement.removeEventListener(
+
"timeupdate",
+
this.boundTimeUpdate,
+
);
}
}
if (transcriptDiv && this.boundTranscriptClick) {
-
transcriptDiv.removeEventListener('click', this.boundTranscriptClick);
+
transcriptDiv.removeEventListener("click", this.boundTranscriptClick);
}
} finally {
this.audioElement = null;
···
override disconnectedCallback() {
this.detachHighlighting();
-
super.disconnectedCallback && super.disconnectedCallback();
+
super.disconnectedCallback?.();
}
-
override updated(changed: Map<string, any>) {
+
override updated(changed: Map<string, unknown>) {
super.updated(changed);
-
if (changed.has('vttContent') || changed.has('audioId')) {
+
if (changed.has("vttContent") || changed.has("audioId")) {
this.setupHighlighting();
}
}
···
for (const segment of segments) {
const id = (segment.index || "").trim();
const match = id.match(/^Paragraph\s+(\d+)-/);
-
const paraNum = match ? match[1] : '0';
+
const paraNum = match?.[1] ?? "0";
if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []);
-
paragraphGroups.get(paraNum)!.push(segment);
+
const group = paragraphGroups.get(paraNum);
+
if (group) group.push(segment);
}
-
const paragraphs = Array.from(paragraphGroups.entries()).map(([_, groupSegments]) => {
-
const fullText = groupSegments.map(s => s.text || '').join(' ');
-
const sentences = fullText.split(/(?<=[\.\!\?])\s+/g).filter(Boolean);
-
const wordCounts = sentences.map((s) => s.split(/\s+/).filter(Boolean).length);
-
const totalWords = Math.max(1, wordCounts.reduce((a, b) => a + b, 0));
-
const paraStart = Math.min(...groupSegments.map(s => s.start ?? 0));
-
const paraEnd = Math.max(...groupSegments.map(s => s.end ?? paraStart));
-
let acc = 0;
-
const paraDuration = paraEnd - paraStart;
+
const paragraphs = Array.from(paragraphGroups.entries()).map(
+
([_, groupSegments]) => {
+
const fullText = groupSegments.map((s) => s.text || "").join(" ");
+
const sentences = fullText.split(/(?<=[.!?])\s+/g).filter(Boolean);
+
const wordCounts = sentences.map(
+
(s) => s.split(/\s+/).filter(Boolean).length,
+
);
+
const totalWords = Math.max(
+
1,
+
wordCounts.reduce((a, b) => a + b, 0),
+
);
+
const paraStart = Math.min(...groupSegments.map((s) => s.start ?? 0));
+
const paraEnd = Math.max(
+
...groupSegments.map((s) => s.end ?? paraStart),
+
);
+
let acc = 0;
+
const paraDuration = paraEnd - paraStart;
-
return html`<div class="paragraph">${sentences.map((sent, si) => {
-
const startOffset = (acc / totalWords) * paraDuration;
-
acc += wordCounts[si];
-
const sentenceDuration = (wordCounts[si] / totalWords) * paraDuration;
-
const endOffset = si < sentences.length - 1 ? startOffset + sentenceDuration - 0.001 : paraEnd - paraStart;
-
const spanStart = paraStart + startOffset;
-
const spanEnd = paraStart + endOffset;
-
return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? ' ' : ''}`;
-
})}</div>`;
-
});
+
return html`<div class="paragraph">${sentences.map((sent, si) => {
+
const wordCount = wordCounts[si];
+
if (wordCount === undefined) return "";
+
+
const startOffset = (acc / totalWords) * paraDuration;
+
acc += wordCount;
+
const sentenceDuration = (wordCount / totalWords) * paraDuration;
+
const endOffset =
+
si < sentences.length - 1
+
? startOffset + sentenceDuration - 0.001
+
: paraEnd - paraStart;
+
const spanStart = paraStart + startOffset;
+
const spanEnd = paraStart + endOffset;
+
return html`<span class="segment" data-start="${spanStart}" data-end="${spanEnd}">${sent}</span>${si < sentences.length - 1 ? " " : ""}`;
+
})}</div>`;
+
},
+
);
return html`${paragraphs}`;
}
···
for (const s of segments) {
const id = (s.index || "").trim();
const match = id.match(/^Paragraph\s+(\d+)-/);
-
const paraNum = match ? match[1] : '0';
+
const paraNum = match?.[1] ?? "0";
if (!paragraphGroups.has(paraNum)) paragraphGroups.set(paraNum, []);
-
paragraphGroups.get(paraNum)!.push(s.text || '');
+
const group = paragraphGroups.get(paraNum);
+
if (group) group.push(s.text || "");
}
-
const paragraphs = Array.from(paragraphGroups.values()).map(group => group.join(' ').replace(/\s+/g, ' ').trim());
-
return paragraphs.join('\n\n').trim();
+
const paragraphs = Array.from(paragraphGroups.values()).map((group) =>
+
group.join(" ").replace(/\s+/g, " ").trim(),
+
);
+
return paragraphs.join("\n\n").trim();
}
private async copyTranscript(e?: Event) {
-
e && e.stopPropagation();
+
e?.stopPropagation();
const text = this.extractPlainText();
if (!text) return;
try {
-
if (navigator && (navigator as any).clipboard && (navigator as any).clipboard.writeText) {
-
await (navigator as any).clipboard.writeText(text);
+
if (navigator?.clipboard?.writeText) {
+
await navigator.clipboard.writeText(text);
} else {
// Fallback
-
const ta = document.createElement('textarea');
+
const ta = document.createElement("textarea");
ta.value = text;
-
ta.style.position = 'fixed';
-
ta.style.opacity = '0';
+
ta.style.position = "fixed";
+
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
-
document.execCommand('copy');
+
document.execCommand("copy");
document.body.removeChild(ta);
}
-
const btn = this.shadowRoot?.querySelector('.copy-btn') as HTMLButtonElement | null;
+
const btn = this.shadowRoot?.querySelector(
+
".copy-btn",
+
) as HTMLButtonElement | null;
if (btn) {
const orig = btn.innerText;
-
btn.innerText = 'Copied!';
-
setTimeout(() => { btn.innerText = orig; }, 1500);
+
btn.innerText = "Copied!";
+
setTimeout(() => {
+
btn.innerText = orig;
+
}, 1500);
}
} catch {
// ignore
+8
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login);
`,
},
+
{
+
version: 9,
+
name: "Add class_name to transcriptions",
+
sql: `
+
ALTER TABLE transcriptions ADD COLUMN class_name TEXT;
+
CREATE INDEX IF NOT EXISTS idx_transcriptions_class_name ON transcriptions(class_name);
+
`,
+
},
];
function getCurrentVersion(): number {
+39 -19
src/index.ts
···
deleteTranscription,
deleteUser,
getAllTranscriptions,
-
getAllUsers,
getAllUsersWithStats,
getSession,
getSessionFromRequest,
getSessionsForUser,
getUserBySession,
getUserSessionsForUser,
+
type UserRole,
updateUserAvatar,
updateUserEmail,
updateUserEmailAddress,
updateUserName,
updateUserPassword,
updateUserRole,
-
type UserRole,
} from "./lib/auth";
+
import { handleError, ValidationErrors } from "./lib/errors";
+
import { requireAdmin, requireAuth } from "./lib/middleware";
import {
createAuthenticationOptions,
createRegistrationOptions,
···
verifyAndAuthenticatePasskey,
verifyAndCreatePasskey,
} from "./lib/passkey";
-
import { handleError, ValidationErrors } from "./lib/errors";
-
import { requireAdmin, requireAuth } from "./lib/middleware";
import { enforceRateLimit } from "./lib/rate-limit";
+
import { getTranscriptVTT } from "./lib/transcript-storage";
import {
MAX_FILE_SIZE,
TranscriptionEventEmitter,
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
-
import { getTranscriptVTT } from "./lib/transcript-storage";
+
import adminHTML from "./pages/admin.html";
+
import classHTML from "./pages/class.html";
+
import classesHTML from "./pages/classes.html";
import indexHTML from "./pages/index.html";
-
import adminHTML from "./pages/admin.html";
import settingsHTML from "./pages/settings.html";
import transcribeHTML from "./pages/transcribe.html";
···
"/admin": adminHTML,
"/settings": settingsHTML,
"/transcribe": transcribeHTML,
+
"/classes": classesHTML,
+
"/class/:className": classHTML,
"/api/auth/register": {
POST: async (req) => {
try {
···
"/api/passkeys/register/verify": {
POST: async (req) => {
try {
-
const user = requireAuth(req);
+
const _user = requireAuth(req);
const body = await req.json();
const { response: credentialResponse, challenge, name } = body;
···
// return info on transcript
const transcript = {
-
id: transcription.id,
-
filename: transcription.original_filename,
-
status: transcription.status,
-
progress: transcription.progress,
-
created_at: transcription.created_at,
-
}
+
id: transcription.id,
+
filename: transcription.original_filename,
+
status: transcription.status,
+
progress: transcription.progress,
+
created_at: transcription.created_at,
+
};
return new Response(JSON.stringify(transcript), {
headers: {
"Content-Type": "application/json",
···
id: string;
filename: string;
original_filename: string;
+
class_name: string | null;
status: string;
progress: number;
created_at: number;
},
[number]
>(
-
"SELECT id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
+
"SELECT id, filename, original_filename, class_name, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
)
.all(user.id);
···
return {
id: t.id,
filename: t.original_filename,
+
class_name: t.class_name,
status: t.status,
progress: t.progress,
created_at: t.created_at,
···
const formData = await req.formData();
const file = formData.get("audio") as File;
+
const className = formData.get("class_name") as string | null;
if (!file) throw ValidationErrors.missingField("audio");
···
const uploadDir = "./uploads";
await Bun.write(`${uploadDir}/${filename}`, file);
-
// Create database record
-
db.run(
-
"INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
-
[transcriptionId, user.id, filename, file.name, "uploading"],
-
);
+
// Create database record with optional class_name
+
if (className?.trim()) {
+
db.run(
+
"INSERT INTO transcriptions (id, user_id, filename, original_filename, class_name, status) VALUES (?, ?, ?, ?, ?, ?)",
+
[
+
transcriptionId,
+
user.id,
+
filename,
+
file.name,
+
className.trim(),
+
"uploading",
+
],
+
);
+
} else {
+
db.run(
+
"INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
+
[transcriptionId, user.id, filename, file.name, "uploading"],
+
);
+
}
// Start transcription in background
whisperService.startTranscription(transcriptionId, filename);
+1 -2
src/lib/admin.test.ts
···
-
import { afterEach, beforeEach, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
+
import { afterEach, beforeEach, expect, test } from "bun:test";
let testDb: Database;
···
.all(userId);
expect(sessions.length).toBe(0);
});
-
+6 -6
src/lib/auth.test.ts
···
-
import { test, expect } from "bun:test";
+
import { expect, test } from "bun:test";
+
import db from "../db/schema";
import {
createSession,
-
getSession,
deleteSession,
+
getSession,
getSessionFromRequest,
} from "./auth";
-
import db from "../db/schema";
test("createSession generates UUID and stores in database", () => {
const userId = 1;
···
}
// Verify sessions table still exists
-
const result = db
-
.query("SELECT COUNT(*) as count FROM sessions")
-
.get() as { count: number };
+
const result = db.query("SELECT COUNT(*) as count FROM sessions").get() as {
+
count: number;
+
};
expect(typeof result.count).toBe("number");
});
+9 -13
src/lib/auth.ts
···
last_login: number | null;
},
[]
-
>("SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC")
+
>(
+
"SELECT id, email, name, avatar, created_at, role, last_login FROM users ORDER BY created_at DESC",
+
)
.all();
}
···
.all(userId, now);
}
-
export function deleteSessionById(
-
sessionId: string,
-
userId: number,
-
): boolean {
-
const result = db.run(
-
"DELETE FROM sessions WHERE id = ? AND user_id = ?",
-
[sessionId, userId],
-
);
+
export function deleteSessionById(sessionId: string, userId: number): boolean {
+
const result = db.run("DELETE FROM sessions WHERE id = ? AND user_id = ?", [
+
sessionId,
+
userId,
+
]);
return result.changes > 0;
}
···
db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
}
-
export function updateUserEmailAddress(
-
userId: number,
-
newEmail: string,
-
): void {
+
export function updateUserEmailAddress(userId: number, newEmail: string): void {
db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]);
}
+1 -1
src/lib/client-auth.test.ts
···
-
import { test, expect } from "bun:test";
+
import { expect, test } from "bun:test";
import { hashPasswordClient } from "./client-auth";
test("hashPasswordClient produces consistent output", async () => {
+4 -3
src/lib/client-passkey.ts
···
} catch (err) {
return {
success: false,
-
error:
-
err instanceof Error ? err.message : "Failed to register passkey",
+
error: err instanceof Error ? err.message : "Failed to register passkey",
};
}
}
···
return {
success: false,
error:
-
err instanceof Error ? err.message : "Failed to authenticate with passkey",
+
err instanceof Error
+
? err.message
+
: "Failed to authenticate with passkey",
};
}
}
+16 -9
src/lib/passkey.ts
···
import {
generateAuthenticationOptions,
generateRegistrationOptions,
+
type VerifiedAuthenticationResponse,
+
type VerifiedRegistrationResponse,
verifyAuthenticationResponse,
verifyRegistrationResponse,
-
type VerifiedAuthenticationResponse,
-
type VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
···
// credential.publicKey is a Uint8Array that needs conversion
const passkeyId = crypto.randomUUID();
const credentialIdBase64 = credential.id;
-
const publicKeyBase64 = Buffer.from(credential.publicKey).toString("base64url");
+
const publicKeyBase64 = Buffer.from(credential.publicKey).toString(
+
"base64url",
+
);
const transports = response.response.transports?.join(",") || null;
db.run(
···
const options = await generateAuthenticationOptions({
rpID,
-
allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined,
+
allowCredentials:
+
allowCredentials.length > 0 ? allowCredentials : undefined,
userVerification: "preferred",
});
···
// Update last used timestamp and counter for passkey
const now = Math.floor(Date.now() / 1000);
-
db.run(
-
"UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?",
-
[now, verification.authenticationInfo.newCounter, passkey.id],
-
);
+
db.run("UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?", [
+
now,
+
verification.authenticationInfo.newCounter,
+
passkey.id,
+
]);
// Update user's last_login
-
db.run("UPDATE users SET last_login = ? WHERE id = ?", [now, passkey.user_id]);
+
db.run("UPDATE users SET last_login = ? WHERE id = ?", [
+
now,
+
passkey.user_id,
+
]);
// Get user
const user = db
+1 -1
src/lib/rate-limit.test.ts
···
import { expect, test } from "bun:test";
-
import { checkRateLimit, cleanupOldAttempts } from "./rate-limit";
import db from "../db/schema";
+
import { checkRateLimit, cleanupOldAttempts } from "./rate-limit";
// Clean up before tests
db.run("DELETE FROM rate_limit_attempts");
+3 -4
src/lib/transcript-storage.test.ts
···
import { expect, test } from "bun:test";
import {
+
deleteTranscript,
+
getTranscript,
getTranscriptVTT,
-
saveTranscriptVTT,
hasTranscript,
saveTranscript,
-
getTranscript,
-
deleteTranscript,
+
saveTranscriptVTT,
} from "./transcript-storage";
test("transcript storage", async () => {
···
// Clean up
await deleteTranscript(testId);
});
-
+11 -15
src/lib/transcription.ts
···
}
}
-
private streamWhisperJob(
-
transcriptionId: string,
-
jobId: string,
-
) {
+
private streamWhisperJob(transcriptionId: string, jobId: string) {
// Prevent duplicate streams using locks
if (this.streamLocks.has(transcriptionId)) {
return;
···
`${this.serviceUrl}/transcribe/${whisperJobId}?format=vtt`,
);
if (vttResponse.ok) {
-
const vttContent = await vttResponse.text();
-
const cleanedVTT = await cleanVTT(transcriptionId, vttContent);
-
await saveTranscriptVTT(transcriptionId, cleanedVTT);
-
this.updateTranscription(transcriptionId, {});
-
}
+
const vttContent = await vttResponse.text();
+
const cleanedVTT = await cleanVTT(transcriptionId, vttContent);
+
await saveTranscriptVTT(transcriptionId, cleanedVTT);
+
this.updateTranscription(transcriptionId, {});
+
}
} catch (error) {
console.warn(
`[Transcription] Failed to fetch VTT for ${transcriptionId}:`,
···
updates.push("error_message = ?");
values.push(data.error_message);
}
-
updates.push("updated_at = ?");
values.push(Math.floor(Date.now() / 1000));
···
`${this.serviceUrl}/transcribe/${whisperJob.id}?format=vtt`,
);
if (vttResponse.ok) {
-
const vttContent = await vttResponse.text();
-
const cleanedVTT = await cleanVTT(transcriptionId, vttContent);
-
await saveTranscriptVTT(transcriptionId, cleanedVTT);
-
this.updateTranscription(transcriptionId, {});
-
}
+
const vttContent = await vttResponse.text();
+
const cleanedVTT = await cleanVTT(transcriptionId, vttContent);
+
await saveTranscriptVTT(transcriptionId, cleanedVTT);
+
this.updateTranscription(transcriptionId, {});
+
}
} catch (error) {
console.warn(
`[Sync] Failed to fetch VTT for ${transcriptionId}:`,
+7 -7
src/lib/vtt-cleaner.test.ts
···
-
import { test, expect } from "bun:test";
+
import { expect, test } from "bun:test";
import { cleanVTT, parseVTT } from "./vtt-cleaner";
const sampleVTT = `WEBVTT
···
test("cleanVTT preserves VTT format when AI key not available", async () => {
// Save original env var
const originalKey = process.env.LLM_API_KEY;
-
+
// Remove key to test fallback
delete process.env.LLM_API_KEY;
-
+
const result = await cleanVTT("test-vtt", sampleVTT);
expect(result).toContain("WEBVTT");
expect(result).toContain("-->");
-
+
// Restore original key
if (originalKey) {
process.env.LLM_API_KEY = originalKey;
···
expect(result).toContain("WEBVTT");
expect(result).toContain("-->");
-
+
// AI should clean up tags
expect(result).not.toContain("<|startoftranscript|>");
expect(result).not.toContain("[SIDE CONVERSATION]");
-
+
// Should have paragraph formatting
expect(result).toContain("Paragraph");
-
+
console.log("AI-cleaned VTT preview:", result.substring(0, 300));
}, 30000);
+96 -78
src/lib/vtt-cleaner.ts
···
* Find paragraph boundaries in processed VTT content
* Returns the segments in the last paragraph and highest paragraph number found
*/
-
function extractLastParagraphAndHighestNumber(vttContent: string): {
-
segments: string,
-
paragraphNumber: string | null,
-
highestParagraphNumber: number
+
function extractLastParagraphAndHighestNumber(vttContent: string): {
+
segments: string;
+
paragraphNumber: string | null;
+
highestParagraphNumber: number;
} {
-
if (!vttContent) return { segments: '', paragraphNumber: null, highestParagraphNumber: 0 };
-
+
if (!vttContent)
+
return { segments: "", paragraphNumber: null, highestParagraphNumber: 0 };
+
// Split into segments (separated by double newline)
-
const segments = vttContent.split('\n\n').filter(Boolean);
-
if (segments.length === 0) return { segments: '', paragraphNumber: null, highestParagraphNumber: 0 };
-
+
const segments = vttContent.split("\n\n").filter(Boolean);
+
if (segments.length === 0)
+
return { segments: "", paragraphNumber: null, highestParagraphNumber: 0 };
+
// Get all segments from the last paragraph number
const lastSegments: string[] = [];
let currentParagraphNumber: string | null = null;
let highestParagraphNumber = 0;
-
+
// First, scan through all segments to find the highest paragraph number
for (const segment of segments) {
if (!segment) continue;
-
-
const lines = segment.split('\n');
-
const firstLine = lines[0] || '';
-
+
+
const lines = segment.split("\n");
+
const firstLine = lines[0] || "";
+
// Check for paragraph number pattern
const paragraphMatch = /Paragraph (\d+)-\d+/.exec(firstLine);
if (paragraphMatch?.[1]) {
const paragraphNum = parseInt(paragraphMatch[1], 10);
-
if (!Number.isNaN(paragraphNum) && paragraphNum > highestParagraphNumber) {
+
if (
+
!Number.isNaN(paragraphNum) &&
+
paragraphNum > highestParagraphNumber
+
) {
highestParagraphNumber = paragraphNum;
}
}
}
-
+
// Start from the end and work backwards to find the last paragraph
for (let i = segments.length - 1; i >= 0; i--) {
const segment = segments[i];
if (!segment) continue;
-
-
const lines = segment.split('\n');
-
const firstLine = lines[0] || '';
-
+
+
const lines = segment.split("\n");
+
const firstLine = lines[0] || "";
+
// Check for paragraph number pattern
const paragraphMatch = /Paragraph (\d+)-\d+/.exec(firstLine);
if (paragraphMatch?.[1]) {
const paragraphNumber = paragraphMatch[1];
-
+
if (!currentParagraphNumber) {
// This is the first paragraph number we've found working backwards
currentParagraphNumber = paragraphNumber;
···
}
}
}
-
+
return {
-
segments: lastSegments.join('\n\n'),
+
segments: lastSegments.join("\n\n"),
paragraphNumber: currentParagraphNumber,
-
highestParagraphNumber
+
highestParagraphNumber,
};
}
···
*/
async function processVTTChunk(
transcriptionId: string,
-
inputSegments: Array<{index: number, timestamp: string, text: string}>,
+
inputSegments: Array<{ index: number; timestamp: string; text: string }>,
chunkIndex: number,
previousParagraphNumber: string | null,
apiKey: string,
···
previousParagraphText?: string,
): Promise<string> {
const chunkId = `${transcriptionId}-chunk${chunkIndex}`;
-
+
const hasTextContext = !!previousParagraphText;
-
-
console.log(`[VTTCleaner] Processing chunk ${chunkIndex} with ${inputSegments.length} segments${hasTextContext ? ' and previous paragraph text context' : ''}`);
-
-
const nextParagraphNumber = previousParagraphNumber ? String(parseInt(previousParagraphNumber, 10) + 1) : '1';
-
+
+
console.log(
+
`[VTTCleaner] Processing chunk ${chunkIndex} with ${inputSegments.length} segments${hasTextContext ? " and previous paragraph text context" : ""}`,
+
);
+
+
const nextParagraphNumber = previousParagraphNumber
+
? String(parseInt(previousParagraphNumber, 10) + 1)
+
: "1";
+
const prompt = `Can you turn this into a paragraph separated vtt file?
Use the format "Paragraph X-Y" where X is the paragraph number and Y is the segment number within that paragraph:
···
Also go through and rewrite the words to extract the meaning and not necessarily the exact phrasing if it sounds unnatural when written. I want the text to remain lined up with the original though so don't rewrite entire paragraphs but you can remove ums, alrights, and similar. Also remove all contextual tags like [background noise]. Add punctuation if it's missing to make the text readable. If there is no more context to fit a segment then just skip it and move to the next one.
-
${hasTextContext ?
-
`The following is the last paragraph from the previous chunk and is provided for context only. DO NOT include it in your output - it's already in the transcript:
+
${
+
hasTextContext
+
? `The following is the last paragraph from the previous chunk and is provided for context only. DO NOT include it in your output - it's already in the transcript:
${previousParagraphText}
-
Now process the following new segments, continuing from the previous paragraph. ${previousParagraphNumber ? `Start your paragraphs with number ${nextParagraphNumber} (unless you're continuing the previous paragraph).` : ''}`
-
: 'Process the following segments:'}
+
Now process the following new segments, continuing from the previous paragraph. ${previousParagraphNumber ? `Start your paragraphs with number ${nextParagraphNumber} (unless you're continuing the previous paragraph).` : ""}`
+
: "Process the following segments:"
+
}
${JSON.stringify(inputSegments, null, 2)}
Return ONLY the VTT content WITHOUT the "WEBVTT" header and nothing else. No explanations or additional text.`;
try {
-
const response = await fetch(
-
`${apiBaseUrl}/chat/completions`,
-
{
-
method: "POST",
-
headers: {
-
"Content-Type": "application/json",
-
"Authorization": `Bearer ${apiKey}`,
-
"HTTP-Referer": "https://thistle.app",
-
"X-Title": `Thistle Transcription Chunk ${chunkIndex}`,
-
},
-
body: JSON.stringify({
-
model,
-
messages: [
-
{ role: "user", content: prompt },
-
],
-
temperature: 0.3,
-
max_tokens: 8192, // Reduced for chunks
-
}),
+
const response = await fetch(`${apiBaseUrl}/chat/completions`, {
+
method: "POST",
+
headers: {
+
"Content-Type": "application/json",
+
Authorization: `Bearer ${apiKey}`,
+
"HTTP-Referer": "https://thistle.app",
+
"X-Title": `Thistle Transcription Chunk ${chunkIndex}`,
},
-
);
+
body: JSON.stringify({
+
model,
+
messages: [{ role: "user", content: prompt }],
+
temperature: 0.3,
+
max_tokens: 8192, // Reduced for chunks
+
}),
+
});
if (!response.ok) {
const errorText = await response.text();
···
const apiKey = process.env.LLM_API_KEY;
const apiBaseUrl = process.env.LLM_API_BASE_URL;
const model = process.env.LLM_MODEL;
-
+
if (!apiKey || !apiBaseUrl || !model) {
-
console.warn("[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT");
+
console.warn(
+
"[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT",
+
);
return vttContent;
}
···
const end = Math.min(i + CHUNK_SIZE, inputSegments.length);
chunks.push(inputSegments.slice(i, end));
}
-
-
console.log(`[VTTCleaner] Split into ${chunks.length} chunks for sequential processing with paragraph context`);
-
+
+
console.log(
+
`[VTTCleaner] Split into ${chunks.length} chunks for sequential processing with paragraph context`,
+
);
+
// Process chunks sequentially with context from previous chunk
const processedChunks: string[] = [];
let previousParagraphText: string | undefined;
let previousParagraphNumber: string | null = null;
-
+
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!chunk || chunk.length === 0) continue;
-
+
try {
const processedChunk = await processVTTChunk(
-
transcriptionId,
-
chunk,
+
transcriptionId,
+
chunk,
i,
previousParagraphNumber,
-
apiKey,
-
apiBaseUrl,
+
apiKey,
+
apiBaseUrl,
model,
-
previousParagraphText
+
previousParagraphText,
);
processedChunks.push(processedChunk);
-
console.log(`[VTTCleaner] Completed chunk ${i}/${chunks.length - 1}${previousParagraphText ? ' (with context)' : ''}`);
-
+
console.log(
+
`[VTTCleaner] Completed chunk ${i}/${chunks.length - 1}${previousParagraphText ? " (with context)" : ""}`,
+
);
+
// Extract context for the next chunk
if (i < chunks.length - 1) {
-
const { segments: lastParagraphText, paragraphNumber, highestParagraphNumber } = extractLastParagraphAndHighestNumber(processedChunk);
-
+
const {
+
segments: lastParagraphText,
+
paragraphNumber,
+
highestParagraphNumber,
+
} = extractLastParagraphAndHighestNumber(processedChunk);
+
if (lastParagraphText) {
-
console.log(`[VTTCleaner] Using paragraph ${paragraphNumber || 'unknown'} as context for next chunk (highest paragraph: ${highestParagraphNumber})`);
+
console.log(
+
`[VTTCleaner] Using paragraph ${paragraphNumber || "unknown"} as context for next chunk (highest paragraph: ${highestParagraphNumber})`,
+
);
previousParagraphText = lastParagraphText;
previousParagraphNumber = highestParagraphNumber.toString();
} else {
···
} catch (error) {
console.error(`[VTTCleaner] Chunk ${i} failed:`, error);
// Return the original segments for this chunk if processing fails
-
const fallbackChunk = chunk.map(seg =>
-
`${seg.index || ''}\n${seg.timestamp}\n${seg.text}`
-
).join('\n\n');
+
const fallbackChunk = chunk
+
.map((seg) => `${seg.index || ""}\n${seg.timestamp}\n${seg.text}`)
+
.join("\n\n");
processedChunks.push(fallbackChunk);
previousParagraphText = undefined;
previousParagraphNumber = null;
}
}
-
+
// Combine all processed chunks
-
const finalVTT = `WEBVTT\n\n${processedChunks.join('\n\n')}`;
-
+
const finalVTT = `WEBVTT\n\n${processedChunks.join("\n\n")}`;
+
console.log(
`[VTTCleaner] Successfully cleaned ${segments.length} segments in ${chunks.length} sequential chunks with paragraph context`,
);
···
console.warn("[VTTCleaner] Falling back to uncleaned VTT");
return vttContent;
}
-
}
+
}
+3 -3
src/pages/admin.html
···
<link rel="stylesheet" href="../styles/main.css">
<style>
main {
-
max-width: 80rem !important;
+
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
}
···
allTranscriptions = transcriptions;
// Filter transcriptions based on search term
-
let filteredTranscriptions = transcriptions.filter(t => {
+
const filteredTranscriptions = transcriptions.filter(t => {
if (!transcriptSearchTerm) return true;
const term = transcriptSearchTerm.toLowerCase();
const filename = (t.original_filename || '').toLowerCase();
···
allUsers = users;
// Filter users based on search term
-
let filteredUsers = users.filter(u => {
+
const filteredUsers = users.filter(u => {
if (!userSearchTerm) return true;
const term = userSearchTerm.toLowerCase();
const name = (u.name || '').toLowerCase();
+32
src/pages/class.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Class - Thistle</title>
+
<link rel="icon"
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="stylesheet" href="../styles/main.css">
+
</head>
+
+
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<span>🪻</span>
+
<span>Thistle - Classes</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
+
+
<main class="container">
+
<class-view></class-view>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/class-view.ts"></script>
+
</body>
+
+
</html>
+32
src/pages/classes.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Classes - Thistle</title>
+
<link rel="icon"
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
+
<link rel="stylesheet" href="../styles/main.css">
+
</head>
+
+
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<span>🪻</span>
+
<span>Thistle - Classes</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
+
+
<main class="container">
+
<classes-overview></classes-overview>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/classes-overview.ts"></script>
+
</body>
+
+
</html>
+1 -1
src/pages/index.html
···
const isLoggedIn = await authComponent.isAuthenticated();
if (isLoggedIn) {
-
window.location.href = '/transcribe';
+
window.location.href = '/classes';
} else {
authComponent.openAuthModal();
}
+1 -1
src/pages/settings.html
···
<style>
main {
-
max-width: 64rem !important;
+
max-width: 64rem;
}
</style>
</head>
+7 -1
src/pages/transcribe.html
···
</header>
<main>
+
<div style="margin-bottom: 1rem;">
+
<a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;">
+
← Back to classes
+
</a>
+
</div>
+
<div class="page-header">
-
<h1 class="page-title">Audio Transcription</h1>
+
<h1 class="page-title">Upload Transcription</h1>
<p class="page-subtitle">Upload your audio files and get accurate transcripts powered by Whisper</p>
</div>
+20 -20
src/styles/header.css
···
/* Header styles shared across all pages */
header {
-
position: sticky;
-
top: 0;
-
z-index: 1000;
-
background: var(--background);
-
border-bottom: 2px solid var(--secondary);
-
padding: 1rem 2rem;
-
margin: -2rem -2rem 2rem -2rem;
+
position: sticky;
+
top: 0;
+
z-index: 1000;
+
background: var(--background);
+
border-bottom: 2px solid var(--secondary);
+
padding: 1rem 2rem;
+
margin: -2rem -2rem 2rem -2rem;
}
.header-content {
-
max-width: 1200px;
-
margin: 0 auto;
-
display: flex;
-
justify-content: space-between;
-
align-items: center;
+
max-width: 1200px;
+
margin: 0 auto;
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
}
.site-title {
-
font-size: 1.5rem;
-
font-weight: 600;
-
color: var(--text);
-
text-decoration: none;
-
display: flex;
-
align-items: center;
-
gap: 0.5rem;
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
text-decoration: none;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
}
.site-title:hover {
-
color: var(--primary);
+
color: var(--primary);
}
+44 -44
src/styles/main.css
···
-
@import url('./buttons.css');
-
@import url('./header.css');
+
@import url("./buttons.css");
+
@import url("./header.css");
:root {
-
/* Color palette */
-
--gunmetal: #2d3142ff;
-
--paynes-gray: #4f5d75ff;
-
--silver: #bfc0c0ff;
-
--off-white: #fcf6f1;
-
--coral: #ef8354ff;
-
--success-green: #16a34a;
+
/* Color palette */
+
--gunmetal: #2d3142ff;
+
--paynes-gray: #4f5d75ff;
+
--silver: #bfc0c0ff;
+
--off-white: #fcf6f1;
+
--coral: #ef8354ff;
+
--success-green: #16a34a;
-
/* Semantic color assignments */
-
--text: var(--gunmetal);
-
--background: var(--off-white);
-
--primary: var(--paynes-gray);
-
--secondary: var(--silver);
-
--accent: var(--coral);
-
--success: var(--success-green);
+
/* Semantic color assignments */
+
--text: var(--gunmetal);
+
--background: var(--off-white);
+
--primary: var(--paynes-gray);
+
--secondary: var(--silver);
+
--accent: var(--coral);
+
--success: var(--success-green);
}
body {
-
font-family: 'Charter', 'Bitstream Charter', 'Sitka Text', Cambria, serif;
-
font-weight: 400;
-
margin: 0;
-
padding: 2rem;
-
line-height: 1.6;
-
background: var(--background);
-
color: var(--text);
+
font-family: "Charter", "Bitstream Charter", "Sitka Text", Cambria, serif;
+
font-weight: 400;
+
margin: 0;
+
padding: 2rem;
+
line-height: 1.6;
+
background: var(--background);
+
color: var(--text);
}
h1,
···
h3,
h4,
h5 {
-
font-family: 'Charter', 'Bitstream Charter', 'Sitka Text', Cambria, serif;
-
font-weight: 600;
-
line-height: 1.2;
-
color: var(--text);
+
font-family: "Charter", "Bitstream Charter", "Sitka Text", Cambria, serif;
+
font-weight: 600;
+
line-height: 1.2;
+
color: var(--text);
}
html {
-
font-size: 100%;
+
font-size: 100%;
}
/* 16px */
h1 {
-
font-size: 4.210rem;
-
/* 67.36px */
-
margin-top: 0;
+
font-size: 4.21rem;
+
/* 67.36px */
+
margin-top: 0;
}
h2 {
-
font-size: 3.158rem;
-
/* 50.56px */
+
font-size: 3.158rem;
+
/* 50.56px */
}
h3 {
-
font-size: 2.369rem;
-
/* 37.92px */
+
font-size: 2.369rem;
+
/* 37.92px */
}
h4 {
-
font-size: 1.777rem;
-
/* 28.48px */
+
font-size: 1.777rem;
+
/* 28.48px */
}
h5 {
-
font-size: 1.333rem;
-
/* 21.28px */
+
font-size: 1.333rem;
+
/* 21.28px */
}
small {
-
font-size: 0.750rem;
-
/* 12px */
+
font-size: 0.75rem;
+
/* 12px */
}
main {
-
margin: 0 auto;
-
max-width: 48rem;
-
}
+
margin: 0 auto;
+
max-width: 48rem;
+
}