|
import { useState, useRef, useEffect, useCallback, useMemo } from "react"; |
|
import { useVoxtral } from "./useVoxtral"; |
|
import HfIcon from "./HfIcon"; |
|
|
|
type Transcription = { |
|
id: string; |
|
filename: string; |
|
date: string; |
|
text: string | null; |
|
audioKey?: string; |
|
language: string; |
|
}; |
|
|
|
type Screen = "intro" | "loading" | "main"; |
|
|
|
const LANGUAGES = [ |
|
{ code: "en", label: "English", icon: "🇬🇧" }, |
|
{ code: "fr", label: "Français", icon: "🇫🇷" }, |
|
{ code: "de", label: "Deutsch", icon: "🇩🇪" }, |
|
{ code: "es", label: "Español", icon: "🇪🇸" }, |
|
{ code: "it", label: "Italiano", icon: "🇮🇹" }, |
|
{ code: "pt", label: "Português", icon: "🇵🇹" }, |
|
{ code: "nl", label: "Nederlands", icon: "🇳🇱" }, |
|
{ code: "hi", label: "हिन्दी", icon: "🇮🇳" }, |
|
]; |
|
|
|
const DB_NAME = "voxtral-db"; |
|
const DB_VERSION = 1; |
|
const HISTORY_STORE = "history"; |
|
const AUDIO_STORE = "audio"; |
|
|
|
let dbPromise: Promise<IDBDatabase> | null = null; |
|
|
|
function getDB(): Promise<IDBDatabase> { |
|
if (!dbPromise) { |
|
dbPromise = new Promise((resolve, reject) => { |
|
const req = indexedDB.open(DB_NAME, DB_VERSION); |
|
req.onupgradeneeded = () => { |
|
const db = req.result; |
|
if (!db.objectStoreNames.contains(HISTORY_STORE)) { |
|
db.createObjectStore(HISTORY_STORE, { keyPath: "id" }); |
|
} |
|
if (!db.objectStoreNames.contains(AUDIO_STORE)) { |
|
db.createObjectStore(AUDIO_STORE, { keyPath: "key" }); |
|
} |
|
}; |
|
req.onsuccess = () => resolve(req.result); |
|
req.onerror = () => { |
|
dbPromise = null; |
|
reject(req.error); |
|
}; |
|
}); |
|
} |
|
return dbPromise; |
|
} |
|
|
|
function promiseify<T>(request: IDBRequest<T>): Promise<T> { |
|
return new Promise((resolve, reject) => { |
|
request.onsuccess = () => resolve(request.result); |
|
request.onerror = () => reject(request.error); |
|
}); |
|
} |
|
|
|
function transactionPromise(tx: IDBTransaction): Promise<void> { |
|
return new Promise((resolve, reject) => { |
|
tx.oncomplete = () => resolve(); |
|
tx.onerror = () => reject(tx.error); |
|
}); |
|
} |
|
|
|
async function getHistoryDB(): Promise<Transcription[]> { |
|
const db = await getDB(); |
|
const tx = db.transaction(HISTORY_STORE, "readonly"); |
|
const store = tx.objectStore(HISTORY_STORE); |
|
return (await promiseify(store.getAll())) as Transcription[]; |
|
} |
|
|
|
async function saveHistoryDB(history: Transcription[]) { |
|
const db = await getDB(); |
|
const tx = db.transaction(HISTORY_STORE, "readwrite"); |
|
const store = tx.objectStore(HISTORY_STORE); |
|
history.forEach((item) => store.put(item)); |
|
return transactionPromise(tx); |
|
} |
|
|
|
async function removeHistoryItemDB(id: string) { |
|
const db = await getDB(); |
|
const tx = db.transaction(HISTORY_STORE, "readwrite"); |
|
tx.objectStore(HISTORY_STORE).delete(id); |
|
return transactionPromise(tx); |
|
} |
|
|
|
async function saveAudioToDB(key: string, file: File): Promise<void> { |
|
const db = await getDB(); |
|
const arrayBuffer = await file.arrayBuffer(); |
|
const tx = db.transaction(AUDIO_STORE, "readwrite"); |
|
tx.objectStore(AUDIO_STORE).put({ key, buffer: arrayBuffer, type: file.type }); |
|
return transactionPromise(tx); |
|
} |
|
|
|
async function getAudioUrlFromDB(key: string): Promise<string | null> { |
|
try { |
|
const db = await getDB(); |
|
const tx = db.transaction(AUDIO_STORE, "readonly"); |
|
const result = await promiseify(tx.objectStore(AUDIO_STORE).get(key)); |
|
|
|
if (result?.buffer) { |
|
const blob = new Blob([result.buffer], { type: result.type || "audio/wav" }); |
|
return URL.createObjectURL(blob); |
|
} |
|
return null; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
async function removeAudioFromDB(key?: string) { |
|
if (!key) return; |
|
const db = await getDB(); |
|
const tx = db.transaction(AUDIO_STORE, "readwrite"); |
|
tx.objectStore(AUDIO_STORE).delete(key); |
|
return transactionPromise(tx); |
|
} |
|
|
|
function inferAudioKey(item: Transcription): string | undefined { |
|
return item.audioKey ?? (item.id ? `voxtral_audio_${item.id}` : undefined); |
|
} |
|
|
|
async function fileToAudioBuffer(file: File, targetSampleRate: number): Promise<AudioBuffer | null> { |
|
try { |
|
const arrayBuffer = await file.arrayBuffer(); |
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); |
|
const decoded = await audioCtx.decodeAudioData(arrayBuffer); |
|
|
|
if (decoded.sampleRate === targetSampleRate) { |
|
await audioCtx.close(); |
|
return decoded; |
|
} |
|
|
|
const offlineCtx = new OfflineAudioContext( |
|
decoded.numberOfChannels, |
|
Math.ceil(decoded.duration * targetSampleRate), |
|
targetSampleRate, |
|
); |
|
const src = offlineCtx.createBufferSource(); |
|
src.buffer = decoded; |
|
src.connect(offlineCtx.destination); |
|
src.start(); |
|
const rendered = await offlineCtx.startRendering(); |
|
await audioCtx.close(); |
|
return rendered; |
|
} catch (error) { |
|
console.error("Failed to decode or resample audio:", error); |
|
return null; |
|
} |
|
} |
|
|
|
export default function App() { |
|
const [screen, setScreen] = useState<Screen>("intro"); |
|
const [history, setHistory] = useState<Transcription[]>([]); |
|
const [viewedTranscription, setViewedTranscription] = useState<Transcription | null>(null); |
|
const [pendingTranscriptionId, setPendingTranscriptionId] = useState<string | null>(null); |
|
const [audioSaveError, setAudioSaveError] = useState<string | null>(null); |
|
const [editingFilename, setEditingFilename] = useState(false); |
|
const [selectedLanguage, setSelectedLanguage] = useState("en"); |
|
const [search, setSearch] = useState(""); |
|
|
|
const [audioUrlCache, setAudioUrlCache] = useState<Map<string, string>>(new Map()); |
|
const { status, error, transcription, setTranscription, loadModel, transcribe, stopTranscription } = useVoxtral(); |
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null); |
|
const filenameInputRef = useRef<HTMLInputElement | null>(null); |
|
const introRef = useRef<HTMLDivElement>(null); |
|
const urlCacheRef = useRef(audioUrlCache); |
|
|
|
const sortedHistory = useMemo(() => [...history].sort((a, b) => Number(b.id) - Number(a.id)), [history]); |
|
const currentTranscription = useMemo(() => { |
|
if (!viewedTranscription) return ""; |
|
if (pendingTranscriptionId === viewedTranscription.id) { |
|
return transcription; |
|
} |
|
return viewedTranscription.text; |
|
}, [viewedTranscription, pendingTranscriptionId, transcription]); |
|
|
|
const audioSrc = useMemo(() => { |
|
if (!viewedTranscription) return null; |
|
const key = inferAudioKey(viewedTranscription); |
|
return key ? (audioUrlCache.get(key) ?? null) : null; |
|
}, [viewedTranscription, audioUrlCache]); |
|
|
|
const filteredHistory = useMemo(() => { |
|
if (!search.trim()) return sortedHistory; |
|
const s = search.trim().toLowerCase(); |
|
return sortedHistory.filter( |
|
(item) => item.filename.toLowerCase().includes(s) || (item.text && item.text.toLowerCase().includes(s)), |
|
); |
|
}, [sortedHistory, search]); |
|
|
|
const handleFile = useCallback( |
|
async (file: File) => { |
|
const id = Date.now().toString(); |
|
const audioKey = `voxtral_audio_${id}`; |
|
setAudioSaveError(null); |
|
|
|
const objectUrl = URL.createObjectURL(file); |
|
setAudioUrlCache((prev) => { |
|
if (prev.get(audioKey) === objectUrl) return prev; |
|
return new Map(prev).set(audioKey, objectUrl); |
|
}); |
|
|
|
const entry: Transcription = { |
|
id, |
|
filename: file.name.replace(/\.[^/.]+$/, ""), |
|
date: new Date().toLocaleString(), |
|
text: null, |
|
audioKey, |
|
language: selectedLanguage, |
|
}; |
|
|
|
setTranscription(""); |
|
setHistory((prev) => [entry, ...prev]); |
|
setViewedTranscription(entry); |
|
setPendingTranscriptionId(id); |
|
|
|
saveAudioToDB(audioKey, file).catch((e) => { |
|
console.error("DB save error:", e); |
|
setAudioSaveError("Failed to save to IndexedDB. Storage may be full."); |
|
}); |
|
saveHistoryDB([entry]).catch(() => {}); |
|
|
|
const audioBuffer = await fileToAudioBuffer(file, 16000); |
|
const result = audioBuffer |
|
? await transcribe(audioBuffer.getChannelData(0), selectedLanguage) |
|
: "Failed to decode audio."; |
|
|
|
const finalEntry = { ...entry, text: result ?? "Transcription failed." }; |
|
setHistory((currentHistory) => { |
|
const finalHistory = currentHistory.map((h) => (h.id === id ? finalEntry : h)); |
|
saveHistoryDB(finalHistory); |
|
return finalHistory; |
|
}); |
|
setViewedTranscription(finalEntry); |
|
setPendingTranscriptionId(null); |
|
}, |
|
[transcribe, setTranscription, selectedLanguage], |
|
); |
|
|
|
const deleteHistoryItem = useCallback( |
|
async (e: React.MouseEvent, item: Transcription) => { |
|
e.stopPropagation(); |
|
const isCurrentlyViewed = viewedTranscription?.id === item.id; |
|
const key = inferAudioKey(item); |
|
const newHistory = history.filter((h) => h.id !== item.id); |
|
|
|
setHistory(newHistory); |
|
if (isCurrentlyViewed) { |
|
setViewedTranscription(newHistory.length > 0 ? newHistory[0] : null); |
|
} |
|
|
|
await removeHistoryItemDB(item.id); |
|
await removeAudioFromDB(key); |
|
|
|
if (key) { |
|
setAudioUrlCache((prev) => { |
|
const newCache = new Map(prev); |
|
const urlToRevoke = newCache.get(key); |
|
if (urlToRevoke) { |
|
URL.revokeObjectURL(urlToRevoke); |
|
} |
|
newCache.delete(key); |
|
return newCache; |
|
}); |
|
} |
|
}, |
|
[history, viewedTranscription], |
|
); |
|
|
|
const updateFilename = useCallback(async (id: string, newFilename: string) => { |
|
setHistory((prev) => { |
|
const updated = prev.map((h) => (h.id === id ? { ...h, filename: newFilename } : h)); |
|
saveHistoryDB(updated); |
|
return updated; |
|
}); |
|
}, []); |
|
|
|
const updateTranscriptionText = useCallback(async (id: string, newText: string) => { |
|
setViewedTranscription((prev) => (prev && prev.id === id ? { ...prev, text: newText } : prev)); |
|
setHistory((prev) => { |
|
const updated = prev.map((h) => (h.id === id ? { ...h, text: newText } : h)); |
|
saveHistoryDB(updated); |
|
return updated; |
|
}); |
|
}, []); |
|
|
|
useEffect(() => { |
|
const input = document.createElement("input"); |
|
input.type = "file"; |
|
input.accept = "audio/*,video/*"; |
|
input.style.display = "none"; |
|
const handleChange = (e: Event) => { |
|
const target = e.target as HTMLInputElement; |
|
const file = target.files?.[0]; |
|
if (file) { |
|
handleFile(file); |
|
} |
|
target.value = ""; |
|
}; |
|
input.addEventListener("change", handleChange); |
|
document.body.appendChild(input); |
|
fileInputRef.current = input; |
|
|
|
return () => { |
|
input.removeEventListener("change", handleChange); |
|
document.body.removeChild(input); |
|
}; |
|
}, [handleFile]); |
|
|
|
useEffect(() => { |
|
(async () => { |
|
const hist = await getHistoryDB(); |
|
setHistory(hist); |
|
})(); |
|
}, []); |
|
|
|
useEffect(() => { |
|
if (!viewedTranscription) return; |
|
|
|
const key = inferAudioKey(viewedTranscription); |
|
if (!key || audioUrlCache.has(key)) { |
|
return; |
|
} |
|
|
|
let cancelled = false; |
|
(async () => { |
|
const url = await getAudioUrlFromDB(key); |
|
if (url && !cancelled) { |
|
setAudioUrlCache((prev) => new Map(prev).set(key, url)); |
|
} |
|
})(); |
|
|
|
return () => { |
|
cancelled = true; |
|
}; |
|
}, [viewedTranscription, audioUrlCache]); |
|
|
|
useEffect(() => { |
|
return () => { |
|
for (const url of urlCacheRef.current.values()) { |
|
URL.revokeObjectURL(url); |
|
} |
|
}; |
|
}, []); |
|
|
|
useEffect(() => { |
|
if (screen === "main" && sortedHistory.length > 0 && !viewedTranscription) { |
|
setViewedTranscription(sortedHistory[0]); |
|
} |
|
}, [screen, sortedHistory, viewedTranscription]); |
|
|
|
useEffect(() => { |
|
if (screen !== "intro" || !introRef.current) return; |
|
const ref = introRef.current; |
|
const handleMouseMove = (e: MouseEvent) => { |
|
const { clientX, clientY } = e; |
|
const { offsetWidth, offsetHeight } = ref; |
|
const x = (clientX / offsetWidth) * 100; |
|
const y = (clientY / offsetHeight) * 100; |
|
ref.style.setProperty("--mouse-x", `${x}%`); |
|
ref.style.setProperty("--mouse-y", `${y}%`); |
|
}; |
|
window.addEventListener("mousemove", handleMouseMove); |
|
return () => window.removeEventListener("mousemove", handleMouseMove); |
|
}, [screen]); |
|
|
|
useEffect(() => { |
|
if (editingFilename && filenameInputRef.current) { |
|
filenameInputRef.current.focus(); |
|
filenameInputRef.current.select(); |
|
} |
|
}, [editingFilename]); |
|
|
|
if (screen === "intro") { |
|
return ( |
|
<div |
|
ref={introRef} |
|
className="relative flex flex-col items-center justify-center min-h-screen bg-[#0A0A0A] text-white overflow-hidden" |
|
style={{ "--mouse-x": "50%", "--mouse-y": "50%" } as React.CSSProperties} |
|
> |
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:3rem_3rem]"></div> |
|
<div className="absolute inset-0 bg-[radial-gradient(circle_400px_at_var(--mouse-x)_var(--mouse-y),rgba(124,58,237,0.2),transparent_80%)]"></div> |
|
|
|
<main className="text-center z-10 p-4"> |
|
<div className="inline-block mb-6 px-3 py-1 text-md bg-gray-800/50 border border-gray-700 rounded-full"> |
|
Powered by{" "} |
|
<a href="https://github.com/huggingface/transformers.js" target="_blank" rel="noopener noreferrer"> |
|
<HfIcon className="w-5 inline translate-y-[-1px]" /> |
|
Transformers.js |
|
</a> |
|
</div> |
|
<h1 className="text-7xl font-extrabold tracking-tight mb-4">Voxtral WebGPU</h1> |
|
<p className="max-w-2xl mx-auto text-xl text-gray-400 mb-8"> |
|
State-of-the-art audio transcription directly in your browser. |
|
</p> |
|
<div className="max-w-xl mx-auto text-md text-gray-500 mb-8 space-y-2 text-left bg-black/20 p-4 border border-gray-800 rounded-lg"> |
|
<p> |
|
You are about to load{" "} |
|
<a |
|
href="https://huggingface.co/onnx-community/Voxtral-Mini-3B-2507-ONNX" |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
className="text-purple-400 opacity-90 hover:opacity-100 hover:underline font-semibold transition-opacity" |
|
> |
|
Voxtral-Mini |
|
</a> |
|
, a 4.68B parameter model, optimized for inference on the web. |
|
</p> |
|
<p> |
|
Everything runs entirely in your browser with <strong>Transformers.js</strong> and{" "} |
|
<strong>ONNX Runtime Web</strong>, meaning no data is sent to a server. |
|
</p> |
|
<p>Get started by clicking the button below.</p> |
|
</div> |
|
<div className="flex justify-center"> |
|
<button |
|
className="px-6 py-3 bg-purple-600 rounded-lg font-semibold hover:bg-purple-700 transition-transform hover:scale-105 cursor-pointer" |
|
onClick={async () => { |
|
setScreen("loading"); |
|
await loadModel(); |
|
setScreen("main"); |
|
}} |
|
> |
|
Load Model |
|
</button> |
|
</div> |
|
</main> |
|
</div> |
|
); |
|
} |
|
|
|
if (screen === "loading" || status === "loading") { |
|
return ( |
|
<div className="flex flex-col items-center justify-center min-h-screen bg-[#0A0A0A] text-white p-4"> |
|
<div className="flex flex-col items-center p-8 bg-gray-900/50 border border-gray-700 rounded-xl shadow-lg w-full max-w-2xl"> |
|
<h2 className="text-2xl font-semibold mb-2 text-purple-400">Loading Voxtral Model...</h2> |
|
<p className="text-gray-400 text-center mb-8"> |
|
This may take some time to download on first load. |
|
<br /> |
|
Afterwards, the model will be cached for future use. |
|
</p> |
|
<div className="w-full space-y-3"> |
|
<div className="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden"> |
|
<div |
|
className="bg-purple-500 h-2.5 rounded-full animate-progress" |
|
style={{ width: "75%", animationDelay: "0.1s" }} |
|
></div> |
|
</div> |
|
<div className="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden"> |
|
<div |
|
className="bg-purple-500 h-2.5 rounded-full animate-progress" |
|
style={{ width: "75%", animationDelay: "0.2s" }} |
|
></div> |
|
</div> |
|
<div className="w-full bg-gray-700 rounded-full h-2.5 overflow-hidden"> |
|
<div |
|
className="bg-purple-500 h-2.5 rounded-full animate-progress" |
|
style={{ width: "75%", animationDelay: "0.3s" }} |
|
></div> |
|
</div> |
|
</div> |
|
{error && ( |
|
<div className="mt-6 text-red-500 bg-red-900/20 border border-red-500/30 p-3 rounded-lg">{error}</div> |
|
)} |
|
</div> |
|
</div> |
|
); |
|
} |
|
|
|
const isProcessing = pendingTranscriptionId && pendingTranscriptionId === viewedTranscription?.id; |
|
|
|
return ( |
|
<div className="flex h-screen bg-[#0A0A0A] text-white font-sans"> |
|
<aside className="w-80 bg-black/30 border-r border-gray-800 flex flex-col"> |
|
<div className="p-4 border-b border-gray-800 flex items-center justify-between"> |
|
<div className="flex flex-col flex-1"> |
|
<h2 className="text-lg font-bold text-gray-200">Transcript History</h2> |
|
<select |
|
className="mt-2 bg-gray-800 text-gray-200 rounded px-2 py-1 text-sm border border-gray-700 focus:outline-none focus:ring-2 focus:ring-purple-500 w-[150px]" |
|
value={selectedLanguage} |
|
onChange={(e) => setSelectedLanguage(e.target.value)} |
|
title="Select language" |
|
> |
|
{LANGUAGES.map((lang) => ( |
|
<option key={lang.code} value={lang.code}> |
|
{lang.label} |
|
</option> |
|
))} |
|
</select> |
|
<input |
|
className="mt-2 bg-gray-800 text-gray-200 rounded px-2 py-1 text-sm border border-gray-700 focus:outline-none focus:ring-2 focus:ring-purple-500 w-full" |
|
type="text" |
|
placeholder="Search transcripts..." |
|
value={search} |
|
onChange={(e) => setSearch(e.target.value)} |
|
/> |
|
</div> |
|
<button |
|
className="ml-2 p-2 rounded-full bg-gray-800 hover:bg-gray-700 text-gray-300 transition-colors disabled:bg-gray-800/50 disabled:text-gray-500 disabled:cursor-not-allowed" |
|
title="Add new file" |
|
onClick={() => fileInputRef.current?.click()} |
|
disabled={status === "transcribing"} |
|
> |
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> |
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /> |
|
</svg> |
|
</button> |
|
</div> |
|
<div className="flex-1 overflow-y-auto"> |
|
{filteredHistory.length === 0 ? ( |
|
<div className="p-6 text-center text-gray-500 text-sm">No transcriptions yet.</div> |
|
) : ( |
|
<ul> |
|
{filteredHistory.map((item) => { |
|
const language = LANGUAGES.find((l) => l.code === item.language); |
|
return ( |
|
<li |
|
key={item.id} |
|
className={`border-b border-gray-800 px-4 py-3 hover:bg-gray-800/50 transition-colors cursor-pointer flex items-start group relative ${viewedTranscription?.id === item.id ? "bg-purple-900/20" : ""}`} |
|
onClick={() => setViewedTranscription(item)} |
|
> |
|
{viewedTranscription?.id === item.id && ( |
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-purple-500 rounded-r-full"></div> |
|
)} |
|
<div className="flex-1 min-w-0 pl-2 flex items-center gap-2"> |
|
<div className="flex-1 min-w-0"> |
|
<div className="font-semibold text-gray-300 truncate">{item.filename}</div> |
|
<div className="text-xs text-gray-500 mb-1">{item.date}</div> |
|
<div className="text-sm text-gray-400 line-clamp-2"> |
|
{item.text !== null ? ( |
|
item.text |
|
) : ( |
|
<span className="text-gray-500 italic">Transcription in progress...</span> |
|
)} |
|
</div> |
|
</div> |
|
<span className="text-lg flex-shrink-0 ml-2" title={language?.label || item.language}> |
|
{language?.icon || "🌐"} |
|
</span> |
|
</div> |
|
<button |
|
className="ml-2 text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" |
|
title="Delete transcription" |
|
onClick={(e) => deleteHistoryItem(e, item)} |
|
> |
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> |
|
<path |
|
fillRule="evenodd" |
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" |
|
clipRule="evenodd" |
|
></path> |
|
</svg> |
|
</button> |
|
</li> |
|
); |
|
})} |
|
</ul> |
|
)} |
|
</div> |
|
</aside> |
|
|
|
<main className="flex-1 flex flex-col overflow-y-auto relative"> |
|
<div className="w-full h-full flex-1 p-8 flex justify-center"> |
|
<div className="w-full max-w-4xl h-full flex flex-col"> |
|
{audioSaveError && ( |
|
<div className="mb-4 text-red-500 bg-red-900/20 border border-red-500/30 p-3 rounded-lg"> |
|
{audioSaveError} |
|
</div> |
|
)} |
|
{!viewedTranscription ? ( |
|
<div className="flex-1 flex flex-col items-center justify-center text-center text-gray-600"> |
|
<svg className="w-16 h-16 mb-4 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
<path |
|
strokeLinecap="round" |
|
strokeLinejoin="round" |
|
strokeWidth="1" |
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" |
|
/> |
|
</svg> |
|
<h2 className="text-2xl font-bold text-gray-500">Select a transcription</h2> |
|
<p className="text-gray-600">Choose an item from the history or add a new file to begin.</p> |
|
</div> |
|
) : ( |
|
<div className="flex flex-col h-full"> |
|
<div className="mb-4 relative"> |
|
{editingFilename ? ( |
|
<input |
|
ref={filenameInputRef} |
|
className="text-2xl font-bold text-gray-200 truncate bg-gray-800 border border-purple-500 rounded px-2 py-1 outline-none w-full" |
|
style={{ |
|
lineHeight: "2.25rem", |
|
minHeight: "2.75rem", |
|
height: "2.75rem", |
|
fontFamily: "inherit", |
|
fontWeight: "700", |
|
fontSize: "1.5rem", |
|
padding: "0.25rem 0.5rem", |
|
}} |
|
value={viewedTranscription.filename} |
|
onChange={(e) => setViewedTranscription({ ...viewedTranscription, filename: e.target.value })} |
|
onBlur={() => { |
|
setEditingFilename(false); |
|
updateFilename(viewedTranscription.id, viewedTranscription.filename); |
|
}} |
|
onKeyDown={(e) => { |
|
if (e.key === "Enter" || e.key === "Escape") { |
|
e.preventDefault(); |
|
setEditingFilename(false); |
|
updateFilename(viewedTranscription.id, viewedTranscription.filename); |
|
} |
|
}} |
|
/> |
|
) : ( |
|
<h1 |
|
className="text-2xl font-bold text-gray-200 truncate cursor-pointer hover:underline w-full px-2 py-1 rounded" |
|
style={{ |
|
lineHeight: "2.25rem", |
|
minHeight: "2.75rem", |
|
height: "2.75rem", |
|
fontFamily: "inherit", |
|
fontWeight: "700", |
|
fontSize: "1.5rem", |
|
padding: "0.25rem 0.5rem", |
|
border: "1px solid transparent", |
|
boxSizing: "border-box", |
|
pointerEvents: isProcessing ? "none" : "auto", |
|
}} |
|
title="Click to rename" |
|
onClick={() => setEditingFilename(true)} |
|
> |
|
{viewedTranscription.filename} |
|
</h1> |
|
)} |
|
<p className="text-sm text-gray-500 pl-2">{viewedTranscription.date}</p> |
|
</div> |
|
|
|
<div className="mb-6"> |
|
<audio src={audioSrc || undefined} controls className="w-full styled-audio" /> |
|
</div> |
|
|
|
<div className="flex-1 flex flex-col min-h-0 relative"> |
|
<div className="absolute inset-0 bg-gray-900/50 border border-gray-700 rounded-lg p-1"> |
|
<textarea |
|
className="w-full h-full bg-transparent rounded-md p-4 text-gray-300 font-mono text-base resize-none focus:outline-none focus:ring-2 focus:ring-purple-500/50 placeholder:text-gray-500" |
|
value={currentTranscription || ""} |
|
onChange={(e) => updateTranscriptionText(viewedTranscription.id, e.target.value)} |
|
readOnly={!!isProcessing} |
|
/> |
|
</div> |
|
{isProcessing && ( |
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/90 rounded-lg z-10"> |
|
<div className="flex flex-col items-center text-gray-400 relative"> |
|
<span className="relative flex h-10 w-10"> |
|
<span className="relative inline-flex rounded-full h-10 w-10 items-center justify-center animate-spin-slow"> |
|
<svg className="h-7 w-7 text-purple-400" viewBox="0 0 24 24" fill="none"> |
|
<circle |
|
className="opacity-30" |
|
cx="12" |
|
cy="12" |
|
r="10" |
|
stroke="currentColor" |
|
strokeWidth="4" |
|
/> |
|
<path |
|
d="M22 12a10 10 0 00-10-10" |
|
stroke="currentColor" |
|
strokeWidth="4" |
|
strokeLinecap="round" |
|
className="text-purple-500" |
|
/> |
|
</svg> |
|
</span> |
|
</span> |
|
<span className="mt-2 pointer-events-none"> |
|
{transcription.length === 0 ? "Processing audio..." : "Transcribing..."} |
|
</span> |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
<div className="flex justify-between mt-4"> |
|
{isProcessing ? ( |
|
<button |
|
className="bottom-8 left-8 z-20 px-4 py-2 bg-gray-800 text-red-400 rounded shadow-lg transition-colors text-base font-medium flex items-center gap-2 cursor-pointer hover:bg-red-900 hover:text-white" |
|
title="Stop transcription" |
|
onClick={stopTranscription} |
|
> |
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> |
|
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" /> |
|
</svg> |
|
Stop |
|
</button> |
|
) : ( |
|
<span /> |
|
)} |
|
<button |
|
className={`bottom-8 right-8 z-20 px-4 py-2 bg-gray-800 text-gray-200 rounded shadow-lg transition-colors text-base font-medium flex items-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed${ |
|
viewedTranscription.text ? " hover:bg-purple-700" : "" |
|
}`} |
|
title="Download transcript" |
|
disabled={!viewedTranscription.text} |
|
onClick={() => { |
|
const baseName = viewedTranscription.filename; |
|
const filename = `${baseName}.txt`; |
|
const blob = new Blob([viewedTranscription.text ?? ""], { type: "text/plain" }); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement("a"); |
|
a.href = url; |
|
a.download = filename; |
|
document.body.appendChild(a); |
|
a.click(); |
|
setTimeout(() => { |
|
URL.revokeObjectURL(url); |
|
document.body.removeChild(a); |
|
}, 100); |
|
}} |
|
> |
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> |
|
<path |
|
strokeLinecap="round" |
|
strokeLinejoin="round" |
|
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" |
|
/> |
|
</svg> |
|
Download |
|
</button> |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</main> |
|
</div> |
|
); |
|
} |
|
|