🪻 distributed transcription service thistle.dunkirk.sh

feat: add transcription UI and integrate service

API:
- POST /api/transcriptions - upload audio files
- GET /api/transcriptions - list user transcriptions
- GET /api/transcriptions/:id/stream - SSE progress updates
- Use structured error handling for validation

Frontend:
- Add transcription component with drag-and-drop
- Real-time progress via SSE (no polling)
- Support for MP3, WAV, M4A, AAC, OGG, WebM, FLAC
- Add transcribe link to nav menu
- Update homepage with CTA buttons

💘 Generated with Crush

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

dunkirk.sh 7b01011c 93cff389

verified
Changed files
+860 -93
src
+9 -5
src/components/auth.ts
···
this.user = await response.json();
this.closeModal();
await this.checkAuth();
} else {
const response = await fetch("/api/auth/login", {
method: "POST",
···
this.user = await response.json();
this.closeModal();
await this.checkAuth();
}
} finally {
this.isSubmitting = false;
···
try {
await fetch("/api/auth/logout", { method: "POST" });
this.user = null;
} catch {
// Silent fail
}
···
${
this.showModal
? html`
-
<div class="user-menu">
-
<a href="/settings" @click=${this.closeModal}>Settings</a>
-
<button @click=${this.handleLogout}>Logout</button>
-
</div>
-
`
: ""
}
</div>
···
this.user = await response.json();
this.closeModal();
await this.checkAuth();
+
window.dispatchEvent(new CustomEvent("auth-changed"));
} else {
const response = await fetch("/api/auth/login", {
method: "POST",
···
this.user = await response.json();
this.closeModal();
await this.checkAuth();
+
window.dispatchEvent(new CustomEvent("auth-changed"));
}
} finally {
this.isSubmitting = false;
···
try {
await fetch("/api/auth/logout", { method: "POST" });
this.user = null;
+
window.dispatchEvent(new CustomEvent("auth-changed"));
} catch {
// Silent fail
}
···
${
this.showModal
? html`
+
<div class="user-menu">
+
<a href="/transcribe" @click=${this.closeModal}>Transcribe</a>
+
<a href="/settings" @click=${this.closeModal}>Settings</a>
+
<button @click=${this.handleLogout}>Logout</button>
+
</div>
+
`
: ""
}
</div>
+424
src/components/transcription.ts
···
···
+
import { css, html, LitElement } from "lit";
+
import { customElement, state } from "lit/decorators.js";
+
+
interface TranscriptionJob {
+
id: string;
+
filename: string;
+
status: "uploading" | "processing" | "completed" | "failed";
+
progress: number;
+
transcript?: string;
+
created_at: number;
+
}
+
+
@customElement("transcription-component")
+
export class TranscriptionComponent extends LitElement {
+
@state() jobs: TranscriptionJob[] = [];
+
@state() isUploading = false;
+
@state() dragOver = false;
+
@state() serviceAvailable = true;
+
+
static override styles = css`
+
:host {
+
display: block;
+
}
+
+
.upload-area {
+
border: 2px dashed var(--secondary);
+
border-radius: 8px;
+
padding: 3rem 2rem;
+
text-align: center;
+
transition: all 0.2s;
+
cursor: pointer;
+
background: var(--background);
+
}
+
+
.upload-area:hover,
+
.upload-area.drag-over {
+
border-color: var(--primary);
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
}
+
+
.upload-area.disabled {
+
border-color: var(--secondary);
+
opacity: 0.6;
+
cursor: not-allowed;
+
}
+
+
.upload-area.disabled:hover {
+
border-color: var(--secondary);
+
background: transparent;
+
}
+
+
.upload-icon {
+
font-size: 3rem;
+
color: var(--secondary);
+
margin-bottom: 1rem;
+
}
+
+
.upload-text {
+
color: var(--text);
+
font-size: 1.125rem;
+
font-weight: 500;
+
margin-bottom: 0.5rem;
+
}
+
+
.upload-hint {
+
color: var(--text);
+
opacity: 0.7;
+
font-size: 0.875rem;
+
}
+
+
.jobs-section {
+
margin-top: 2rem;
+
}
+
+
.jobs-title {
+
font-size: 1.25rem;
+
font-weight: 600;
+
color: var(--text);
+
margin-bottom: 1rem;
+
}
+
+
.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-status {
+
padding: 0.25rem 0.75rem;
+
border-radius: 4px;
+
font-size: 0.75rem;
+
font-weight: 600;
+
text-transform: uppercase;
+
}
+
+
.status-uploading {
+
background: color-mix(in srgb, var(--primary) 10%, transparent);
+
color: var(--primary);
+
}
+
+
.status-processing {
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
+
color: var(--accent);
+
}
+
+
.status-completed {
+
background: color-mix(in srgb, var(--success) 10%, transparent);
+
color: var(--success);
+
}
+
+
.status-failed {
+
background: color-mix(in srgb, var(--text) 10%, transparent);
+
color: var(--text);
+
}
+
+
.progress-bar {
+
width: 100%;
+
height: 4px;
+
background: var(--secondary);
+
border-radius: 2px;
+
margin-bottom: 1rem;
+
}
+
+
.progress-fill {
+
height: 100%;
+
background: var(--primary);
+
border-radius: 2px;
+
transition: width 0.3s;
+
}
+
+
.job-transcript {
+
background: color-mix(in srgb, var(--primary) 5%, transparent);
+
border-radius: 6px;
+
padding: 1rem;
+
margin-top: 1rem;
+
white-space: pre-wrap;
+
font-family: monospace;
+
font-size: 0.875rem;
+
color: var(--text);
+
}
+
+
.hidden {
+
display: none;
+
}
+
+
.file-input {
+
display: none;
+
}
+
`;
+
+
private eventSources: Map<string, EventSource> = new Map();
+
private handleAuthChange = async () => {
+
await this.loadJobs();
+
this.connectToJobStreams();
+
};
+
+
override async connectedCallback() {
+
super.connectedCallback();
+
await this.loadJobs();
+
this.connectToJobStreams();
+
+
// Listen for auth changes to reload jobs
+
window.addEventListener("auth-changed", this.handleAuthChange);
+
}
+
+
override disconnectedCallback() {
+
super.disconnectedCallback();
+
// Clean up all event sources
+
for (const es of this.eventSources.values()) {
+
es.close();
+
}
+
this.eventSources.clear();
+
window.removeEventListener("auth-changed", this.handleAuthChange);
+
}
+
+
private connectToJobStreams() {
+
// Connect to SSE streams for active jobs
+
for (const job of this.jobs) {
+
if (job.status === "processing" || job.status === "uploading") {
+
this.connectToJobStream(job.id);
+
}
+
}
+
}
+
+
private connectToJobStream(jobId: string, retryCount = 0) {
+
if (this.eventSources.has(jobId)) {
+
return; // Already connected
+
}
+
+
const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
+
+
eventSource.onmessage = (event) => {
+
const update = JSON.parse(event.data);
+
console.log(`[Client received] Job ${jobId}:`, update);
+
+
// Update the job in our list efficiently (mutate in place for Lit)
+
const job = this.jobs.find((j) => j.id === jobId);
+
if (job) {
+
// Update properties directly
+
if (update.status !== undefined) job.status = update.status;
+
if (update.progress !== undefined) job.progress = update.progress;
+
if (update.transcript !== undefined) job.transcript = update.transcript;
+
+
// Trigger Lit re-render by creating new array reference
+
this.jobs = [...this.jobs];
+
+
// Close connection if job is complete or failed
+
if (update.status === "completed" || update.status === "failed") {
+
eventSource.close();
+
this.eventSources.delete(jobId);
+
}
+
}
+
};
+
+
eventSource.onerror = (error) => {
+
console.warn(`SSE connection error for job ${jobId}:`, error);
+
eventSource.close();
+
this.eventSources.delete(jobId);
+
+
// Retry connection up to 3 times with exponential backoff
+
if (retryCount < 3) {
+
const backoff = 2 ** retryCount * 1000; // 1s, 2s, 4s
+
console.log(
+
`Retrying connection in ${backoff}ms (attempt ${retryCount + 1}/3)`,
+
);
+
setTimeout(() => {
+
this.connectToJobStream(jobId, retryCount + 1);
+
}, backoff);
+
} else {
+
console.error(`Failed to connect to job ${jobId} after 3 attempts`);
+
}
+
};
+
+
this.eventSources.set(jobId, eventSource);
+
}
+
+
async loadJobs() {
+
try {
+
const response = await fetch("/api/transcriptions");
+
if (response.ok) {
+
const data = await response.json();
+
this.jobs = data.jobs;
+
this.serviceAvailable = true;
+
} else if (response.status === 404) {
+
// Transcription service not available - show empty state
+
this.jobs = [];
+
this.serviceAvailable = false;
+
} else {
+
console.error("Failed to load jobs:", response.status);
+
this.serviceAvailable = false;
+
}
+
} catch (error) {
+
// Network error or service unavailable - don't break the page
+
console.warn("Transcription service unavailable:", error);
+
this.jobs = [];
+
this.serviceAvailable = false;
+
}
+
}
+
+
private handleDragOver(e: DragEvent) {
+
e.preventDefault();
+
this.dragOver = true;
+
}
+
+
private handleDragLeave(e: DragEvent) {
+
e.preventDefault();
+
this.dragOver = false;
+
}
+
+
private async handleDrop(e: DragEvent) {
+
e.preventDefault();
+
this.dragOver = false;
+
+
const files = e.dataTransfer?.files;
+
const file = files?.[0];
+
if (file) {
+
await this.uploadFile(file);
+
}
+
}
+
+
private async handleFileSelect(e: Event) {
+
const input = e.target as HTMLInputElement;
+
const file = input.files?.[0];
+
if (file) {
+
await this.uploadFile(file);
+
}
+
}
+
+
private async uploadFile(file: File) {
+
const allowedTypes = [
+
"audio/mpeg", // MP3
+
"audio/wav", // WAV
+
"audio/x-wav", // WAV (alternative)
+
"audio/m4a", // M4A
+
"audio/mp4", // MP4 audio
+
"audio/aac", // AAC
+
"audio/ogg", // OGG
+
"audio/webm", // WebM audio
+
"audio/flac", // FLAC
+
];
+
+
// Also check file extension for M4A files (sometimes MIME type isn't set correctly)
+
const isM4A = file.name.toLowerCase().endsWith(".m4a");
+
const isAllowedType =
+
allowedTypes.includes(file.type) || (isM4A && file.type === "");
+
+
if (!isAllowedType) {
+
alert(
+
"Please select a supported audio file (MP3, WAV, M4A, AAC, OGG, WebM, or FLAC)",
+
);
+
return;
+
}
+
+
if (file.size > 25 * 1024 * 1024) {
+
// 25MB limit
+
alert("File size must be less than 25MB");
+
return;
+
}
+
+
this.isUploading = true;
+
+
try {
+
const formData = new FormData();
+
formData.append("audio", file);
+
+
const response = await fetch("/api/transcriptions", {
+
method: "POST",
+
body: formData,
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
alert(
+
data.error ||
+
"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);
+
}
+
} catch {
+
alert("Upload failed - transcription service may be unavailable");
+
} finally {
+
this.isUploading = false;
+
}
+
}
+
+
private getStatusClass(status: string) {
+
return `status-${status}`;
+
}
+
+
override render() {
+
return html`
+
<div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}"
+
@dragover=${this.serviceAvailable ? this.handleDragOver : null}
+
@dragleave=${this.serviceAvailable ? this.handleDragLeave : null}
+
@drop=${this.serviceAvailable ? this.handleDrop : null}
+
@click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}>
+
<div class="upload-icon">🎵</div>
+
<div class="upload-text">
+
${
+
!this.serviceAvailable
+
? "Transcription service unavailable"
+
: this.isUploading
+
? "Uploading..."
+
: "Drop audio file here or click to browse"
+
}
+
</div>
+
<div class="upload-hint">
+
${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 25MB - Requires faster-whisper server" : "Transcription service unavailable"}
+
</div>
+
<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"
+
? html`
+
<div class="progress-bar">
+
<div class="progress-fill" style="width: ${job.progress}%"></div>
+
</div>
+
`
+
: ""
+
}
+
+
${
+
job.transcript
+
? html`
+
<div class="job-transcript">${job.transcript}</div>
+
`
+
: ""
+
}
+
</div>
+
`,
+
)}
+
</div>
+
`;
+
}
+
}
+259 -83
src/index.ts
···
import {
-
authenticateUser,
-
cleanupExpiredSessions,
-
createSession,
-
createUser,
-
deleteSession,
-
deleteUser,
-
getSession,
-
getSessionFromRequest,
-
getUserBySession,
-
getUserSessionsForUser,
-
updateUserAvatar,
-
updateUserEmail,
-
updateUserName,
updateUserPassword,
} from "./lib/auth";
import indexHTML from "./pages/index.html";
import settingsHTML from "./pages/settings.html";
// Clean up expired sessions every hour
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
const server = Bun.serve({
port: 3000,
routes: {
"/": indexHTML,
"/settings": settingsHTML,
"/api/auth/register": {
POST: async (req) => {
try {
const body = await req.json();
const { email, password, name } = body;
-
if (!email || !password) {
return Response.json(
{ error: "Email and password required" },
{ status: 400 },
);
}
-
if (password.length < 8) {
return Response.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 },
);
}
-
const user = await createUser(email, password, name);
const ipAddress =
req.headers.get("x-forwarded-for") ??
···
"unknown";
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
-
return Response.json(
{ user: { id: user.id, email: user.email } },
{
···
try {
const body = await req.json();
const { email, password } = body;
-
if (!email || !password) {
return Response.json(
{ error: "Email and password required" },
{ status: 400 },
);
}
-
const user = await authenticateUser(email, password);
-
if (!user) {
return Response.json(
{ error: "Invalid email or password" },
{ status: 401 },
);
}
-
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
-
return Response.json(
{ user: { id: user.id, email: user.email } },
{
···
},
},
);
-
} catch (_) {
return Response.json({ error: "Login failed" }, { status: 500 });
}
},
},
"/api/auth/logout": {
-
POST: (req) => {
const sessionId = getSessionFromRequest(req);
if (sessionId) {
deleteSession(sessionId);
}
-
return Response.json(
{ success: true },
{
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
return Response.json({
email: user.email,
name: user.name,
avatar: user.avatar,
-
created_at: user.created_at,
});
},
},
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
const sessions = getUserSessionsForUser(user.id);
return Response.json({
sessions: sessions.map((s) => ({
···
user_agent: s.user_agent,
created_at: s.created_at,
expires_at: s.expires_at,
-
is_current: s.id === sessionId,
})),
});
},
···
if (!currentSessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(currentSessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
const body = await req.json();
const targetSessionId = body.sessionId;
-
if (!targetSessionId) {
return Response.json(
{ error: "Session ID required" },
{ status: 400 },
);
}
-
// Verify the session belongs to the user
const targetSession = getSession(targetSessionId);
if (!targetSession || targetSession.user_id !== user.id) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
-
deleteSession(targetSessionId);
-
return Response.json({ success: true });
},
},
-
"/api/auth/delete-account": {
DELETE: (req) => {
const sessionId = getSessionFromRequest(req);
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
deleteUser(user.id);
-
return Response.json(
{ success: true },
{
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
const body = await req.json();
const { email } = body;
-
if (!email) {
return Response.json({ error: "Email required" }, { status: 400 });
}
-
try {
updateUserEmail(user.id, email);
return Response.json({ success: true });
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
const body = await req.json();
const { password } = body;
-
if (!password) {
return Response.json({ error: "Password required" }, { status: 400 });
}
-
if (password.length < 8) {
return Response.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 },
);
}
-
try {
await updateUserPassword(user.id, password);
return Response.json({ success: true });
···
},
},
"/api/user/name": {
-
PUT: async (req) => {
-
const sessionId = getSessionFromRequest(req);
-
if (!sessionId) {
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
-
}
-
-
const user = getUserBySession(sessionId);
-
if (!user) {
-
return Response.json({ error: "Invalid session" }, { status: 401 });
-
}
-
-
const body = await req.json();
-
const { name } = body;
-
-
if (!name) {
-
return Response.json({ error: "Name required" }, { status: 400 });
-
}
-
-
try {
-
updateUserName(user.id, name);
-
return Response.json({ success: true });
-
} catch {
-
return Response.json(
-
{ error: "Failed to update name" },
-
{ status: 500 },
-
);
-
}
-
},
},
"/api/user/avatar": {
PUT: async (req) => {
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
-
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
-
const body = await req.json();
const { avatar } = body;
-
if (!avatar) {
return Response.json({ error: "Avatar required" }, { status: 400 });
}
-
try {
updateUserAvatar(user.id, avatar);
return Response.json({ success: true });
···
}
},
},
},
development: {
hmr: true,
console: true,
},
});
-
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
···
+
import db from "./db/schema";
import {
+
authenticateUser,
+
cleanupExpiredSessions,
+
createSession,
+
createUser,
+
deleteSession,
+
deleteUser,
+
getSession,
+
getSessionFromRequest,
+
getUserBySession,
+
getUserSessionsForUser,
+
updateUserAvatar,
+
updateUserEmail,
+
updateUserName,
updateUserPassword,
} from "./lib/auth";
+
import { handleError, ValidationErrors } from "./lib/errors";
+
import { requireAuth } from "./lib/middleware";
+
import {
+
MAX_FILE_SIZE,
+
TranscriptionEventEmitter,
+
type TranscriptionUpdate,
+
WhisperServiceManager,
+
} from "./lib/transcription";
import indexHTML from "./pages/index.html";
import settingsHTML from "./pages/settings.html";
+
import transcribeHTML from "./pages/transcribe.html";
+
+
// Environment variables
+
const WHISPER_SERVICE_URL =
+
process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
+
+
// Create uploads directory if it doesn't exist
+
await Bun.write("./uploads/.gitkeep", "");
+
+
// Initialize transcription system
+
const transcriptionEvents = new TranscriptionEventEmitter();
+
const whisperService = new WhisperServiceManager(
+
WHISPER_SERVICE_URL,
+
db,
+
transcriptionEvents,
+
);
// Clean up expired sessions every hour
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
+
// Sync with Whisper DB on startup
+
await whisperService.syncWithWhisper();
+
+
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
+
setInterval(() => whisperService.syncWithWhisper(), 5 * 60 * 1000);
+
+
// Clean up stale files daily
+
setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
+
const server = Bun.serve({
port: 3000,
+
idleTimeout: 120, // 120 seconds for SSE connections
routes: {
"/": indexHTML,
"/settings": settingsHTML,
+
"/transcribe": transcribeHTML,
"/api/auth/register": {
POST: async (req) => {
try {
const body = await req.json();
const { email, password, name } = body;
if (!email || !password) {
return Response.json(
{ error: "Email and password required" },
{ status: 400 },
);
}
if (password.length < 8) {
return Response.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 },
);
}
const user = await createUser(email, password, name);
const ipAddress =
req.headers.get("x-forwarded-for") ??
···
"unknown";
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
{ user: { id: user.id, email: user.email } },
{
···
try {
const body = await req.json();
const { email, password } = body;
if (!email || !password) {
return Response.json(
{ error: "Email and password required" },
{ status: 400 },
);
}
const user = await authenticateUser(email, password);
if (!user) {
return Response.json(
{ error: "Invalid email or password" },
{ status: 401 },
);
}
const ipAddress =
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
"unknown";
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
return Response.json(
{ user: { id: user.id, email: user.email } },
{
···
},
},
);
+
} catch {
return Response.json({ error: "Login failed" }, { status: 500 });
}
},
},
"/api/auth/logout": {
+
POST: async (req) => {
const sessionId = getSessionFromRequest(req);
if (sessionId) {
deleteSession(sessionId);
}
return Response.json(
{ success: true },
{
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
return Response.json({
email: user.email,
name: user.name,
avatar: user.avatar,
});
},
},
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
const sessions = getUserSessionsForUser(user.id);
return Response.json({
sessions: sessions.map((s) => ({
···
user_agent: s.user_agent,
created_at: s.created_at,
expires_at: s.expires_at,
})),
});
},
···
if (!currentSessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(currentSessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
const body = await req.json();
const targetSessionId = body.sessionId;
if (!targetSessionId) {
return Response.json(
{ error: "Session ID required" },
{ status: 400 },
);
}
// Verify the session belongs to the user
const targetSession = getSession(targetSessionId);
if (!targetSession || targetSession.user_id !== user.id) {
return Response.json({ error: "Session not found" }, { status: 404 });
}
deleteSession(targetSessionId);
return Response.json({ success: true });
},
},
+
"/api/user": {
DELETE: (req) => {
const sessionId = getSessionFromRequest(req);
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
deleteUser(user.id);
return Response.json(
{ success: true },
{
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
const body = await req.json();
const { email } = body;
if (!email) {
return Response.json({ error: "Email required" }, { status: 400 });
}
try {
updateUserEmail(user.id, email);
return Response.json({ success: true });
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
const body = await req.json();
const { password } = body;
if (!password) {
return Response.json({ error: "Password required" }, { status: 400 });
}
if (password.length < 8) {
return Response.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 },
);
}
try {
await updateUserPassword(user.id, password);
return Response.json({ success: true });
···
},
},
"/api/user/name": {
+
PUT: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
const body = await req.json();
+
const { name } = body;
+
if (!name) {
+
return Response.json({ error: "Name required" }, { status: 400 });
+
}
+
try {
+
updateUserName(user.id, name);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update name" },
+
{ status: 500 },
+
);
+
}
+
},
},
"/api/user/avatar": {
PUT: async (req) => {
···
if (!sessionId) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}
const user = getUserBySession(sessionId);
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
const body = await req.json();
const { avatar } = body;
if (!avatar) {
return Response.json({ error: "Avatar required" }, { status: 400 });
}
try {
updateUserAvatar(user.id, avatar);
return Response.json({ success: true });
···
}
},
},
+
"/api/transcriptions/:id/stream": {
+
GET: (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
const transcriptionId = req.params.id;
+
// Verify ownership
+
const transcription = db
+
.query<{ id: string; user_id: number; status: string }, [string]>(
+
"SELECT id, user_id, status FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
if (!transcription || transcription.user_id !== user.id) {
+
return Response.json(
+
{ error: "Transcription not found" },
+
{ status: 404 },
+
);
+
}
+
// Event-driven SSE stream (NO POLLING!)
+
const stream = new ReadableStream({
+
start(controller) {
+
const encoder = new TextEncoder();
+
+
const sendEvent = (data: Partial<TranscriptionUpdate>) => {
+
controller.enqueue(
+
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
+
);
+
};
+
// Send initial state from DB
+
const current = db
+
.query<
+
{
+
status: string;
+
progress: number;
+
transcript: string | null;
+
},
+
[string]
+
>(
+
"SELECT status, progress, transcript FROM transcriptions WHERE id = ?",
+
)
+
.get(transcriptionId);
+
if (current) {
+
sendEvent({
+
status: current.status as TranscriptionUpdate["status"],
+
progress: current.progress,
+
transcript: current.transcript || undefined,
+
});
+
}
+
// If already complete, close immediately
+
if (
+
current?.status === "completed" ||
+
current?.status === "failed"
+
) {
+
controller.close();
+
return;
+
}
+
// Subscribe to EventEmitter for live updates
+
const updateHandler = (data: TranscriptionUpdate) => {
+
console.log(`[SSE to client] Job ${transcriptionId}:`, data);
+
// Only send changed fields to save bandwidth
+
const payload: Partial<TranscriptionUpdate> = {
+
status: data.status,
+
progress: data.progress,
+
};
+
+
if (data.transcript !== undefined) {
+
payload.transcript = data.transcript;
+
}
+
if (data.error_message !== undefined) {
+
payload.error_message = data.error_message;
+
}
+
+
sendEvent(payload);
+
+
// Close stream when done
+
if (data.status === "completed" || data.status === "failed") {
+
transcriptionEvents.off(transcriptionId, updateHandler);
+
controller.close();
+
}
+
};
+
transcriptionEvents.on(transcriptionId, updateHandler);
+
// Cleanup on client disconnect
+
return () => {
+
transcriptionEvents.off(transcriptionId, updateHandler);
+
};
+
},
+
});
+
return new Response(stream, {
+
headers: {
+
"Content-Type": "text/event-stream",
+
"Cache-Control": "no-cache",
+
Connection: "keep-alive",
+
},
+
});
+
},
+
},
+
"/api/transcriptions": {
+
GET: (req) => {
+
try {
+
const user = requireAuth(req);
+
+
const transcriptions = db
+
.query<
+
{
+
id: string;
+
filename: string;
+
original_filename: string;
+
status: string;
+
progress: number;
+
transcript: string | null;
+
created_at: number;
+
},
+
[number]
+
>(
+
"SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
+
)
+
.all(user.id);
+
+
return Response.json({
+
jobs: transcriptions.map((t) => ({
+
id: t.id,
+
filename: t.original_filename,
+
status: t.status,
+
progress: t.progress,
+
transcript: t.transcript,
+
created_at: t.created_at,
+
})),
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
+
const formData = await req.formData();
+
const file = formData.get("audio") as File;
+
+
if (!file) throw ValidationErrors.missingField("audio");
+
+
if (!file.type.startsWith("audio/")) {
+
throw ValidationErrors.unsupportedFileType(
+
"MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
+
);
+
}
+
+
if (file.size > MAX_FILE_SIZE) {
+
throw ValidationErrors.fileTooLarge("25MB");
+
}
+
+
// Generate unique filename
+
const transcriptionId = crypto.randomUUID();
+
const fileExtension = file.name.split(".").pop();
+
const filename = `${transcriptionId}.${fileExtension}`;
+
+
// Save file to disk
+
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"],
+
);
+
+
// Start transcription in background
+
whisperService.startTranscription(transcriptionId, filename);
+
+
return Response.json({
+
id: transcriptionId,
+
message: "Upload successful, transcription started",
+
});
+
} catch (error) {
+
return handleError(error);
+
}
+
},
+
},
},
development: {
hmr: true,
console: true,
},
});
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
+75 -5
src/pages/index.html
···
<style>
main {
max-width: 48rem;
}
</style>
</head>
···
<auth-component></auth-component>
<main>
-
<h1>Thistle</h1>
-
<p>Here is a basic counter to figure out the basics of web components</p>
-
-
<counter-component></counter-component>
</main>
-
<script type="module" src="../components/counter.ts"></script>
<script type="module" src="../components/auth.ts"></script>
</body>
···
<style>
main {
max-width: 48rem;
+
text-align: center;
+
padding: 4rem 2rem;
+
}
+
+
.hero-title {
+
font-size: 3rem;
+
font-weight: 700;
+
color: var(--text);
+
margin-bottom: 1rem;
+
}
+
+
.hero-subtitle {
+
font-size: 1.25rem;
+
color: var(--text);
+
opacity: 0.8;
+
margin-bottom: 2rem;
+
}
+
+
.cta-buttons {
+
display: flex;
+
gap: 1rem;
+
justify-content: center;
+
margin-top: 2rem;
+
}
+
+
.btn {
+
padding: 0.75rem 1.5rem;
+
border-radius: 6px;
+
font-size: 1rem;
+
font-weight: 500;
+
cursor: pointer;
+
transition: all 0.2s;
+
font-family: inherit;
+
border: 2px solid;
+
text-decoration: none;
+
display: inline-block;
+
}
+
+
.btn-primary {
+
background: var(--primary);
+
color: white;
+
border-color: var(--primary);
+
}
+
+
.btn-primary:hover {
+
background: transparent;
+
color: var(--primary);
+
}
+
+
.btn-secondary {
+
background: transparent;
+
color: var(--text);
+
border-color: var(--secondary);
+
}
+
+
.btn-secondary:hover {
+
border-color: var(--primary);
+
color: var(--primary);
+
}
+
+
@media (max-width: 640px) {
+
.hero-title {
+
font-size: 2.5rem;
+
}
+
+
.cta-buttons {
+
flex-direction: column;
+
align-items: center;
+
}
}
</style>
</head>
···
<auth-component></auth-component>
<main>
+
<h1 class="hero-title">Thistle</h1>
+
<p class="hero-subtitle">AI-powered audio transcription made simple</p>
+
<div class="cta-buttons">
+
<a href="/transcribe" class="btn btn-primary">Start Transcribing</a>
+
<a href="/settings" class="btn btn-secondary">Settings</a>
+
</div>
</main>
<script type="module" src="../components/auth.ts"></script>
</body>
+21
src/pages/settings.html
···
<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>
<auth-component></auth-component>
<main>
<h1>Settings</h1>
<user-settings></user-settings>
</main>
···
<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">
+
<style>
+
.back-link {
+
display: inline-flex;
+
align-items: center;
+
gap: 0.5rem;
+
color: var(--text);
+
opacity: 0.7;
+
text-decoration: none;
+
font-size: 0.875rem;
+
margin-bottom: 2rem;
+
transition: opacity 0.2s;
+
}
+
+
.back-link:hover {
+
opacity: 1;
+
}
+
</style>
</head>
<body>
<auth-component></auth-component>
<main>
+
<a href="/" class="back-link">
+
← Back to Home
+
</a>
+
<h1>Settings</h1>
<user-settings></user-settings>
</main>
+72
src/pages/transcribe.html
···
···
+
<!DOCTYPE html>
+
<html lang="en">
+
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Transcribe - 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">
+
<style>
+
main {
+
max-width: 48rem;
+
}
+
+
.page-header {
+
text-align: center;
+
margin-bottom: 3rem;
+
}
+
+
.page-title {
+
font-size: 2.5rem;
+
font-weight: 700;
+
color: var(--text);
+
margin-bottom: 0.5rem;
+
}
+
+
.page-subtitle {
+
font-size: 1.125rem;
+
color: var(--text);
+
opacity: 0.8;
+
}
+
+
.back-link {
+
display: inline-flex;
+
align-items: center;
+
gap: 0.5rem;
+
color: var(--text);
+
opacity: 0.7;
+
text-decoration: none;
+
font-size: 0.875rem;
+
margin-bottom: 2rem;
+
transition: opacity 0.2s;
+
}
+
+
.back-link:hover {
+
opacity: 1;
+
}
+
</style>
+
</head>
+
+
<body>
+
<auth-component></auth-component>
+
+
<main>
+
<a href="/" class="back-link">
+
← Back to Home
+
</a>
+
+
<div class="page-header">
+
<h1 class="page-title">Audio Transcription</h1>
+
<p class="page-subtitle">Upload your audio files and get accurate transcripts powered by AI</p>
+
</div>
+
+
<transcription-component></transcription-component>
+
</main>
+
+
<script type="module" src="../components/auth.ts"></script>
+
<script type="module" src="../components/transcription.ts"></script>
+
</body>
+
+
</html>