From 78aae55b87a6c398e972e3d77321a6664c256139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdur-Rahm=C4=81n=20Bil=C4=81l?= Date: Sat, 14 Feb 2026 18:00:02 -0500 Subject: [PATCH 01/60] refactor: Phase 1 - foundation utilities and cleanup - Add zustand dependency - Extract export format generators to lib/export-formats.ts - Extract formatDuration/formatFileSize to lib/format-utils.ts - Extract speaker colors to lib/speaker-colors.ts - Add useDebounce hook - Consolidate Firebase initialization (single app instance) - Add AIFeatures, TranscriptionOptions, AudioSource to types - Create types barrel export - Delete dead code: App.tsx, v2-debug.ts, useV2Announcement.ts Co-Authored-By: Claude Opus 4.6 --- bun.lock | 3 + package.json | 3 +- src/App.tsx | 19 ---- src/hooks/useDebounce.ts | 17 +++ src/hooks/useV2Announcement.ts | 24 ----- src/lib/export-formats.ts | 186 +++++++++++++++++++++++++++++++++ src/lib/firebase-utils.ts | 20 +--- src/lib/firebase.ts | 28 +++-- src/lib/format-utils.ts | 12 +++ src/lib/speaker-colors.ts | 13 +++ src/lib/v2-debug.ts | 23 ---- src/types/index.ts | 15 +++ src/types/transcription.ts | 24 +++++ 13 files changed, 296 insertions(+), 91 deletions(-) delete mode 100644 src/App.tsx create mode 100644 src/hooks/useDebounce.ts delete mode 100644 src/hooks/useV2Announcement.ts create mode 100644 src/lib/export-formats.ts create mode 100644 src/lib/format-utils.ts create mode 100644 src/lib/speaker-colors.ts delete mode 100644 src/lib/v2-debug.ts create mode 100644 src/types/index.ts diff --git a/bun.lock b/bun.lock index a5429c5..e69276e 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "uuid": "^13.0.0", + "zustand": "^5.0.11", }, "devDependencies": { "@eslint/js": "^9.39.2", @@ -2010,6 +2011,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], diff --git a/package.json b/package.json index 24f9f57..3920f04 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 1de2fc5..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { AnimatePresence } from "framer-motion"; -import { MainLayout } from "./components/layout/MainLayout"; - -// Default: General Feedback -window.feedbackType = "general"; - -declare global { - interface Window { - feedbackType: "general" | "issue" | "feature" | "other"; - } -} - -export default function App() { - return ( - - - - ); -} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..b9fa8ee --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from "react" + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/src/hooks/useV2Announcement.ts b/src/hooks/useV2Announcement.ts deleted file mode 100644 index 949230e..0000000 --- a/src/hooks/useV2Announcement.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from "react"; -import { initializeV2Debug } from "../lib/v2-debug"; - -// Hook to check if V2 announcement should be shown -export function useV2Announcement() { - const [shouldShow, setShouldShow] = useState(false); - - useEffect(() => { - // Check if user has seen V2 announcement - const hasSeenV2 = localStorage.getItem("seenV2"); - if (!hasSeenV2) { - setShouldShow(true); - } - - // Initialize debug function - initializeV2Debug(); - }, []); - - const hideAnnouncement = () => { - setShouldShow(false); - }; - - return { shouldShow, hideAnnouncement }; -} diff --git a/src/lib/export-formats.ts b/src/lib/export-formats.ts new file mode 100644 index 0000000..7c52456 --- /dev/null +++ b/src/lib/export-formats.ts @@ -0,0 +1,186 @@ +import { Document, Packer, Paragraph, TextRun, HeadingLevel } from "docx" +import type { TranscriptionSegment, TranscriptionIntelligence } from "@/types/transcription" +import { formatDuration } from "./format-utils" + +export const formatTimeForSRT = (seconds: number): string => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + const milliseconds = Math.round((seconds % 1) * 1000) + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")},${milliseconds.toString().padStart(3, "0")}` +} + +export const formatTimeForVTT = (seconds: number): string => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + const milliseconds = Math.round((seconds % 1) * 1000) + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${milliseconds.toString().padStart(3, "0")}` +} + +export const generateSRT = ( + transcription: string, + segments?: TranscriptionSegment[], +): string => { + if (!segments || segments.length === 0) { + return "1\n00:00:00,000 --> 00:00:00,100\n" + transcription + } + + return segments + .map((segment) => { + const startTime = formatTimeForSRT(segment.start) + const endTime = formatTimeForSRT(segment.end) + const speakerPrefix = segment.speaker ? `[Speaker ${segment.speaker}] ` : "" + return `${segment.id + 1}\n${startTime} --> ${endTime}\n${speakerPrefix}${segment.text.trim()}\n` + }) + .join("\n") +} + +export const generateVTT = ( + transcription: string, + segments?: TranscriptionSegment[], +): string => { + if (!segments || segments.length === 0) { + return "WEBVTT\n\n00:00:00.000 --> 00:00:00.100\n" + transcription + } + + let vtt = "WEBVTT\n\n" + vtt += segments + .map((segment) => { + const startTime = formatTimeForVTT(segment.start) + const endTime = formatTimeForVTT(segment.end) + const speakerPrefix = segment.speaker ? `[Speaker ${segment.speaker}] ` : "" + return `${startTime} --> ${endTime}\n${speakerPrefix}${segment.text.trim()}\n` + }) + .join("\n") + return vtt +} + +export const generateJSON = ( + transcription: string, + segments?: TranscriptionSegment[], + intelligence?: TranscriptionIntelligence, +): string => { + return JSON.stringify( + { + exportedAt: new Date().toISOString(), + transcription, + segments: segments || [], + intelligence: intelligence || undefined, + metadata: { + wordCount: transcription.split(/\s+/).length, + characterCount: transcription.length, + segmentCount: segments?.length || 0, + }, + }, + null, + 2, + ) +} + +export const generateCSV = ( + transcription: string, + segments?: TranscriptionSegment[], +): string => { + if (!segments || segments.length === 0) { + return 'id,start,end,text\n1,0,0,"' + transcription.replace(/"/g, '""') + '"' + } + const header = "id,start,end,duration,text" + const rows = segments.map( + (seg) => + `${seg.id},${seg.start.toFixed(3)},${seg.end.toFixed(3)},${(seg.end - seg.start).toFixed(3)},"${seg.text.replace(/"/g, '""')}"`, + ) + return [header, ...rows].join("\n") +} + +export const generateMarkdown = ( + transcription: string, + segments?: TranscriptionSegment[], + intelligence?: TranscriptionIntelligence, +): string => { + let md = "# Transcription\n\n" + md += `*Exported: ${new Date().toLocaleString()}*\n\n` + md += "---\n\n" + + if (intelligence?.summary) { + md += "## Summary\n\n" + md += `${intelligence.summary}\n\n` + md += "---\n\n" + } + + if (intelligence?.chapters && intelligence.chapters.length > 0) { + md += "## Chapters\n\n" + intelligence.chapters.forEach((ch) => { + md += `### ${ch.headline}\n\n` + md += `*${formatDuration(ch.start)} - ${formatDuration(ch.end)}*\n\n` + md += `${ch.summary}\n\n` + }) + md += "---\n\n" + } + + if (segments && segments.length > 0) { + md += "## Transcript\n\n" + segments.forEach((seg) => { + const speakerPrefix = seg.speaker ? `**Speaker ${seg.speaker}:** ` : "" + md += `**[${formatDuration(seg.start)} - ${formatDuration(seg.end)}]** ${speakerPrefix}\n\n` + md += `${seg.text.trim()}\n\n` + }) + } else { + md += "## Full Transcript\n\n" + md += transcription + } + + md += "\n---\n\n" + md += `*Word count: ${transcription.split(/\s+/).length}*\n` + return md +} + +export const generateDOCX = async ( + transcription: string, + _segments?: TranscriptionSegment[], + intelligence?: TranscriptionIntelligence, +): Promise => { + const docChildren: Paragraph[] = [ + new Paragraph({ + text: "Transcription", + heading: HeadingLevel.TITLE, + }), + new Paragraph({ children: [new TextRun("")] }), + ] + + if (intelligence?.summary) { + docChildren.push( + new Paragraph({ text: "Summary", heading: HeadingLevel.HEADING_1 }), + new Paragraph({ children: [new TextRun("")] }), + ...intelligence.summary + .split("\n") + .filter(Boolean) + .map( + (line: string) => + new Paragraph({ + children: [new TextRun(line.replace(/^[-*\u2022]\s*/, ""))], + }), + ), + new Paragraph({ children: [new TextRun("")] }), + ) + } + + docChildren.push( + new Paragraph({ text: "Transcript", heading: HeadingLevel.HEADING_1 }), + new Paragraph({ children: [new TextRun("")] }), + ...transcription.split("\n").map( + (line: string) => + new Paragraph({ + children: [new TextRun(line)], + }), + ), + ) + + const doc = new Document({ + sections: [{ children: docChildren }], + }) + const buffer = await Packer.toBuffer(doc) + return new Blob([new Uint8Array(buffer)], { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }) +} diff --git a/src/lib/firebase-utils.ts b/src/lib/firebase-utils.ts index 94a03dc..c098075 100644 --- a/src/lib/firebase-utils.ts +++ b/src/lib/firebase-utils.ts @@ -1,23 +1,9 @@ -import { initializeApp, FirebaseApp } from "firebase/app"; import { - getStorage, ref, uploadBytes, getDownloadURL, - FirebaseStorage, -} from "firebase/storage"; -import { getFirebaseConfig } from "@/lib/firebase"; - -let storage: FirebaseStorage; // Cache storage instance - -const initializeFirebase = (): FirebaseStorage => { - if (!storage) { - const firebaseApp: FirebaseApp = initializeApp(getFirebaseConfig()); - storage = getStorage(firebaseApp); - console.log("Firebase initialized for utils."); - } - return storage; -}; +} from "firebase/storage" +import { getStorage } from "@/lib/firebase" const generateUniqueFilename = (originalName: string): string => { const timestamp = Date.now(); @@ -36,7 +22,7 @@ export async function uploadBase64ToFirebase( base64Data: string, mimeType: string = "audio/mpeg", ): Promise<{ url: string; path: string }> { - const storageInstance = initializeFirebase(); + const storageInstance = getStorage() try { const base64WithoutPrefix = base64Data.replace(/^data:.*;base64,/, ""); diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index bf05150..e4485d4 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -1,5 +1,5 @@ -import { initializeApp } from "firebase/app"; -import { getStorage } from "firebase/storage"; +import { initializeApp, getApps, type FirebaseApp } from "firebase/app" +import { getStorage, type FirebaseStorage } from "firebase/storage" const firebaseConfig = { apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, @@ -8,11 +8,25 @@ const firebaseConfig = { storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, -}; +} -const app = initializeApp(firebaseConfig); -const storage = getStorage(app); +let app: FirebaseApp +let storage: FirebaseStorage -export const getFirebaseConfig = () => firebaseConfig; +const getApp = (): FirebaseApp => { + if (!app) { + const existing = getApps() + app = existing.length > 0 ? existing[0] : initializeApp(firebaseConfig) + } + return app +} -export { storage }; +const getFirebaseStorage = (): FirebaseStorage => { + if (!storage) { + storage = getStorage(getApp()) + } + return storage +} + +export { getFirebaseStorage as getStorage, getApp } +export const getFirebaseConfig = () => firebaseConfig diff --git a/src/lib/format-utils.ts b/src/lib/format-utils.ts new file mode 100644 index 0000000..708cc0d --- /dev/null +++ b/src/lib/format-utils.ts @@ -0,0 +1,12 @@ +export const formatDuration = (seconds?: number): string => { + if (!seconds) return "--:--" + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, "0")}` +} + +export const formatFileSize = (bytes?: number): string => { + if (!bytes) return "--" + const mb = bytes / (1024 * 1024) + return `${mb.toFixed(1)} MB` +} diff --git a/src/lib/speaker-colors.ts b/src/lib/speaker-colors.ts new file mode 100644 index 0000000..53d3fed --- /dev/null +++ b/src/lib/speaker-colors.ts @@ -0,0 +1,13 @@ +export const SPEAKER_COLORS = [ + { border: "border-l-blue-500", badge: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" }, + { border: "border-l-green-500", badge: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" }, + { border: "border-l-purple-500", badge: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" }, + { border: "border-l-orange-500", badge: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" }, + { border: "border-l-pink-500", badge: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200" }, + { border: "border-l-teal-500", badge: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200" }, +] as const + +export const getSpeakerColor = (speaker: string) => { + const index = speaker.charCodeAt(0) - "A".charCodeAt(0) + return SPEAKER_COLORS[index % SPEAKER_COLORS.length] +} diff --git a/src/lib/v2-debug.ts b/src/lib/v2-debug.ts deleted file mode 100644 index 688a22c..0000000 --- a/src/lib/v2-debug.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Debug utilities for V2 announcement modal - -// Extend the Window interface to include our debug function -declare global { - interface Window { - seenV2: (seen: boolean) => void; - } -} - -// Initialize debug function -export const initializeV2Debug = () => { - if (typeof window !== "undefined") { - window.seenV2 = (seen: boolean) => { - if (seen) { - localStorage.setItem("seenV2", "true"); - console.log("V2 announcement disabled - will not show on next visit"); - } else { - localStorage.removeItem("seenV2"); - console.log("V2 announcement enabled - will show on next page refresh"); - } - }; - } -}; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..2a347e3 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,15 @@ +export type { + TranscriptionWord, + TranscriptionSegment, + Chapter, + SentimentResult, + Entity, + KeyPhrase, + ContentSafety, + Topics, + TranscriptionIntelligence, + TranscriptionOutput, + AIFeatures, + TranscriptionOptions, + AudioSource, +} from "./transcription" diff --git a/src/types/transcription.ts b/src/types/transcription.ts index de613b1..9ff5889 100644 --- a/src/types/transcription.ts +++ b/src/types/transcription.ts @@ -76,3 +76,27 @@ export interface TranscriptionOutput { detected_language: string | null intelligence?: TranscriptionIntelligence } + +export interface AIFeatures { + autoChapters: boolean + summarization: boolean + sentimentAnalysis: boolean + entityDetection: boolean + keyPhrases: boolean + contentModeration: boolean + topicDetection: boolean +} + +export interface TranscriptionOptions { + language: string + diarize: boolean + aiFeatures: AIFeatures +} + +export interface AudioSource { + name?: string + url?: string + duration?: number + size?: number + type: "file" | "url" +} From e96ee1aeef8da92402d1ae3124ba21d0ffa4cea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdur-Rahm=C4=81n=20Bil=C4=81l?= Date: Sat, 14 Feb 2026 18:00:39 -0500 Subject: [PATCH 02/60] refactor: Phase 2 - Zustand stores for options and history - Create options-store with language, diarize, AI features state - Create history-store with IndexedDB-backed persistence - Both stores provide reactive state for the new route-based architecture Co-Authored-By: Claude Opus 4.6 --- src/stores/history-store.ts | 127 ++++++++++++++++++++++++++++++++++++ src/stores/options-store.ts | 65 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/stores/history-store.ts create mode 100644 src/stores/options-store.ts diff --git a/src/stores/history-store.ts b/src/stores/history-store.ts new file mode 100644 index 0000000..b441ff3 --- /dev/null +++ b/src/stores/history-store.ts @@ -0,0 +1,127 @@ +import { create } from "zustand" +import type { AudioSource, TranscriptionOptions } from "@/types/transcription" + +export interface HistoryEntry { + predictionId: string + audioSource: AudioSource + options: TranscriptionOptions + status: string + createdAt: number + result?: string +} + +interface HistoryStore { + entries: HistoryEntry[] + isLoaded: boolean + load: () => Promise + add: (entry: HistoryEntry) => Promise + remove: (predictionId: string) => Promise + clear: () => Promise +} + +const DB_NAME = "transcriptr-db" +const HISTORY_STORE_NAME = "transcription-history" +const DB_VERSION = 2 + +const initDb = (): Promise => { + return new Promise((resolve, reject) => { + if (typeof window === "undefined" || !window.indexedDB) { + reject(new Error("IndexedDB not available")) + return + } + + const request = window.indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => reject(new Error("Could not open IndexedDB")) + + request.onsuccess = () => resolve(request.result) + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + // Keep old store for migration if exists + if (!db.objectStoreNames.contains(HISTORY_STORE_NAME)) { + const store = db.createObjectStore(HISTORY_STORE_NAME, { + keyPath: "predictionId", + }) + store.createIndex("createdAt", "createdAt", { unique: false }) + store.createIndex("status", "status", { unique: false }) + } + + // Keep old sessions store if it exists (backward compat during transition) + if (!db.objectStoreNames.contains("transcription-sessions")) { + const sessStore = db.createObjectStore("transcription-sessions", { + keyPath: "id", + }) + sessStore.createIndex("status", "status", { unique: false }) + sessStore.createIndex("expiresAt", "expiresAt", { unique: false }) + sessStore.createIndex("predictionId", "predictionId", { unique: false }) + } + } + }) +} + +const dbOperation = async ( + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => IDBRequest, +): Promise => { + const db = await initDb() + return new Promise((resolve, reject) => { + const tx = db.transaction([HISTORY_STORE_NAME], mode) + const store = tx.objectStore(HISTORY_STORE_NAME) + const request = fn(store) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(new Error("IndexedDB operation failed")) + }) +} + +export const useHistoryStore = create((set, get) => ({ + entries: [], + isLoaded: false, + + load: async () => { + if (get().isLoaded) return + try { + const entries = await dbOperation("readonly", (store) => + store.getAll(), + ) + // Sort by createdAt descending + entries.sort((a, b) => b.createdAt - a.createdAt) + set({ entries, isLoaded: true }) + } catch (error) { + console.error("Failed to load history:", error) + set({ entries: [], isLoaded: true }) + } + }, + + add: async (entry) => { + try { + await dbOperation("readwrite", (store) => store.put(entry)) + set((state) => ({ + entries: [entry, ...state.entries.filter((e) => e.predictionId !== entry.predictionId)], + })) + } catch (error) { + console.error("Failed to add history entry:", error) + } + }, + + remove: async (predictionId) => { + try { + await dbOperation("readwrite", (store) => store.delete(predictionId)) + set((state) => ({ + entries: state.entries.filter((e) => e.predictionId !== predictionId), + })) + } catch (error) { + console.error("Failed to remove history entry:", error) + } + }, + + clear: async () => { + try { + await dbOperation("readwrite", (store) => store.clear()) + set({ entries: [] }) + } catch (error) { + console.error("Failed to clear history:", error) + } + }, +})) diff --git a/src/stores/options-store.ts b/src/stores/options-store.ts new file mode 100644 index 0000000..d534b44 --- /dev/null +++ b/src/stores/options-store.ts @@ -0,0 +1,65 @@ +import { create } from "zustand" +import type { AIFeatures } from "@/types/transcription" + +const DEFAULT_AI_FEATURES: AIFeatures = { + autoChapters: false, + summarization: false, + sentimentAnalysis: false, + entityDetection: false, + keyPhrases: false, + contentModeration: false, + topicDetection: false, +} + +interface OptionsStore { + language: string + diarize: boolean + aiFeatures: AIFeatures + setLanguage: (lang: string) => void + setDiarize: (val: boolean) => void + toggleAiFeature: (key: keyof AIFeatures) => void + setAllAiFeatures: (val: boolean) => void + reset: () => void +} + +export const useOptionsStore = create((set) => ({ + language: "auto", + diarize: false, + aiFeatures: DEFAULT_AI_FEATURES, + + setLanguage: (lang) => set({ language: lang }), + + setDiarize: (val) => set({ diarize: val }), + + toggleAiFeature: (key) => + set((state) => { + const updated = { ...state.aiFeatures, [key]: !state.aiFeatures[key] } + // autoChapters and summarization are mutually exclusive in AssemblyAI + if (key === "autoChapters" && updated.autoChapters) { + updated.summarization = false + } else if (key === "summarization" && updated.summarization) { + updated.autoChapters = false + } + return { aiFeatures: updated } + }), + + setAllAiFeatures: (val) => + set({ + aiFeatures: { + autoChapters: val, + summarization: false, // mutually exclusive with autoChapters + sentimentAnalysis: val, + entityDetection: val, + keyPhrases: val, + contentModeration: val, + topicDetection: val, + }, + }), + + reset: () => + set({ + language: "auto", + diarize: false, + aiFeatures: DEFAULT_AI_FEATURES, + }), +})) From f42f0fe6ebbc1295682aa38d925da680f9488b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdur-Rahm=C4=81n=20Bil=C4=81l?= Date: Sat, 14 Feb 2026 18:04:44 -0500 Subject: [PATCH 03/60] refactor: Phase 3 - route-based architecture - New upload page (/) - thin, focused on upload + redirect - New /transcribe/[id] page - processing, polling, results - New /studio/[id] page - fetches from API, full studio - New /history page - IndexedDB-backed history list - New /about page - marketing features overview - Unified Header with responsive nav + mobile drawer - Simplified Footer with link-based navigation - Delete god components: TranscriptionForm, TranscriptionProcessing, TranscriptionResult, TranscriptionError, SessionRecoveryPrompt - Delete mobile duplicates: MobileHeader, MobileFooter, MobileTranscriptionResult - Delete useSessionPersistence hook (replaced by URL routing) - State now lives in the URL (prediction ID = route) Co-Authored-By: Claude Opus 4.6 --- src/app/about/page.tsx | 154 ++++ src/app/history/page.tsx | 196 +++++ src/app/page.tsx | 342 ++++----- src/app/studio/[id]/page.tsx | 194 +++++ src/app/studio/page.tsx | 223 +----- src/app/transcribe/[id]/page.tsx | 388 ++++++++++ src/components/layout/Footer.tsx | 127 +--- src/components/layout/Header.tsx | 181 ++--- src/components/layout/MobileFooter.tsx | 118 --- src/components/layout/MobileHeader.tsx | 90 --- .../MobileTranscriptionResult.tsx | 265 ------- .../transcription/SessionRecoveryPrompt.tsx | 122 --- .../transcription/TranscriptionError.tsx | 212 ------ .../transcription/TranscriptionForm.tsx | 698 ------------------ .../transcription/TranscriptionProcessing.tsx | 97 --- .../transcription/TranscriptionResult-new.tsx | 223 ------ .../transcription/TranscriptionResult.tsx | 524 ------------- .../transcription/TranscriptionStudio.tsx | 44 +- src/hooks/useSessionPersistence.ts | 167 ----- tsconfig.tsbuildinfo | 2 +- 20 files changed, 1216 insertions(+), 3151 deletions(-) create mode 100644 src/app/about/page.tsx create mode 100644 src/app/history/page.tsx create mode 100644 src/app/studio/[id]/page.tsx create mode 100644 src/app/transcribe/[id]/page.tsx delete mode 100644 src/components/layout/MobileFooter.tsx delete mode 100644 src/components/layout/MobileHeader.tsx delete mode 100644 src/components/transcription/MobileTranscriptionResult.tsx delete mode 100644 src/components/transcription/SessionRecoveryPrompt.tsx delete mode 100644 src/components/transcription/TranscriptionError.tsx delete mode 100644 src/components/transcription/TranscriptionForm.tsx delete mode 100644 src/components/transcription/TranscriptionProcessing.tsx delete mode 100644 src/components/transcription/TranscriptionResult-new.tsx delete mode 100644 src/components/transcription/TranscriptionResult.tsx delete mode 100644 src/hooks/useSessionPersistence.ts diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..ea34423 --- /dev/null +++ b/src/app/about/page.tsx @@ -0,0 +1,154 @@ +import Link from "next/link" +import { Header } from "@/components/layout/Header" +import { Footer } from "@/components/layout/Footer" +import { + FileAudio, + Languages, + Brain, + Download, + Zap, + Users, +} from "lucide-react" + +const features = [ + { + icon: Brain, + title: "AI-Powered Analysis", + description: + "Auto chapters, summarization, sentiment analysis, entity detection, and key phrase extraction.", + }, + { + icon: Languages, + title: "30+ Languages", + description: + "Support for English, Spanish, French, German, Arabic, Chinese, Japanese, and many more.", + }, + { + icon: Download, + title: "Multiple Export Formats", + description: + "Export as TXT, DOCX, SRT, VTT, JSON, CSV, or Markdown with full timestamps.", + }, + { + icon: Zap, + title: "Fast Processing", + description: + "Powered by AssemblyAI with state-of-the-art speech recognition models.", + }, + { + icon: Users, + title: "Speaker Diarization", + description: + "Identify and label different speakers in conversations, interviews, and meetings.", + }, + { + icon: FileAudio, + title: "Studio Workspace", + description: + "Full studio experience with audio playback, karaoke highlighting, and keyboard shortcuts.", + }, +] + +const steps = [ + { step: "1", title: "Upload", description: "Drop a file or paste an audio URL" }, + { step: "2", title: "Transcribe", description: "AI processes your audio in minutes" }, + { step: "3", title: "Explore", description: "View, search, export, and analyze" }, +] + +export default function AboutPage() { + return ( +
+
+ +
+ {/* Hero */} +
+
+

