Voxtral-WebGPU / src /App.tsx
Xenova's picture
Xenova HF Staff
Upload 14 files
c3ece4c verified
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>
);
}