+ Audio to Text,{" "} + Powered by AI +

+

+ Transcriptr converts your audio files into accurate, searchable text + with AI-powered transcription, speaker identification, and intelligent analysis. +

+ + + Start Transcribing + +
+
+ + {/* Features */} +
+
+

+ Features +

+
+ {features.map((feature) => ( +
+ +

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+
+ + {/* How it works */} +
+
+

+ How It Works +

+
+ {steps.map((s, i) => ( +
+
+ {s.step} +
+
+

{s.title}

+

{s.description}

+
+ {i < steps.length - 1 && ( +
+ )} +
+ ))} +
+
+
+ + {/* CTA */} +
+
+

+ Ready to get started? +

+

+ Free to use. No account required. +

+ + Start Transcribing + +
+
+
+ +
+
+ ) +} diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx new file mode 100644 index 0000000..55e80d7 --- /dev/null +++ b/src/app/history/page.tsx @@ -0,0 +1,196 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Header } from "@/components/layout/Header" +import { Footer } from "@/components/layout/Footer" +import { + ArrowLeft, + FileAudio, + Trash2, + ExternalLink, + Search, + Clock, + AlertCircle, +} from "lucide-react" +import { useHistoryStore, type HistoryEntry } from "@/stores/history-store" + +export default function HistoryPage() { + const router = useRouter() + const { entries, isLoaded, load, remove, clear } = useHistoryStore() + const [searchTerm, setSearchTerm] = useState("") + + useEffect(() => { + load() + }, [load]) + + const filtered = entries.filter((entry) => { + if (!searchTerm) return true + const term = searchTerm.toLowerCase() + return ( + (entry.audioSource.name?.toLowerCase().includes(term) ?? false) || + entry.predictionId.toLowerCase().includes(term) || + (entry.result?.toLowerCase().includes(term) ?? false) + ) + }) + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + } + + const handleOpen = (entry: HistoryEntry) => { + if (entry.status === "succeeded") { + router.push(`/studio/${entry.predictionId}`) + } else { + router.push(`/transcribe/${entry.predictionId}`) + } + } + + if (!isLoaded) { + return ( +
+
+
+
+
+
+ ) + } + + return ( +
+
+ +
+
+
+
+

History

+

+ Your past transcriptions +

+
+ {entries.length > 0 && ( + + )} +
+ + {entries.length > 0 && ( +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ )} + + {filtered.length === 0 ? ( + + +
+ +
+

+ {entries.length === 0 ? "No transcriptions yet" : "No results"} +

+

+ {entries.length === 0 + ? "Start your first transcription to see it here." + : "Try a different search term."} +

+ {entries.length === 0 && ( + + )} +
+
+ ) : ( +
+ {filtered.map((entry) => ( + handleOpen(entry)} + > + +
+
+ {entry.status === "succeeded" ? ( + + ) : entry.status === "processing" ? ( + + ) : ( + + )} +
+
+

+ {entry.audioSource.name || `Transcription ${entry.predictionId.slice(0, 8)}`} +

+
+ {formatDate(entry.createdAt)} + + {entry.status} + +
+
+
+
+ + +
+
+
+ ))} +
+ )} +
+
+ +
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d279290..375996d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,203 +1,159 @@ -"use client"; - -import { Suspense, useState, useEffect, lazy } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Card, CardContent } from "../components/ui/card"; -import { Header } from "../components/layout/Header"; -import { Footer } from "../components/layout/Footer"; -import { TranscriptionForm } from "../components/transcription/TranscriptionForm"; -import { Toaster } from "sonner"; -import { FeedbackModals } from "../components/feedback/FeedbackModals"; -import { ChangelogModal } from "../components/ChangelogModal"; -import { V3AnnouncementModal } from "../components/V3AnnouncementModal"; -import TranscriptionHistory from "../components/transcription/TranscriptionHistory"; -import { fadeInUp, expandCenter } from "../lib/animations"; -import { TranscriptionSession } from "@/lib/persistence-service"; - -const TranscriptionResult = lazy( - () => import("../components/transcription/TranscriptionResult"), -); -const TranscriptionError = lazy(() => - import("../components/transcription/TranscriptionError").then((module) => ({ - default: module.TranscriptionError, - })), -); - -export default function Page() { - const [showResult] = useState(false); - const [showError, setShowError] = useState(false); - const [showChangelogModal, setShowChangelogModal] = useState(false); - const [showHistoryModal, setShowHistoryModal] = useState(false); - const [showV3Modal, setShowV3Modal] = useState(false); - const [formKey, setFormKey] = useState(Date.now()); // Key to force re-render TranscriptionForm when needed - - // For handling session selection from history - const [selectedSession, setSelectedSession] = - useState(null); - const [transcriptionResult] = useState(null); - - // Auto-show V3.2 announcement for users who haven't seen it - useEffect(() => { - const hasSeen = localStorage.getItem("v3.2SAW") - if (!hasSeen) { - setShowV3Modal(true) - } - }, []) - - // Updated to use the new window method instead of direct DOM manipulation - const openFeedbackModal = (type: "general" | "issue" | "feature") => { - if (window.openFeedbackModal) { - window.openFeedbackModal(type); - } - }; - - const openChangelogModal = () => { - setShowChangelogModal(true); - }; - - const closeChangelogModal = () => { - setShowChangelogModal(false); - }; - - const openV3Modal = () => { - setShowV3Modal(true); - }; - - const closeV3Modal = () => { - setShowV3Modal(false); - }; - - const openHistoryModal = () => { - setShowHistoryModal(true); - }; - - const closeHistoryModal = () => { - setShowHistoryModal(false); - }; - - const handleDeleteSession = async (sessionId: string) => { - try { - // Import dynamically to prevent circular dependencies - const { deleteSession } = await import("@/lib/persistence-service"); - await deleteSession(sessionId); - } catch (error) { - console.error("Failed to delete session:", error); - } - }; - - const handleSelectSession = (session: TranscriptionSession) => { - setSelectedSession(session); - closeHistoryModal(); - // Force re-render the TranscriptionForm component to pick up the selected session - setFormKey(Date.now()); - }; +"use client" + +import { useState, useCallback, Suspense } from "react" +import { useRouter } from "next/navigation" +import { UploadAudio } from "@/components/UploadAudio" +import { Header } from "@/components/layout/Header" +import { Footer } from "@/components/layout/Footer" +import { Toaster, toast } from "sonner" +import { FeedbackModals } from "@/components/feedback/FeedbackModals" +import { uploadLargeFile } from "@/lib/storage-service" +import { getApiUrl } from "@/services/transcription" +import { getUserFriendlyErrorMessage } from "@/lib/error-utils" +import { useHistoryStore } from "@/stores/history-store" +import type { AIFeatures } from "@/types/transcription" + +export default function UploadPage() { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + const addToHistory = useHistoryStore((s) => s.add) + + const handleUpload = useCallback( + async ( + data: FormData | { audioUrl: string }, + options: { language: string; diarize: boolean; aiFeatures: AIFeatures }, + ) => { + setIsSubmitting(true) + + try { + const requestBody: { + options: { language?: string; diarize?: boolean; aiFeatures?: AIFeatures } | null + audioUrl?: string + } = { options: null } + + let audioSourceName = "Audio" + let audioSourceSize: number | undefined + let audioSourceType: "file" | "url" = "url" + let audioUrl: string | undefined + + if (data instanceof FormData) { + const file = data.get("file") as File + if (!file) throw new Error("No file found") + + audioSourceName = file.name + audioSourceSize = file.size + audioSourceType = "file" + + toast.info("Uploading file...") + const uploadResult = await uploadLargeFile(file) + requestBody.audioUrl = uploadResult.url + audioUrl = uploadResult.url + localStorage.setItem("studioAudioUrl", uploadResult.url) + } else { + requestBody.audioUrl = data.audioUrl + audioUrl = data.audioUrl + audioSourceName = data.audioUrl + audioSourceType = "url" + localStorage.setItem("studioAudioUrl", data.audioUrl) + } + + requestBody.options = { + language: options.language, + diarize: options.diarize || false, + aiFeatures: options.aiFeatures, + } + + toast.info("Starting transcription...") + + const response = await fetch(getApiUrl("transcribe"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }) + + if (!response.ok) { + let errorBody = "Unknown server error" + try { + const errorJson = await response.json() + errorBody = errorJson.error || errorJson.message || JSON.stringify(errorJson) + } catch { + errorBody = `Server error (${response.status})` + } + throw new Error(errorBody) + } + + const resultData = await response.json() + + if (!resultData?.id) { + throw new Error("Invalid API response: Missing prediction ID") + } + + if (resultData.audioUrl) { + localStorage.setItem("studioAudioUrl", resultData.audioUrl) + audioUrl = resultData.audioUrl + } + + addToHistory({ + predictionId: resultData.id, + audioSource: { + name: audioSourceName, + size: audioSourceSize, + type: audioSourceType, + url: audioUrl, + }, + options, + status: "processing", + createdAt: Date.now(), + }) + + router.push(`/transcribe/${resultData.id}`) + } catch (err) { + console.error("Upload failed:", err) + const errorInfo = getUserFriendlyErrorMessage(err) + toast.error(errorInfo.userMessage) + setIsSubmitting(false) + } + }, + [router, addToHistory], + ) return ( -
-
-
- - - - - - Loading form...
- } - > - - - - - - -
- -